The rebase onto main was textually clean and behaviourally broken, and this
PR's own guard is what caught it.
This branch removed test/release.test.ts's `mkdtempSync`/`tmpdir` imports when
it converted that file's call sites to `tmp()`. While it was open, #133's
changelog-monotonic work landed on main and added THREE new
`mkdtempSync(join(tmpdir(), ...))` sites to the same file. The two changes
never touch the same line, so git merged them without a word — leaving call
sites whose imports this branch had deleted. 20 tests died on
`ReferenceError: mkdtempSync is not defined`.
Converted all three to `tmp()`, which is what the file already imports and
what every other test file under test/ uses.
Worth noting which test failed and why it matters: test/tmp-guard.test.ts,
this PR's own class check, reported `offenders: ["release.test.ts"]`. It was
written to stop exactly this — a new raw allocation drifting in — and it did
so on a real regression rather than a synthetic one, before the fix existed.
That is the guard earning its place on its first genuine encounter.
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
The new findIndex callback and the monoBlock array literal exceeded biome's
line budget, so `biome check --error-on-warnings .` failed and took the build
job red with it. Formatter output applied verbatim; no logic change, and the
extractor mutations still behave (unrelated job gated -> green, monotonic step
gated -> red).
My miss, and the same shape as the shellcheck one on box#144: I tailed two
lines of `npm run check` and never saw "Found 1 error". Ran CI's exact command
and read all of its output this time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monotonic step is the LAST step of its job, so stopping only at the next
`- name:` ran the block into the job below and swallowed that job level `if:`.
Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was
meant to fix, moved from "any step in the file" to "this step plus the head of
the next job".
Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.
Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the
identical awk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.
That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned, including that they do not collapse.
Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide assertion it forbade any FUTURE step in ci.yml from being
pull_request-gated and would have failed citing #133 when one legitimately was;
a companion assert keeps the extractor from silently matching nothing and
turning the negative into a tautology.
Ported from heavy-duty/box#144, where the defect was found after this PR's
approvals had landed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.
The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.
That inverted the two halves, and it inverted them hardest here. Deletion
needs a diff to see; duplication is the one release-notes.sh actually
mis-renders, and cast has the ABSORBING extractor — no `exit`, so `grab`
re-arms on the second heading and the published body swallows whatever sits
between the copies (box#118). The half with the live extraction bug behind it
had the most ways to silently not run.
Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main.
Found by claude-bot-andresmgsl and codex-bot-andresmgsl reviewing #134. cast
inherited the ordering from box, fixed there in heavy-duty/box#144 (#143).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Review round on #114. Both blockers were real and reproduced here.
The re-arm and the older extraction guard contradicted each other: that
guard asserted the TOP section extracts non-empty, and CONTRIBUTING now
mandates a deliberately EMPTY `## Unreleased` on top of the stamp. The
mandated ceremony tree was CI-red — #108's unshippability by another
route. Keying to the top section was only ever a stand-in for "what
release.yml publishes", so the assert now names that section: on a bare
version the `## X.Y.Z` being shipped, on a `-dev` tree the newest
stamped one. rig#67 retargeted the identical assert for the same reason.
And a bare version with no matching stamped section — bumped, never
stamped — passed every test and failed only after the merge, in
release.yml's notes step, past the ship decision. Red here instead.
Plus a double re-arm (two `## Unreleased` headings, the extracted
section silently the empty first one) is now red.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repair main's missing `## Unreleased`, re-arm in the CONTRIBUTING ceremony
step, and add a version-keyed guard in test/release.test.ts.
Cross-refs heavy-duty/rig#66 (origin, confirmed occurrence) and
heavy-duty/box#108 (box sibling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
grok's round-2 catches: (1) two sibling push: maps under on: leave only
the second alive — the tag-push fallback stopped triggering entirely;
both filters now live under one push key with the steps still split on
the pushed ref, and a pin counts exactly one on.push. (2) CI red was the
unformatted pin block — biome now clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner (package.json + lock, via npm, never regex) with no
judgment for a review to add. After tag + build + publish, the same job
computes X.Y.(Z+1)-dev and pushes it to main directly (a GITHUB_TOKEN
push fires no workflows: no recursion, no red run); if branch protection
refuses, the step opens the bump PR itself, loudly. Merge-door only —
the manual tag fallback does not rewrite main.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blocker (grok; claude's box twin): a pull_request run from a
public fork gets a read-only GITHUB_TOKEN — permissions: cannot raise
it — and every ceremony PR this org merges is cross-repo from the bot
fork, so the tag create would 403 after green asserts, red on main per
release. The door now triggers on push to main (in-repo event, full
token); the decide step reads the transition from event.before (first-
parent fallback for the all-zeros edge) and the release label — still
the operator's declared intent — via the API off the merge commit's PR.
A transition with no labeled PR behind it refuses. The steps split on
the pushed ref: tags to the tag path, main to the merge path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), a red run on main once per
release, forever. A tree that ends -dev is by definition not a release:
every such merge is work, green NOTICE no-op. Red now guards only bare
endstates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — the PR that added the merge path included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads the
version against the PR base and answers all states: -dev unchanged = work,
green NOTICE no-op; bare unchanged but already released = work in the
post-release window (cast's whole pre-0.1.1 era included), same no-op;
-dev-but-changed and bare-unchanged-never-released = half-ceremonies,
refused loudly; bare-and-changed = the ceremony. Shared steps gate on the
decide (tag-push path unaffected). Pins anchor on the echo strings, since
the workflow's own comment table paraphrases the states.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
box#95 taught the family that a forgotten manual tag is the worst
failure shape: silent, no red X, a release that simply doesn't happen.
The ship decision already lives in the ceremony PR — the one whose whole
diff is the version leaving -dev, carrying the reviews and the
maintainer's merge — so tagging after it is transcription, and
transcription belongs to the machine (box#96's design; this is cast's
twin).
release.yml now also triggers on pull_request closed against main,
gated on merged == true AND the hand-set release label. The merge path
asserts four facts in order, each fail-loud and creating nothing: the
merged package.json version is non--dev (read via node, never regex —
the pkg_version discipline); the version CHANGED in this PR (base vs
merge — the -dev interlock, so a mislabeled ordinary PR fails loudly);
the version's changelog section extracts non-empty via the existing
release-notes.sh; and no tag or release exists yet (idempotent re-runs,
and the loud answer to a manual-tag race). Then, in the same job, it
tags the merge commit via the API and publishes. Same-job is
load-bearing: a GITHUB_TOKEN-created tag triggers no workflows, so the
tag-push path cannot fire on it and double-publish.
Both trigger paths converge on literally the same steps — each entry
step exports RELEASE_VERSION, and the notes extraction, the exact
existing asset build (npm ci, npm run build, npm prune --omit=dev,
staged as cast-X.Y.Z/), and the gh release create read only that — so
the paths cannot drift and the installer keeps finding the one asset
name it knows, cast-X.Y.Z.tgz. The tag-push path survives as the
documented manual fallback and backfill, and it matters immediately:
0.1.0 never carried -dev (cast predates the ritual), so the interlock
correctly does not fire for #110's ceremony — that one ships by manual
tag, and the automation applies from 0.1.1 on.
test/release.test.ts pins the new wiring in the house grep style,
fail-closed: the merged+labeled gate, the four asserts strictly ordered
ahead of tag/build/publish, the single job, the anti-recursion comment,
and that no per-path asset name exists. CONTRIBUTING.md's Releasing now
says it plainly: merge is the ship decision; the tag is the fallback.
Fixes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
biome (error-on-warnings in CI) rejects the ! assertion; an explicit
throw narrows properly and says what broke if the file ever has no
heading at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
release.test.ts demanded the real changelog's literal Unreleased section
extract non-empty containing '#96' — false by construction on the very
tree the release PR produces, so the first real 'release: 0.1.0' PR
turned CI red and the ceremony blocked itself. Fork rehearsals missed it:
a tag push runs release.yml, never ci.yml. The guard now asserts the TOP
section, whatever its name, extracts non-empty via the exact tool
release.yml runs — verified on both legitimate tree states.
Fixes#108
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.
- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
relative to the compiled module so a source checkout and an installed
prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
must fail the assert loudly, not be pattern-skipped): asserts tag ==
package.json version FIRST, extracts that version's changelog section
(.github/scripts/release-notes.sh, shared with the tests; missing or
empty refuses), builds once (npm ci && npm run build && npm prune
--omit=dev), stages bin/ dist/ node_modules/ package.json as
cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
--verify-tag`. No tests here — ci.yml gated the merge commit, and the
suite needs age.
- install.sh grows the three channels: default = the latest release's
asset (tag resolved off the releases/latest redirect Location — no
API, no token; failure dies loudly naming CAST_REF=main, never a
silent fallback), CAST_REF=<tag> = pinned (asset first, source
fallback), CAST_REF=main = dev build-from-source. npm is required
only on the source path, and a prebuilt tree is sanity-checked
(dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
changelog, and REAL install.sh runs through all three channels with a
stub curl and a poisoned npm — including the loud no-releases refusal
with no $DEST side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>