forked from heavy-duty/box
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony PR — the PR that added the merge door included. The old assert pair turned every such merge into a red run on main. The fused decide step reads VERSION against the merge commit's first parent and answers all states: -dev unchanged = work, green NOTICE no-op; bare unchanged but already released = work in the post-release window, same no-op; -dev-but-changed and bare-unchanged-never-released = half-ceremonies, refused loudly; bare-and-changed = the ceremony. Later steps gate on its output. Five new pins in test/release.sh cover each verdict and the gating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
8.2 KiB
YAML
160 lines
8.2 KiB
YAML
name: release
|
|
# The release publisher — two doors into the same act (#83, #96):
|
|
#
|
|
# * The merge door (#96): merging the `release`-labeled PR into main IS the
|
|
# release. The label is the intent, the version transition is the
|
|
# interlock — VERSION at the merge commit must be non-`-dev` AND must have
|
|
# changed in this PR, so a mislabeled ordinary PR fails loudly and creates
|
|
# NOTHING. The job then tags the merge commit via the API and publishes,
|
|
# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger
|
|
# other workflows (GitHub's anti-recursion), so that tag can never re-enter
|
|
# the tag door below and double-publish — publishing here is the only
|
|
# chance, and the no-existing-tag/release assert covers a manual tag
|
|
# racing the merge.
|
|
#
|
|
# * The tag door (#83) stays as the documented manual fallback and backfill,
|
|
# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v'
|
|
# prefix). The tag must name the tree's own VERSION (a mismatch fails
|
|
# loudly and creates NOTHING — a wrong release is worse than a missing
|
|
# one).
|
|
#
|
|
# Both doors publish the release body from that version's CHANGELOG.md
|
|
# section (.github/scripts/release-notes.sh, shared with test/release.sh) —
|
|
# the curated prose, not the generated PR list. No assets are uploaded: for a
|
|
# pure-bash tree, GitHub's source tarball for the tag IS the package, and
|
|
# install.sh downloads exactly that.
|
|
on:
|
|
pull_request:
|
|
types: [closed]
|
|
branches: [main]
|
|
push:
|
|
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
|
|
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
|
|
# below, not be silently skipped by a pattern that didn't match.
|
|
tags: ["**"]
|
|
|
|
permissions:
|
|
contents: write # create the tag ref + gh release create
|
|
|
|
jobs:
|
|
# The merge door (#96). Closed-unmerged never fires, and a merged PR
|
|
# without the hand-set `release` label (LABELS.md: automation never guesses
|
|
# intent) is skipped. The label is read from the event payload, not the
|
|
# API, so no pull-requests permission is needed.
|
|
release-on-merge:
|
|
if: >-
|
|
github.event_name == 'pull_request' &&
|
|
github.event.pull_request.merged == true &&
|
|
contains(github.event.pull_request.labels.*.name, 'release')
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# The merge commit plus its first parent (fetch-depth: 2): the
|
|
# first parent is main the instant before this PR landed, which the
|
|
# changed-in-this-PR assert compares against. (The payload's
|
|
# base.sha can be stale; the merge commit's first parent cannot.)
|
|
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
|
fetch-depth: 2
|
|
# 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
|
|
# job 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): green NOTICE no-op
|
|
# bare, unchanged, UNreleased→ the label says ship but this PR did
|
|
# not mint the version: refuse to guess
|
|
# 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="$(git show HEAD^1: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 — creating nothing." >&2
|
|
exit 1 ;;
|
|
esac
|
|
if [ "$base" = "$ver" ]; then
|
|
if gh release view "$ver" --json name >/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
|
|
exit 1
|
|
fi
|
|
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
|
- name: release notes — the version's own CHANGELOG.md section
|
|
if: steps.decide.outputs.ceremony == 'yes'
|
|
# 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 "$(cat VERSION)" > "$RUNNER_TEMP/notes.md"
|
|
cat "$RUNNER_TEMP/notes.md"
|
|
- name: nothing may exist yet — no tag, no release (idempotency)
|
|
if: steps.decide.outputs.ceremony == 'yes'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then
|
|
echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2
|
|
exit 1
|
|
fi
|
|
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
|
echo "release '$ver' already exists — creating nothing." >&2
|
|
exit 1
|
|
fi
|
|
- name: tag the merge commit, then publish — one job, on purpose
|
|
if: steps.decide.outputs.ceremony == 'yes'
|
|
# Same job as the asserts: the GITHUB_TOKEN-created tag triggers no
|
|
# workflows (GitHub's anti-recursion), so the tag door cannot fire
|
|
# off it — this step is the release's only chance to publish.
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA"
|
|
gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md"
|
|
|
|
# The tag door (#83) — the manual fallback and backfill, unchanged. Gated
|
|
# to the push event so a closed PR (the trigger above) never runs it
|
|
# against a branch ref.
|
|
release:
|
|
if: github.event_name == 'push'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: the tag must name the tree's VERSION
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
|
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
|
|
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
|
exit 1
|
|
fi
|
|
- name: release notes — the version's own CHANGELOG.md section
|
|
# 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 "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
|
|
cat "$RUNNER_TEMP/notes.md"
|
|
- name: create the release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
|