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>
All four planned sub-commits (4c15ac2 → 26a2b37) landed; the unplanned
docs/ rewrite (da04843) followed, lifting long-form material out of
the README into ten topic guides under docs/.
Done-criteria checked:
- bin/php-qml-init scaffolds a runnable app (sub-commit 2).
- DevConsole.qml + childLogLine signal surface child stdout/stderr
with Ctrl+` toggle in skeleton + todo example (sub-commit 1).
- README is end-to-end (60-second tour + links into docs/).
- CHANGELOG v0.1.0 entry records every Phase 0–4a + Phase 5 deliverable.
- `make quality` from framework/skeleton passes (16 tests, 45
assertions; PHPStan clean; cs-fixer clean; qmllint warnings only;
maker snapshots match).
Status line at PLAN.md head bumped to "Phases 0–5 complete; v0.1.0
ready to tag pending LICENSE selection + Gitea host URL substitution".
Two release-prep items remain — both user-driven, neither a framework
regression — captured in the new Phase-5-closure paragraph: choose +
add a LICENSE (composer.json still says "proprietary"), and replace
the `gitea.example/<org>/` placeholder URLs in CHANGELOG / README /
docs once the repo's published location is fixed. Tagging itself
remains the user's call per the release-process memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.
docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
/ command.
- configuration.md — every env var (host, Symfony, dev script,
packaging script, perfsmoke), every CLI flag (php-qml-init,
build-appimage.sh), make targets, default ports/paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README.md rewritten to reflect actual onboarding (clone → php-qml-init
→ make dev / make appimage) instead of the planning-stage placeholder.
Phase status checklist now reflects 0–4a green and 5 in progress.
CHANGELOG.md created at repo root following Keep-a-Changelog
conventions, with a v0.1.0 entry that summarises Phases 0–4a plus the
Phase 5 polish work (DevConsole, php-qml-init, editor configs,
hot-reload docs). Date is TBD; tagging is the user's call.
PLAN.md gains a Status banner so it's obvious at a glance which phase
the implementation tracks against the design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skeleton README documents the hot-reload story end-to-end:
- PHP-side: frankenphp run --watch (already what `make dev` uses).
- QML-side: Qt Creator Reload, qmlls live preview, run-from-source.
- Dev console: Ctrl+` toggle from sub-commit 1.
Both skeleton and todo example ship .vscode/ (launch.json with Xdebug
attach + Qt-host gdb launch + a compound config, tasks.json for the
make targets, settings.json) and .idea/runConfigurations/ shell run
configs for `make dev`, `make doctor`, `make quality` (and `make
appimage` in the todo example). PhpStorm's Xdebug listener is global
so we don't ship a project-level run config for it; the README
points users at the toolbar toggle.
php-qml-init also rewrites .vscode/launch.json's binary path and
config label so a fresh scaffold's debugger configs point at the
new project's binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-file bash that copies framework/skeleton/ into <name>/, rewrites
project identifiers (CMake project, Qt target, QML URI, app title,
SingleInstance lock id), repoints the path-composer-repo and the
add_subdirectory(framework/qml) reference, runs composer install +
first-run migrations, and prints the make targets to take next.
--vendor copies framework/php and framework/qml into .bridge/ and
.bridge-qml/ inside the new project so it's portable away from the
framework checkout. Default uses absolute paths so updates propagate.
Auto-detects FRAMEWORK when run from a checkout (script lives at
<repo>/bin/php-qml-init). Curl-bootstrap users pass --framework or set
PHP_QML_FRAMEWORK.
Smoke-tested end to end: scaffold → composer install → migrations →
make build links a working binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection now captures the bundled FrankenPHP child's merged
stdout+stderr into a 500-line ring buffer, mirrors each line through
qCInfo(lcBundled) so terminal users still see logs, and exposes
childLogTail() / childLogLine for QML.
DevConsole.qml is an opt-in monospaced viewer with auto-scroll + clear
that the skeleton and the todo example bind to Ctrl+`. Dev mode (when
BRIDGE_URL is set, no bundled child) renders an explanatory hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 closes out the remaining DX seams from PLAN.md §8: child-output
capture with an optional DevConsole.qml, a single-file `bin/php-qml-init`
bash script for scaffolding fresh apps (chosen over a Composer template
since the project mixes PHP and CMake/Qt — no clean Composer fit), the
hot-reload story documented and IDE configs shipped with the skeleton,
then a release-readiness pass (README, CHANGELOG.md) culminating in a
plausible v0.1.0 milestone — tagging stays user-driven per the
release-process feedback rule.
4 sub-commits. macOS / Windows packaging stays deferred to 4b / 4c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo/tests/perfsmoke.sh asserts the PLAN.md §11 budgets
against the built AppImage:
- Bundle size ≤ 200 MB (hard cap; ≤ 120 MB target)
- Cold start ≤ 2000 ms from launch to first /healthz 200
- Idle RSS (host + descendants in the process group) ≤ 200 MB after
a 2 s settle.
Each budget is overridable via env (PERF_COLD_START_MS etc.) for slow
shared CI runners; defaults are the strict numbers from the plan. Runs
the AppImage under xvfb-run when DISPLAY is unset; falls back to
QT_QPA_PLATFORM=offscreen otherwise (the build script already bundles
libqoffscreen.so via EXTRA_PLATFORM_PLUGINS).
Wired into:
- examples/todo/Makefile → `make perf`
- .gitea/workflows/release.yml → runs after AppImage build, before
zsync + upload, with cold-start budget bumped to 4 s for CI.
CI now also installs zsync + xvfb in one step.
examples/todo/README.md gains an "AppImage packaging (Phase 4a)"
section walking through `make appimage`, bundled-mode behaviour, the
auto-update QML hooks (BackendConnection.checkForUpdates() / applyUpdate()),
and `make perf`.
PLAN.md §13 Phase 4 marked **4a closed**. 4b (macOS) and 4c (Windows)
stay stubs until their runners + certs exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.
BackendConnection:
- Q_INVOKABLE checkForUpdates()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
1 → updatesAvailable, anything else → updateCheckFailed.
Dev mode: emits updateCheckFailed("…dev-mode only").
- Q_INVOKABLE applyUpdate()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--remove-old <APPIMAGE>. Replaces the running AppImage in
place; user must restart. Emits updateApplied or
updateApplyFailed.
- Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
- APPIMAGE env (set by the AppImage runtime) determines the target
file. Outside an AppImage both methods fail loudly.
build-appimage.sh:
- Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
- New --update-info flag, forwarded to appimagetool's -u so the
.upd_info ELF section carries an "zsync|<URL>" string the sidecar
will fetch.
examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.
release.yml:
- Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
Gitea Releases asset URL for this tag.
- Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
- Generates a JSON appcast (latest.json) with version / url / sha256 /
size / zsync URL / released_at — useful as an HTTP-fetchable
fallback for clients that prefer a structured manifest.
- SHA256SUMS now covers AppImage + zsync + latest.json.
- Uploads all four assets to the Gitea Release.
AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI was failing on the Install-bundle-dependencies step because
shivammathur/setup-php was installing 8.3 while Symfony 8.x dependencies
declare php >= 8.4. Local composer install worked because the dev box
runs PHP 8.5.5; CI doesn't.
Bumps:
- framework/php/composer.json
- framework/skeleton/symfony/composer.json
- examples/todo/symfony/composer.json
- .gitea/workflows/ci.yml php-version: '8.3' → '8.4'
- .gitea/workflows/release.yml same
- PLAN.md §13 Phase 1 *Detailed scope* PHP minimum row
PHPStan / cs-fixer / PHPUnit stay green locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.gitea/workflows/release.yml builds the example's AppImage on every
v* tag push and uploads it to a Gitea Release together with a signed
SHA256SUMS:
- Same PHP / Qt / FrankenPHP setup as the quality job (cached).
- Reuses the AppImage recipe via `make appimage` (FRANKENPHP env
points at the runner's installed binary; APPIMAGE_EXTRACT_AND_RUN=1
to avoid FUSE inside CI).
- sha256sum → SHA256SUMS.
- When a GPG_KEY secret is present, imports it and emits
SHA256SUMS.asc (--detach-sign --armor). Skipped silently if
secrets aren't configured — CI red-lines for real failures, not
for missing operational secrets.
- Creates the Release via the Gitea API
(POST /repos/{repo}/releases) and uploads
AppImage + SHA256SUMS + SHA256SUMS.asc.
Workflow exists from this commit onward even before a Gitea runner is
provisioned; it'll just fail at the runner-needed steps if no runner
picks it up.
Required Gitea secrets (configurable when ready):
- GITEA_TOKEN — repo-scoped token, write:repository
- GPG_KEY — ASCII-armoured private key (optional)
- GPG_PASSPHRASE — the key's passphrase (optional)
Sub-commit 4 will append a zsync + appcast (latest.json) step here for
auto-update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>