Cut a mecatl release
A release is a vX.Y.Z git tag. Pushing that tag triggers .github/workflows/release.yml,
which does two things. It builds, signs, and attests the mecated, mecatui, mecak8s, and
slack-bot images plus the mecak8s Helm chart to GHCR (jobs publish, publish-mecatui,
publish-mecak8s, publish-slack-bot, publish-helm-chart). It also publishes a GitHub
Release carrying darwin/linux x amd64/arm64 archives, a checksums.txt, cosign
bundles, SBOMs, and build provenance — and pushes a mecatl formula bump to the public
stacklok/homebrew-tap repository, which is what makes brew install stacklok/tap/mecatl
resolve (job publish-cli). There is no version baked into the Go code — the tag IS the
release.
Two consequences of the Homebrew half, before you start:
- A published tag is immutable in practice. The formula records the release archives'
SHA256 checksums for a specific tag. Deleting and re-pushing a
vX.Y.Z that already produced
a release and a tap commit leaves the tap pointing at checksums that no longer match, which
breaks brew install for everyone. If a release goes wrong after the tap commit lands,
fix forward with the next patch version. Re-tagging is only an option when the run failed
before publishing anything.
- Verify a release build BEFORE tagging, not by pushing a throwaway tag — a pushed tag is a
public release and a tap commit.
task release:snapshot && task release:verify builds the
archives and the formula locally, with no tag, no upload and no tokens.
Nothing pushes a commit to main. The release runs through an ordinary pull request:
you dispatch a workflow, a bot opens the PR, a human merges it, and a bot tags the merge
commit. You never run git push origin main, and you never create the tag by hand.
VERSION (repo root, bare semver — 0.0.34, not v0.0.34) is the single authored source
of the release version, and the only file a release changes. The release PR is a one-line diff.
It did not used to be. mecatequi-reusable.yml referenced its three sibling composite actions
by a hardcoded @vX.Y.Z literal, so every release had to bump those pins in the same tagged
commit or ship version skew. That self-reference — a file naming a tag that does not exist yet —
is why a release needed a commit on main at all. The pins are now $/ self-repository refs,
which resolve to this repo at the exact ref the workflow is running from, so there is nothing
left to bump and skew is impossible rather than merely policed.
Steps
Run from the repo root.
Confirm what you're shipping. The release tags whatever is on main when the release PR
merges. Review what has landed since the last tag:
git tag --sort=-v:refname --list 'v*' | head -1 # e.g. v0.0.33
git log <last-tag>..origin/main --oneline
Pick the bump type from that: patch for fixes, minor for additive behavior, major for
a break. Releases so far have all been patch.
Dispatch the release-PR workflow. This is the only step that starts a release:
gh workflow run create-release-pr.yml -f bump_type=patch
gh run watch "$(gh run list --workflow=create-release-pr.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
It bumps VERSION and the three Mecatequi pins, opens Release vX.Y.Z from branch
release/vX.Y.Z, and then asserts the diff shape — exactly VERSION plus three changed
lines in mecatequi-reusable.yml. If that verification step fails, do not merge the PR;
close it, delete the branch, and read the job log. The likely cause is a reordered step in
mecatequi-reusable.yml (see the RELEASE-PINNED comments there).
Review the release PR like any other PR and confirm the diff is only the version bump:
gh pr list --head "release/vX.Y.Z" --json number,url,files
gh pr diff <number>
Wait for CI to go green. The PR is opened by the release GitHub App, so it triggers checks
normally.
Squash-merge it. A human does this — it is the approval gate, and it is the only way
VERSION changes on main:
gh pr merge <number> --squash
The tagging workflow does not read the commit subject — it asks GitHub which PR produced
the commit and requires a merged, bot-opened PR from branch release/vX.Y.Z whose diff is
only VERSION plus the three pins. So the squash title does not matter, but adding anything
else to the release PR will stop the tag.
Watch the tag get created. Merging fires create-release-tag.yml, which re-verifies the
commit and pushes the annotated tag. That push fires release.yml on its own — the tag is
pushed by a GitHub App installation token precisely so the cascade happens, where a
GITHUB_TOKEN-pushed tag would trigger nothing:
gh run watch "$(gh run list --workflow=create-release-tag.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
git fetch --tags && git tag --sort=-v:refname --list 'v*' | head -1
Confirm the release run started, then wait for it. The archive/Homebrew job is the one
that reaches outside this repository, so it is the one to watch:
gh run list --workflow=release.yml --limit 3
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
Verify the GitHub Release carries every artifact. It must not be a draft, and it must
have four archives plus a checksum file, with a cosign bundle and an SBOM alongside each:
gh release view vX.Y.Z --json isDraft,assets --jq '{draft: .isDraft, assets: [.assets[].name]}'
Then prove one archive is actually usable rather than trusting the asset list. Use the
repo-local .scratch/ dir, never /tmp (AGENTS.md):
mkdir -p .scratch/release-vX.Y.Z
gh release download vX.Y.Z -p 'checksums.txt' -p '*darwin_arm64*' -D .scratch/release-vX.Y.Z
(cd .scratch/release-vX.Y.Z \
&& shasum -a 256 -c checksums.txt --ignore-missing \
&& tar -xzf mecatl_*_darwin_arm64.tar.gz \
&& ./mecatui --version && ./mecated --version)
Both --version lines must print the tag you just cut. A dev+<revision> output means the
release build lost its BUILD_ID linker stamp — a release bug, not a cosmetic one, because
the public install docs claim a released binary reports its tag.
If the run died between "release created" and "assets uploaded" it leaves a DRAFT, which
a lookup by tag does not return, so a naive re-run fails trying to create the release again.
Recover with gh release delete vX.Y.Z --cleanup-tag=false --yes, then re-dispatch.
Verify the Homebrew tap got the formula bump:
gh api repos/stacklok/homebrew-tap/commits --jq '.[0].commit.message'
gh api repos/stacklok/homebrew-tap/contents/Formula/mecatl.rb --jq '.content' \
| base64 -d | grep -E 'version|url|sha256' | head
The top commit must name the version you just cut, and the formula's url and sha256
values must match the release assets from step 7.
While stacklok/mecatl is private, brew install stacklok/tap/mecatl fails even after a
correct tap commit: Homebrew's downloader does not authenticate, so it cannot fetch a release
archive from a private repository. The tap commit landing is the whole verification until the
repository goes public; this is known and accepted. Once it is public, run the real
end-to-end check once:
brew update && brew install stacklok/tap/mecatl && mecatui --version
The engine module is tagged separately
The vX.Y.Z release above is the root repo / mecated image release. The importable
core, github.com/stacklok/mecatl/engine, is its own Go module (ADR 0036) with its own
tag grammar engine/vX.Y.Z (distinct from the root tags). It carries a public-API
compatibility contract (engine/COMPATIBILITY.md, ADR 0037).
- The first
engine/vX.Y.Z tag is engine/v0.0.1 — a deliberate "earliest, no stability
promise" initial cut (the lowest pre-v1 patch, signalling zero stability commitment for the very
first published surface). Cutting it is a deliberate maintainer decision (deferred per ADR 0037) —
do NOT cut it as part of a routine root release unless asked. The grammar is engine/vX.Y.Z,
distinct from the root vX.Y.Z tags; the two version lines are independent. SUBSEQUENT bumps
follow engine/COMPATIBILITY.md (pre-v1: minor = additive, patch = fixes).
Cutting an engine tag (mirrors the root flow)
Run from the repo root.
Pick the engine version. First cut = engine/v0.0.1 (a deliberate "earliest, no stability
promise" initial cut); thereafter increment per semver, classified per engine/COMPATIBILITY.md
(pre-v1: Added = minor, Changed/Removed = minor too; patch = fixes). The latest engine tag (none
yet on the first cut):
git tag --sort=-v:refname --list 'engine/v*' | head -1
Pre-flight. Confirm engine/CHANGELOG.md has an [Unreleased] entry covering everything
since the last engine tag (on the first cut that is the whole initial surface — the existing
[Unreleased] baseline section). Then run the advisory gorelease check:
task api:release-check
On the FIRST cut this is a no-op / uninformative: gorelease can only classify the surface
against a prior engine/vX.Y.Z base tag, and none exists yet — so it has nothing to compare
to. That is expected. The authoritative guard is the api-compat gate (task api:check), which
already guarantees the committed engine/api/*.txt snapshots match the surface being tagged.
Create the annotated tag with a concise summary:
git tag -a engine/vX.Y.Z -m "engine/vX.Y.Z — <one-line summary>"
Push the tag:
git push origin engine/vX.Y.Z
This line is deliberately still manual. An engine tag adds no commit to main and carries
no pin bump, so it never needed the release-PR flow the root vX.Y.Z line uses — pushing the
tag is the whole release.
IMPORTANT — an engine tag fires NO image build, NO GitHub Release, and NO Homebrew formula bump. release.yml triggers on v* (the root tag
glob), which does not match engine/v*, so cutting an engine tag runs none of the ko build /
cosign / SBOM / SLSA pipeline. It only publishes the module version, making it resolvable for
go get github.com/stacklok/mecatl/engine@engine/vX.Y.Z consumers (ADR 0036/0037). There is no pin
bump and no release.yml run to confirm — the push of the tag is the whole release.
Notes
- Two publishing destinations, one tag. A run can succeed on the GHCR images and still fail
on the release or the tap (or vice versa). GoReleaser's brew pipe continues on error and the
Release is created before the formula is pushed, so a bad tap token loses the formula but NOT
the Release. Steps 7 and 8 are not optional: a green
gh run list line is not proof that
brew install works.
- Never hand-edit
stacklok/homebrew-tap. The formula is generated from the tag by the
release workflow and carries a DO NOT EDIT header. A manual edit is overwritten by the next
release and desynchronizes the checksums in the meantime.
- Never push to
main, and never create a root vX.Y.Z tag by hand. Both are the
workflows' job. A hand-pushed pin bump skips code review, and a hand-created tag would point
at a commit whose pins the release gate then rejects. If VERSION is edited on main outside
a release PR, create-release-tag.yml refuses to tag it rather than cutting a release from
it. release.yml's guard job additionally refuses to publish anything from a tag that is
not an ancestor of main, so a tag cut on a branch builds nothing. (This applies to the ROOT
v* line only — the engine/v* tags below are still cut by hand, deliberately: they carry
no pin bump and add no commit to main.)
- Annotated tags only (
git tag -a), matching prior releases — they carry a tagger + message.
create-release-tag.yml does this; the tagger is github-actions[bot].
- Don't bump illustrative documentation refs unless asked — the
@vX.Y.Z examples in
user-docs/building/deployment/mecatequi.md are illustrative and do NOT gate the release. The
release flow deliberately leaves them alone.
- If the release run fails on the pin gate, the tagged commit didn't carry the bumped pins.
That should be impossible through the normal flow —
create-release-pr.yml verifies the bump
before the PR can merge, and create-release-tag.yml tags only the merge commit. It means
someone tagged by hand, or VERSION and the pins drifted apart. Fix forward with a patch.
- Rerunning is safe.
create-release-tag.yml makes one decision from the tag's state and
the commit's provenance, so it is quiet when there is nothing to do (the tag already points
here, or VERSION names an already-released tag this commit did not produce) and loud only
when a tag should have been created and something is wrong. release.yml re-signs
idempotently via its workflow_dispatch tag input.
- Setup, once. Both workflows read the release GitHub App from a
release GitHub
Environment (vars.RELEASE_APP_CLIENT_ID, secrets.RELEASE_APP_PRIVATE_KEY), whose
deployment-branch policy must be restricted to main. The App needs exactly two repository
permissions — Contents: write and Pull requests: write. It does NOT need Workflows: write,
because a release no longer edits anything under .github/workflows/. Repo-level secrets would let anyone
with push access dispatch a modified workflow from a branch and mint the App credential.
- One release at a time. If any
release/v* PR is open, the next dispatch refuses and names
it — merge or close it first. Once none is open, leftover release/v* branches from failed
runs are deleted automatically before the new PR is cut.
- The release PR's whole diff is
VERSION. Both workflows assert that: anything else in
the commit stops the release rather than being tagged. There is nothing to dry-run locally.
1---2name: cut-release3description: Cut a tagged release of mecatl — dispatch the Create Release PR workflow, review and merge the release PR, then verify the tag and the artifacts it publishes (ko images + Helm chart to GHCR, plus a GitHub Release with signed archives and a Homebrew formula bump). Use when asked to cut/ship/tag/publish a release or bump the version. NOT for general git tagging unrelated to a mecatl release.4---56# Cut a mecatl release78A release is a `vX.Y.Z` git tag. Pushing that tag triggers `.github/workflows/release.yml`,9which does two things. It builds, signs, and attests the `mecated`, `mecatui`, `mecak8s`, and10slack-bot images plus the `mecak8s` Helm chart to GHCR (jobs `publish`, `publish-mecatui`,11`publish-mecak8s`, `publish-slack-bot`, `publish-helm-chart`). It also publishes a **GitHub12Release** carrying `darwin`/`linux` x `amd64`/`arm64` archives, a `checksums.txt`, cosign13bundles, SBOMs, and build provenance — and pushes a `mecatl` formula bump to the public14`stacklok/homebrew-tap` repository, which is what makes `brew install stacklok/tap/mecatl`15resolve (job `publish-cli`). There is **no** version baked into the Go code — the tag IS the16release.1718Two consequences of the Homebrew half, before you start:1920- **A published tag is immutable in practice.** The formula records the release archives'21 SHA256 checksums for a specific tag. Deleting and re-pushing a `vX.Y.Z` that already produced22 a release and a tap commit leaves the tap pointing at checksums that no longer match, which23 breaks `brew install` for everyone. If a release goes wrong after the tap commit lands,24 **fix forward with the next patch version.** Re-tagging is only an option when the run failed25 before publishing anything.26- **Verify a release build BEFORE tagging**, not by pushing a throwaway tag — a pushed tag is a27 public release and a tap commit. `task release:snapshot && task release:verify` builds the28 archives and the formula locally, with no tag, no upload and no tokens.2930**Nothing pushes a commit to `main`.** The release runs through an ordinary pull request:31you dispatch a workflow, a bot opens the PR, a human merges it, and a bot tags the merge32commit. You never run `git push origin main`, and you never create the tag by hand.3334`VERSION` (repo root, **bare** semver — `0.0.34`, not `v0.0.34`) is the single authored source35of the release version, and the only file a release changes. The release PR is a one-line diff.3637It did not used to be. `mecatequi-reusable.yml` referenced its three sibling composite actions38by a hardcoded `@vX.Y.Z` literal, so every release had to bump those pins in the same tagged39commit or ship version skew. That self-reference — a file naming a tag that does not exist yet —40is why a release needed a commit on `main` at all. The pins are now `$/` self-repository refs,41which resolve to this repo at the exact ref the workflow is running from, so there is nothing42left to bump and skew is impossible rather than merely policed.4344## Steps4546Run from the repo root.47481. **Confirm what you're shipping.** The release tags whatever is on `main` when the release PR49 merges. Review what has landed since the last tag:50 ```sh51 git tag --sort=-v:refname --list 'v*' | head -1 # e.g. v0.0.3352 git log <last-tag>..origin/main --oneline53 ```54 Pick the bump type from that: `patch` for fixes, `minor` for additive behavior, `major` for55 a break. Releases so far have all been `patch`.56572. **Dispatch the release-PR workflow.** This is the only step that starts a release:58 ```sh59 gh workflow run create-release-pr.yml -f bump_type=patch60 gh run watch "$(gh run list --workflow=create-release-pr.yml --limit 1 --json databaseId --jq '.[0].databaseId')"61 ```62 It bumps `VERSION` and the three Mecatequi pins, opens `Release vX.Y.Z` from branch63 `release/vX.Y.Z`, and then asserts the diff shape — exactly `VERSION` plus three changed64 lines in `mecatequi-reusable.yml`. **If that verification step fails, do not merge the PR**;65 close it, delete the branch, and read the job log. The likely cause is a reordered step in66 `mecatequi-reusable.yml` (see the `RELEASE-PINNED` comments there).67683. **Review the release PR like any other PR** and confirm the diff is only the version bump:69 ```sh70 gh pr list --head "release/vX.Y.Z" --json number,url,files71 gh pr diff <number>72 ```73 Wait for CI to go green. The PR is opened by the release GitHub App, so it triggers checks74 normally.75764. **Squash-merge it.** A human does this — it is the approval gate, and it is the only way77 `VERSION` changes on `main`:78 ```sh79 gh pr merge <number> --squash80 ```81 The tagging workflow does not read the commit subject — it asks GitHub which PR produced82 the commit and requires a merged, bot-opened PR from branch `release/vX.Y.Z` whose diff is83 only `VERSION` plus the three pins. So the squash title does not matter, but adding anything84 else to the release PR will stop the tag.85865. **Watch the tag get created.** Merging fires `create-release-tag.yml`, which re-verifies the87 commit and pushes the annotated tag. That push fires `release.yml` on its own — the tag is88 pushed by a GitHub App installation token precisely so the cascade happens, where a89 `GITHUB_TOKEN`-pushed tag would trigger nothing:90 ```sh91 gh run watch "$(gh run list --workflow=create-release-tag.yml --limit 1 --json databaseId --jq '.[0].databaseId')"92 git fetch --tags && git tag --sort=-v:refname --list 'v*' | head -193 ```94956. **Confirm the release run started, then wait for it.** The archive/Homebrew job is the one96 that reaches outside this repository, so it is the one to watch:97 ```sh98 gh run list --workflow=release.yml --limit 399 gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')"100 ```1011027. **Verify the GitHub Release carries every artifact.** It must not be a draft, and it must103 have four archives plus a checksum file, with a cosign bundle and an SBOM alongside each:104 ```sh105 gh release view vX.Y.Z --json isDraft,assets --jq '{draft: .isDraft, assets: [.assets[].name]}'106 ```107 Then prove one archive is actually usable rather than trusting the asset list. Use the108 repo-local `.scratch/` dir, never `/tmp` (AGENTS.md):109 ```sh110 mkdir -p .scratch/release-vX.Y.Z111 gh release download vX.Y.Z -p 'checksums.txt' -p '*darwin_arm64*' -D .scratch/release-vX.Y.Z112 (cd .scratch/release-vX.Y.Z \113 && shasum -a 256 -c checksums.txt --ignore-missing \114 && tar -xzf mecatl_*_darwin_arm64.tar.gz \115 && ./mecatui --version && ./mecated --version)116 ```117 Both `--version` lines must print the tag you just cut. A `dev+<revision>` output means the118 release build lost its `BUILD_ID` linker stamp — a release bug, not a cosmetic one, because119 the public install docs claim a released binary reports its tag.120121 **If the run died between "release created" and "assets uploaded"** it leaves a DRAFT, which122 a lookup by tag does not return, so a naive re-run fails trying to create the release again.123 Recover with `gh release delete vX.Y.Z --cleanup-tag=false --yes`, then re-dispatch.1241258. **Verify the Homebrew tap got the formula bump:**126 ```sh127 gh api repos/stacklok/homebrew-tap/commits --jq '.[0].commit.message'128 gh api repos/stacklok/homebrew-tap/contents/Formula/mecatl.rb --jq '.content' \129 | base64 -d | grep -E 'version|url|sha256' | head130 ```131 The top commit must name the version you just cut, and the formula's `url` and `sha256`132 values must match the release assets from step 7.133134 **While `stacklok/mecatl` is private, `brew install stacklok/tap/mecatl` fails** even after a135 correct tap commit: Homebrew's downloader does not authenticate, so it cannot fetch a release136 archive from a private repository. The tap commit landing is the whole verification until the137 repository goes public; this is known and accepted. Once it is public, run the real138 end-to-end check once:139 ```sh140 brew update && brew install stacklok/tap/mecatl && mecatui --version141 ```142143## The engine module is tagged separately144145The `vX.Y.Z` release above is the **root repo / `mecated` image** release. The importable146core, `github.com/stacklok/mecatl/engine`, is its **own Go module** (ADR 0036) with its own147tag grammar `engine/vX.Y.Z` (distinct from the root tags). It carries a public-API148compatibility contract (`engine/COMPATIBILITY.md`, ADR 0037).149150- **The first `engine/vX.Y.Z` tag is `engine/v0.0.1`** — a deliberate "earliest, no stability151 promise" initial cut (the lowest pre-v1 patch, signalling zero stability commitment for the very152 first published surface). Cutting it is a deliberate maintainer decision (deferred per ADR 0037) —153 do NOT cut it as part of a routine root release unless asked. The grammar is `engine/vX.Y.Z`,154 **distinct** from the root `vX.Y.Z` tags; the two version lines are independent. SUBSEQUENT bumps155 follow `engine/COMPATIBILITY.md` (pre-v1: minor = additive, patch = fixes).156157### Cutting an engine tag (mirrors the root flow)158159Run from the repo root.1601611. **Pick the engine version.** First cut = `engine/v0.0.1` (a deliberate "earliest, no stability162 promise" initial cut); thereafter increment per semver, classified per `engine/COMPATIBILITY.md`163 (pre-v1: Added = minor, Changed/Removed = minor too; patch = fixes). The latest engine tag (none164 yet on the first cut):165 ```sh166 git tag --sort=-v:refname --list 'engine/v*' | head -1167 ```1681692. **Pre-flight.** Confirm `engine/CHANGELOG.md` has an `[Unreleased]` entry covering everything170 since the last engine tag (on the **first** cut that is the whole initial surface — the existing171 `[Unreleased]` baseline section). Then run the advisory `gorelease` check:172 ```sh173 task api:release-check174 ```175 **On the FIRST cut this is a no-op / uninformative:** `gorelease` can only classify the surface176 against a *prior* `engine/vX.Y.Z` base tag, and none exists yet — so it has nothing to compare177 to. That is expected. The authoritative guard is the `api-compat` gate (`task api:check`), which178 already guarantees the committed `engine/api/*.txt` snapshots match the surface being tagged.1791803. **Create the annotated tag** with a concise summary:181 ```sh182 git tag -a engine/vX.Y.Z -m "engine/vX.Y.Z — <one-line summary>"183 ```1841854. **Push the tag:**186 ```sh187 git push origin engine/vX.Y.Z188 ```189190**This line is deliberately still manual.** An engine tag adds no commit to `main` and carries191no pin bump, so it never needed the release-PR flow the root `vX.Y.Z` line uses — pushing the192tag is the whole release.193194**IMPORTANT — an engine tag fires NO image build, NO GitHub Release, and NO Homebrew formula bump.** `release.yml` triggers on `v*` (the root tag195glob), which does **not** match `engine/v*`, so cutting an engine tag runs none of the ko build /196cosign / SBOM / SLSA pipeline. It only publishes the module version, making it resolvable for197`go get github.com/stacklok/mecatl/engine@engine/vX.Y.Z` consumers (ADR 0036/0037). There is no pin198bump and no `release.yml` run to confirm — the push of the tag is the whole release.199200## Notes201202- **Two publishing destinations, one tag.** A run can succeed on the GHCR images and still fail203 on the release or the tap (or vice versa). GoReleaser's brew pipe continues on error and the204 Release is created before the formula is pushed, so a bad tap token loses the formula but NOT205 the Release. Steps 7 and 8 are not optional: a green `gh run list` line is not proof that206 `brew install` works.207- **Never hand-edit `stacklok/homebrew-tap`.** The formula is generated from the tag by the208 release workflow and carries a `DO NOT EDIT` header. A manual edit is overwritten by the next209 release and desynchronizes the checksums in the meantime.210- **Never push to `main`, and never create a root `vX.Y.Z` tag by hand.** Both are the211 workflows' job. A hand-pushed pin bump skips code review, and a hand-created tag would point212 at a commit whose pins the release gate then rejects. If `VERSION` is edited on `main` outside213 a release PR, `create-release-tag.yml` refuses to tag it rather than cutting a release from214 it. `release.yml`'s `guard` job additionally refuses to publish anything from a tag that is215 not an ancestor of `main`, so a tag cut on a branch builds nothing. (This applies to the ROOT216 `v*` line only — the `engine/v*` tags below are still cut by hand, deliberately: they carry217 no pin bump and add no commit to `main`.)218- **Annotated tags only** (`git tag -a`), matching prior releases — they carry a tagger + message.219 `create-release-tag.yml` does this; the tagger is `github-actions[bot]`.220- **Don't bump illustrative documentation refs** unless asked — the `@vX.Y.Z` examples in221 `user-docs/building/deployment/mecatequi.md` are illustrative and do NOT gate the release. The222 release flow deliberately leaves them alone.223- **If the release run fails on the pin gate**, the tagged commit didn't carry the bumped pins.224 That should be impossible through the normal flow — `create-release-pr.yml` verifies the bump225 before the PR can merge, and `create-release-tag.yml` tags only the merge commit. It means226 someone tagged by hand, or `VERSION` and the pins drifted apart. Fix forward with a patch.227- **Rerunning is safe.** `create-release-tag.yml` makes one decision from the tag's state and228 the commit's provenance, so it is quiet when there is nothing to do (the tag already points229 here, or `VERSION` names an already-released tag this commit did not produce) and loud only230 when a tag should have been created and something is wrong. `release.yml` re-signs231 idempotently via its `workflow_dispatch` `tag` input.232- **Setup, once.** Both workflows read the release GitHub App from a `release` GitHub233 Environment (`vars.RELEASE_APP_CLIENT_ID`, `secrets.RELEASE_APP_PRIVATE_KEY`), whose234 deployment-branch policy must be restricted to `main`. The App needs exactly two repository235 permissions — Contents: write and Pull requests: write. It does NOT need Workflows: write,236 because a release no longer edits anything under `.github/workflows/`. Repo-level secrets would let anyone237 with push access dispatch a modified workflow from a branch and mint the App credential.238- **One release at a time.** If any `release/v*` PR is open, the next dispatch refuses and names239 it — merge or close it first. Once none is open, leftover `release/v*` branches from failed240 runs are deleted automatically before the new PR is cut.241- **The release PR's whole diff is `VERSION`.** Both workflows assert that: anything else in242 the commit stops the release rather than being tagged. There is nothing to dry-run locally.