diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63160ca..21c4033 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,32 +88,57 @@ jobs: # base-side VERSION is readable for the interlock below. ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - # Assert 1 — the merged tree says it is a release. A `-dev` VERSION - # here means the label lied (or the ceremony PR forgot the bump). - - name: assert the merged tree is a release (non-dev VERSION) - run: | - ver="$(cat VERSION)" - case "$ver" in - *-dev) - echo "VERSION '$ver' is still -dev — a release PR ships a bare X.Y.Z; refusing to release a dev tree" >&2 - exit 1 ;; - esac - # Assert 2 — THIS PR is the one that changed VERSION (base vs merge). - # The `-dev` transition as a safety interlock: an ordinary PR someone - # mislabels `release` fails here loudly instead of shipping main - # under a version some earlier PR minted. - - name: assert VERSION changed in this PR (the mislabel interlock) + # The decide step — asserts 1+2 fused, because the `release` label + # carries TWO legitimate meanings (LABELS.md: "release flow and + # version/packaging work"): the ceremony PR that ships a version, and + # ordinary work ON the release machinery — this very PR included. + # The version tells them apart. A `-dev` VERSION left UNTOUCHED by the + # PR is release-flow work: a green no-op, not a red run on main every + # time the flow itself is improved. Everything in between is a + # half-ceremony and dies loudly: + # -dev, unchanged → work under the label: NOTICE + green no-op + # -dev, changed → a bump that forgot to leave -dev: refuse + # bare, unchanged, + # already released → work merged in the post-release window + # (ceremony landed, the -dev bump has not): + # NOTICE + green no-op + # bare, unchanged, + # never released → the label says ship, the tree names an + # unshipped version this PR did not mint: + # genuinely ambiguous, refuse + # bare, changed → the ceremony: proceed + - name: 'decide: ceremony, or release-flow work under the label?' + id: decide + env: + GH_TOKEN: ${{ github.token }} run: | ver="$(cat VERSION)" base_ver="$(git show "$BASE_SHA:VERSION")" + case "$ver" in + *-dev) + if [ "$base_ver" = "$ver" ]; then + echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "VERSION changed ('$base_ver' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z. Refusing." >&2 + exit 1 ;; + esac if [ "$base_ver" = "$ver" ]; then - echo "VERSION did not change in this PR ('$ver' before and after) — a 'release'-labeled PR must be the ceremony PR that bumps it; refusing to release" >&2 + if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "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." >&2 exit 1 fi + echo "ceremony=yes" >> "$GITHUB_OUTPUT" # Assert 3 — the changelog names exactly this version, and the one # extractor (shared with the tag job and test/release.sh) gets a # non-empty body out of it. The notes are kept for the publish. - name: assert the changelog section for this version extracts + if: steps.decide.outputs.ceremony == 'yes' run: | . .github/scripts/release-lib.sh ver="$(cat VERSION)" @@ -126,6 +151,7 @@ jobs: # Assert 4 — nothing exists yet, tag or release: a re-run of this job # (or a manual tag that beat it) must refuse, not clobber. - name: assert no tag and no release exist yet (idempotent re-runs) + if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | @@ -142,6 +168,7 @@ jobs: # assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive # workflow runs — see the job comment.) - name: tag the merge commit and publish the release + if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/test/release.sh b/test/release.sh index 6e19686..5bdfaa1 100644 --- a/test/release.sh +++ b/test/release.sh @@ -141,10 +141,22 @@ check "release.yml: only a MERGED PR releases (closed-unmerged never fires)" 0 " grep -qF "github.event.pull_request.merged == true" "$RY" check "release.yml: only the 'release' label carries the intent" 0 "" \ grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY" -check "release.yml: assert 1 — a still-dev VERSION refuses" 0 "" \ - grep -qF "refusing to release a dev tree" "$RY" -check "release.yml: assert 2 — an unchanged VERSION refuses (the mislabel interlock)" 0 "" \ - grep -qF "VERSION did not change in this PR" "$RY" +# The decide step tells the label's two meanings apart (LABELS.md gives +# `release` to release-flow WORK as well as to the ceremony PR): work under +# the label is a green NOTICE no-op — in the -dev steady state and in the +# post-release window (bare, unchanged, already released) — while every +# half-ceremony refuses. Pin each verdict's message and the gating output. +check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \ + grep -qF "release-flow work under the release label, not a ceremony" "$RY" +check "release.yml: decide — a half-ceremony (-dev but changed) refuses" 0 "" \ + grep -qF "half a ceremony" "$RY" +check "release.yml: decide — post-release-window work no-ops green" 0 "" \ + grep -qF "release-flow work merged in the post-release window" "$RY" +check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ + grep -qF "Refusing to guess" "$RY" +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "release.yml: decide gates every later step on ceremony=yes" 0 "" \ + bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY" check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \ grep -qF "refusing to publish an empty release" "$RY" check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \