diff --git a/.github/workflows/release-dispatch.yml b/.github/workflows/release-dispatch.yml index 951b4a0..96ce8b8 100644 --- a/.github/workflows/release-dispatch.yml +++ b/.github/workflows/release-dispatch.yml @@ -5,6 +5,12 @@ # releasing the same day just means several small parallel PRs — no shared # branch, no races. # +# The PR is merged here, on the same terms as the daily sweep (see the +# auto-merge block below): the diff is one app's pins, and that payload was +# already unpacked with the app's own apply_extra.sh two steps up. Waiting for +# a human would only mean the app is late — the same bytes land automatically +# at 06:17 UTC anyway. +# # update-check.yml (the daily full sweep) stays the safety net: it catches # releases whose Linux asset was uploaded after the ping, and its supersede # step closes any auto/release-* PR still open once the batch covers it. @@ -19,6 +25,7 @@ on: permissions: contents: write pull-requests: write + actions: write # dispatch publish.yml after the auto-merge # Serialize per app: a re-ping for the same app (retag, workflow re-run) # queues behind the running one instead of racing the force-push. Different @@ -38,6 +45,14 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 + # update-pins validates upstream's release notes before writing them into + # the metainfo, but only if appstreamcli is there — a missing validator + # makes that guard a silent no-op. This job writes a entry from + # notes we do not control and merges it unattended, so install it. + - name: Install appstreamcli + run: | + sudo apt-get update + sudo apt-get install -y appstream # Payload values go through env, never inline ${{ }} inside run — the # Worker validates them, but they still originate outside this repo. - name: Recompute pins for the released app @@ -67,30 +82,116 @@ jobs: env: APP_ID: ${{ github.event.client_payload.app_id }} run: INSTALL_RUNTIME=1 ./scripts/check-apply-extra.sh "$APP_ID" - - name: Open / update PR if pins changed + - name: Open / update PR if pins changed, then merge it env: GH_TOKEN: ${{ github.token }} APP_ID: ${{ github.event.client_payload.app_id }} TAG: ${{ github.event.client_payload.tag }} UPSTREAM: ${{ github.event.client_payload.repo }} + # The same kill switch as the daily sweep, deliberately not a second + # variable: setting AUTO_MERGE_PINS=false stops both modes at once, + # which is what anyone reaching for it wants. + AUTO_MERGE: ${{ vars.AUTO_MERGE_PINS }} run: | if git diff --quiet -- registry/; then echo "no pin changes — asset not uploaded yet or already current; daily update-check is the fallback" exit 0 fi + # What the resolver actually rewrote, captured before the commit so + # this needs no git history (the checkout is shallow). Untracked files + # count too — `git add registry/` below would sweep them in. + touched="$(git status --porcelain --untracked-files=all -- registry/ | sed 's/^...//')" branch="auto/release-$APP_ID" git config user.name "flatpark-bot" git config user.email "bot@flatpark.org" git checkout -B "$branch" git add registry/ git commit -m "chore(update): $APP_ID $TAG (upstream release)" + head_sha="$(git rev-parse HEAD)" # to find the doomed pr-checks run after --delete-branch git push -f origin "$branch" title="Update $APP_ID to $TAG" - body="$(printf 'Upstream published release `%s` on `%s` and pinged us via flatpark/publish-action.\n\nPins recomputed for `%s` only; the resolver decides the actual version. Merging triggers a rebuild + publish of this app.' "$TAG" "${UPSTREAM:-?}" "$APP_ID")" + body="$(printf 'Upstream published release `%s` on `%s` and pinged us via flatpark/publish-action.\n\nPins recomputed for `%s` only; the resolver decides the actual version. The payload was unpacked with this app'"'"'s `apply_extra.sh` before the PR opened; merging triggers a rebuild + publish of this app.' "$TAG" "${UPSTREAM:-?}" "$APP_ID")" if [ -z "$(gh pr list --head "$branch" --state open --json number -q '.[].number')" ]; then gh pr create --base main --head "$branch" --title "$title" --body "$body" else gh pr edit "$branch" --title "$title" --body "$body" fi + new="$(gh pr list --head "$branch" --state open --json number -q '.[0].number')" + + # Auto-merge, on the daily sweep's terms: nothing here waits for a + # review, because the diff is pins only and the payload behind it was + # unpacked by "Verify the new payload unpacks" above — that check, not + # a human read of a sha256, is what actually catches a bad update. + # + # Guard: check-updates.sh `eval`s the app's `update.command`, so a + # resolver can in principle write anywhere in the tree. A pin refresh + # only ever rewrites that app's manifest and the metainfo beside it, so + # anything else in the diff means something wrote outside its lane. + # Stricter than the sweep's regex on two counts, both free here: the + # files must belong to the app that was pinged, and they must carry its + # exact names. Every app in the registry is laid out that way today; one + # that is not just stays a PR for a human, which is the right failure. + stray="" + old_ifs="$IFS"; IFS=$'\n' + for f in $touched; do + case "$f" in + "registry/$APP_ID/$APP_ID.yml"|"registry/$APP_ID/$APP_ID.metainfo.xml") ;; + *) stray="$stray$f"$'\n' ;; + esac + done + IFS="$old_ifs" + + if [ "${AUTO_MERGE:-true}" = "false" ]; then + echo "auto-merge disabled by the AUTO_MERGE_PINS repo variable; leaving #$new open" + exit 0 + fi + if [ -n "$stray" ]; then + echo "::warning title=auto-merge skipped::diff touches files outside this app's pin surface" + printf '%s' "$stray" + gh pr comment "$new" --body "$(printf 'Auto-merge skipped — this diff touches files a pin refresh for `%s` never writes:\n\n```\n%s```\n\nA resolver writing anywhere else needs a human look.' "$APP_ID" "$stray")" + exit 0 + fi + + gh pr merge "$new" --squash --delete-branch + # A push made with GITHUB_TOKEN never triggers another workflow, so + # publish.yml does NOT see this merge. workflow_dispatch is the + # documented exception to that rule, so ask for the publish here. It + # diffs from the last published sha recorded in R2, not from this push, + # so it builds exactly the app this merge changed — and publish's own + # `concurrency: publish` queues it behind any run already going, so a + # busy release day is several small publishes in a row, not a race. + gh workflow run publish.yml --ref main + echo "merged #$new and dispatched publish" + + # The `opened` event for #$new queued a pr-checks run against this + # branch. --delete-branch above removed the ref it would have checked + # out, so that run cannot start a single job — it lands as a red "This + # run likely failed because of a workflow file issue" with zero jobs. + # Everything in the merged diff was verified above and publish.yml + # rebuilds the app, so that run has nothing to add. Wait for it to + # appear (the webhook lags the merge) and delete it. Best-effort: a run + # that never appears, or a GitHub that learns to skip it, both just + # mean nothing to clean up. Needs the actions:write granted at the top. + if [ "$(gh pr view "$new" --json state -q .state)" = "MERGED" ]; then + rid="" + for _ in $(seq 1 15); do + rid="$(gh run list --workflow pr-checks.yml --event pull_request \ + --limit 20 --json databaseId,headSha \ + -q "[.[] | select(.headSha == \"$head_sha\") | .databaseId][0] // empty")" + [ -n "$rid" ] && break + sleep 6 + done + if [ -n "$rid" ]; then + for _ in $(seq 1 15); do + [ "$(gh run view "$rid" --json status -q .status 2>/dev/null)" = completed ] && break + sleep 6 + done + gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/runs/$rid" \ + && echo "deleted the orphaned pr-checks run $rid for #$new" \ + || echo "could not delete pr-checks run $rid — already gone?" + else + echo "no orphaned pr-checks run appeared for $branch; nothing to clean" + fi + fi diff --git a/docs/publish-action-launch.md b/docs/publish-action-launch.md index 84754e6..b75173b 100644 --- a/docs/publish-action-launch.md +++ b/docs/publish-action-launch.md @@ -8,10 +8,12 @@ Three components, in dependency order: 1. **Dispatch workflow** — `.github/workflows/release-dispatch.yml` fires on `repository_dispatch` (type `app-release`), recomputes pins for that one - app, and opens a focused PR on `auto/release-` — several apps - releasing the same day = several small parallel PRs. The daily - `update-check.yml` full sweep stays the safety net and supersedes any - per-app PR its batch already covers. Already in this repo; works + app, opens a focused PR on `auto/release-`, and merges it on the + same terms as the daily sweep (payload unpacked, diff confined to that + app's manifest + metainfo, `AUTO_MERGE_PINS` kill switch) — several apps + releasing the same day = several small parallel PRs, each publishing on + its own. The daily `update-check.yml` full sweep stays the safety net and + supersedes any per-app PR still open. Already in this repo; works standalone the moment it's merged. 2. **Auth bridge Worker** — `workers/release-hook/`, serves `hooks.flatpark.org/release`. Validates unauthenticated pings from @@ -38,7 +40,9 @@ gh api repos/flatpark/flatpark/dispatches \ Expected: a release-dispatch run appears in Actions, recomputes pins for that app only, and exits quietly if nothing moved (e.g. the tag is already pinned). When something did move it opens `Update to ` from -`auto/release-`. A ping for a release whose Linux asset isn't uploaded +`auto/release-`, squash-merges it and dispatches `publish.yml` for +that one app; a diff reaching past that app's `.yml` / `.metainfo.xml` +leaves the PR open with a comment instead. A ping for a release whose Linux asset isn't uploaded yet is also a quiet no-op — tomorrow's update-check cron picks it up, and its supersede step closes any per-app PR the daily batch already covers. @@ -85,7 +89,8 @@ curl -sS -d '{"app_id":"me.tyrrrz.DiscordChatExporter","tag":"no-such-tag"}' -H Optional hardening: a Cloudflare rate-limiting rule on `hooks.flatpark.org` (e.g. 10 req/min per IP). Abuse ceiling without it is just extra update-check -runs; the human-merged PR remains the gate. +runs against the app's own registered upstream; the apply_extra unpack gate +and the pin-surface guard, not a human, are what those runs have to clear. ## C. Action repository diff --git a/workers/release-hook/src/index.js b/workers/release-hook/src/index.js index f0684d4..9ca5b42 100644 --- a/workers/release-hook/src/index.js +++ b/workers/release-hook/src/index.js @@ -9,7 +9,10 @@ // the request — so a caller can't point us at an arbitrary repo, // 3. the tag must actually exist as a release on that repo. // Worst case for an abusive caller: they re-trigger the same update check the -// daily cron runs anyway, whose only output is a PR a human merges. +// daily cron runs anyway. That check auto-merges its own PR in both modes, so +// the ceiling is publishing a pin the cron would have published within a day — +// the bytes still come from the app's registered upstream, and still have to +// survive the apply_extra unpack gate on our side. const APP_ID_RE = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$/; // reverse-DNS flatpak id const TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,99}$/;