cast/.github/workflows/ci.yml
dan-claude-bot 72030511b9 fix: assert no shipped changelog heading is deleted or duplicated
Release headings are append-only: the ceremony (#111) adds one and nothing
in CONTRIBUTING's release flow ever removes one. Nothing asserted that.

The arming rule (test/release.test.ts, rig#66) is narrow by design — it asks
whether the TOP section agrees with package.json's version, about ONE
heading, the one a PR is about to write under. It says nothing about the rest
of the file, and cannot: "a heading disappeared" is not a property of a tree,
it is a property of a DIFF.

So an author adding an entry under '## Unreleased' who types OVER the heading
below it instead of inserting above it produces a tree every existing guard
calls green. git merges it cleanly — a one-line edit in a file nobody touched
concurrently, no conflict, no signal. The shipped section's body is now
sitting under '## Unreleased' and the version it belonged to has no section
at all. It surfaces at the NEXT release, when release-notes.sh cannot find
the section it extracts by heading, or worse republishes the absorbed prose.

Ports box's changelog-monotonic.sh (box#122, caught in review of box#118)
rather than reimplementing the invariant a third time in TypeScript, and
keeps both halves. Containment catches a DELETED heading; it cannot catch a
DUPLICATED one, because a duplicate is head-side surplus and base-minus-head
is blind to extras on the head side. Uniqueness on HEAD is asserted alongside
it, and that half matters more in cast than in box: release-notes.sh's awk
has no `exit`, so `grab` re-arms on every matching '## ' line and two copies
of a version heading make the published body ABSORB whatever sits between
them — with the stranded entry dropped from the next release's notes too.
(rig's extractor truncates instead; cast has the absorbing one.) The existing
"double re-arm" test covers duplicate '## Unreleased' only, not duplicate
VERSION headings, which are the ones that reach release-notes.sh.

Wired into ci.yml as its own step so a red run names the invariant that
broke; pull requests only, because on a push to main the merge base IS HEAD
and the assert is vacuous; STRICT=1 with fetch-depth: 0 so a checkout that
cannot reach the base ref fails loudly instead of skipping quietly forever.
'## Unreleased' stays outside the guarded set — the arming rule owns that
heading and the ceremony legitimately consumes it.

Closes #133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:12 +00:00

87 lines
3.8 KiB
YAML

name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# tree this size costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
# the secrets tests round-trip a real age identity
- run: sudo apt-get update && sudo apt-get install -y age
- run: npm ci
- run: npm run check
- run: npm run build
- run: npm test
- name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# ...and no SHIPPED release heading was deleted (#133; box#122's guard).
# Its own step so that when it goes red the log names the invariant that
# broke — and a DIFFERENT invariant from the arming rule npm test
# carries: arming is a fact about this tree, monotonicity is a fact
# about this tree versus its merge base. Pull requests only: on a push
# to main the merge base IS HEAD, so the assert is vacuous and would
# only add a green step that proves nothing. STRICT=1 so a checkout that
# cannot reach the base ref fails here instead of skipping quietly
# forever.
- name: no shipped changelog heading was deleted
if: github.event_name == 'pull_request'
env:
CHANGELOG_MONOTONIC_STRICT: "1"
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned
# layout, the current symlink, the PATH chain, the uninstall's absence
# assert), not a hand-built imitation of it. Box's CI installs box the
# same way. This is the one place the real npm ci + tsc build path runs
# end to end; the vitest installer tests cover the layout semantics
# offline with a shimmed npm.
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: install via install.sh, from this checkout
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f "$HOME/.local/bin/cast" | grep '/versions/'
"$HOME/.local/bin/cast" --version
"$HOME/.local/bin/cast" versions
- name: converging no-op — a re-run changes nothing and builds nothing
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \
| tee /tmp/rerun.log
grep -q 'already installed' /tmp/rerun.log
- name: uninstall --all — ends with the absence assert
run: |
CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all
test ! -e "$HOME/.local/share/cast"
test ! -e "$HOME/.local/bin/cast"
test ! -L "$HOME/.local/bin/cast"