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 "$@"