diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 685e1da5..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,17 +24,31 @@ 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`. -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. + `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 + 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,17 +57,22 @@ 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. -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 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. +- `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 @@ -68,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 7b859267..3ab9dcf7 100644 --- a/.github/workflows/release-bump.yml +++ b/.github/workflows/release-bump.yml @@ -13,6 +13,27 @@ 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. +# +# 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: @@ -20,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: @@ -43,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 @@ -53,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 @@ -65,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" @@ -73,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" @@ -110,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: @@ -132,10 +203,12 @@ 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 - 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..fff3d019 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -728,21 +728,32 @@ 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`), 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. +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 +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` / `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 57053237..d65dfb1e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,35 +1,95 @@ # 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: -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 - 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, 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 + +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