From a5910eacee8ee7b1ffcb2f70a4f5d3dc3376cfa6 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:28:58 +0000 Subject: [PATCH] fix: the release label's two meanings part ways in a decide step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LABELS.md gives 'release' to release-flow WORK as well as to the ceremony PR — the PR that added the merge path included. The old assert pair turned every such merge into a red run on main. The fused decide step reads the version against the PR base and answers all states: -dev unchanged = work, green NOTICE no-op; bare unchanged but already released = work in the post-release window (cast's whole pre-0.1.1 era included), same no-op; -dev-but-changed and bare-unchanged-never-released = half-ceremonies, refused loudly; bare-and-changed = the ceremony. Shared steps gate on the decide (tag-push path unaffected). Pins anchor on the echo strings, since the workflow's own comment table paraphrases the states. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 69 ++++++++++++++++++++++++----------- test/release.test.ts | 29 +++++++++------ 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725e112..6f12c37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,39 +68,64 @@ jobs: exit 1 fi echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - - name: "merged release PR: the version must have left -dev in THIS PR" + # The decide step — the version asserts 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 — the PR that added this very + # trigger included. The version tells them apart, in four states: + # -dev, unchanged → work under the label: green NOTICE + # no-op, not a red run per infra PR + # -dev, changed → a bump that forgot to leave -dev: + # half a ceremony, refuse + # bare, unchanged, released → work merged in the post-release + # window (ceremony landed, the -dev + # bump has not — and cast's ENTIRE + # pre-0.1.1 era, since 0.1.0 never + # carried -dev): green NOTICE no-op + # bare, unchanged, UNreleased→ the label says ship but this PR did + # not mint the version: refuse to + # guess. This is also the known + # first-release edge (#111): the 0.1.0 + # ceremony (#110) ships by manual tag, + # the fallback path; the automation + # applies from 0.1.1 on. + # bare, changed → the ceremony: proceed + - name: 'decide: ceremony, or release-flow work under the label?' + id: decide if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} run: | - # Assert 1 — the merged tree carries a release version, read via - # node, never regex (the pkg_version discipline). + # Versions read via node, never regex (the pkg_version discipline). ver="$(node -p 'require("./package.json").version')" - case "$ver" in - *-dev) - echo "package.json version '$ver' is a -dev version — creating nothing." >&2 - echo "A release PR bumps package.json OFF -dev (CONTRIBUTING.md, Releasing); this merge did not." >&2 - exit 1 ;; - esac - # Assert 2 — the version CHANGED in this PR (PR base vs merge): - # the -dev transition is the interlock, so a mislabeled ordinary - # PR — whose merge leaves the version untouched — fails HERE, - # loudly, instead of re-releasing main's standing version. - # - # Known first-release edge (#111): 0.1.0 never carried -dev (cast - # predates the -dev ritual), so this interlock correctly does NOT - # fire for the 0.1.0 ceremony (#110) — that one ships by manual - # tag, the fallback path; the automation applies from 0.1.1 on. git fetch --depth=1 origin "$BASE_SHA" git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')" + case "$ver" in + *-dev) + if [ "$base" = "$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') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z (CONTRIBUTING.md, Releasing) — creating nothing." >&2 + exit 1 ;; + esac if [ "$base" = "$ver" ]; then - echo "package.json version is '$ver' both before and after this PR — creating nothing." >&2 - echo "A release-labeled PR must BE the version transition (#111). If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump." >&2 + if gh release view "$ver" > /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 — creating nothing." >&2 + echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&2 exit 1 fi + echo "ceremony=yes" >> "$GITHUB_OUTPUT" echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - name: release notes — the version's own CHANGELOG.md section + if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' # Assert 3 on the merge path, the same fact on the tag path: # release-notes.sh fails loudly on a missing/empty section, which # fails the release here — before anything is created. @@ -108,7 +133,7 @@ jobs: bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: "merged release PR: nothing exists yet, then tag the merge commit" - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} @@ -133,6 +158,7 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/git/refs" \ -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" - name: build the prebuilt dist asset + if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' # Build ONCE, in CI — the whole point of the asset (#96): the # installer's release channels never run npm or tsc. Deliberately no # check/tests here: ci.yml already gated the merge commit this @@ -147,6 +173,7 @@ jobs: cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/" tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION" - name: create the release + if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/test/release.test.ts b/test/release.test.ts index 27dc2e5..33a4820 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -193,19 +193,24 @@ describe("release.yml", () => { ); }); - it("the merge path asserts its four facts IN ORDER, all before tag-create, build, and publish", () => { - // Assert 1: non--dev version at the merge commit. Assert 2: the version - // changed in THIS PR (the -dev interlock; base read from git, version - // read via node). Assert 3: the shared notes extraction. Assert 4: no - // existing tag or release. Only then the acts: API-tag the merge - // commit, build, publish — every marker present, strictly in file - // order, fail-closed. + it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => { + // The decide step (the fused version asserts — see the workflow's + // four-state table): base read from git, versions via node, work under + // the label no-ops green, half-ceremonies refuse. Then: the shared + // notes extraction, the no-existing-tag/release asserts, and only then + // the acts — API-tag the merge commit, build, publish. Every marker + // present, strictly in file order, fail-closed. const markers = [ - "is a -dev version", // assert 1 - 'git show "$BASE_SHA:package.json"', // assert 2 — base vs merge - ".github/scripts/release-notes.sh", // assert 3 - 'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert 4a - 'gh release view "$RELEASE_VERSION"', // assert 4b + 'git show "$BASE_SHA:package.json"', // decide — base vs merge + // Code-unique phrasings (the workflow's own comment table paraphrases + // these states, so the pins anchor on the echo strings, not prose): + "release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green + "— half a ceremony; a release PR ships a bare X.Y.Z", // -dev but changed: refuse + "release-flow work merged in the post-release window (before the -dev bump)", // window no-op + "Refusing to guess — creating nothing.", // bare, unchanged, unreleased: refuse + ".github/scripts/release-notes.sh", // assert: notes extract + 'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert: no tag + 'gh release view "$RELEASE_VERSION"', // assert: no release (the decide's own view sits earlier — count checked below) 'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit "npm prune --omit=dev", // act: build 'gh release create "$RELEASE_VERSION"', // act: publish