cast/.github/workflows/release.yml
dan-claude-bot fcb7ce3b7d fix: a -dev endstate is always work — the post-release bump must not run red
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), a red run on main once per
release, forever. A tree that ends -dev is by definition not a release:
every such merge is work, green NOTICE no-op. Red now guards only bare
endstates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:42:06 +00:00

185 lines
10 KiB
YAML

name: release
# The release publisher (#96; box#83's design) — two ways in, one act (#111;
# box#96's design):
#
# - Merging a `release`-labeled PR into main IS the release. The ceremony
# PR carries the bumped version and the stamped changelog; the
# maintainer's merge is the ship decision, and tagging after it is
# transcription — exactly where humans err silently and machines fail
# loudly. This path asserts four facts (each fail-loud, creating
# nothing), then tags the merge commit and publishes.
# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme)
# stays as the documented manual fallback and backfill.
#
# Both paths converge on the SAME steps below — one notes extraction, one
# build, one asset name, one create — so they cannot drift.
#
# Where cast differs from its siblings: the release carries a PREBUILT
# asset. box and rig are pure bash, so GitHub's source tarball for the tag
# IS their package; cast's source tarball is not runnable — it needs npm ci
# and tsc first. So the build happens ONCE, here, and the asset is the
# runnable tree: bin/, dist/, production node_modules/, package.json.
on:
push:
# Every tag, not a shape filter (box's and rig's precedent): a tag that
# mismatches package.json — a habitual v0.1.0, a typo — must fail the
# assert LOUDLY below, not be silently skipped by a pattern that didn't
# match.
tags: ["**"]
pull_request:
# The merge-is-the-release path (#111). `closed` is the only type that
# can mean "merged"; the job gate below drops closed-unmerged and
# unlabeled closures.
types: [closed]
branches: [main]
permissions:
contents: write # tag create via the API + gh release create
jobs:
release:
# Tag pushes always enter (the asserts below are the filter). PR
# closures enter only when the PR actually MERGED and carries the
# hand-set `release` label (LABELS.md: `release` is the operator's —
# automation never guesses intent).
if: >-
github.event_name == 'push' ||
(github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Tag push: the tag. Merged PR: the MERGE COMMIT on main — the
# exact tree the maintainer shipped, which the tag created below
# will name.
ref: ${{ github.event.pull_request.merge_commit_sha || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: "tag push: the tag must name package.json's version"
if: github.event_name == 'push'
run: |
ver="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
# 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 → still a dev tree, so still work —
# the post-release bump PR above all
# (bare -> -dev after every release):
# green NOTICE no-op
# 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: |
# Versions read via node, never regex (the pkg_version discipline).
ver="$(node -p 'require("./package.json").version')"
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 "NOTICE: version changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0 ;;
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.
run: |
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' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
# Assert 4 — no tag and no release exist for this version. Re-runs
# stay idempotent, and a manual race (an operator who tagged by
# hand between merge and here) fails loudly instead of
# double-publishing.
if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then
echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2
exit 1
fi
if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then
echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2
exit 1
fi
# The act begins: tag the merge commit via the API. A tag created
# with GITHUB_TOKEN does not trigger other workflows, so the
# tag-push trigger above CANNOT fire on this tag and
# double-publish — which is also why the publish must happen in
# THIS job.
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
# release names, and the test suite needs `age`, which this runner
# does not install. The staged tree is exactly what an install needs
# to run.
run: |
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION"
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: |
gh release create "$RELEASE_VERSION" --verify-tag \
--title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \
"$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz"