The merge door runs on **every** push to main, and the `release` label
legitimately means two things (release ceremonies, and ordinary work *on*
the release machinery), so the door's first act is a decision: the 5-state
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
block *is* the spec, and the table is contract-tested offline). Rendered
for operators:
| # | the tree your merge produced | the run | what it means — and your move |
|---|---|---|---|
| 1 | version `-dev`, unchanged | green `NOTICE`, no-op | Almost every PR — including release-flow work under the `release` label. Nothing to publish, nothing to do. |
| 2 | version changed, still `-dev` | green `NOTICE`, no-op | The post-release bump, or a renumber. "A dev tree is by definition not a release." Nothing to do. |
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. |
The green rows are the point as much as the red ones: the machinery must
be safe to work on, so every legitimate non-ceremony is a green `NOTICE`
no-op — never a red run on main per infra PR
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
automation never guesses; the version transition is the interlock, and
label-without-transition (row 4) and transition-without-label (row 5) both
refuse (#1 constraint 8).
## The guards
Three composite actions run in every consumer's CI (and in this repo's
own). Shared shape: version-keyed where the tree's state matters, loud
where it fails, and **a file of its own so a test can drive it**. The full
war stories are in the scripts' header comments — authoritative and longer
`$VER`-style variables appear as the run interpolates them.
### The decision refused ([lib/decide.sh](lib/decide.sh))
> the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing.
> (If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.)
Row 4 ([L129–L133](lib/decide.sh#L129-L133)). The message is the remedy:
drop the label, or re-do the ceremony with the bump, or take the tag door.
> the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
Row 5 ([L147–L149](lib/decide.sh#L147-L149)). Someone pushed or merged a
version transition without the `release` label. Label a proper ceremony PR,
or — if the tree is genuinely the release — publish by the tag door.
> VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing.
> BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing.
> RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
> LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369))
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
remedy.
> CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
never stamped. Stamp first, then delete and re-push the tag.
### Red main that is not the release workflow
Consumer CI runs its guard steps on pushes to main too (this repo's
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
operator will actually meet on main is
**changelog-armed after a re-arm was forgotten**: the ceremony stamped
without putting `## Unreleased` back, the release's own `-dev` bump
landed, and the guard now says (first line):
> changelog-armed: the version is '$ver' (a development tree) but the top
> section of $changelog is: …
The fix is a one-line PR: add an empty `## Unreleased` above the stamped