Stamps `## Unreleased` as `## 0.1.1 — 2026-07-19` and re-arms the
changelog with a fresh, empty `## Unreleased` above it — both halves in
this diff, per CONTRIBUTING's ceremony (#113/#114; heavy-duty/rig#66).
Bumps package.json and package-lock.json 0.1.1-dev -> 0.1.1. Patch is
correct: the shipped section carries only `### Fixed`.
This is the first cast release to go through the merge door. 0.1.0 could
not — the interlock refused it (run 29698017907) because cast had said
`0.1.0` since its first commit, so there was no `-dev` transition to
detect, and it shipped by the manual tag path. main now genuinely reads
`0.1.1-dev`, so release.yml's decide step sees bare-and-changed: the
ceremony state.
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>
The post-release step of the ceremony, on the manual tag path where it
stays the author's. Depends on #114 — do not merge before it.
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>
cast's first release: package.json has said 0.1.0 since the first commit,
so the ceremony's bump is a no-op and this PR is the stamp — the
Unreleased section becomes 0.1.0 with today's date. The tag will make it
true: release.yml builds the prebuilt cast-0.1.0.tgz asset and the
installer's default channel goes live. Drilled twice on 2026-07-19,
including the asset path end to end on a scratch fork.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude-bot's round-4 blockers: (1) a declared permissions: block zeroes
every unspecified scope, so the decide step's label read
(commits/<sha>/pulls) and the bump fallback's gh pr create could only
403 — every genuine ceremony would end red at the label check, the exact
failure shape this feature exists to kill, one layer down. labels.yml
already carries the precedent; pull-requests: write added with the two
consumers named. (2) CONTRIBUTING still prescribed the follow-up bump PR
the workflow now performs itself, and the changelog entry described the
old PR-base interlock — both now tell the shipped story (event.before
interlock, self-re-arm, manual-path bump stays the operator's). Nits
taken: fetch-depth 2 for the all-zeros fallback's first parent, re-runs
refuse-loudly wording, the bump-window arithmetic comment.
Co-Authored-By: Claude Fable 5 <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>
envName.toUpperCase() alone turned env 'drill-b' into
CAST_AGE_KEY_FILE_DRILL-B — a variable no POSIX shell can export, so the
injected-key channel (and its process-substitution trick) was unreachable
for every hyphenated environment name, while the refusal advertised it
anyway. Characters outside [A-Z0-9] now map to _; the standing-key path
keeps the exact env name, so names that collide on the variable still
resolve their own keys on disk.
Fixes#102
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes#104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found live in the 2026-07-19 release drill: a manifest declaring only
databases (applications: {}) rendered its plan of two creates and then
died in preflight on "no GitHub App bound" — over a binding nothing in
the run would ever have used. A GitHub App exists to clone application
source, and cast reads it in exactly one call, the application create
(POST /applications/private-github-app); databases and services never
touch it. Resolving it unconditionally gated infra-only projects — the
databases a fleet's other projects share — behind the GitHub-App
browser-registration ceremony for no reason.
apply now resolves the App (binding lookup and uuid resolution both)
only when the desired state contains at least one application. The
executor's githubAppUuid field is typed string | null, and its single
consumer guards the null with cast's own internal error — unreachable
by construction, since a plan can only create resources the desired
state holds, but a null slipping onto the wire would otherwise surface
as a Coolify 422 about somebody else's field.
Keyed off desired rather than the plan's changes, deliberately: a
manifest that declares an application keeps the missing-binding refusal
even on a clean plan, byte-identical to before — that binding is state
the next create will need, and the operator should hear about it now,
not mid-bootstrap.
Fixes#103
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>
claude-bot's round-2 catch: cast is the sibling that runs on the
operator's own machine, and the layout port carried two Linux
assumptions in with it.
- The atomic current flip spelled 'replace, don't descend' the GNU way
(mv -Tf); BSD/macOS mv has no -T and dies. The flip now rides node's
fs.renameSync — rename(2) is POSIX, node is a cast prerequisite on
every platform — as one flip_current(), byte-identical in install.sh
and bin/cast, added to the anti-drift diff.
- cmd_uninstall's de-dup used mapfile — bash 4, and macOS ships bash
3.2. Now a portable while-read append.
- readlink -f: Apple's readlink grew -f in macOS 12.3 (March 2022); the
installer now probes it once among the prerequisites and refuses
loudly on older systems instead of failing weirdly mid-flip.
- A portability test pins both spellings out of the two scripts, so a
reintroduction fails in CI, not on the first operator Mac.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: this PR's one goal is the versioned layout, the same
one box#79 built and rig#36 ported — the release flow (tags, release.yml,
prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has.
So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware
installer channels leave this branch, and in their place cast gets the
family layout for real:
- install.sh lands each build at $DEST/versions/<package.json version>,
'current' names the default (atomic rename flips), $BINDIR/cast points
through it. Converging no-op on an installed version (nothing rebuilt),
CAST_REINSTALL=1 replaces, a new version installs beside and becomes
default. Pre-versioning flat installs migrate in place, bit for bit.
CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's
RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live
boxes, rig warns on a converged host — cast is an API client, a flip
strands nothing, 'cast use <old>' is one command away.
- bin/cast grows the layout verbs in bash (they must work when dist/ is
broken): versions (marks current+running), use (atomic flip, then
asserts the chain ANSWERS the new version), uninstall (consent gate,
CURRENT guard, dangling-current guard, ends with the absence assert).
valid_version/pkg_version are byte-identical copies in both files; a
test diffs them so the gates cannot drift.
- cast --version stays: package.json is the single source of truth,
printed with the install root, rig-style.
- ci.yml gains the install job: the real installer, from this checkout,
layout asserted, converge no-op asserted, uninstall --all asserted
absent — the box CI precedent.
- Tests drive the REAL install.sh and bin/cast offline (npm shim, local
source): the layout, the chain answering end to end, no-op/reinstall/
side-by-side/migration semantics, the hostile-version gates, refs/heads
download, every uninstall refusal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The release job ran `npm run check` and `npm test` on a bare runner
with no `age` installed — ci.yml apt-installs it because the secrets
tests round-trip a real age identity, so the first real tag push would
have died at `npm test` and minted no release. The job now does exactly
what the flow (cast#96 / box#83) assigns it: `npm ci && npm run build
&& npm prune --omit=dev`, tar, `gh release create` — check and tests
already gated the merge commit the tag points at.
Also aligns CHANGELOG.md with the family preamble ("History before
0.1.0 lives in git") and with the workflow's actual build steps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cast gets the family's release flow (box#83's shape), plus the piece
unique to cast: because cast compiles, the source tarball is not the
package — so release.yml builds ONCE in CI and attaches cast-X.Y.Z.tgz,
and the installer's default channel extracts that asset instead of
running npm ci + tsc on the operator's machine.
- cast --version: package.json is the single source of truth (no VERSION
file); prints the install root too, rig-style.
- CHANGELOG.md with Unreleased; release notes are the curated section
(scripts/changelog-section.sh), never the auto-generated PR list.
- release.yml on a bare X.Y.Z tag: assert tag == package.json version,
check + build + test, prune, tar the runnable tree, gh release create.
- install.sh channels: unset → latest release asset (resolved via the
releases/latest redirect — no API, no token); CAST_REF=X.Y.Z → that
tag's asset; CAST_REF=<branch> → build-from-source, the old path.
- Tests drive the REAL install.sh offline via curl/npm PATH shims (all
three channels, plus the broken-asset and no-release refusals), and
the real changelog-section.sh against fixture changelogs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex's late #85/#98 round-3 finding, valid post-merge: the needs-human
auto-request fired only when the human had NEVER reviewed, so any earlier
human comment or stale approval left a fully-approved PR labeled
needs-human with nobody actually requested — a wedged handoff.
human_request_needed() now asks whether a fresh head-current human review
is missing (live request or head-current approval → nothing to ask;
anything else → request). Five new fixtures cover the wedge, the stale
approval, the satisfied handoff, and request suppression (19 total).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
durable markers (Verdict: Approve / I agree with everything / leading ✅) —
the gate to needs-human can actually close. Formal verdicts remain the
contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
PR in addressing (agent owes re-request) instead of promoting unreviewed
code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
edits warn instead of wedging; the self-heal claim now matches reality
(dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
test/labels-reconcile.sh proves 14 fixture transitions — comment-only
agreement, stale approval, comment-without-verdict, human precedence and
human-block — wired into CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer rule: a comment-only review is a non-verdict the state machine
(and the board) cannot read. Verdict carries blockingness only; nits ride an
approval, blockers — including verdict-gating questions — are request-changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>