From 8057d7b18ab9d42a31c79753ccf2701039e5e056 Mon Sep 17 00:00:00 2001 From: Craig Booth Date: Tue, 4 Aug 2026 07:05:09 -0500 Subject: [PATCH] feat: make releases atomic Releasing was a six-step checklist in LIFECYCLE.md and the steps came apart. An audit of all sixteen published tags finds nine internally inconsistent: v2.1.0-v2.4.0 each ship a gates.yml fetching gate scripts from v2.0.0, and v3.0.0's Makefile.colormath points three releases back. v3.1.0 was stamped into main and written up in the changelog but never tagged, so main has been advertising a ref that 404s for every consumer running `make preflight`. The cause is stamping forward: writing the next version into the tree days before the tag exists, leaving a window that closes only if someone remembers. Stamps now move only in the release commit, which is tagged with the version it stamps in a single `git push --atomic` -- both refs land or neither does. release/cut.sh is the one gesture. release/verify.sh replaces refs-lockstep and is a strict superset: it covers plugin.json (a stamp site nothing checked, and which had been missed twice) and asks whether the stamped ref resolves, which is the question that would have caught both bugs. Documentation examples are de-versioned to @vX.Y.Z rather than stamped -- nine had rotted one to three majors stale. Tooling lives in release/, not scripts/: gates.yml sparse-checks-out `scripts` onto consumer runners at 16 call sites, and cone-mode sparse checkout is recursive. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011614N8pZWRHsCE6HGpJNXm --- .github/workflows/ci.yml | 33 ++--- .github/workflows/gates.yml | 4 +- .github/workflows/release.yml | 67 +++++++++ .github/workflows/review.yml | 2 +- CHANGELOG.md | 64 +++++++- LIFECYCLE.md | 75 ++++++++-- Makefile.colormath | 2 +- README.md | 32 ++-- release/backfill-releases.sh | 95 ++++++++++++ release/cut.sh | 156 ++++++++++++++++++++ release/lib.sh | 138 +++++++++++++++++ release/notes.sh | 34 +++++ release/publish-release.sh | 59 ++++++++ release/stamp.sh | 67 +++++++++ release/verify.sh | 268 ++++++++++++++++++++++++++++++++++ 15 files changed, 1045 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 release/backfill-releases.sh create mode 100755 release/cut.sh create mode 100755 release/lib.sh create mode 100755 release/notes.sh create mode 100755 release/publish-release.sh create mode 100755 release/stamp.sh create mode 100755 release/verify.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 574a5d9..41a9e96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,27 +20,24 @@ concurrency: cancel-in-progress: true jobs: - # The two stamped refs must agree: gates.yml's `colormath-ref` default is how - # CI fetches its scripts, and Makefile.colormath's COLORMATH_REF is how the - # local mirror fetches the same ones. When they drift, `make preflight` runs - # different gate scripts than CI does — silently, and only wrongly once a - # script changes between the two tags. LIFECYCLE step 2 stamps both; this is - # the check that it actually happened. - refs-lockstep: + # Every version this repo writes down must agree, and must name a tag that + # exists. Consumers pin exact tags, so a stamp naming an untagged version + # means every consumer's `make preflight` 404s on every gate script, and a + # stamp naming an older tag means CI and the local mirror silently run + # different gate scripts. Both have happened; see release/verify.sh. + # + # Safe to block, despite LIFECYCLE.md's warning about drift gates, because it + # can only fail on a PR that edited a version string. A PR touching none of + # them inherits main's consistent state and passes unconditionally — this + # gate can never stand between a hotfix and main. + release-consistency: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: colormath-ref default matches COLORMATH_REF - run: | - wf=$(grep -A3 'colormath-ref:' .github/workflows/gates.yml \ - | grep -m1 'default:' | sed 's/.*default: *"\(.*\)".*/\1/') - mk=$(grep -m1 '^COLORMATH_REF' Makefile.colormath | sed 's/.*= *//') - echo "gates.yml colormath-ref default : $wf" - echo "Makefile.colormath COLORMATH_REF : $mk" - if [ "$wf" != "$mk" ]; then - echo "::error::Stamped refs drifted. Update both (LIFECYCLE.md, Releasing step 2)." - exit 1 - fi + with: + # verify.sh compares the stamps against the newest reachable tag. + fetch-depth: 0 + - run: ./release/verify.sh gates: uses: ./.github/workflows/gates.yml diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 6c4e921..8f09d46 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -7,7 +7,7 @@ name: Gates # # jobs: # gates: -# uses: ColorMath/ci/.github/workflows/gates.yml@v1.1.0 +# uses: ColorMath/ci/.github/workflows/gates.yml@vX.Y.Z # see /releases/latest # with: # python-version: "3.12" # default-branch: main @@ -77,7 +77,7 @@ on: colormath-ref: description: colormath ref to fetch gate scripts from (stamped per release; do not set as a consumer) type: string - default: "v3.1.0" + default: "v3.0.0" ruff-spec: description: pip requirement spec for ruff (match your pyproject dev pin) type: string diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8ec92d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +# Cut a release: stamp, commit, annotated tag, atomic push, GitHub Release. +# +# A thin wrapper. All of the logic lives in release/cut.sh so that a release can +# be rehearsed and debugged locally (`./release/cut.sh vX.Y.Z --dry-run`) rather +# than only ever running inside Actions, where a half-finished release is the +# expensive way to find a bug. +# +# workflow_dispatch only. Releasing is a decision, and the one gesture that +# makes it is a human choosing the version here — everything after that is +# machine-driven and verified. + +on: + workflow_dispatch: + inputs: + version: + description: Version to release, e.g. 3.1.0 (the tag becomes v3.1.0) + type: string + required: true + dry-run: + description: Verify and show the release diff, but publish nothing + type: boolean + default: false + +# Never cancel a release in flight: the push is atomic, but the GitHub Release +# that follows it is a second system, and a cancel between the two leaves work +# for the resume path to finish rather than something merely half-cancelled. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + # The repo's default workflow token is read-only; pushing main and the tag + # needs this. `actions: read` is for the CI-is-green precondition. + contents: write + actions: read + steps: + - uses: actions/checkout@v6 + with: + # Full history and tags: verify.sh compares against the newest + # reachable tag, and cut.sh refuses to run on a shallow clone. + fetch-depth: 0 + # Keep the credential so `git push --atomic` works. + persist-credentials: true + + - name: Configure the release committer + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Cut ${{ inputs.version }} + env: + GH_TOKEN: ${{ github.token }} + run: | + ./release/cut.sh "${{ inputs.version }}" \ + ${{ inputs.dry-run && '--dry-run' || '' }} + + # Note: pushes made with GITHUB_TOKEN do not trigger further workflows, so + # CI will not re-run on the release commit. That is fine and deliberate — + # ci.yml's `gates` job pins colormath-ref to github.sha and so never + # exercises the stamps anyway, cut.sh required CI to be green on the + # commit being released, and verify.sh --expect ran against the exact tree + # that got tagged. diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index b628d7a..2519863 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -27,7 +27,7 @@ name: Review # types: [created] # jobs: # review: -# uses: ColorMath/ci/.github/workflows/review.yml@v1.0.0 +# uses: ColorMath/ci/.github/workflows/review.yml@vX.Y.Z # see /releases/latest # permissions: # contents: read # pull-requests: write diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ea7c3..dbce193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ one SemVer stream, exact-tag pins, MAJOR = anything that can turn a consumer's green CI red without the consumer editing anything. While on `0.x`, breaking changes may land in any release. -## v3.1.0 — 2026-08-04 +Changes land under `## Unreleased`; `release/cut.sh` renames that heading to the +version being cut and opens a fresh one. The date on a section is the date the +release was cut, and every section from `v3.1.0` on is also the body of that +version's [GitHub Release](https://github.com/ColorMath/ci/releases). + +## Unreleased MINOR. Three new plugin skills and a new vendored file are additive per [LIFECYCLE.md](LIFECYCLE.md), and the skill rename below — while a real break @@ -44,10 +49,59 @@ red, which is the test that makes a release MAJOR. Harmless so far purely by luck: `audit-deps.sh`, `migrations-sync.sh` and `diff-coverage.sh` are byte-identical between `v2.0.0` and `v3.0.0`. The first script change would have made local and CI disagree with no signal. - `COLORMATH_REF` is corrected to `v3.0.0`, LIFECYCLE step 2 now names both, - and a `refs-lockstep` job in colormath's own CI fails the PR when they - disagree — machinery rather than a checklist habit, because the checklist - already had the step and it was still missed. + + The `refs-lockstep` job added here was the right instinct and the wrong + check. It compared the two stamps to *each other* and never asked whether the + ref resolved — so when this release was stamped `v3.1.0` before the tag + existed, it passed while every consumer's `make preflight` would have 404'd + on every gate script. See the release-machinery entry below, which replaces + it and makes hand-stamping impossible in the first place. + +- **Releases are now atomic** (`release/`, `.github/workflows/release.yml`, + `.github/workflows/ci.yml`, `LIFECYCLE.md`). Releasing was a six-step + checklist, and the steps came apart. An audit of all sixteen published tags + found nine internally inconsistent: `v2.1.0` through `v2.4.0` each ship a + `gates.yml` that fetches its gate scripts from `v2.0.0`, and `v3.0.0`'s + `Makefile.colormath` points three releases back. This release was itself + stamped into `main` and written up here without ever being tagged, leaving + `main` advertising a ref that 404s. + + The root cause was stamping *forward*: a human wrote the next version into + the tree days before the tag existed, and the window between the two closed + only if they remembered. Stamps now move only in the release commit, which is + tagged with the version it stamps in a single `git push --atomic` — git + updates both refs or neither, so the window is gone rather than merely + shortened. In steady state `main` is stamped at the last released tag and + every ref in it resolves. + + `release/cut.sh` is the one gesture: it refuses to start unless the tree is + clean, synced with `origin/main`, and green in CI; stamps; re-verifies; + commits; creates an *annotated* tag (the history alternates between + lightweight and annotated); pushes atomically; then publishes the GitHub + Release from this file's section for that version. A rejected push rolls the + local commit and tag back, and a failure after the push is resumable, because + publishing is idempotent. + + `release/verify.sh` replaces `refs-lockstep` in CI and is a strict superset: + it covers `plugin.json` — a stamp site nothing checked, and which had been + missed twice — asks whether the stamped ref *resolves*, and requires + `example/`'s vendored copies to stay byte-identical to the root ones. + `--audit-all` produced the drift table above and stays advisory, because + published tags are never rewritten. + +- **Documentation no longer names a version** (`README.md`, + `.github/workflows/gates.yml`, `.github/workflows/review.yml`). Nine + copy-paste pins had rotted — the README told consumers to pin `@v2.0.0` and + the two workflow usage-comments said `@v1.1.0` and `@v1.0.0`, while the repo + was on `v3.1.0`. They now read `@vX.Y.Z` and point at + `/releases/latest`, and `verify.sh` fails the PR if a concrete version + reappears. Automating the stamping of prose would have worked; deleting the + data was cheaper and cannot regress. + +- **Every tag has a GitHub Release.** All sixteen were bare; the notes existed + only here. `release/backfill-releases.sh` created them retroactively from + this file, and `cut.sh` creates them going forward, so + `/releases/latest` is now a real answer to "what should I pin to?" ### Changed diff --git a/LIFECYCLE.md b/LIFECYCLE.md index ed55e7a..48da9fd 100644 --- a/LIFECYCLE.md +++ b/LIFECYCLE.md @@ -42,25 +42,70 @@ contractual at `v1.0.0`. 1. Land the change on `main` via PR — colormath's own CI (the gate suite running against `example/`) must be green. If the change touches the gates' behavior, `example/` must be updated in the same PR to keep it passing. -2. **Stamp both refs** to the tag you are about to cut: - - the `colormath-ref` input default in `.github/workflows/gates.yml` — how - the workflow fetches its own matching scripts/actions in consumer repos; - - `COLORMATH_REF` in `Makefile.colormath` — how the *local* mirror fetches - those same scripts, so `make preflight` runs what CI runs. - - They must match, and CI's `refs-lockstep` job fails the PR when they don't. - Miss the second and preflight silently runs an older gate script than CI — - invisible until a script changes between the two tags. -3. Update `CHANGELOG.md` (with Upgrade notes if MAJOR). -4. Tag: `git tag vX.Y.Z && git push origin vX.Y.Z`. -5. **Canary**: bump talas first (PR with the new `@vX.Y.Z`), merge when its + Write the release notes under `## Unreleased` in `CHANGELOG.md` as you go + (with Upgrade notes if MAJOR). **Never stamp a version by hand.** +2. Cut it: run the **Release** workflow with the version, or locally + `./release/cut.sh vX.Y.Z` (`--dry-run` first to see the diff). That stamps + every version site, dates the changelog section, commits, creates an + annotated tag, pushes both atomically, and publishes the GitHub Release. +3. **Canary**: bump talas first (PR with the new `@vX.Y.Z`), merge when its gates are green, let one staging deploy soak. -6. Then intendent, then runwayz — runwayz always last (furthest from the +4. Then intendent, then runwayz — runwayz always last (furthest from the template; its bump PRs are where weird interactions surface, and by then the release is proven). -This is a checklist habit, not machinery. The automated bump workflow -(`colormath-bump.yml` in each consumer) arrives with the Copier channel. +Steps 3–4 stay human. Everything before them is machinery, because the +checklist that used to cover them had the steps written down and they were +still missed. The automated bump workflow (`colormath-bump.yml` in each +consumer) arrives with the Copier channel. + +### The invariant + +> On `main`, at every commit: every version this repo writes down agrees, equals +> the newest tag reachable from `HEAD`, and that tag exists. + +Consumers pin exact tags, and the stamps are how colormath fetches its own +scripts at that pin. Break the invariant in one direction — a stamp naming a +tag that does not exist yet — and every consumer's `make preflight` 404s on +every gate script. Break it in the other — a stamp naming an older tag — and CI +and the local mirror silently run different gate scripts, wrong only once a +script changes between the two. Both have happened here. Nine of the sixteen +tags up to `v3.0.0` are internally inconsistent (`release/verify.sh +--audit-all` prints the table). + +The cause was stamping *forward*: writing the next version into the tree days +before the tag existed, leaving a window that closed only if someone +remembered. So **stamps move only in the release commit**, which is tagged with +the version it stamps in a single `git push --atomic` — git updates both refs +or neither. There is no window to forget about, and the invariant is therefore +true continuously, including on the release commit itself. + +That is what makes `release-consistency` safe to run as a blocking check in CI, +despite the warning under [Drift detection](#drift-detection-steady-state) +below: it can only fail on a PR that edited a version string. A PR that touches +none of them inherits main's consistent state and passes unconditionally, so it +can never stand between a hotfix and `main`. + +Documentation examples are deliberately **not** stamped — they read `@vX.Y.Z` +and point at `/releases/latest`. Nine of them had rotted to versions one to +three majors stale. Deleting the data beat automating it. + +### When a release goes wrong + +Re-running is always safe. `cut.sh` refuses to start from an inconsistent tree, +rolls back its own commit and tag if the atomic push is rejected, and resumes at +the publish step if the tag landed but the GitHub Release did not. + +**Never move or delete a published tag.** Not `git tag -f`, not delete-and- +recreate. A consumer that already fetched it gets `would clobber existing tag` +on their next fetch, and cached CI runners go nondeterministic — this repo +exists to not do that to consumers. If a release is wrong, burn the version: +cut the next one immediately and edit the bad Release's body to lead with +**WITHDRAWN — use vX.Y.Z+1**. That costs one integer and stays honest. + +Tags at and below `v3.0.0` are a mix of lightweight and annotated, and several +are internally inconsistent. Both are historical artifacts, left alone on +purpose. Every tag from `v3.1.0` on is annotated and verified before it exists. ## Propagation (steady state, once Copier lands) diff --git a/Makefile.colormath b/Makefile.colormath index c71e7ca..6c85350 100644 --- a/Makefile.colormath +++ b/Makefile.colormath @@ -21,7 +21,7 @@ # up the gate, shipping and guardrail conventions from one shared copy # ---------------------------------------------------------------------------- -COLORMATH_REF = v3.1.0 +COLORMATH_REF = v3.0.0 COLORMATH_RAW = https://raw.githubusercontent.com/ColorMath/ci/$(COLORMATH_REF) # Overridable knobs — set before the include if your project differs. diff --git a/README.md b/README.md index 2b34f9d..7ec7899 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ concurrency: jobs: gates: - uses: ColorMath/ci/.github/workflows/gates.yml@v2.0.0 + uses: ColorMath/ci/.github/workflows/gates.yml@vX.Y.Z # latest: /releases/latest with: python-version: "3.12" default-branch: main @@ -120,7 +120,7 @@ existing findings, land the caller with the failing gates disabled, burn the findings down, and enable them one by one: ```yaml - uses: ColorMath/ci/.github/workflows/gates.yml@v2.0.0 + uses: ColorMath/ci/.github/workflows/gates.yml@vX.Y.Z # latest: /releases/latest with: python-version: "3.12" default-branch: main @@ -229,7 +229,7 @@ on: jobs: review: - uses: ColorMath/ci/.github/workflows/review.yml@v2.0.0 + uses: ColorMath/ci/.github/workflows/review.yml@vX.Y.Z # latest: /releases/latest permissions: contents: read pull-requests: write @@ -346,11 +346,15 @@ factory options and escape hatches), and [Agent docs](#agent-docs) below). Vendor them once: ```sh -curl -fsSLO https://raw.githubusercontent.com/ColorMath/ci/v2.0.0/Makefile.colormath -curl -fsSLO https://raw.githubusercontent.com/ColorMath/ci/v2.0.0/eslint.config.colormath.mjs -curl -fsSLO https://raw.githubusercontent.com/ColorMath/ci/v2.0.0/AGENTS.colormath.md +# REF is the release you are pinning to — see github.com/ColorMath/ci/releases/latest +REF=vX.Y.Z +curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/Makefile.colormath" +curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/eslint.config.colormath.mjs" +curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/AGENTS.colormath.md" ``` +Thereafter `make colormath-update REF=vX.Y.Z` refreshes all three in one step. + then include the Makefile from yours, providing the one target it expects from you (`test`) and setting knobs before the include if your project differs: @@ -407,9 +411,18 @@ tokens live. [example/AGENTS.md](example/AGENTS.md) shows the wiring. ## Versioning and upgrades -One SemVer tag stream, and consumers pin **exact tags only** (`@v2.0.0`, never -a floating major tag): an upgrade should arrive as a reviewable PR whose diff -and changelog explain themselves — not as a surprise inside an unrelated one. +One SemVer tag stream, and consumers pin **exact tags only** (a specific +`@vX.Y.Z`, never a floating major tag): an upgrade should arrive as a +reviewable PR whose diff and changelog explain themselves — not as a surprise +inside an unrelated one. Every tag has a +[GitHub Release](https://github.com/ColorMath/ci/releases) carrying that +version's changelog section, so +[releases/latest](https://github.com/ColorMath/ci/releases/latest) is the +authoritative answer to "what should I pin to?" + +The snippets above deliberately say `vX.Y.Z` rather than a real version. Pins +written into documentation rot silently — these had been sitting three +releases stale — so there is nothing here to keep up to date. The rule for MAJOR: *if a consumer's CI can go from green to red without the consumer editing anything, it's MAJOR.* New gates ship disabled-by-default in @@ -430,6 +443,7 @@ Makefile.colormath # shared local gate targets — vendored by consu eslint.config.colormath.mjs # shared eslint base — vendored by consumers AGENTS.colormath.md # shared agent conventions — vendored by consumers scripts/ # gate scripts, fetched by the workflow at its own ref +release/ # release tooling — stamp, verify, cut (not shipped) example/ # minimal compliant consumer + contract reference docs/ # adoption notes for the maintainer's own products ``` diff --git a/release/backfill-releases.sh b/release/backfill-releases.sh new file mode 100755 index 0000000..980020d --- /dev/null +++ b/release/backfill-releases.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Create the missing GitHub Releases for tags that were cut before there was +# machinery to create them. +# +# All sixteen tags up to v3.0.0 were published bare: the release notes existed +# only in CHANGELOG.md, so `/releases/latest` — the obvious place a consumer +# looks to answer "what should I pin to?" — was empty. This walks the tags +# oldest to newest and fills them in from the changelog. +# +# Idempotent: a tag that already has a Release is skipped, so a partial run is +# fixed by rerunning. One-shot in practice — cut.sh creates the Release for +# every tag from v3.1.0 on. +# +# Usage: backfill-releases.sh [--apply] +# +# Dry-run by default. --apply is required to create anything, because this +# writes to a public repo and notifies watchers. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/lib.sh" + +apply=0 +[ "${1:-}" = "--apply" ] && apply=1 + +gh auth status >/dev/null 2>&1 || die "gh is not authenticated (run: gh auth login)" +repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +tags=$(git -C "$COLORMATH_ROOT" tag --list 'v[0-9]*' --sort=v:refname) +[ -n "$tags" ] || die "no tags found" +newest=$(printf '%s\n' "$tags" | tail -1) + +echo "Repo: $repo" +echo "Newest: $newest (gets --latest; every other tag gets --latest=false)" +echo + +created=0 skipped=0 missing=0 + +for tag in $tags; do + if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then + printf ' %-10s skip (Release already exists)\n' "$tag" + skipped=$((skipped + 1)) + continue + fi + + if ! "$here/notes.sh" "$tag" >/dev/null 2>&1; then + printf ' %-10s SKIP — no non-empty CHANGELOG.md section\n' "$tag" + missing=$((missing + 1)) + continue + fi + + # LIFECYCLE.md: pins become contractual at v1.0.0, so the 0.x tags are + # honestly pre-releases. This also keeps them out of "latest" regardless of + # the flag below. + prerelease="" + case "$tag" in v0.*) prerelease="--prerelease" ;; esac + + latest_flag="--latest=false" + [ "$tag" = "$newest" ] && latest_flag="--latest=true" + + if [ "$apply" -eq 0 ]; then + printf ' %-10s would create (%s lines of notes) %s %s\n' \ + "$tag" "$("$here/notes.sh" "$tag" | wc -l)" "$latest_flag" "$prerelease" + created=$((created + 1)) + continue + fi + + notes_file=$(mktemp) + "$here/notes.sh" "$tag" >"$notes_file" + # --verify-tag: never invent a tag that does not exist. + gh release create "$tag" \ + --repo "$repo" \ + --title "$tag" \ + --notes-file "$notes_file" \ + --verify-tag \ + $latest_flag $prerelease >/dev/null + rm -f "$notes_file" + printf ' %-10s created %s %s\n' "$tag" "$latest_flag" "$prerelease" + created=$((created + 1)) +done + +echo +if [ "$apply" -eq 0 ]; then + echo "Dry run: $created would be created, $skipped already exist, $missing have no notes." + echo "Rerun with --apply to create them." +else + echo "Done: $created created, $skipped already existed, $missing skipped for missing notes." +fi + +# GitHub does not allow backdating published_at, so every backfilled Release +# shows today's date. The changelog date is in each body and the tag's own +# commit date is on the release page, so the historical record survives where it +# matters. diff --git a/release/cut.sh b/release/cut.sh new file mode 100755 index 0000000..b847cd3 --- /dev/null +++ b/release/cut.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# Cut a colormath release: stamp, commit, tag, push, publish. One gesture. +# +# The whole point is atomicity. The old process was a six-step checklist in +# LIFECYCLE.md, and the steps drifted apart: v3.1.0 was stamped into main and +# written into the changelog but never tagged, leaving every consumer's +# `make preflight` fetching gate scripts from a ref that 404s. Nine of the +# sixteen published tags are internally inconsistent for the same reason. +# +# The fix is that the stamps and the tag land in a single `git push --atomic`. +# Git updates both refs or neither, so there is no window in which main +# advertises a tag that does not exist — not days, not seconds. If someone else +# pushed to main in the meantime, the whole push is rejected and nothing +# partial happens; rerun after pulling. +# +# The one thing that cannot be inside the transaction is the GitHub Release, +# which lives in a different system. So that step is idempotent and this script +# is resumable: if the tag is already on origin at HEAD, it skips straight to +# publishing. +# +# Usage: +# cut.sh vX.Y.Z cut the release +# cut.sh vX.Y.Z --dry-run do everything except commit/tag/push/publish +# +# Env: +# COLORMATH_SKIP_CI_CHECK=1 skip the "CI is green on this SHA" precondition + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/lib.sh" + +version=$(normalize_version "${1:?usage: cut.sh vX.Y.Z [--dry-run]}") +shift +dry_run=0 +[ "${1:-}" = "--dry-run" ] && dry_run=1 + +cd "$COLORMATH_ROOT" + +step() { echo; echo "==> $*"; } + +# --- resume path ------------------------------------------------------------ +# +# Push succeeded, publish failed. Don't redo the transaction; finish it. +if tag_exists_on_origin "$version"; then + remote_sha=$(git ls-remote --tags origin "refs/tags/$version^{}" | cut -f1) + [ -n "$remote_sha" ] || remote_sha=$(git ls-remote --tags origin "refs/tags/$version" | cut -f1) + if [ "$remote_sha" = "$(git rev-parse HEAD)" ]; then + step "$version is already on origin at HEAD — resuming at the publish step" + exec "$here/publish-release.sh" "$version" ${dry_run:+$([ "$dry_run" -eq 1 ] && echo --dry-run)} + fi + die "$version already exists on origin, pointing at $remote_sha (HEAD is $(git rev-parse HEAD))" +fi + +# --- preconditions ---------------------------------------------------------- + +step "Checking preconditions" + +[ -z "$(git status --porcelain)" ] || die "working tree is dirty — commit or stash first" +note "working tree is clean" + +branch=$(git rev-parse --abbrev-ref HEAD) +[ "$branch" = "main" ] || die "releases are cut from main, not $branch" +note "on main" + +git fetch --quiet origin main --tags +[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || + die "main is not in sync with origin/main — pull, or push what you have, then rerun" +note "in sync with origin/main" + +gh auth status >/dev/null 2>&1 || die "gh is not authenticated (run: gh auth login)" +note "gh is authenticated" + +if [ "${COLORMATH_SKIP_CI_CHECK:-0}" != "1" ]; then + sha=$(git rev-parse HEAD) + conclusion=$(gh run list --workflow=CI --branch=main --limit=20 \ + --json headSha,conclusion,status \ + --jq "[.[] | select(.headSha == \"$sha\")] | first | .conclusion // \"none\"" 2>/dev/null || echo none) + case "$conclusion" in + success) note "CI is green on $sha" ;; + none) die "no CI run found for $sha — wait for CI, or set COLORMATH_SKIP_CI_CHECK=1" ;; + *) die "the CI run for $sha concluded '$conclusion' — fix main before releasing" ;; + esac +else + note "CI check skipped (COLORMATH_SKIP_CI_CHECK=1)" +fi + +# --- verify the baseline ---------------------------------------------------- + +step "Verifying the current state is consistent before changing anything" +"$here/verify.sh" + +# --- stamp ------------------------------------------------------------------ + +step "Stamping $version" +"$here/stamp.sh" "$version" + +step "Verifying the stamped tree" +"$here/verify.sh" --expect "$version" + +echo +echo "==> Release diff" +git --no-pager diff --stat +echo +git --no-pager diff + +if [ "$dry_run" -eq 1 ]; then + echo + echo "==> DRY RUN — reverting the stamp, nothing was committed" + git checkout -- . + echo + echo "Would have run:" + echo " git commit -m 'chore(release): $version'" + echo " git tag -a $version -m " + echo " git push --atomic origin main refs/tags/$version" + echo " publish-release.sh $version" + exit 0 +fi + +# --- the transaction -------------------------------------------------------- + +step "Committing and tagging" +git add -A +git commit --quiet -m "chore(release): $version" + +# Annotated, always. The repo's history flips between lightweight and annotated +# tags (v2.0.0-v2.3.0 annotated, v2.4.0 and v3.0.0 lightweight); annotated tags +# carry the tagger, date, and message, so the release notes travel with the tag +# itself and not only with the GitHub Release. +notes_file=$(mktemp) +trap 'rm -f "$notes_file"' EXIT +{ + echo "$version" + echo + "$here/notes.sh" "$version" +} >"$notes_file" +git tag -a "$version" -F "$notes_file" + +step "Pushing main and $version atomically" +if ! git push --atomic origin main "refs/tags/$version"; then + echo + echo "Atomic push rejected — NOTHING was published." >&2 + echo "Neither main nor the tag moved on origin. Cleaning up the local commit and tag." >&2 + git tag -d "$version" >/dev/null + git reset --hard HEAD~1 >/dev/null + die "push rejected (someone else pushed to main?). Pull and rerun." +fi +note "main and $version are on origin" + +step "Publishing the GitHub Release" +"$here/publish-release.sh" "$version" + +echo +echo "Released $version." +echo "Next, per LIFECYCLE.md: canary talas, then intendent, then runwayz." diff --git a/release/lib.sh b/release/lib.sh new file mode 100755 index 0000000..a0207e0 --- /dev/null +++ b/release/lib.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Shared helpers for the colormath release scripts. Sourced, never executed. +# +# Everything that knows *where* a version is written down lives here, in the +# read_*/write_* pairs below. Adding a new stamp site means adding one pair and +# one entry in STAMP_SITES — stamp.sh and verify.sh both drive off that list, so +# a new site cannot be stamped-but-unverified or verified-but-unstamped. That +# asymmetry is exactly how plugin.json drifted: it was a stamp site nothing +# checked. +# +# Note these are the *machine-read* sites only — refs that something resolves at +# runtime. Documentation examples are deliberately not stamped; they carry a +# literal "vX.Y.Z" placeholder so there is nothing there to rot. +# +# This tooling lives in release/ rather than scripts/ on purpose. gates.yml +# sparse-checks-out `scripts` onto every consumer runner (16 call sites), and +# cone-mode sparse checkout is recursive — so scripts/release/ would ship to +# every consumer CI run and become part of the published contract surface. +# release/ sits beside docs/ and example/, outside that checkout. + +set -euo pipefail + +# Repo root, regardless of where a script was invoked from. +COLORMATH_ROOT="${COLORMATH_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +GATES_WF="$COLORMATH_ROOT/.github/workflows/gates.yml" +MAKEFILE="$COLORMATH_ROOT/Makefile.colormath" +PLUGIN_JSON="$COLORMATH_ROOT/plugin/.claude-plugin/plugin.json" +CHANGELOG="$COLORMATH_ROOT/CHANGELOG.md" + +# The machine-read stamp sites, as "label:reader:writer". stamp.sh runs every +# writer; verify.sh runs every reader and requires them to agree. +STAMP_SITES=( + "gates.yml colormath-ref default:read_gates_ref:write_gates_ref" + "Makefile.colormath COLORMATH_REF:read_makefile_ref:write_makefile_ref" + "plugin.json version:read_plugin_version:write_plugin_version" +) + +die() { + echo "error: $*" >&2 + exit 1 +} + +note() { echo " $*"; } + +# --- version helpers -------------------------------------------------------- + +# Accepts "3.1.0" or "v3.1.0"; always prints the "v" form. Rejects anything +# else, so a typo becomes an error rather than a tag named "v3..1.0". +normalize_version() { + local v="${1#v}" + [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "not a SemVer version: $1 (expected X.Y.Z or vX.Y.Z)" + echo "v$v" +} + +# The newest tag reachable from HEAD, by SemVer order. +# +# Deliberately not `git describe --tags --abbrev=0`, which returns the most +# recent tag by *commit distance*. On a history where v2.4.0 and v3.0.0 sit on +# adjacent merges, distance and SemVer can disagree, and the invariant is a +# statement about SemVer. +newest_reachable_tag() { + git -C "$COLORMATH_ROOT" tag --merged HEAD --sort=-v:refname | head -1 +} + +# True when $1 sorts strictly above $2 in SemVer order. +version_gt() { + [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] +} + +tag_exists_locally() { git -C "$COLORMATH_ROOT" rev-parse -q --verify "refs/tags/$1" >/dev/null 2>&1; } + +tag_exists_on_origin() { [ -n "$(git -C "$COLORMATH_ROOT" ls-remote --tags origin "refs/tags/$1" 2>/dev/null)" ]; } + +# --- stamp site readers ----------------------------------------------------- +# +# Each reader prints the version in "vX.Y.Z" form, or nothing if the site could +# not be parsed. Callers treat empty as a hard error — a site that stopped +# matching is a site that silently stopped being stamped. + +read_gates_ref() { + awk ' + /^ colormath-ref:/ { in_input = 1; next } + in_input && /^ [a-z]/ { exit } + in_input && /^ default:/ { + gsub(/^ default: *"?|"? *$/, "") + print; exit + } + ' "$GATES_WF" +} + +read_makefile_ref() { + sed -n 's/^COLORMATH_REF[[:space:]]*=[[:space:]]*\(v[0-9][0-9.]*\)[[:space:]]*$/\1/p' "$MAKEFILE" | head -1 +} + +# plugin.json stores a bare "3.1.0"; print it in "v" form so every reader is +# directly comparable. +read_plugin_version() { + local raw + raw=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([0-9][0-9.]*\)".*$/\1/p' "$PLUGIN_JSON" | head -1) + [ -n "$raw" ] && echo "v$raw" +} + +# The newest *released* section heading in the changelog, ignoring Unreleased. +read_changelog_latest() { + sed -n 's/^## \(v[0-9][0-9.]*\) — .*$/\1/p' "$CHANGELOG" | head -1 +} + +changelog_has_unreleased() { grep -q '^## Unreleased[[:space:]]*$' "$CHANGELOG"; } + +# --- stamp site writers ----------------------------------------------------- +# +# Anchored patterns, never a global version replace. The repo is full of prose +# that names old versions on purpose ("Default-on since v2.0.0", "contractual at +# v1.0.0", the adoption notes recording what consumers actually run); rewriting +# any of it would be a silent falsification of the history. + +write_gates_ref() { + local v="$1" + awk -v ver="$v" ' + /^ colormath-ref:/ { in_input = 1 } + in_input && /^ default:/ { + sub(/default:.*/, "default: \"" ver "\"") + in_input = 0 + } + in_input && /^ [a-z]/ && !/^ colormath-ref:/ { in_input = 0 } + { print } + ' "$GATES_WF" >"$GATES_WF.tmp" && mv "$GATES_WF.tmp" "$GATES_WF" +} + +write_makefile_ref() { + sed -i "s|^COLORMATH_REF[[:space:]]*=.*$|COLORMATH_REF = $1|" "$MAKEFILE" +} + +write_plugin_version() { + sed -i "s|^\([[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\"\)[0-9][0-9.]*\(\"\)|\1${1#v}\2|" "$PLUGIN_JSON" +} diff --git a/release/notes.sh b/release/notes.sh new file mode 100755 index 0000000..d9eb0b6 --- /dev/null +++ b/release/notes.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Print one release's section body from CHANGELOG.md. +# +# Single source of truth for release notes: both `cut.sh` (annotated tag message +# + GitHub Release body) and `backfill-releases.sh` read notes from here, so a +# GitHub Release can never say something the changelog doesn't. There is no +# second place to write release notes and therefore no second place to forget. +# +# Usage: notes.sh vX.Y.Z +# +# Prints everything between the `## vX.Y.Z — DATE` heading and the next `## ` +# heading, trimmed of leading/trailing blank lines. Exits non-zero when the +# section is missing or empty — a release with no notes is a release nobody can +# review, and that is worth failing over. + +set -euo pipefail + +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +version=$(normalize_version "${1:?usage: notes.sh vX.Y.Z}") + +body=$(awk -v want="## $version " ' + index($0, want) == 1 { in_section = 1; next } + in_section && /^## / { exit } + in_section { print } +' "$CHANGELOG") + +# Strip leading and trailing blank lines. +body=$(printf '%s\n' "$body" | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac) + +[ -n "$body" ] || die "CHANGELOG.md has no non-empty '## $version — DATE' section" + +printf '%s\n' "$body" diff --git a/release/publish-release.sh b/release/publish-release.sh new file mode 100755 index 0000000..b2b6890 --- /dev/null +++ b/release/publish-release.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Create the GitHub Release for a tag, with the body taken from CHANGELOG.md. +# +# Split out of cut.sh so it is independently re-runnable. The push and the +# release creation cannot be made one transaction — they are two different +# systems — so the design makes the second half idempotent instead: if the tag +# landed but `gh` failed, rerunning fixes it, and rerunning when the release +# already exists is a no-op. +# +# Usage: publish-release.sh vX.Y.Z [--latest=false] [--dry-run] + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/lib.sh" + +version=$(normalize_version "${1:?usage: publish-release.sh vX.Y.Z [--latest=false] [--dry-run]}") +shift +latest_flag="--latest" +dry_run=0 +for arg in "$@"; do + case "$arg" in + --latest=false) latest_flag="--latest=false" ;; + --latest=true) latest_flag="--latest" ;; + --dry-run) dry_run=1 ;; + *) die "unknown argument: $arg" ;; + esac +done + +if gh release view "$version" --repo "$(gh repo view --json nameWithOwner -q .nameWithOwner)" >/dev/null 2>&1; then + note "GitHub Release $version already exists — nothing to do" + exit 0 +fi + +notes=$("$here/notes.sh" "$version") + +if [ "$dry_run" -eq 1 ]; then + echo "DRY RUN: would create GitHub Release $version ($latest_flag) with body:" + echo "---" + printf '%s\n' "$notes" | head -20 + printf '%s\n' "$notes" | tail -n +21 | head -1 | grep -q . && echo " ... ($(printf '%s\n' "$notes" | wc -l) lines total)" + echo "---" + exit 0 +fi + +notes_file=$(mktemp) +trap 'rm -f "$notes_file"' EXIT +printf '%s\n' "$notes" >"$notes_file" + +# --verify-tag refuses to invent a tag: if the ref is not on the remote, this +# fails instead of silently creating one from the default branch. +gh release create "$version" \ + --title "$version" \ + --notes-file "$notes_file" \ + --verify-tag \ + "$latest_flag" + +note "GitHub Release $version created" diff --git a/release/stamp.sh b/release/stamp.sh new file mode 100755 index 0000000..4fbf55f --- /dev/null +++ b/release/stamp.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Write a version into every machine-read stamp site, and promote the +# changelog's Unreleased section to that version. +# +# Pure filesystem: no git, no network, no gh. That is deliberate — it makes the +# risky part (rewriting files with sed/awk) independently testable, and leaves +# cut.sh as the only script in the set that can mutate a ref or touch a remote. +# +# Idempotent: running it twice with the same version is a no-op the second time. +# +# Usage: stamp.sh vX.Y.Z [--date YYYY-MM-DD] +# +# What it does NOT touch, on purpose: prose that names an old version because +# that is a true historical statement ("Default-on since v2.0.0"), the adoption +# notes recording which release each consumer is actually on, and the +# documentation examples, which carry a literal vX.Y.Z placeholder precisely so +# that nothing has to remember to update them. + +set -euo pipefail + +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +version=$(normalize_version "${1:?usage: stamp.sh vX.Y.Z [--date YYYY-MM-DD]}") +shift +release_date=$(date -u +%Y-%m-%d) +if [ "${1:-}" = "--date" ]; then + release_date="${2:?--date needs a YYYY-MM-DD argument}" +fi + +echo "Stamping $version (dated $release_date)" + +# --- the machine-read sites ------------------------------------------------- + +for site in "${STAMP_SITES[@]}"; do + label="${site%%:*}" + writer="${site##*:}" + "$writer" "$version" + note "$label -> $version" +done + +# --- the changelog ---------------------------------------------------------- +# +# Promote "## Unreleased" to "## vX.Y.Z — DATE" and open a fresh empty +# Unreleased above it, so the next change has somewhere to land. The em dash +# matches the existing heading style and is what read_changelog_latest and +# notes.sh both key off. + +if grep -q "^## $version — " "$CHANGELOG"; then + note "CHANGELOG.md already has a $version section — leaving it alone" +else + changelog_has_unreleased || die "CHANGELOG.md has no '## Unreleased' section to promote" + + awk -v ver="$version" -v d="$release_date" ' + !done && /^## Unreleased[[:space:]]*$/ { + print "## Unreleased" + print "" + print "## " ver " — " d + done = 1 + next + } + { print } + ' "$CHANGELOG" >"$CHANGELOG.tmp" && mv "$CHANGELOG.tmp" "$CHANGELOG" + note "CHANGELOG.md: Unreleased -> $version — $release_date" +fi + +echo "Stamped. Nothing has been committed, tagged, or pushed." diff --git a/release/verify.sh b/release/verify.sh new file mode 100755 index 0000000..4c09eae --- /dev/null +++ b/release/verify.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# +# The consistency oracle for colormath releases. One script, three modes, used +# by CI on every PR, by cut.sh immediately before it pushes, and by hand to +# audit tags after the fact. +# +# The invariant it enforces, in default mode: +# +# On main, at every commit: the machine-read stamps agree with each other, +# they equal the newest tag reachable from HEAD, and that tag exists. +# +# Why that is the right invariant. Consumers pin exact tags, and the stamps are +# how colormath fetches its own scripts at that pin. If a stamp names a tag that +# does not exist yet — the state main was left in when v3.1.0 was stamped but +# never tagged — then `make preflight` in every consumer 404s on every gate +# script. If a stamp names an older tag than the commit it ships in — the state +# the tagged v3.0.0 artifact is in today — then CI and the local mirror silently +# run different gate scripts. The old refs-lockstep check caught neither: it +# compared the two stamps to each other and never asked whether the ref +# resolved. +# +# The invariant holds continuously under the stamp-at-release model, including +# on the release commit itself, because that commit is tagged with the version +# it stamps in the same atomic push. That is what makes it safe to make +# blocking: it is already true, so it can never stand between a hotfix and main. +# +# Usage: +# verify.sh default: check the working tree against the tags +# verify.sh --expect vX.Y.Z release-time: check everything is stamped vX.Y.Z +# and that vX.Y.Z is a legal next tag +# verify.sh --tag vX.Y.Z audit: is that already-published tag self-consistent? +# verify.sh --audit-all audit every tag, print a table, never fail + +set -euo pipefail + +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +failures=0 + +fail() { + echo "FAIL: $*" >&2 + failures=$((failures + 1)) + # GitHub Actions surfaces this in the PR's file view when it can. + [ -n "${GITHUB_ACTIONS:-}" ] && echo "::error::$*" + return 0 +} + +pass() { echo "ok: $*"; } + +# Read every stamp site; require each to parse and all to agree. Sets +# SITES_VERSION to the agreed version, or empty when they disagree or one failed +# to parse. +# +# Sets a global rather than printing, deliberately: a command substitution would +# run this in a subshell and every fail() increment would be discarded with it, +# so the script would report success while printing failures. +SITES_VERSION="" +check_sites_agree() { + local agreed="" site label reader value + SITES_VERSION="" + for site in "${STAMP_SITES[@]}"; do + label="${site%%:*}" + reader="${site#*:}" + reader="${reader%%:*}" + value=$("$reader" || true) + if [ -z "$value" ]; then + fail "could not parse a version out of $label — the stamp site moved or changed shape" + return 0 + fi + echo " $label = $value" + if [ -z "$agreed" ]; then + agreed="$value" + elif [ "$value" != "$agreed" ]; then + fail "stamp sites disagree: $label is $value, an earlier site is $agreed" + return 0 + fi + done + SITES_VERSION="$agreed" +} + +verify_worktree() { + local expected="${1:-}" stamped changelog_latest + + check_sites_agree + stamped="$SITES_VERSION" + [ -n "$stamped" ] || return 0 + pass "all ${#STAMP_SITES[@]} stamp sites agree at $stamped" + + changelog_latest=$(read_changelog_latest) + if [ "$changelog_latest" != "$stamped" ]; then + fail "CHANGELOG.md's newest released section is $changelog_latest, but the stamps say $stamped" + else + pass "CHANGELOG.md's newest released section is $stamped" + fi + + if [ -n "$expected" ]; then + # Release mode: the stamps must be the version we are about to cut. + [ "$stamped" = "$expected" ] || fail "expected everything stamped $expected, found $stamped" + else + # Steady-state mode: the stamps must be the newest reachable tag, and + # that tag must actually exist. + local newest + newest=$(newest_reachable_tag) + if [ -z "$newest" ]; then + fail "no tags reachable from HEAD — cannot check the stamps against a release" + elif [ "$stamped" != "$newest" ]; then + if tag_exists_locally "$stamped"; then + fail "stamps say $stamped but the newest reachable tag is $newest — a release was cut without restamping, or a stamp was hand-edited" + else + fail "stamps say $stamped, which is not a tag at all (newest reachable is $newest) — every fetch of scripts/ at that ref will 404" + fi + else + pass "stamps match the newest reachable tag ($newest)" + fi + + if changelog_has_unreleased; then + pass "CHANGELOG.md has an '## Unreleased' section" + else + fail "CHANGELOG.md has no '## Unreleased' section — changes land there, and the release renames it" + fi + fi +} + +# The documentation examples were deliberately de-versioned to a literal vX.Y.Z +# placeholder, because nine of them had rotted to v2.0.0/v1.1.0/v1.0.0 while the +# repo was on v3.1.0. Nothing stamps them, so the only way they stay correct is +# by staying placeholders. This catches a well-meaning "fix" that pins them +# again, anywhere in the repo rather than only in the files that had the problem. +# +# The allowlist is short and each entry is a place where naming an old version is +# the correct, truthful thing to do. +verify_doc_placeholders() { + local hits + hits=$(git -C "$COLORMATH_ROOT" grep -nE 'ColorMath/ci(/\.github/workflows/[a-z-]+\.yml@|/)v[0-9]+\.[0-9]+\.[0-9]+' -- \ + ':!CHANGELOG.md' \ + ':!docs/adoption-notes.md' \ + ':!release/' \ + ':!example/node_modules/' 2>/dev/null || true) + if [ -n "$hits" ]; then + fail "these name a concrete colormath version; docs must stay 'vX.Y.Z' placeholders so they cannot rot:" + printf '%s\n' "$hits" >&2 + else + pass "documentation examples are still vX.Y.Z placeholders" + fi +} + +# example/ vendors two of the three vendored files and they must stay +# byte-identical to the root copies — example/ is the compliance reference, so a +# consumer reading it must see exactly what they would fetch. They are identical +# today, by hand and by luck; nothing checked. +verify_vendored_copies() { + local f + for f in AGENTS.colormath.md eslint.config.colormath.mjs; do + if [ ! -f "$COLORMATH_ROOT/example/$f" ]; then + fail "example/$f is missing — example/ must vendor the same files a consumer would" + elif ! cmp -s "$COLORMATH_ROOT/$f" "$COLORMATH_ROOT/example/$f"; then + fail "example/$f differs from the root copy — example/ must show consumers exactly what they'd fetch" + else + pass "example/$f is byte-identical to the root copy" + fi + done +} + +verify_expect_is_a_legal_next_tag() { + local version="$1" newest + newest=$(newest_reachable_tag) + + if tag_exists_on_origin "$version"; then + fail "$version already exists on origin — pick the next version, or delete the tag if it was a mistake" + else + pass "$version is not yet on origin" + fi + + if [ -n "$newest" ] && ! version_gt "$version" "$newest"; then + fail "$version does not sort above the newest reachable tag ($newest)" + else + pass "$version sorts above $newest" + fi + + local today section_date + today=$(date -u +%Y-%m-%d) + section_date=$(sed -n "s/^## $version — \(.*\)$/\1/p" "$CHANGELOG" | head -1) + if [ "$section_date" != "$today" ]; then + fail "CHANGELOG.md's $version section is dated '$section_date', expected today ($today)" + else + pass "CHANGELOG.md's $version section is dated today" + fi + + # notes.sh already fails on a missing or empty section. + if "$(dirname "${BASH_SOURCE[0]}")/notes.sh" "$version" >/dev/null 2>&1; then + pass "$version has a non-empty changelog body" + else + fail "$version has no non-empty changelog body" + fi +} + +# Audit an already-published tag by reading its blobs directly. No checkout, so +# this is safe to run against any tag from a dirty working tree. +audit_tag() { + local tag="$1" g m p problems="" + # Early tags predate some of these files entirely; a missing blob is "-", + # not an error, so `|| true` guards each pipeline against pipefail. + g=$(git -C "$COLORMATH_ROOT" show "$tag:.github/workflows/gates.yml" 2>/dev/null | + awk '/^ colormath-ref:/{f=1;next} f&&/^ [a-z]/{exit} f&&/^ default:/{gsub(/^ default: *"?|"? *$/,"");print;exit}' || true) + m=$(git -C "$COLORMATH_ROOT" show "$tag:Makefile.colormath" 2>/dev/null | + sed -n 's/^COLORMATH_REF[[:space:]]*=[[:space:]]*\(v[0-9][0-9.]*\).*$/\1/p' | head -1 || true) + p=$(git -C "$COLORMATH_ROOT" show "$tag:plugin/.claude-plugin/plugin.json" 2>/dev/null | + sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([0-9][0-9.]*\)".*$/v\1/p' | head -1 || true) + + [ -n "$g" ] && [ "$g" != "$tag" ] && problems="${problems}gates=$g " + [ -n "$m" ] && [ "$m" != "$tag" ] && problems="${problems}makefile=$m " + [ -n "$p" ] && [ "$p" != "$tag" ] && problems="${problems}plugin=$p " + + printf '%-10s %-10s %-10s %-10s %s\n' "$tag" "${g:--}" "${m:--}" "${p:--}" \ + "$([ -n "$problems" ] && echo "DRIFTED: $problems" || echo "consistent")" +} + +main() { + case "${1:-}" in + --audit-all) + # Advisory only: never exits non-zero. Published tags are immutable in + # practice — consumers are pinned to them — so this reports history, it + # does not gate anything. + printf '%-10s %-10s %-10s %-10s %s\n' TAG GATES MAKEFILE PLUGIN STATUS + local t + for t in $(git -C "$COLORMATH_ROOT" tag --sort=v:refname); do audit_tag "$t"; done + echo + echo "Advisory. Published tags are never rewritten — consumers pin them." + return 0 + ;; + --tag) + local tag + tag=$(normalize_version "${2:?usage: verify.sh --tag vX.Y.Z}") + tag_exists_locally "$tag" || die "no such tag: $tag" + printf '%-10s %-10s %-10s %-10s %s\n' TAG GATES MAKEFILE PLUGIN STATUS + audit_tag "$tag" + return 0 + ;; + --expect) + local version + version=$(normalize_version "${2:?usage: verify.sh --expect vX.Y.Z}") + echo "Verifying the working tree is fully stamped for $version..." + verify_worktree "$version" + verify_expect_is_a_legal_next_tag "$version" + verify_doc_placeholders + verify_vendored_copies + ;; + "") + echo "Verifying release consistency..." + verify_worktree + verify_doc_placeholders + verify_vendored_copies + ;; + *) + die "unknown argument: $1" + ;; + esac + + echo + if [ "$failures" -gt 0 ]; then + echo "$failures check(s) failed." >&2 + echo "See LIFECYCLE.md, 'Releasing' — stamps move only in a release commit." >&2 + exit 1 + fi + echo "All release-consistency checks passed." +} + +main "$@"