Operator decision: the post-release bump PR is ceremony debris — a derivable one-liner (package.json + lock, via npm, never regex) with no judgment for a review to add. After tag + build + publish, the same job computes X.Y.(Z+1)-dev and pushes it to main directly (a GITHUB_TOKEN push fires no workflows: no recursion, no red run); if branch protection refuses, the step opens the bump PR itself, loudly. Merge-door only — the manual tag fallback does not rewrite main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
13 KiB
YAML
233 lines
13 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: ["**"]
|
|
push:
|
|
# The merge-is-the-release path (#111) rides pushes to MAIN, not
|
|
# pull_request events, for one load-bearing reason the first review
|
|
# round caught: a workflow run triggered by a pull_request from a
|
|
# public FORK gets a READ-ONLY GITHUB_TOKEN — `permissions:` cannot
|
|
# raise that ceiling — and every ceremony PR this org has ever merged
|
|
# is cross-repo from the bot fork. The asserts would pass and the tag
|
|
# create would 403, red on main, every release. A push to main is an
|
|
# in-repo event with the full write token, whoever authored the PR.
|
|
branches: [main]
|
|
|
|
permissions:
|
|
contents: write # tag create via the API + gh release create
|
|
|
|
jobs:
|
|
release:
|
|
# Tag pushes and main pushes both enter (the asserts below are the
|
|
# filter); the steps split on the ref. The hand-set `release` label
|
|
# (LABELS.md: `release` is the operator's — automation never guesses
|
|
# intent) is read via the API off the merge commit's PR, inside the
|
|
# decide step — a push event carries no PR payload, and the PR itself
|
|
# lives on a fork (the trigger comment).
|
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Either door: the pushed ref — a tag, or main's new head (the
|
|
# merge commit the maintainer shipped, which the tag created
|
|
# below will name).
|
|
ref: ${{ github.sha }}
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "22"
|
|
cache: npm
|
|
- name: "tag push: the tag must name package.json's version"
|
|
if: startsWith(github.ref, 'refs/tags/')
|
|
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.ref == 'refs/heads/main'
|
|
env:
|
|
BASE_SHA: ${{ github.event.before }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
# Versions read via node, never regex (the pkg_version discipline).
|
|
ver="$(node -p 'require("./package.json").version')"
|
|
# event.before is all-zeros on a branch-create push; the pushed
|
|
# head's first parent is main the instant before, either way.
|
|
case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac
|
|
git fetch --depth=1 origin "$BASE_SHA" || true
|
|
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
|
|
# The version transitioned — now the LABEL, the operator's declared
|
|
# intent, read via the API because a push event carries no PR
|
|
# payload (and the PR lives on a fork — the trigger comment). No
|
|
# merged, release-labeled PR behind this commit = a transition
|
|
# nobody declared: refuse.
|
|
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
|
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
|
|
echo "version transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#111), not a bare push — creating nothing." >&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: startsWith(github.ref, 'refs/tags/') || 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.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
MERGE_SHA: ${{ github.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: startsWith(github.ref, 'refs/tags/') || 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: startsWith(github.ref, 'refs/tags/') || 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"
|
|
# The post-release bump, folded into the release act (#111 followup —
|
|
# operator decision: a mechanical one-liner deserves no PR of its
|
|
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
|
|
# straight to main with this job's token. A GITHUB_TOKEN push fires
|
|
# no workflows (anti-recursion), so the bump triggers neither the
|
|
# merge path nor a red run; should branch protection ever refuse the
|
|
# direct push, the step opens the bump PR itself and says so, loudly.
|
|
# Merge-door only (the decide gate): the manual tag path stays a
|
|
# fallback and does not rewrite main.
|
|
- name: bump main to the next -dev — the release re-arms main itself
|
|
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')"
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
git fetch origin main
|
|
git checkout -B main origin/main
|
|
npm pkg set version="$next"
|
|
npm install --package-lock-only --ignore-scripts
|
|
git add package.json package-lock.json
|
|
git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION"
|
|
if ! git push origin main; then
|
|
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
|
git checkout -b "chore/bump-$next"
|
|
git push origin "chore/bump-$next"
|
|
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
|
--title "chore: bump main to $next" \
|
|
--body "The post-release re-arm, opened by release.yml because the direct push was refused." \
|
|
--label release
|
|
fi
|