PLAN.md §12 *Data backup / export* called for "a bridge:export console
command and a UI hook for backup-to-file from v1". Shipping both now
so the v0.2.0 surface has the data-portability story end-to-end:
PHP side — `bin/console bridge:export <destination>`:
- Reads the source path from DATABASE_URL so it works in dev mode
(developer's source-tree var/data.sqlite) and bundled mode (user
data dir SQLite) without environment-aware logic.
- SQLite-only by design (PLAN.md §6 — single-instance SQLite-first);
emits a clear error for non-sqlite:// URLs rather than pretending
to support drivers that need driver-specific dump tooling.
- Overwrites the destination if it exists (the FileDialog or shell
redirect that produced the path has already confirmed).
- 4 unit tests: happy path, non-SQLite URL, missing source,
overwrite. Test count 24 → 28.
QML side — Q_INVOKABLE BackendConnection.exportDatabase(path):
- Bundled mode only; dev mode emits databaseExportFailed and
returns false (developers own their SQLite directly).
- Accepts both filesystem paths and `file://` URLs (FileDialog
results).
- Returns synchronously with bool but also emits async signals
databaseExported(dst) / databaseExportFailed(reason) so QML
can drive a snackbar / log without polling the return value.
- Removes any existing destination first (QFile::copy refuses
to overwrite); the picker has already confirmed the choice.
Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the
host-less triple-slash trips its strictness). Switched to a
prefix-strip — Doctrine DBAL only emits two URL shapes for
SQLite anyway (sqlite:///abs and sqlite://relative).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PLAN.md §12 *Migrations on schema change* flagged this as a v1.0
prereq. SQLite has no transactional DDL — a half-applied migration
can corrupt the user's data with no rollback path. Cheapest defence
is a copy-aside before each migrate.
backupDatabase() runs at the head of runMigrations() in bundled
mode:
- skipped on first launch (no data.sqlite yet)
- copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak
- trims to kMaxDatabaseBackups=5 most recent (mtime sort, oldest
go first)
- copy failure logs a warning and continues; a missing safety-net
is not a reason to refuse to boot
Dev mode is unaffected — developers own their var/data.sqlite
lifecycle and don't want a backup written every time `make dev`
restarts.
Integration test: bundled-supervisor.sh gained an assertion after
the 2nd-launch /healthz check that at least one
data.sqlite.*.bak file appears under the user data dir. Verified
locally — backup landed at the expected path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PLAN.md §8's fourth makers-table row. Read-models are
server-side projections — joined fetches, aggregates, denormalised
views — that QML reads without going through a writable
#[BridgeResource]. The maker emits:
- src/ReadModel/<Name>ReadModel.php — query service stub injecting
EntityManagerInterface; user fills query() with DQL / QueryBuilder
/ raw SQL as fits.
- src/Controller/<Name>Controller.php — single GET handler at
/api/<kebab-plural>, just normalises the read-model output to JSON.
- {qml_path}/<Name>List.qml — ReactiveListModel bound to the route,
deliberately no Mercure topic.
The "no topic" choice is the design call worth documenting: read-models
are queries, not reactive resources, and pretending otherwise would
either auto-publish stale aggregates on every entity change or require
the user to invent invalidation logic in the listener. Better: pair
the read-model with `make:bridge:event` and call refresh() from the
QML event-handler when the underlying data really changes.
Naming convention: kebab-PLURAL routes (`/api/todo-summaries`) for
consistency with REST list semantics; resource path stays singular
under `src/ReadModel/`.
Wired into services.yaml's when@dev block. Three new snapshot
baselines (TodoSummaryReadModel.php / TodoSummaryController.php /
TodoSummaryList.qml) plus runner extension. All 14 maker outputs
verify on the committed state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PLAN.md §8's third makers-table row. Single-command path
from a PHP domain event to a QML signal-handler:
- src/Event/<Name>Event.php — readonly value object stub
- src/EventSubscriber/<Name>Subscriber.php — listens to the event,
republishes via PublisherInterface on app://event/<kebab-name>
with op:"event"
- {qml_path}/<Name>EventHandler.qml — MercureClient bound to the
topic, re-emits the envelope's data as a typed signal
Stub uses an `array $payload` field so the user can substitute typed
properties for whatever shape they need. Subscriber example uses the
PublisherInterface contract from chunk 1; QML stub uses MercureClient
+ BackendConnection both already shipping.
Wired into services.yaml's when@dev block (autoconfigure picks up
maker.command tag, same pattern as existing BridgeResourceMaker /
BridgeWindowMaker). Three new snapshot baselines plus a snapshot
runner extension exercising the new maker against the same Todo /
TodoCompleted naming the existing baselines use.
End-to-end verified locally: maker output matches baselines, dev
container compiles, listing make:bridge:* shows the new command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where
do they live?" as an open question with the bias "QML side via Qt".
That bias was never written up; without the doc, a Symfony developer
new to Qt would reasonably reach for "POST /api/show-dialog" or roll
a custom QML "FileDialog" using Window + ListView. Both are wrong.
The doc:
- States the boundary plainly (native UI = QML side, never PHP) plus
the architectural reason (PHP's process can't reach the user's
window manager; Qt's can and already wraps every platform's
native API).
- Walks through Qt.labs.platform.FileDialog / MessageDialog /
SystemTrayIcon / StandardPaths with copy-pasteable examples so
apps don't need to discover Qt.labs the hard way.
- Explains the trigger-vs-effect split: user-initiated confirmations
open from the QML handler that fired the action; server-side
events route through Mercure and let QML decide how to surface
them (toast / dialog / tray notification).
Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners,
don't add HTTP endpoints whose only job is to trigger UI side-effects,
don't roll a custom QML file browser.
Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage
covers the common case but routes through the tray. Richer
notifications (action buttons, replies) need platform-specific code
and are deferred — flagged in-doc.
PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the
common cases". Skipped: every common case Qt.labs.platform already
covers, and a wrapper would just shadow upstream's API. If a future
need surfaces a real gap (XDG portal notifications without tray,
say), that's the time to add framework-side code; the doc will
point at it.
No code changes; doc-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both items naturally cluster with the v0.9.0 cross-platform packaging
milestone:
- Multi-arch builds share runner + cert prerequisites with the macOS /
Windows ports already at v0.9.0; doing them as one operational push
is cheaper than fanning out across minors.
- Composer create-project is the "how does a new user get the
framework" PHP-side channel — settling it alongside the OS-installer
paths means all the entry points stabilise together for v1.0.0.
v0.3.0 keeps i18n + persistent log files (both standalone work that
doesn't need v0.9.0's operational lift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The audit's substantive items shipped in chunks 1–4. Two remaining
loose ends inspected and parked:
- Generated controller findOr404 boilerplate. MapEntity changes the
404 response shape away from problem+json unless framework-level
RFC 7807 error config is updated; a private helper is net-zero
on lines. Parking until either (a) skeleton-level RFC 7807 error
wiring, or (b) --with-dto flipping to default-on and the legacy
template's polish becoming irrelevant.
- ModelPublisher::extractId reflection branch. Looks dead because
every maker-output entity has getId(), but it remains a safety net
for hand-written entities that don't. Keeping.
This commit ships:
- BridgeOpTest — locks the enum case values against accidental
rename. Every case value is a documented wire-format token QML
clients hardcode, so renaming a `value` is a wire-protocol break
and this fails the build before it ships.
- PLAN.md §13 v0.2.0 status block with what's shipped on dev
(interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto)
and what's still open (findOr404 polish, --with-dto default flip).
Test count 23 → 24, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the input-validation gap that was the audit's headline finding.
The legacy generated controller's `if (isset($data['title']))…` body
accepted any JSON: empty title slipped through, malformed JSON got
swallowed by `?? []`, wrong types were silently coerced via casts.
The --with-dto flag generates:
- src/Dto/Create<Name>Dto.php — readonly DTO with #[Assert\NotBlank]
on title and #[Assert\Length(max: 255)]
- src/Dto/Update<Name>Dto.php — same DTO with all fields nullable
so PATCH callers send only what changed
- src/Controller/<Name>Controller.php — same shape as the legacy
controller but actions dispatch via #[MapRequestPayload]
Validation failures (missing required field, wrong type, malformed
JSON, oversize string) become RFC 7807 application/problem+json
automatically — Symfony's RequestPayloadValueResolver does the work.
No `if-isset` boilerplate, no silent coercion.
Behaviour:
- --with-dto is opt-in; legacy template still ships unchanged
- audit suggests flipping to default-on once stable; that's a
follow-up
- maker fails loud (composer require hint) if symfony/validator
isn't autoloadable
- skeleton + example/todo composer.json pull symfony/validator so
scaffolded apps work out of the box
Snapshot test exercises both modes (legacy + --with-dto). New
baselines TodoControllerWithDto.php / CreateTodoDto.php /
UpdateTodoDto.php under tests/snapshot/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DRY pass identified by the post-v0.1.2 audit: every make:bridge:*
maker re-implemented the same "prompt, trim, ucfirst, reject empty"
closure in interact(), and the camel-case-to-separator regex was
duplicated between BridgeResourceMaker (`_`-joined route plurals)
and BridgeCommandMaker (`-`-joined kebab slugs).
Two helpers under PhpQml\Bridge\Maker\Support:
- NameInput::askOrFail() — replaces 3× inline closures
- Naming::camelTo($name, $separator) — replaces 2× inline regexes
All 3 makers now go through the helpers; behaviour preserved
(maker snapshot test still passes — generated Todo / TodoController
/ TodoList / MarkAllDoneController / TodoWindow byte-identical to
the v0.1.2 baselines).
NamingTest covers the documented cases plus a regression case for
acronyms (HTTPClient → h-t-t-p-client; the regex splits at every
internal capital, which is correct for the route-slug use case).
Test count 17 → 23, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouples /healthz from the publisher contract. v0.1.1 wired
HealthController to constructor-inject Publisher purely as a "is the
bundle resolvable" probe — that worked but cemented the publisher's
API as a readiness-test dependency, which was awkward once
PublisherInterface landed in v0.2.0 chunk 1.
Replace with BridgeBundleInfo: a tiny readonly VO carrying the
bundle's name + class FQCN. HealthController depends on this instead.
Same deep-load semantics (broken bundle → can't construct the VO →
500 on /healthz), no leaky publisher dep.
/healthz response shape:
- `bundle`: was `PhpQml\\Bridge\\Publisher`,
now `PhpQml\\Bridge\\BridgeBundle`
- `name`: new field, reports `php-qml/bridge`
bundled-supervisor.sh's grep updated to match the new canary value
plus an assertion that the new `name` field is present (catches a
botched BridgeBundleInfo wire-up that the bundle-class-name assertion
alone would miss).
Quality + maker snapshot + bundled-supervisor integration test all
pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.
Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
- PublisherInterface — publish(string, array, bool)
- ModelPublisherInterface — publishEntityChange(object, BridgeOp)
- CorrelationContextInterface — set/get/clear
App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.
BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.
Switched typehints:
- ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
- DoctrineBridgeListener ctor: ModelPublisherInterface
- HealthController ctor: PublisherInterface (still emits `Publisher`
as bundle canary value — `::class` resolves to the concrete class
name regardless of typehint, so bundled-supervisor.sh's grep stays
green)
- skeleton PingController ctor: PublisherInterface (canonical app
pattern — example/todo has no Publisher consumer to update)
Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.
PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
teardownChild called terminate() then waitForFinished(2000), then
disconnected the QProcess signals. But waitForFinished pumps a local
event loop — when frankenphp exited inside that wait, QProcess::finished
fired synchronously, ran onChildFinished as the crash-supervisor's
restart path, and spawned a brand-new frankenphp child during
shutdown. That child's QProcess was then destroyed mid-spawn during
stack unwinding, producing the "QProcess: Destroyed while process is
still running" warning the bundled-supervisor.sh test catches.
Fix: disconnect first, then terminate. Severing signals before the
wait turns terminate() into the synchronous reap it should always
have been; onChildFinished can't run for a process we're explicitly
tearing down.
Local integration test passes clean — both the cache-baked-mount-path
relaunch and the graceful-shutdown assertion go through without the
warning or any orphan frankenphp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The aboutToQuit-based teardown wired in v0.1.2 only fires when something
calls QCoreApplication::quit() — typically a window close. `kill -TERM`
to the host process bypasses Qt entirely (no default SIGTERM handler),
so teardownChild never ran on signal-driven shutdown. Local tests
passed on lucky timing because PR_SET_PDEATHSIG made the kernel SIGTERM
frankenphp once the host died, but the timing was racy and surfaced on
CI as "frankenphp child PID outlived the host (supervisor didn't clean
up)".
Fix: install a SIGTERM/SIGINT handler in BackendConnection that uses
the self-pipe pattern — the C signal handler writes one byte (the only
truly async-signal-safe primitive), a QSocketNotifier on the read end
calls QCoreApplication::quit() in the main thread, and aboutToQuit runs
the existing teardownChild before app.exec() returns. The host now
exits cleanly under `kill -TERM` from service managers, launchers, and
the test harness.
Also bumps the bundled-supervisor.sh first-relaunch grace from 2s to
3s — teardownChild itself waits up to 2s for frankenphp to finish after
SIGTERM, so the host needs ~2.x seconds to exit. The graceful-shutdown
step further down was already at 3s.
No public-API change; production-correctness fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix (c78d471) moved the maker qml_path injection into
when@dev: but didn't repeat _defaults inside it. when@<env> opens a
fresh services block with no inheritance, so the explicit Maker
definitions lost autowire/autoconfigure — and with autoconfigure off,
maker-bundle's `maker.command` tag was never applied. Symptom in CI:
`make:bridge:resource` silently disappears from `bin/console list`
while `make:bridge:command` (registered by the glob, no override)
keeps working. Snapshot test failed with "Command 'make:bridge:resource'
is not defined".
Fix: add _defaults inside the when@dev block. Snapshot test passes
locally; prod cache:clear in --no-dev still compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.1.2 build broke the staging-symfony container compile: explicit
top-level `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` blocks
forced ResolveClassPass to load AbstractMaker, which is excluded by
`composer install --no-dev`. The glob alone tolerates the missing
parent (FileLoader silently drops classes that fail class_exists), but
explicit blocks bypass that check.
Fix: keep v0.1.1's plain glob untouched; move the qml_path argument
overrides into a `when@dev:` envelope that prod/no-dev compiles never
touch. Dev builds still resolve the bound parameter (verified via
debug:container — Argument value `../qml/`); prod cache:clear no
longer aborts on missing AbstractMaker; integration-bundled passes
end-to-end locally.
No public-API change; release CI fix only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit (0cceefc) staged the audit-driven fixes under a
hypothetical v0.1.3 since v0.1.2 hadn't tagged yet. Tagging cadence
decision: ship them all as v0.1.2 — there's no point spending a tag
on the clean-shutdown fix alone when v0.1.3 was already lined up
behind it. PLAN.md §13 + CHANGELOG.md collapse v0.1.2 + v0.1.3 into
the single v0.1.2 entry, dated 2026-05-03; orphaned [0.1.3] link
reference at the foot of CHANGELOG removed.
No code changes — the four fixes from 0cceefc + f132c3c stand as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs surfaced by the post-v0.1.2 architecture audit:
- bridge.qml_path is now actually configurable. BridgeBundle::configure
defines the qml_path scalar node (default ../qml/); loadExtension
exposes it as the bridge.qml_path container parameter; services.yaml
binds it into BridgeResourceMaker + BridgeWindowMaker. Apps override
with `config/packages/bridge.yaml`. The existing maker docstrings
claimed this worked already — they lied; now they don't.
- SessionAuthenticator implements AuthenticationEntryPointInterface and
routes the no-token entry-point path through the same problem+json
helper as onAuthenticationFailure, so QML's RestClient sees one error
shape regardless of which firewall path was taken. Test added.
- CorrelationKeyListener::onTerminate guards on isMainRequest() now,
matching onRequest's existing guard. No user-visible impact in
worker mode (no sub-requests emitted), but the asymmetry was a
defensive bug that would corrupt optimistic-update reconciliation.
PLAN.md §13 gains a v0.1.3 section + folds the audit's API-surface
items (PublisherInterface / ModelPublisherInterface / BridgeOp enum /
maker DRY / DTO-shaped scaffold) into v0.2.0. CHANGELOG.md mirrors.
PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot tests all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.1.2 section had been prepended above v0.1.1 instead of inserted
after it; reads strictly chronologically now (v0.1.0 → v0.1.1 → v0.1.2
→ v0.2.0 → …).
Also brought v0.1.1's heading and bullet list up to current reality:
- heading: "ready to tag" → "shipped 2026-05-03" (it was tagged
earlier today)
- added the cache-wipe-on-bundled-launch fix, which actually landed
in v0.1.1 (rotated into the tag) but was missing from PLAN.md's
summary (CHANGELOG already had it)
Top-of-file status line: "v0.1.1 ready to tag" → "v0.1.0 + v0.1.1
shipped 2026-05-03; v0.1.2 in progress on dev".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom (user report on v0.1.1):
QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running.
…and the frankenphp child + its PHP workers were left orphaned after
the host exited.
Cause: teardownChild() was only called from ~BackendConnection. By
the time that destructor runs, app.exec() has already returned,
QQmlApplicationEngine is mid-destruction, and Qt's event loop is
half-torn-down. waitForFinished() doesn't reliably reap the child in
that window — QProcess gets destroyed by the QObject parent-chain
cleanup before the kernel reports the child as exited.
Fix: in BackendConnection's constructor, connect
QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires
while the event loop is still active and BEFORE main() starts
unwinding the stack, so SIGTERM + waitForFinished can do their job
properly. The destructor's teardownChild call stays as belt-and-
suspenders (no-op once aboutToQuit has already cleaned up — the
function is idempotent via the m_child = nullptr at its end).
The connect happens unconditionally in the constructor (not just for
bundled mode) because m_child is also nullptr in dev mode and
teardownChild handles that with its leading `if (!m_child) return;`.
Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"graceful shutdown" step:
- Snapshots the host's child PIDs before SIGTERM
- SIGTERMs the host, waits up to 3s for clean exit
- Greps the host log for "QProcess: Destroyed while" — fail if found
- Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive
Verified locally: real AppImage + the integration test both clean up
without Qt warnings or orphan processes.
PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry.
CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reproduces with the v0.1.1 AppImage on the second launch (same user
data dir, fresh AppImage mount):
phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..."
Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations"
^^^^^^^
stale path from PREVIOUS launch's cache
Symfony compiles `kernel.project_dir` (an absolute path) into its
cached container under var/cache/. We redirect var/cache into the
user data dir for read-only-mount survival (v0.1.0 fix), but the
*content* of that cache references the mount path that was active
when the cache was built. Next launch gets a different
/tmp/.mount_<random>; the cached refs are stale; first
project_dir-sensitive lookup blows up (doctrine migrations was the
canary; would also surface as misrouted assets, broken Twig template
paths, etc.).
Fix: BackendConnection::initBundledMode does
QDir(cacheDir).removeRecursively() right after creating the dirs but
before runMigrations spawns the doctrine subprocess. Symfony rebuilds
the cache against the current mount on every launch. Cost: ~1-2s of
warmup per cold start.
Permanent fix is build-time cache warmup (ship the prod cache inside
the AppImage, copy to user data dir on first launch, no per-launch
warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1
takes the simpler always-wipe approach since it's bugfix-class.
Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"2nd launch from fresh staging" step that tears down the first host,
re-stages a fresh fake AppImage layout (different /tmp dir = different
"mount path" from BackendConnection's perspective), and asserts
/healthz comes back up. Without the cache wipe, that step would fail
exactly the way doctrine did in the user's report.
Verified locally:
- bundled-supervisor.sh passes (incl. 2nd-launch step)
- Real AppImage: two consecutive launches both reach
"phpqml.bridge.bundled: migrations OK" + frankenphp spawn
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG.md: new [0.1.1] section with Fixed (HealthController
deep-load, Caddyfile fmt) + Added (bundled-mode supervisor test,
skeleton AppImage parity) + a Notes line acknowledging the
port-collision bug deferred to v0.2.0. Date stays TBD until tag push.
Compare/tag link refs updated.
PLAN.md: v0.1.1 section flipped from "open follow-ups" to "ready to
tag" with each item describing what shipped (handy for the release
notes pass). v0.2.0 section gains an explicit "Bundled-mode port
negotiation" entry under Operations — the port-collision bug
surfaced during v0.1.1 prep, but the fix touches every consumer that
hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's
wider than v0.1.x scope. Status line at the head of the file bumped
to "v0.1.1 ready to tag".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.1.0 shakedown fixes for AppImage assembly (path-repo
symlink:false sed, writable-cache redirect) all landed in
examples/todo. The skeleton — which is what bin/php-qml-init copies
when scaffolding a new app — had no `appimage` target at all, so every
scaffolded app would have to either copy the example's Makefile by
hand or re-discover the same shakedown bugs.
Brings parity:
- framework/skeleton/Makefile gains `staging-symfony` and `appimage`
targets, mirroring the example's. Two new variables (BUNDLE_SRC,
PACKAGING) parameterise the framework-tree paths so bin/php-qml-init
can rewrite them at scaffold time without sed-touching the recipe.
- framework/skeleton/packaging/skeleton.{desktop,png} added — minimum
surface for the AppImage assembly to succeed without the user
needing to author them.
- framework/skeleton/Makefile's staging-symfony recipe handles both
relative (framework default `../../php`) and absolute (post-scaffold)
BUNDLE_SRC values via a case statement.
- bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*,
rewrites the .desktop file's Name/Exec/Icon, and updates the
Makefile's --app-name / --output / --desktop / --icon flags +
BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's
packaging/linux/ is also vendored to .bridge-packaging/ alongside
the existing .bridge/ + .bridge-qml/.
Verified by scaffolding both modes:
- non-vendored: BUNDLE_SRC + PACKAGING absolute paths
- --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging,
.bridge-packaging/ contains build-appimage.sh
Skeleton's `make quality` still green; staging-symfony works locally
(vendor/php-qml/bridge resolves to a real directory, not a symlink).
Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in
PLAN.md §13.
Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes
(blank lines around lists) when the IDE saved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stages a fake AppImage layout in /tmp without a real .AppImage build:
$ROOT/usr/bin/<app> — copy of the host binary
$ROOT/usr/bin/frankenphp — symlink to system frankenphp
$ROOT/usr/share/<app>/symfony — staged --no-dev composer copy
$ROOT/usr/share/<app>/Caddyfile
The staged Symfony tree is `chmod -R a-w` to actually exercise the
read-only-mount cache/log redirect (Kernel::getCacheDir +
APP_CACHE_DIR override) — without the override, Symfony would fail
to mkdir var/cache/prod and migrations would error out.
Then runs the host with BRIDGE_URL unset (forces bundled mode), polls
/healthz, and asserts:
- status=ok + bundle="PhpQml\Bridge\Publisher" — proves the
HealthController deep-load (predecessor commit) actually
autowired Publisher, i.e. BridgeBundle is reachable.
- User data dir's var/cache exists — APP_CACHE_DIR override fired.
- Staged tree's var/cache/prod is empty — Symfony didn't write into
the read-only mount.
Together this catches every v0.1.0 shakedown bug in CI:
- doubled bin/frankenphp path (resolveFrankenphpBin)
- composer path-repo symlink dangling (staging-symfony's symlink:false sed)
- read-only mount cache failure (Kernel + supervisor env-vars)
- bundle autoload broken (HealthController canary)
Implementation gotcha (caught during dev): the host binary must be
COPIED into the staged layout, not symlinked. Qt's
applicationDirPath() reads /proc/self/exe which dereferences
symlinks, so a symlinked host would resolve to the original build/
dir and the supervisor would hunt for frankenphp + symfony there
instead of the staged tree. Real AppImages copy the binary, mimicking
that here.
Wiring:
- examples/todo/Makefile: extracted the staging-symfony logic out
of the appimage target into its own staging-symfony target. New
integration-bundled target depends on `build` + `staging-symfony`
and runs tests/bundled-supervisor.sh. quality target now invokes
integration-bundled after the existing dev-mode integration test.
- .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration
test" step right after the dev-mode integration step.
Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked
in PLAN.md §13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0 shipped two bugs that left /healthz returning 200 against a
half-loaded bundle: the path-repo symlink dangling at runtime in the
AppImage (vendor/php-qml/bridge → nonexistent), and the writable
cache-dir bug (Symfony couldn't create var/cache/prod). HealthController
returned a static {status:"ok"} without ever touching any BridgeBundle
service, so perfsmoke + the connection-state probe both passed even
when the bundle's autoload was broken — first sign of trouble was a
500 from /api/todos under real load.
Inject Publisher (the bundle's Mercure-publish wrapper) via constructor
and reference its FQN in the response body. Two effects:
- Symfony's container resolves Publisher when the controller is
instantiated; if the bundle's autoload is broken, the controller
can't even construct, /healthz returns 500.
- The response now includes `bundle: "PhpQml\Bridge\Publisher"` —
proves to perfsmoke + dev console that the canary is live, not a
cached static response.
Connection-state probe semantics unchanged: still 200 = Online,
non-200 = Reconnecting/Offline. Probe interval is 5s — Publisher's
construction is constant-time, no perf concern.
No new public API: /healthz response gained a `bundle` field
(additive, JSON parsers ignore unknown keys); 200 vs 500 boundary is
preserved. No existing consumer broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FrankenPHP logs a warning on every boot:
Caddyfile input is not formatted; run 'caddy fmt --overwrite' to
fix inconsistencies
Cosmetic but clutters the dev console (and the bundled-mode logs).
The actual diff is one blank line in each file: caddy fmt rejects an
empty line between a leading comment and the `{` global-options
block. tests/var/Caddyfile was already clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same logic as the macOS/Windows + telemetry moves: alternate
distribution channels are operational work (Flatpak manifest +
Flathub PR review; snapcraft.yaml + Snap Store listing) that fits
the cross-platform packaging milestone, not the v0.3.0 grab-bag.
Tightened the v0.9.0 framing to make this explicit: AppImage is the
only packaged target through v0.2.0, v0.3.0, and the v1.0.0 prep —
all packaging churn concentrated into v0.9.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telemetry was split awkwardly: v0.3.0 had "Sentry + opt-in telemetry"
(build the pipeline) and v1.0.0 had "Telemetry / crash reporting
opt-in plumbing" (settle the API). The cross-platform crash-dump side
is per-OS work — Apple Crash Reporter, Windows WER, Linux core dumps
all differ — so it naturally rides with the v0.9.0 cross-platform
packaging push rather than landing twice.
Single v0.9.0 entry now covers both: PHP-side Sentry + per-platform
crash-dump pipeline, opt-in only, plumbing settled before v1.0.0 even
if no default endpoint ships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each cross-platform target carries operational prerequisites that
v0.2.0 isn't ready to absorb (Apple Developer cert + notarisation
pipeline + macOS runner; Authenticode cert + SmartScreen reputation
warm-up + Windows runner). Folding both into the next minor would
either delay v0.2.0 indefinitely or ship a half-done port.
Better: keep Linux AppImage as the only packaged target until the
framework's public API surface settles, then concentrate the
cross-platform push into a single v0.9.0 release-candidate milestone
right before v1.0.0. The §11 *Distribution UX* foot-guns (Gatekeeper,
SmartScreen, AV pre-submissions, file-association docs) ride along
with that milestone.
v0.2.0 stays focused on the smaller deferred items (deferred makers,
ReactiveObject pagination, qmltestrunner, end-to-end UI test,
auto-backup, bridge:export, periodic auto-update, build-time cache
warmup, native-dialog boundary doc) — all things a Linux-only
contributor can deliver without operational blockers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`github.server_url` on Gitea Actions resolves to the runner's
internal Gitea endpoint (e.g. http://gitea:3000) — fine for API
calls the runner makes itself, broken for URLs end-user machines
have to resolve. Both v0.1.0 user-facing places used it:
- latest.json's `url` and `zsync` fields (read by AppImageUpdate
on user machines).
- The AppImage's embedded `--update-info` ELF section (also read
by AppImageUpdate to find the appcast).
Result: v0.1.0's latest.json shipped pointing at gitea:3000, which
no end-user machine can reach.
Fix: add a job-level `PUBLIC_REPO_URL` env var (single source of
truth, easy to change if Gitea ever moves) and use it for both
artefact-URL composition sites. The release-create + asset-upload
API calls keep using `github.server_url`/`api/v1` — those are
runner→Gitea internal traffic where the internal URL is correct.
Note: v0.1.0's already-uploaded latest.json still has the broken
URLs. Either leave it (no auto-update consumers yet) or PATCH the
asset out of band; future tags will be correct once this lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-1.0 releases get the prerelease flag in Gitea — pre-1.0 means
public API may break between minors (SemVer permits this), so these
shouldn't display as stable releases.
Computed from $TAG via `case` so the flag auto-flips to false when
v1.0.0 lands; no further workflow change needed at that point.
case "$TAG" in
v0.*) prerelease=true ;;
*) prerelease=false ;;
esac
Passed to jq via --argjson (not --arg) so it stays a JSON boolean
rather than the string "true" / "false".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§13 was 332 lines of phase-by-phase implementation history (Phase 0
spike → Phase 5 closure). All of that is now redundant with
CHANGELOG.md (per-version summary) and `git log` (per-commit detail);
keeping it in PLAN.md was duplication that would only rot.
Replaced with a Versions section organised around SemVer:
- v0.1.0 — shipped 2026-05-03 (links to CHANGELOG + release page).
- v0.1.1 — bugfix follow-ups from v0.1.0 shakedown (perfsmoke gap,
bin/php-qml-init parity with the AppImage fixes that landed in
examples/todo, bundled-mode integration test, Caddyfile fmt).
- v0.2.0 — minor features: macOS/Windows packaging (was Phase 4b/4c),
deferred makers (event/read-model), ReactiveObject pagination,
qmltestrunner + end-to-end UI test, pre-migration auto-backup,
bridge:export, periodic auto-update check, build-time cache warmup,
native-dialogs boundary doc.
- v0.3.0 — bigger pieces: i18n bridge, persistent log files +
rotation, multi-arch builds, Sentry + opt-in telemetry, Flathub /
Snap, Composer create-project package.
- v1.0.0 — when public API stabilises: auth model, Mercure storage,
AppImage relinkability, telemetry plumbing, security audit,
FrankenPHP-as-library evaluation.
Every deferred item from the original §11/§12/Phase 3-5 deferral
lists got a version target — no orphans.
Top-of-file status line and "Where else to look" pointer added so
readers know docs/ is for how-to-use, CHANGELOG for what-shipped, and
PLAN.md keeps why + what's next.
Net: 836 → 561 lines (33% smaller). §1-12 (architectural rationale)
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symfony defaults Kernel::getCacheDir/getLogDir to <project>/var/cache
and <project>/var/log. In bundled mode those resolve inside the
AppImage's FUSE mount (/tmp/.mount_<hash>/usr/share/<app>/symfony/) —
read-only. Migrations fail at startup with:
Unable to create the "cache" directory
(/tmp/.mount_<hash>/usr/share/<app>/symfony/var/cache/prod).
…and frankenphp's worker can't warm a cache either, so even after the
binary spawns, the app is in a half-working state (which probably also
explains the persistent Reconnecting banner the user reported — once
migrations fail the supervisor sets Offline; even a successful
re-probe of /healthz wouldn't recover from a half-warm state).
Two-part fix, framework-side seam + app-side override:
1. BackendConnection.cpp (runMigrations + spawnChild): mkdir
<m_dataDir>/var/{cache,log} and pass them as APP_CACHE_DIR /
APP_LOG_DIR env vars. <m_dataDir> resolves to
~/.local/share/<app> via QStandardPaths::AppDataLocation, so
it's user-writable.
2. App Kernel.php (skeleton + todo): override getCacheDir /
getLogDir to honour the env vars. Falls back to parent
behaviour when unset (dev mode keeps writing to var/cache like
normal).
Database file already lives at <m_dataDir>/var/data.sqlite, so the DB
side was fine. Caddy autosaves to ~/.config/caddy and TLS storage to
~/.local/share/caddy — both user-writable. Mercure ran in-memory
mode in earlier logs so no extra storage redirect needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each tag rotation re-runs release.yml. The create-release POST
returns 4xx for the existing release, the script falls back to GET
on the existing one — and then re-POSTs the same asset names to the
upload endpoint. Gitea appends each upload as a new asset rather
than replacing, so the release page accumulates Todo-x86_64.AppImage
once per rotation, same for .zsync / latest.json / SHA256SUMS.
Fix: between getting the release id and the upload loop, list all
existing assets and DELETE them first. Single rotation = single set
of assets, regardless of how many times release.yml has run for this
tag.
Release body stays as set on first creation (the GET returns the
original). If a future rotation needs to refresh the body too, that
would be a separate PATCH on the release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Makefile's appimage target ran composer install with the path repo
configured as `"symlink": true`. Composer created a symlink at
vendor/php-qml/bridge → <BUNDLE_ABS>. rsync into the AppDir preserved
the symlink, whose target path doesn't exist on the user's machine.
At runtime: Caddy + frankenphp boot fine, /healthz returns 200 (no
bundle services touched), but every API request fails with:
Warning: include(.../symfony/vendor/composer/../php-qml/bridge/src/
BridgeBundle.php): Failed to open stream: No such file or directory
…and the migrations step fails identically on first launch.
COMPOSER_MIRROR_PATH_REPOS=1 is the documented env-var lever, but
explicit `"symlink": true` in composer.json takes precedence over it
(verified the env var alone leaves the symlink in place). Dropping the
env var; instead, sed the symlink option to `false` in the staging
composer.json, alongside the existing URL rewrite.
Composer.json source-of-truth keeps `symlink: true` so dev-mode
installs are still hot-reloadable against framework/php source. Only
the staging copy used for AppImage assembly is mirrored.
Verified locally: `vendor/php-qml/bridge` is now a real directory after
composer install; `BridgeBundle.php` exists as a regular file.
Note for follow-up (out of scope here): perfsmoke didn't catch this
because /healthz doesn't touch any BridgeBundle services. Worth
extending perfsmoke to also exercise an actual API endpoint so packaging
regressions of this shape fail loudly in CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed idle memory on the Gitea act-runner was 434 MB vs the 200 MB
strict baseline. Two things inflate the number under CI:
1. Qt has no GPU under xvfb, so it falls back to Mesa llvmpipe; the
LLVM 20 libs + softpipe rasterizer add ~30-50 MB per process.
2. perfsmoke sums VmRSS across host + descendants, which
double-counts shared library pages (libllvm, libmesa) loaded into
both the Qt host and any frankenphp child workers.
Could fix#2 by switching to PSS (smaps_rollup) accounting, but that's
a bigger change than rotation can absorb here. For now: lift the
budget to 600 MB (3x baseline). Still catches order-of-magnitude
regressions; the strict 200 MB budget remains the bare-metal default
for `make perf`.
PERF_IDLE_MEM_MB: 200 (default) → 600 (CI override)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed cold start on the Gitea act-runner is ~6s — legitimately:
AppImage extract (~0.5s) + xvfb startup (~0.5s) + Qt platform init
(~1-2s) + frankenphp spawn + Symfony cold-cache bootstrap (~1.5-2s)
+ first /healthz roundtrip (~0.5-1s). The previous 4s budget (2x the
strict PLAN.md §11 number) was too tight for that environment.
PERF_COLD_START_MS: 4000 → 10000 (5x strict baseline)
PERF_HEALTHZ_DEADLINE_MS: 8000 → 15000 (room for retry beyond budget)
Bundle-size (200 MB) and idle-memory (200 MB) budgets stay strict —
those are environment-independent. The strict 2s cold-start baseline
also stays for `make perf` runs against bare metal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolveFrankenphpBin() returned `applicationDirPath() + "/bin/frankenphp"`,
but applicationDirPath() inside an AppImage is already `usr/bin/` —
where the host binary itself runs from. The "/bin/" prefix produced
the doubled path, e.g.:
/tmp/appimage_extracted_<hash>/usr/bin/bin/frankenphp
…which doesn't exist. The supervisor logged the lookup failure and
kept retrying, eventually hitting the perfsmoke 8s cold-start
deadline.
build-appimage.sh:148 installs frankenphp at `usr/bin/frankenphp`
(sibling of the host binary, per the layout comment at line 18). The
fix is to drop the spurious `/bin/`. Other resolvers in the same
file (resolveSymfonyDir, resolveCaddyfilePath) already use the
correct `here + "/<file>"` pattern.
Bug shipped since the bundled-mode supervisor was added — would
have hit anyone running the AppImage. Local `make quality` only
exercises dev mode (BRIDGE_URL set), so the integration test loop
never reached this codepath; CI's perfsmoke against the actual
AppImage is the only thing that catches it. Manual test would be:
launch the AppImage with BRIDGE_FRANKENPHP_BIN unset and watch the
phpqml.bridge.bundled log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two warnings, two distinct kinds of fix:
1. `Item { width: 12 }` (skeleton:77) — explicit width on a
layout-managed Item is undefined behaviour per qmllint. Replaced
with `Item { Layout.preferredWidth: 12 }`, matching the pattern
already used at line 85 (`Item { Layout.fillWidth: true }`). Real
fix, not a suppression.
2. `target: SingleInstance` (skeleton:48, todo/Main.qml:220) — false
positive. SingleInstance is intentionally a context property set
by main() before the QML engine boots (see SingleInstance.h
doc comment), so qmllint can't see it via static analysis.
Disabled the `unqualified` warning at the call site with an inline
`// qmllint disable unqualified` directive plus a one-line
explanation comment above. (Note: the disable directive parses
every word after `disable` as a category name, so the prose has
to live on the previous line — found the hard way after qmllint
complained about the "unknown category" of every English word in
the explanation.)
Verified `make quality` from framework/skeleton green (qmllint clean
across both targets — `php_qml_bridge_qmllint` and `skeleton_qmllint`
both build with zero warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the create-release POST sent only `{tag_name, name, draft,
prerelease}` — Gitea created the release with an empty description, so
users hitting the release page saw the tag name and nothing else.
Extracts the relevant `## [<version>]` block from CHANGELOG.md (using
$TAG with the leading `v` stripped) via awk, stopping at the next
`## [<other>]` section header or the trailing `[link-ref]: url` block,
and passes it as the `body` field to the release creation API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Qt 6.5.3's qmllint exits 255 on these two warnings even though older
qmllints only warn:
- Outer-id access from a delegate (`width: view.width`) requires
`pragma ComponentBehavior: Bound` to make the binding explicit.
- Implicit role injection (`text: model.text`) should be a
`required property`. Renamed the model role from "text" to "line"
so the required property doesn't shadow Label's own `text`.
Behaviour unchanged. Verified `make quality` from framework/skeleton
green; the framework's `php_qml_bridge_qmllint` target now lints
clean (no more warnings on DevConsole.qml).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qmllint resolves QML modules by walking the import path looking for a
directory layout that mirrors the URI (PhpQml.Bridge → PhpQml/Bridge/).
qt_add_qml_module's OUTPUT_DIRECTORY defaults to CMAKE_CURRENT_BINARY_DIR
— which, when consumers add_subdirectory() this with their own binary_dir
(skeleton: build/qml/php_qml_bridge, todo: build/qml/php_qml_bridge),
ends in `php_qml_bridge` instead of `PhpQml/Bridge`. cmake configure
warns about the mismatch:
The php_qml_bridge target is a QML module with target path
PhpQml/Bridge. It uses an OUTPUT_DIRECTORY of .../php_qml_bridge,
which should end in the same target path, but doesn't. Tooling
such as qmllint may not work correctly.
…and at lint time, qmllint can't find the module, so every file that
`import PhpQml.Bridge` (AppShell.qml, DevConsole.qml) fails with
"Failed to import PhpQml.Bridge", which cascades into bogus
"Unqualified access" warnings for every BackendConnection reference.
The cascade exits 255 in Qt 6.5.3's qmllint (CI), even when an older
local qmllint would only warn.
Fix: pin OUTPUT_DIRECTORY in the framework's own qt_add_qml_module so
the layout is correct regardless of how consumers wire up the
add_subdirectory binary_dir. Single source of truth in the framework,
no consumer-side change needed.
Verified locally: rebuild from scratch + `make quality` green
(qmllint clean of the cascade — only the pre-existing
DevConsole/Main.qml warnings remain, all non-fatal). PHPStan +
cs-fixer + 16 tests + maker snapshots also still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the QML/C++ build started succeeding, `make appimage` failed at
the staging-symfony step ("rsync: No such file or directory") — slim
runner image again. Adding:
- rsync — used by examples/todo/Makefile to stage a
--no-dev composer copy of the Symfony tree
into build/staging-symfony/.
- file — appimagetool/linuxdeploy invoke `file` to
detect ELF type (AppImage, AppDir contents).
- libfuse2 — AppImage runtime mounts the squashfs via
libfuse2; without it appimagetool refuses
to assemble. (Alternative is
APPIMAGE_EXTRACT_AND_RUN=1 but installing
libfuse2 keeps the script unchanged.)
- desktop-file-utils — appimagetool validates the bundled
.desktop file via desktop-file-validate.
ci.yml only needs cmake + ninja + rsync (the symfony staging happens
in `make build` which it runs too, after the QML build) — no AppImage
assembly there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub-hosted ubuntu-latest preinstalls cmake and ninja. Gitea's
act-runner uses a minimal Ubuntu image (catthehacker/ubuntu:act-*)
which doesn't, so the build step fails with "cmake: command not
found" (exit 127).
apt-get update was already run by install-qt-action's deps step
earlier in the job, so the lists are populated — just install.
Same step added to ci.yml and release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aqt's "modules" list is for *additional* modules beyond the base Qt
install (qt5compat, qtcharts, qtmultimedia, qtwebsockets, etc.).
QtQuick.Controls 2 was a separately-shipped Qt 5 module, but in Qt
6.0+ it was folded into qtdeclarative — part of every base install.
aqt 3.3 rejects the obsolete name with:
ERROR: The packages ['qtquickcontrols2'] were not found while
parsing XML of package information!
Project's CMakeLists request `Core Gui Quick QuickControls2 Network
Qml` — all in base Qt 6.5. No `modules:` line needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install-qt-action defaults `dir:` to `${{ runner.temp }}` if unset.
GitHub-hosted runners populate runner.temp; Gitea/Forgejo's act-runner
leaves it as the empty string. The action then rejects the empty path
with `TypeError: "dir" input may not be empty`.
Hardcoding `dir: ${{ github.workspace }}/qt` works on both — workspace
is always populated and writable, and the path is inside the job's
working tree so it's auto-cleaned with the workspace.
Same change in ci.yml and release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jurplel/install-qt-action@v4 invokes actions/setup-python internally
with the same `cache:` value the user passes to the Qt action. Our
`cache: true` (intended for Qt's own install cache) gets propagated
verbatim, so setup-python receives `cache: 'true'` — which it rejects
with "Caching for 'true' is not supported" because the only valid
values are 'pip' / 'pipenv' / 'poetry'. Surfaces consistently on
Gitea/Forgejo's act-runner.
Workaround: provide our own actions/setup-python@v5 step (Python is
needed for aqtinstall, the tool install-qt-action uses to fetch Qt),
and set `setup-python: false` on the Qt action so it skips the
broken internal call. Qt's own install cache (the `cache: true` we
actually want) keeps working.
Applied to both .gitea/workflows/ci.yml and .gitea/workflows/release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two release-prep items called out in the Phase 5 closure
paragraph (a3d35a7).
License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which
keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied
and avoids version-mixing friction with upstream Qt. Two files at the
repo root:
- LICENSE — LGPL-3.0 text (the project license).
- LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates
("This version of the GNU Lesser General Public
License incorporates the terms and conditions of
version 3 of the GNU General Public License…").
framework/php/composer.json: "license": "proprietary" → SPDX
"LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual
license + LICENSE/LICENSE.GPL pointer.
Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>`
in docs/packaging-linux.md) replaced with the real
`src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md,
CHANGELOG.md (compare + tag links), docs/getting-started.md,
docs/packaging-linux.md (build-appimage --update-info example +
latest.json appcast example).
PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later
license shipped, repo URL fixed". Phase 5 closure paragraph rewritten
to record both items resolved (rather than pending).
Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` →
`[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual
`git tag v0.1.0 && git push --tags` itself, which triggers
.gitea/workflows/release.yml. Per the branching memory, releases land
on main — merge dev → main first.
Verified: `make quality` from framework/skeleton green (16 tests, 45
assertions; PHPStan + cs-fixer clean; maker snapshots match).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>