From b251708b771e4f2939adf915dfa59a1d087247d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:57:39 +0000 Subject: [PATCH 1/2] Start the release when the version-bump PR merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releasing took two deliberate acts from the maintainer that were really the same decision: merge the version-bump PR, then go dispatch `release` with the same version the PR just landed. The second one carried no new judgement — it restated the first — but it was the one step that couldn't be done from a review page, so a release stalled on someone opening the Actions tab. `release.yml` now also triggers on a push to `main` that touches `App/Blurt/project.yml`, and a new Linux `resolve` job decides whether that push is actually a release: it diffs `CFBundleShortVersionString` against the push's previous commit and checks no `vX.Y.Z` tag exists yet. A project.yml edit that adds a source file resolves to `release=false` and skips the build and publish jobs; only a bump landing on main goes through. Running first, on ubuntu, means a non-release push costs seconds and never reaches the runner holding the Developer ID key. The dispatch path stays, and is what re-runs a failed build, republishes, and does the non-main dry run — `skip_checks`, `skip_smoke`, and `republish` are dispatch-only inputs, so the merged-bump path always runs the full checks. Both jobs still pin `github.sha`, so a release is still exactly one reviewed commit, and publishing still parks on the `release-publish` approval gate. The `resolve` job supersedes the build job's version guard as the source of the version; the guard stays as a re-check on the signing runner itself, so the artifact name and the built binary can't disagree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F952B3qz1QeXYv5sPGwPh3 --- .claude/skills/release/SKILL.md | 25 +++--- .github/workflows/release-bump.yml | 8 +- .github/workflows/release.yml | 137 ++++++++++++++++++++++++++--- AGENTS.md | 18 ++-- RELEASE.md | 57 ++++++++---- 5 files changed, 203 insertions(+), 42 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 685e1da5..8503e999 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -27,13 +27,16 @@ before dispatching** — publishing is hard to undo. `scripts/release-bump.sh` on `macos-26` (marketing version + build number in `App/Blurt/project.yml`, regenerate the project, commit) and pushes `release/vX.Y.Z`. -2. **Open the PR** for that branch and merge it once green. The workflow does not - open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers `check` and - so can never merge. Opening it from here works — an agent's own credentials - are not `GITHUB_TOKEN`. -3. **Dispatch `release`** with the same version. The `build` job does the whole - Apple path (`xcodebuild` Release → sign nested code → notarize → staple → DMG - → verify) and uploads the artifacts. +2. **Open the PR** for that branch and hand it to the user to merge. The workflow + does not open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers + `check` and so can never merge. Opening it from here works — an agent's own + credentials are not `GITHUB_TOKEN`. +3. **Merging it starts `release`** — no dispatch. `release.yml` triggers on a + push to `main` touching `project.yml`; its `resolve` job confirms the version + changed and isn't already tagged, then the `build` job does the whole Apple + path (`xcodebuild` Release → sign nested code → notarize → staple → DMG → + verify) and uploads the artifacts. Dispatch `release` by hand only to re-run + a failed build, to `republish`, or for a non-`main` dry run. 4. **Hand the ship gate to the user** — the `publish` job parks on the `release-publish` environment. Tell them to download the DMG from the run's artifacts, install it, and approve once it works. Nothing is rebuilt after @@ -42,7 +45,7 @@ before dispatching** — publishing is hard to undo. **Never approve the `release-publish` deployment yourself**, even though the API allows it. The gate exists so a human confirms the real artifact reached users in -working order; approving a build you dispatched is not a gate. If the user wants +working order; approving a build you started is not a gate. If the user wants unattended releases, that is a deliberate change to the environment's reviewers, not something to route around. @@ -51,8 +54,10 @@ local orchestrator script — the workflows are the only path. ## Guardrails / gotchas -- The workflow is dispatch-only and both jobs pin `github.sha`, so a release can - only ever be the exact reviewed commit. Don't add a push/tag trigger. +- The only push trigger is `main` + `project.yml` changed, narrowed further by + `resolve` (version actually changed, no existing tag), and both jobs pin + `github.sha` — so a release can only ever be the exact reviewed commit that + carried the bump. Don't widen that trigger, and never add a tag trigger. - Notarization rejects any nested mach-o/framework lacking a **secure timestamp**; the build re-signs frameworks for this reason — don't remove that. - The signer-pin (`verify_signer`) checks the produced artifacts against a diff --git a/.github/workflows/release-bump.yml b/.github/workflows/release-bump.yml index 7b859267..ac18e559 100644 --- a/.github/workflows/release-bump.yml +++ b/.github/workflows/release-bump.yml @@ -13,6 +13,10 @@ name: release-bump # and would sit unmergeable. Opening it from outside Actions — a person, or an # agent with its own credentials — makes `check` run normally. The job summary # prints a one-click link for that. +# +# The same GITHUB_TOKEN rule applies at the other end: merging that PR is what +# starts `release`, so the merge has to come from outside Actions too. A human +# clicking Merge, auto-merge, or an agent's own credentials all qualify. on: workflow_dispatch: inputs: @@ -137,5 +141,7 @@ jobs: echo echo "[**Open the pull request**](https://github.com/$REPO/compare/main...release/v$VERSION?expand=1)" echo - echo "Then merge it and dispatch the \`release\` workflow with the same version." + echo "Merging it starts the \`release\` workflow by itself — the version landing on" + echo "\`main\` is the trigger, so there is nothing left to dispatch. Publishing then" + echo "waits on the \`release-publish\` approval once you've tested the DMG." } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e94953d..5482a995 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,19 @@ name: release # GitHub secrets (see RELEASE.md) instead of one laptop's keychain, so a release # no longer depends on who is at which desk. # -# Dispatch-only on purpose: a release must be a deliberate act against a -# specific reviewed commit of main, never a side effect of a push or a tag. -# Dispatch it after the version-bump PR from `release-bump.yml` has merged. +# Two ways in, and both are a deliberate act against one reviewed commit of +# main — never a side effect of an arbitrary push, and never a tag trigger: +# +# - Merging the version-bump PR from `release-bump.yml`. The push lands on main +# with a changed `CFBundleShortVersionString`, which is what the path filter +# and the `resolve` job below look for. Approving that PR is the deliberate +# act; it is reviewed, it ran `check`, and it is one specific commit. This is +# the normal path — it means a release needs no workflow dispatch at all. +# - A manual dispatch, for re-running a release whose build failed, for +# `republish`, and for the non-main build-only dry run. +# +# Either way the release still parks on the `release-publish` approval gate +# before anything reaches users, and both jobs pin `github.sha`. on: workflow_dispatch: inputs: @@ -27,6 +37,13 @@ on: description: "Skip the launch smoke test (use if the runner has no usable GUI session)" type: boolean default: false + push: + branches: [main] + # Narrow on purpose. project.yml is the only file a version bump touches + # that isn't generated, so this is "something about the app's version may + # have changed" — the `resolve` job then decides whether it actually did. + paths: + - App/Blurt/project.yml # One release at a time, and never cancel one in flight: a half-run that has # already notarized or tagged is worse than a queued one. @@ -38,10 +55,104 @@ permissions: contents: read jobs: + # Decide what — if anything — this run is releasing, before any macOS minutes + # or the signing key are spent. Cheap and Linux-only: it is bash over + # project.yml and the tag list. + # + # A dispatch says the version outright. A push has to be interrogated: the + # path filter fires for any project.yml edit (adding a source file, changing a + # build setting), and only a changed CFBundleShortVersionString with no tag + # behind it is a release. + resolve: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.resolve.outputs.version }} + release: ${{ steps.resolve.outputs.release }} + + steps: + - name: Checkout blurt + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: blurt + ref: ${{ github.sha }} + # Tags decide whether this version already shipped, and the push's + # previous commit is what "the version changed" is measured against — + # a shallow clone has neither. + fetch-depth: 0 + persist-credentials: false + + - name: Resolve the release version + id: resolve + working-directory: blurt + env: + EVENT: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + REPO_ROOT="$PWD" + # shellcheck disable=SC1091 # sourced at runtime from the checkout + source scripts/release-lib.sh + + version="$(require_project_version App/Blurt/project.yml)" + + if [ "$EVENT" = "workflow_dispatch" ]; then + # The dispatcher named a version; it must be the one this commit + # carries, and their say-so is the deliberate act — republish and + # re-runs of a failed build both have to work, so no tag check here. + is_semver "$INPUT_VERSION" || die "version input must be X.Y.Z (got: $INPUT_VERSION)" + [ "$version" = "$INPUT_VERSION" ] \ + || die "dispatched version $INPUT_VERSION but ${GITHUB_SHA:0:7} is at $version — dispatch the commit that carries the bump" + echo "version=$version" >>"$GITHUB_OUTPUT" + echo "release=true" >>"$GITHUB_OUTPUT" + info "dispatched: releasing v$version" + exit 0 + fi + + # --- push to main --- + skip() { + echo "release=false" >>"$GITHUB_OUTPUT" + echo "::notice::$1 — not releasing." + { echo "### No release from this push"; echo; echo "$1."; } >>"$GITHUB_STEP_SUMMARY" + exit 0 + } + + # Did the version actually change in this push? Fail open if the + # previous commit can't be resolved (a force-push, a first push): an + # extra build costs runner time, and the publish gate still holds. + prev="$BEFORE" + git cat-file -e "${prev}^{commit}" 2>/dev/null || prev="$(git rev-parse --verify --quiet 'HEAD^' || true)" + if [ -n "$prev" ]; then + prev_version="$(git show "$prev:App/Blurt/project.yml" 2>/dev/null | parse_short_version || true)" + if [ "$prev_version" = "$version" ]; then + skip "project.yml changed but the version is still $version" + fi + info "version changed: ${prev_version:-unknown} → $version" + else + echo "::notice::could not resolve the previous commit — treating $version as new." + fi + + if tag_exists_locally "v$version" || tag_exists_on_origin "v$version"; then + skip "v$version is already tagged" + fi + + echo "version=$version" >>"$GITHUB_OUTPUT" + echo "release=true" >>"$GITHUB_OUTPUT" + info "bump merged: releasing v$version from $GITHUB_SHA" + { + echo "### Releasing v$version" + echo + echo "Triggered by the version bump landing on \`main\`. The build job signs and" + echo "notarizes; publishing then waits on the \`release-publish\` approval." + } >>"$GITHUB_STEP_SUMMARY" + # Build + sign + notarize + staple + DMG. Produces the exact artifacts the # publish job uploads — nothing is rebuilt downstream, so what a human tests # from this run's artifacts is byte-for-byte what ships. build: + needs: resolve + if: needs.resolve.outputs.release == 'true' runs-on: macos-26 timeout-minutes: 120 # Holds the signing + notary secrets. Restrict this environment to the @@ -60,18 +171,21 @@ jobs: ref: ${{ github.sha }} persist-credentials: false - - name: Guard the dispatched version + - name: Guard the release version working-directory: blurt + # `resolve` already established this, on its own checkout. Re-checking it + # here on the runner that does the signing keeps the artifact name and + # the built binary provably the same version. env: - WANT_VERSION: ${{ inputs.version }} + WANT_VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail # shellcheck disable=SC1091 # sourced at runtime from the checkout source scripts/release-lib.sh GOT_VERSION="$(require_project_version App/Blurt/project.yml)" - is_semver "$WANT_VERSION" || die "version input must be X.Y.Z (got: $WANT_VERSION)" + is_semver "$WANT_VERSION" || die "resolved version must be X.Y.Z (got: $WANT_VERSION)" [ "$GOT_VERSION" = "$WANT_VERSION" ] \ - || die "dispatched version $WANT_VERSION but ${GITHUB_SHA:0:7} is at $GOT_VERSION — dispatch the commit that carries the bump" + || die "releasing $WANT_VERSION but ${GITHUB_SHA:0:7} is at $GOT_VERSION — release the commit that carries the bump" info "releasing v$GOT_VERSION from $GITHUB_SHA" - name: Check the signing secrets are configured @@ -128,6 +242,9 @@ jobs: BLURT_NOTARY_KEY_P8_BASE64: ${{ secrets.NOTARY_KEY_P8_BASE64 }} BLURT_NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} BLURT_NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} + # Both are dispatch-only escape hatches, so both are empty (and the + # full checks run) on the merged-bump path. That is the right default: + # nobody is standing there to judge whether skipping was safe. SKIP_CHECKS: ${{ inputs.skip_checks }} SKIP_SMOKE: ${{ inputs.skip_smoke }} run: | @@ -140,7 +257,7 @@ jobs: - name: Upload release artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: release-${{ inputs.version }} + name: release-${{ needs.resolve.outputs.version }} # Explicit paths, not the whole build/release tree: derived/ and # stage/ are multi-GB build scratch that nothing downstream reads. path: | @@ -158,7 +275,7 @@ jobs: # this job parks until a human has downloaded the DMG from the build job's # artifacts, installed it, and confirmed it works. publish: - needs: build + needs: [resolve, build] # Releases ship from main. A dispatch from any other ref stops after the # build job, which makes this workflow safe to exercise as a dry run. if: github.ref == 'refs/heads/main' @@ -183,7 +300,7 @@ jobs: - name: Download release artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: release-${{ inputs.version }} + name: release-${{ needs.resolve.outputs.version }} path: blurt/build/release - name: Identify the tagger diff --git a/AGENTS.md b/AGENTS.md index 4187b372..cc3f60cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -729,15 +729,21 @@ matches `project.yml`. Releases run entirely in GitHub Actions, so they can be driven from a browser or a chat session with no terminal: dispatch `release-bump` with the target version (it bumps `project.yml`, regenerates the -project on `macos-26`, and pushes `release/vX.Y.Z`), open and merge that PR, then dispatch `release`. -The bump workflow deliberately does not open the PR — a PR created by `GITHUB_TOKEN` never triggers -`check`, so it could never merge; opening it from outside Actions works fine. The publish job then -parks on the `release-publish` environment until a human approves — that approval is the ship gate, -so download and test the DMG from the build job's artifacts first. Nothing is rebuilt after approval. +project on `macos-26`, and pushes `release/vX.Y.Z`), then open and merge that PR. **Merging it starts +`release`** — `release.yml` also triggers on a push to `main` that changes `project.yml`, and its +`resolve` job releases only when the version actually changed and no `vX.Y.Z` tag exists yet, so a +project.yml edit that isn't a bump skips the build. Dispatching `release` by hand still works and is +what re-runs a failed build, `republish`, and the non-`main` dry run. The bump workflow deliberately +does not open the PR — a PR created by `GITHUB_TOKEN` never triggers `check`, so it could never +merge; opening it from outside Actions works fine, and the same rule applies to the merge (an Actions +merge with `GITHUB_TOKEN` raises no `push` event, so it would not start `release`). The publish job +then parks on the `release-publish` environment until a human approves — that approval is the ship +gate, so download and test the DMG from the build job's artifacts first. Nothing is rebuilt after +approval. **If you are an agent: never approve that deployment yourself**, even though the API allows it and you may have the permission. The gate exists so a person confirms the real artifact works before it -reaches users; approving a build you dispatched is not a gate. This rule is repeated in +reaches users; approving a build you started is not a gate. This rule is repeated in `.claude/skills/release` and lives here too because that skill is `disable-model-invocation: true` — it does _not_ load when someone just says "release", which is exactly when the rule matters. Dispatching `release-bump` with an empty version takes the next patch, resolved by `default_target` / diff --git a/RELEASE.md b/RELEASE.md index 57053237..9ec21ee7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,18 +1,18 @@ # Release runbook Blurt is built, signed, notarized, and published by GitHub Actions, not from a -maintainer's Mac. Every step is a workflow dispatch or a web action, so a release -can be driven from a browser, a phone, or a chat session with no terminal +maintainer's Mac. Every step is a workflow run or a click in the GitHub UI, so a +release can be driven from a browser, a phone, or a chat session with no terminal anywhere in the loop. This file covers the security-critical custody and policy decisions that aren't obvious from the scripts and the workflows. ## Shape of a release -| Stage | Where | Gate | -| ----------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | -| Bump `CFBundleShortVersionString` + `CFBundleVersion` | `release-bump` workflow on `macos-26` (`scripts/release-bump.sh`) | Lands on `main` via PR: normal review + the `check` workflow | -| Build → sign → notarize → staple → DMG | `release` workflow, `build` job (`scripts/release-build.sh`) | Signer-pin, Gatekeeper assessment, mount-and-verify (all in-script) | -| Tag, push, publish the GitHub Release | `release` workflow, `publish` job (`scripts/release-publish.sh`) | **Required reviewer on the `release-publish` environment** | +| Stage | Where | Gate | +| ----------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Bump `CFBundleShortVersionString` + `CFBundleVersion` | `release-bump` workflow on `macos-26` (`scripts/release-bump.sh`) | Lands on `main` via PR: normal review + the `check` workflow — merging it starts the release | +| Build → sign → notarize → staple → DMG | `release` workflow, `build` job (`scripts/release-build.sh`) | Signer-pin, Gatekeeper assessment, mount-and-verify (all in-script) | +| Tag, push, publish the GitHub Release | `release` workflow, `publish` job (`scripts/release-publish.sh`) | **Required reviewer on the `release-publish` environment** | Start to finish: @@ -22,14 +22,41 @@ Start to finish: workflows, so a PR opened from inside Actions would never get a `check` run and could never merge. The job summary links a one-click compare page. 2. **Open that PR and merge it.** Opened from outside Actions — by a person or - by an agent with its own credentials — `check` runs normally. -3. **Dispatch `release`** with the same version. -4. **Approve the `release-publish` deployment** once you've tested the DMG. - -The `release` workflow is **dispatch-only**: a release is a deliberate act -against one reviewed commit, never a side effect of a push or a tag. Both jobs -check out `github.sha` — the exact commit the workflow was dispatched at — so the -tag cannot land on a commit other than the one that was built. + by an agent with its own credentials — `check` runs normally. **Merging it + starts `release`**; there is nothing to dispatch. +3. **Approve the `release-publish` deployment** once you've tested the DMG. + +So a release is two clicks on the maintainer's side: merge the bump PR, approve +the ship gate. Everything between them is the workflow's. + +### What starts a release + +A release is a deliberate act against one reviewed commit — never a side effect +of an arbitrary push, and never a tag trigger. Two things qualify: + +- **The version-bump PR merging into `main`.** The push carries a changed + `CFBundleShortVersionString`, which is what `release.yml`'s path filter and its + `resolve` job look for. That merge is the deliberate act: the commit is + reviewed, it passed `check`, and it is one specific sha. +- **A manual dispatch**, for re-running a release whose build failed, for + `republish`, and for the non-`main` build-only dry run. + +`resolve` runs first, on Linux, before any macOS minutes or the signing key are +spent. On a push it releases only when the version actually changed (it diffs +`project.yml` against the push's previous commit) **and** no `vX.Y.Z` tag exists +yet; otherwise the run ends with the build and publish jobs skipped. So a +project.yml edit that adds a source file doesn't build a release, and a re-merge +after a shipped version doesn't republish one. Both jobs still check out +`github.sha`, so the tag cannot land on a commit other than the one built. + +The merged-bump path never passes `skip_checks`, `skip_smoke`, or `republish` — +those are dispatch-only, because nobody is standing there to judge whether +skipping was safe. + +One `GITHUB_TOKEN` caveat, the mirror of the one that keeps `release-bump` from +opening the PR: a merge performed **by** Actions with `GITHUB_TOKEN` doesn't +raise a `push` event, so it wouldn't start `release` either. A person clicking +Merge, GitHub's auto-merge, or an agent using its own credentials all work. Leave the version input empty and `release-bump` takes the next patch itself, using the same `default_target` / `decide_run` rules the release scripts have From 93f869de4b4b461133237137409d332e0ba6a5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 05:05:12 +0000 Subject: [PATCH 2/2] Start release-bump by pushing a marker branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last dispatch in the release flow was the first one. Dispatching a workflow needs the Actions UI or `actions: write`; a chat or web session typically has neither, but does have push. So the one step that could kick a release off was the one step an agent couldn't do, and every release still began with the maintainer opening the Actions tab. `release-bump.yml` now also triggers on a push of `release/v[0-9]*`. The branch name is the request — `release/v0.1.37` asks for 0.1.37, with no default_target guessing, because a marker that named no version would be asking the workflow to guess what its own name meant. The job checks out main, verifies the marker is an ancestor of main, bumps on top of main's tip, and force-pushes the result onto the same branch with the lease pinned to the sha it vetted. The ancestor check is the load-bearing one. Without it, pushing a branch would be a way to get a bot-authored commit sitting on top of arbitrary content, and the PR that followed would quietly be about more than a version bump. With it, the marker is a signal and the bump is the only commit that lands on it. This leans on the GITHUB_TOKEN rule in the opposite direction from the rest of the release path: the bump commit the job force-pushes does not re-fire the push trigger that started it, which is what keeps it from looping. Noted in the workflow, RELEASE.md, and the skill's guardrails, since moving this job to a PAT would silently turn it into a loop. Dispatch still works, still guards that it came from main, and remains the only path that accepts an empty version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F952B3qz1QeXYv5sPGwPh3 --- .claude/skills/release/SKILL.md | 38 ++++++++---- .github/workflows/release-bump.yml | 95 +++++++++++++++++++++++++----- AGENTS.md | 11 +++- RELEASE.md | 41 +++++++++++-- 4 files changed, 153 insertions(+), 32 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 8503e999..3c26f910 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -7,8 +7,9 @@ disable-model-invocation: true # Releasing Blurt Everything happens in GitHub Actions, not on this machine — so this works from a -web or chat session with no terminal. **Confirm the target version with the user -before dispatching** — publishing is hard to undo. +web or chat session with no terminal, and without permission to dispatch a +workflow. **Confirm the target version with the user before starting** — +publishing is hard to undo. ## Preconditions (verify first) @@ -23,10 +24,21 @@ before dispatching** — publishing is hard to undo. ## Steps -1. **Dispatch `release-bump`** with the target version. It runs +1. **Start `release-bump`.** You almost certainly can't dispatch a workflow (that + needs `actions: write`), so push a marker branch instead — it needs only the + push you already do: + + ```sh + git push origin main:refs/heads/release/v0.1.37 + ``` + + The branch name names the version, and it must carry nothing of its own: the + workflow refuses a marker that isn't an ancestor of `main`. It then runs `scripts/release-bump.sh` on `macos-26` (marketing version + build number in - `App/Blurt/project.yml`, regenerate the project, commit) and pushes - `release/vX.Y.Z`. + `App/Blurt/project.yml`, regenerate the project, commit) and force-pushes the + bump onto that same branch. Dispatching still works if you do have the + permission, and only that path accepts an empty version (next patch). + 2. **Open the PR** for that branch and hand it to the user to merge. The workflow does not open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers `check` and so can never merge. Opening it from here works — an agent's own @@ -49,15 +61,18 @@ working order; approving a build you started is not a gate. If the user wants unattended releases, that is a deliberate change to the environment's reviewers, not something to route around. -Dispatching `release-bump` with no version takes the next patch. There is no -local orchestrator script — the workflows are the only path. +There is no local orchestrator script — the workflows are the only path. ## Guardrails / gotchas -- The only push trigger is `main` + `project.yml` changed, narrowed further by - `resolve` (version actually changed, no existing tag), and both jobs pin - `github.sha` — so a release can only ever be the exact reviewed commit that +- `release.yml`'s only push trigger is `main` + `project.yml` changed, narrowed + further by `resolve` (version actually changed, no existing tag), and both jobs + pin `github.sha` — so a release can only ever be the exact reviewed commit that carried the bump. Don't widen that trigger, and never add a tag trigger. +- `release-bump.yml`'s marker trigger is safe only because of its ancestor check + (the branch carries nothing of its own) and because a `GITHUB_TOKEN` push + doesn't re-fire the trigger. Don't drop either, and don't move that job to a + PAT or app token without adding a loop guard. - Notarization rejects any nested mach-o/framework lacking a **secure timestamp**; the build re-signs frameworks for this reason — don't remove that. - The signer-pin (`verify_signer`) checks the produced artifacts against a @@ -73,4 +88,5 @@ local orchestrator script — the workflows are the only path. ref — with the environment restricted to `main`, there is no branch dry run. Read the script or workflow you're about to run before running it, surface what -it will do, and get a go-ahead before dispatching. +it will do, and get a go-ahead before starting it. Pushing a marker branch is as +consequential as dispatching was — it starts the same pipeline. diff --git a/.github/workflows/release-bump.yml b/.github/workflows/release-bump.yml index ac18e559..3ab9dcf7 100644 --- a/.github/workflows/release-bump.yml +++ b/.github/workflows/release-bump.yml @@ -17,6 +17,23 @@ name: release-bump # The same GITHUB_TOKEN rule applies at the other end: merging that PR is what # starts `release`, so the merge has to come from outside Actions too. A human # clicking Merge, auto-merge, or an agent's own credentials all qualify. +# +# Two ways in: +# +# - **Push a marker branch** `release/vX.Y.Z` pointing at `main`, carrying no +# commits of its own. The branch name is the request — it names the version — +# and pushing a branch is something anyone with repo write can do from a web +# or chat session, unlike dispatching a workflow. This is what lets an agent +# start a release end to end, leaving the maintainer nothing but the two +# approvals (merge the PR, approve the ship gate). +# - **Dispatch it**, which additionally accepts an empty version and takes the +# next patch itself. +# +# The marker-branch path relies on the same GITHUB_TOKEN rule as everything else +# here, in the opposite direction: the bump commit this job force-pushes onto +# `release/vX.Y.Z` does NOT re-trigger the push event that started it. That is +# what keeps this from looping. Don't switch this job to a PAT or an app token +# without adding a loop guard. on: workflow_dispatch: inputs: @@ -24,6 +41,11 @@ on: description: "Version to bump to (X.Y.Z). Leave empty to take the next patch." required: false type: string + push: + branches: + # The digit keeps a typo'd branch from spending a macOS runner just to + # fail the semver check; that check is still the authority. + - "release/v[0-9]*" # One bump at a time; never cancel one mid-push. concurrency: @@ -47,7 +69,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: blurt - ref: ${{ github.sha }} + # `main`, not `github.sha`: a bump is always computed against main's + # current version, and on the marker-branch path `github.sha` is the + # marker, which is a signal rather than the base to bump from. + ref: main # Tags are what release-bump.sh checks the target version against, and # a shallow clone has none — it would miss an existing vX.Y.Z. fetch-depth: 0 @@ -57,11 +82,27 @@ jobs: - name: Guard the dispatch ref # Versions are bumped from main; a bump computed against any other ref # would be measured from the wrong current version. - if: github.ref != 'refs/heads/main' + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' run: | echo "::error::release-bump must be dispatched from main (got ${GITHUB_REF})" exit 1 + - name: Guard the marker branch + # The marker must carry nothing of its own. Otherwise pushing a branch + # would be a way to get a bot-authored commit sitting on top of + # arbitrary content, and the PR that follows would quietly be about + # more than a version bump. + if: github.event_name == 'push' + working-directory: blurt + run: | + set -euo pipefail + # HEAD is main, from the checkout above. + if ! git merge-base --is-ancestor "$GITHUB_SHA" HEAD; then + echo "::error::$GITHUB_REF_NAME carries commits that are not on main. Push it at main's tip with nothing of its own. (If it already has the bump commit, this job already ran — open the pull request instead.)" + exit 1 + fi + echo "marker $GITHUB_REF_NAME is clean (an ancestor of main)" + - name: Resolve the target version id: target working-directory: blurt @@ -69,7 +110,9 @@ jobs: # than minutes. release-bump.sh re-checks all of this; these are the # same rules applied early enough to be useful. env: + EVENT: ${{ github.event_name }} INPUT_VERSION: ${{ inputs.version }} + BRANCH: ${{ github.ref_name }} run: | set -euo pipefail REPO_ROOT="$PWD" @@ -77,25 +120,33 @@ jobs: source scripts/release-lib.sh main_v="$(require_project_version App/Blurt/project.yml)" - version="${INPUT_VERSION:-}" - if [ -z "$version" ]; then - version="$(default_target "$main_v" "$(latest_release_tag)")" \ - || die "could not derive a default target from main ($main_v)" - info "no version given — defaulting to $version" + if [ "$EVENT" = "push" ]; then + # The branch name is the request. No default_target on this path: + # a marker branch that named no version would be asking the + # workflow to guess what its own name meant. + version="${BRANCH#release/v}" + info "marker branch $BRANCH asks for $version" + else + version="${INPUT_VERSION:-}" + if [ -z "$version" ]; then + version="$(default_target "$main_v" "$(latest_release_tag)")" \ + || die "could not derive a default target from main ($main_v)" + info "no version given — defaulting to $version" + fi fi is_semver "$version" || die "version must be X.Y.Z (got: $version)" run="$(decide_run "$main_v" "$version")" \ || die "target $version is behind main ($main_v)" [ "$run" = "bump" ] \ - || die "main is already at $version and it has not been released — dispatch the 'release' workflow instead of bumping again" + || die "main is already at $version and it has not been released — that bump already landed, so the release has already started from it; check the 'release' workflow's runs rather than bumping again" # An abandoned release leaves a tag with no release behind it, which # burns that version. default_target takes the next patch rather than # the next unused one, deliberately: stopping here is better than # silently renumbering the release someone asked for. if tag_exists_on_origin "v$version"; then - die "tag v$version already exists on origin — that version is burned; dispatch again with an explicit higher version" + die "tag v$version already exists on origin — that version is burned; start again at an explicit higher version" fi echo "version=$version" >>"$GITHUB_OUTPUT" @@ -114,19 +165,35 @@ jobs: - name: Bump the version working-directory: blurt env: + EVENT: ${{ github.event_name }} VERSION: ${{ steps.target.outputs.version }} run: | set -euo pipefail branch="release/v$VERSION" - if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + + if [ "$EVENT" = "workflow_dispatch" ] \ + && git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then echo "::error::branch $branch already exists on origin — delete it or pick another version" exit 1 fi - git checkout -b "$branch" + + # -B, not -b: on the marker path the branch already exists on origin, + # and this puts the bump on top of main's tip rather than on top of + # wherever the marker was pointing. + git checkout -B "$branch" # Unmodified: it owns the semver / greater-than / tag-collision guards, # the project.yml edit, the xcodegen regeneration, and the commit. scripts/release-bump.sh "$VERSION" - git push -u origin "$branch" + + if [ "$EVENT" = "push" ]; then + # The marker is at or behind main, so adding a commit on main's tip + # is not always a fast-forward. The lease is pinned to the exact sha + # the guard above vetted, so this can only ever overwrite the marker + # that started this run — never a commit that arrived since. + git push --force-with-lease="$branch:$GITHUB_SHA" origin "HEAD:refs/heads/$branch" + else + git push -u origin "$branch" + fi - name: Summarize env: @@ -136,8 +203,8 @@ jobs: { echo "### Bumped to v$VERSION" echo - echo "Branch \`release/v$VERSION\` pushed. It has no pull request yet —" - echo "one opened from inside Actions would never run \`check\`." + echo "Branch \`release/v$VERSION\` now carries the bump commit. It has no pull" + echo "request yet — one opened from inside Actions would never run \`check\`." echo echo "[**Open the pull request**](https://github.com/$REPO/compare/main...release/v$VERSION?expand=1)" echo diff --git a/AGENTS.md b/AGENTS.md index cc3f60cd..fff3d019 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -728,8 +728,12 @@ matches `project.yml`. ## Releasing Releases run entirely in GitHub Actions, so they can be driven from a browser or a chat session with -no terminal: dispatch `release-bump` with the target version (it bumps `project.yml`, regenerates the -project on `macos-26`, and pushes `release/vX.Y.Z`), then open and merge that PR. **Merging it starts +no terminal, and without permission to dispatch a workflow. Start `release-bump` by pushing a marker +branch at main — `git push origin main:refs/heads/release/v0.1.37`, which names the version and +carries nothing else — or by dispatching it (that path also takes an empty version and derives the +next patch). It bumps `project.yml`, regenerates the project on `macos-26`, and leaves the bump on +`release/vX.Y.Z`; on the marker path it first checks the branch is an ancestor of `main`, so pushing +a branch can't smuggle content under a bot-authored commit. Then open and merge that PR. **Merging it starts `release`** — `release.yml` also triggers on a push to `main` that changes `project.yml`, and its `resolve` job releases only when the version actually changed and no `vX.Y.Z` tag exists yet, so a project.yml edit that isn't a bump skips the build. Dispatching `release` by hand still works and is @@ -748,7 +752,8 @@ reaches users; approving a build you started is not a gate. This rule is repeate it does _not_ load when someone just says "release", which is exactly when the rule matters. Dispatching `release-bump` with an empty version takes the next patch, resolved by `default_target` / `decide_run` in `scripts/release-lib.sh` (unit-tested by `release.test.sh`, so it is verifiable off a -Mac). There is no local orchestrator script — the workflows are the only path, so there is nothing to +Mac); the marker-branch path has no such default, because a branch that named no version would be +asking the workflow to guess what its own name meant. There is no local orchestrator script — the workflows are the only path, so there is nothing to drift out of sync with them. Signing-key custody (a base64 `.p12` secret imported into an ephemeral keychain, never a persistent diff --git a/RELEASE.md b/RELEASE.md index 9ec21ee7..d65dfb1e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -16,9 +16,10 @@ decisions that aren't obvious from the scripts and the workflows. Start to finish: -1. **Dispatch `release-bump`** with the target version. It bumps `project.yml`, - regenerates the project, and pushes `release/vX.Y.Z`. It deliberately does - **not** open the PR — events created by `GITHUB_TOKEN` don't trigger +1. **Start `release-bump`**, either by pushing a marker branch `release/vX.Y.Z` + at `main` or by dispatching the workflow. It bumps `project.yml`, regenerates + the project, and leaves the bump commit on `release/vX.Y.Z`. It deliberately + does **not** open the PR — events created by `GITHUB_TOKEN` don't trigger workflows, so a PR opened from inside Actions would never get a `check` run and could never merge. The job summary links a one-click compare page. 2. **Open that PR and merge it.** Opened from outside Actions — by a person or @@ -27,7 +28,39 @@ Start to finish: 3. **Approve the `release-publish` deployment** once you've tested the DMG. So a release is two clicks on the maintainer's side: merge the bump PR, approve -the ship gate. Everything between them is the workflow's. +the ship gate. Everything between them is the workflow's, and step 1 needs no +Actions permission — which is what lets an agent start the whole thing. + +### Starting a bump without a dispatch + +Dispatching a workflow needs the Actions UI or an API token with `actions: +write`. Pushing a branch doesn't, and a chat or web session generally has the +latter and not the former. So `release-bump` also triggers on a push of +`release/v[0-9]*`: + +```sh +git push origin main:refs/heads/release/v0.1.37 # names the version; carries nothing +``` + +The branch name **is** the request — it names the version, and there is no +`default_target` guessing on this path. The workflow then checks out `main`, +verifies the marker is an **ancestor of `main`** (so it carries no commits of +its own), bumps on top of `main`'s tip, and force-pushes the result onto the +same branch, with the lease pinned to the exact sha it vetted. + +That ancestor check is the security-relevant one. Without it, pushing a branch +would be a way to get a bot-authored commit sitting on top of arbitrary content, +and the PR that followed would quietly be about more than a version bump. With +it, the marker is a signal and the only commit that ends up on the branch is the +bump. + +This path leans on the `GITHUB_TOKEN` rule in the opposite direction from +everywhere else here: the bump commit the job force-pushes does **not** re-fire +the push trigger that started it, which is what keeps it from looping. Don't +move this job to a PAT or an app token without adding a loop guard. + +Re-pushing a marker that already carries the bump commit fails the ancestor +check with a message saying so — the job already ran; open the PR. ### What starts a release