Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 15 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 59 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
75 changes: 60 additions & 15 deletions LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion Makefile.colormath
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
```
Expand Down
Loading