fix: the release label's two meanings part ways in a decide step
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 <noreply@anthropic.com>
This commit is contained in:
parent
775af63063
commit
a5910eacee
2 changed files with 65 additions and 33 deletions
69
.github/workflows/release.yml
vendored
69
.github/workflows/release.yml
vendored
|
|
@ -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 "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
|
||||
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
|
||||
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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue