feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
# Changelog
|
|
|
|
|
|
|
|
|
|
History before 0.1.0 lives in git — cast has said `0.1.0` in `package.json`
|
|
|
|
|
since its first commit, but grew its release surface (this file,
|
|
|
|
|
`cast --version`, tagged releases with a prebuilt asset) on the way to
|
|
|
|
|
actually cutting it, and this file starts there.
|
|
|
|
|
|
2026-07-19 19:39:45 +00:00
|
|
|
## Unreleased
|
|
|
|
|
|
refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
`state:needs-rebase` is retired. PR labels now sit on two axes: `state:*`
(whose ball it is, exactly one) and `blocker:*` (what is in the way,
additive — conflict / ci-red / unrequested). One rule joins them:
`state:needs-human` requires zero blockers.
The single-label design projected independent facts — mergeability, check
status, where the review round stands — onto one totally-ordered value. A
total order must pick a winner, so the rest silently vanished, and every
precedence bug this machine has had lived on that ordering.
`state:needs-rebase` was the clearest casualty: it fired on both a conflict
and a red check, which need opposite work, and told an agent to rebase when
what it owed was a bug fix. Blockers are a set, so there is no precedence
between them to get wrong; what remains on the ordered axis is purely about
reviews, the one place an ordering is meaningful.
`state:bots-reviewing` tightens to mean strictly "a request is live". A ready
PR nobody was asked to review is `state:addressing` + `blocker:unrequested`,
not "waiting on the reviewers" for the 48h it took the stale sweep to notice.
The reconciler carries a RETIRED array and strips `state:needs-rebase` on
sight, so the retirement heals the board instead of stranding a label nothing
recomputes. Fixtures 51 -> 64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:31:18 +00:00
|
|
|
### Changed
|
|
|
|
|
|
|
|
|
|
- **PR labels split into two axes: `state:*` (whose ball) and `blocker:*`
|
|
|
|
|
(what is in the way)** (heavy-duty/box#138) — `state:needs-rebase` is
|
|
|
|
|
retired, replaced by `blocker:conflict`, `blocker:ci-red` and
|
|
|
|
|
`blocker:unrequested`. One rule joins the axes: `state:needs-human` requires
|
|
|
|
|
zero blockers.
|
|
|
|
|
|
|
|
|
|
The entry below this one, added a day ago, closed by noting that cast had
|
|
|
|
|
not yet been bitten only because nothing had conflicted — that three PRs sat
|
|
|
|
|
at `state:needs-human` at once and would conflict through this very file the
|
|
|
|
|
moment one landed. #128 landed. A dry sweep now finds #119, #120 and #122
|
|
|
|
|
all still wearing `state:needs-human` over branches GitHub calls
|
|
|
|
|
`CONFLICTING`, which is the predicted failure arriving on schedule.
|
|
|
|
|
|
|
|
|
|
Fixing *that* is what the previous entry did. What this one fixes is the
|
|
|
|
|
shape that kept regrowing it. The single-label design projected independent
|
|
|
|
|
facts — mergeability, check status, where the review round stands — onto one
|
|
|
|
|
totally-ordered value, and a total order must pick a winner, so the rest
|
|
|
|
|
silently vanish. Every precedence bug this machine has had lived on that
|
|
|
|
|
ordering: `needs-human` surviving a conflict, `MISSING` swallowing `STALE`,
|
|
|
|
|
and `state:needs-rebase` firing on both a conflict and a red check when
|
|
|
|
|
those need opposite work — telling an agent to rebase when what it owed was
|
|
|
|
|
a bug fix. Blockers are a set. A set has no precedence between its members
|
|
|
|
|
to get wrong, and what remains on the ordered axis is purely about reviews,
|
|
|
|
|
the one place an ordering actually means something.
|
|
|
|
|
|
|
|
|
|
`state:bots-reviewing` tightens with it, to mean strictly *a request is live
|
|
|
|
|
and an answer is coming*. A ready PR nobody was asked to review used to read
|
|
|
|
|
"waiting on the reviewers" for the 48 hours it took the stale sweep to
|
|
|
|
|
notice; it is now `state:addressing` + `blocker:unrequested`, because the
|
|
|
|
|
ask is the agent's to make. Drafts are exempt, and so is an explicit human
|
|
|
|
|
request — a maintainer claiming a PR early is deliberate, not a dropped ball.
|
|
|
|
|
|
|
|
|
|
The reconciler carries a `RETIRED` array and strips `state:needs-rebase` on
|
|
|
|
|
sight, so retiring a label heals the board instead of stranding one that
|
fix(labels): never name a label the repo lacks, and do not read an unreadable rollup as green
Round-1 review fixes, canonical across box/rig/cast.
`gh issue edit --add-label` rejects the WHOLE call on one unknown label name,
applying nothing. Batching state and blockers into a single edit for
anti-flicker meant one missing `blocker:*` would take the `state:*`
convergence down with it — and since the taxonomy was only ever created by a
manual workflow_dispatch, the first sweep after the two-axis change would have
healed nothing on exactly the PRs it exists to fix, surfacing only as a log
line. The add side is now filtered against the repo's real label set, read
once per sweep. Removals need no filter (built from has_label, so they
provably exist); an unreadable label set filters nothing rather than
everything, because a failed read must not silently strip the board.
`checks_state` returns UNREADABLE when the `statusCheckRollup` key is absent —
what a failed `gh pr view` leaves behind — distinct from NONE for a
present-but-empty array. Collapsing the two let an API hiccup present as
"nothing is failing", i.e. as mergeable-by-a-human: the unknown-certified-as-
green shape this machine exists to stop, surviving where the #128 fix never
looked. The sweep now leaves that PR exactly as it is. Deliberately not a
blocker: blocking would flap the whole board on one bad call.
`blocker:unrequested` also fires on a STALE round, not just a MISSING one.
Both mean this head has no verdict from that reviewer and both owe an ask; the
stale round is the worse of the two, since it carries approvals on the page
that no longer describe the tree.
Fixtures 64 -> 68.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:03 +00:00
|
|
|
nothing recomputes. A verdict is owed in two shapes and both raise
|
|
|
|
|
`blocker:unrequested`: `MISSING` (nobody reviewed) and `STALE` (everybody
|
|
|
|
|
reviewed an older head). Fixtures 51 → 68.
|
refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
`state:needs-rebase` is retired. PR labels now sit on two axes: `state:*`
(whose ball it is, exactly one) and `blocker:*` (what is in the way,
additive — conflict / ci-red / unrequested). One rule joins them:
`state:needs-human` requires zero blockers.
The single-label design projected independent facts — mergeability, check
status, where the review round stands — onto one totally-ordered value. A
total order must pick a winner, so the rest silently vanished, and every
precedence bug this machine has had lived on that ordering.
`state:needs-rebase` was the clearest casualty: it fired on both a conflict
and a red check, which need opposite work, and told an agent to rebase when
what it owed was a bug fix. Blockers are a set, so there is no precedence
between them to get wrong; what remains on the ordered axis is purely about
reviews, the one place an ordering is meaningful.
`state:bots-reviewing` tightens to mean strictly "a request is live". A ready
PR nobody was asked to review is `state:addressing` + `blocker:unrequested`,
not "waiting on the reviewers" for the 48h it took the stale sweep to notice.
The reconciler carries a RETIRED array and strips `state:needs-rebase` on
sight, so the retirement heals the board instead of stranding a label nothing
recomputes. Fixtures 51 -> 64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:31:18 +00:00
|
|
|
|
fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.
Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.
Closes #127
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
fix(labels): never name a label the repo lacks, and do not read an unreadable rollup as green
Round-1 review fixes, canonical across box/rig/cast.
`gh issue edit --add-label` rejects the WHOLE call on one unknown label name,
applying nothing. Batching state and blockers into a single edit for
anti-flicker meant one missing `blocker:*` would take the `state:*`
convergence down with it — and since the taxonomy was only ever created by a
manual workflow_dispatch, the first sweep after the two-axis change would have
healed nothing on exactly the PRs it exists to fix, surfacing only as a log
line. The add side is now filtered against the repo's real label set, read
once per sweep. Removals need no filter (built from has_label, so they
provably exist); an unreadable label set filters nothing rather than
everything, because a failed read must not silently strip the board.
`checks_state` returns UNREADABLE when the `statusCheckRollup` key is absent —
what a failed `gh pr view` leaves behind — distinct from NONE for a
present-but-empty array. Collapsing the two let an API hiccup present as
"nothing is failing", i.e. as mergeable-by-a-human: the unknown-certified-as-
green shape this machine exists to stop, surviving where the #128 fix never
looked. The sweep now leaves that PR exactly as it is. Deliberately not a
blocker: blocking would flap the whole board on one bad call.
`blocker:unrequested` also fires on a STALE round, not just a MISSING one.
Both mean this head has no verdict from that reviewer and both owe an ask; the
stale round is the worse of the two, since it carries approvals on the page
that no longer describe the tree.
Fixtures 64 -> 68.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:03 +00:00
|
|
|
- **A label the repo does not have no longer takes the whole edit down with
|
|
|
|
|
it** — `gh issue edit --add-label` rejects the *entire* call on one unknown
|
|
|
|
|
name, applying nothing. Batching state and blockers into a single edit (for
|
|
|
|
|
anti-flicker) meant one missing `blocker:*` would also drop the `state:*`
|
|
|
|
|
convergence, and the taxonomy was only created by a manual
|
|
|
|
|
`workflow_dispatch` — so the first sweep after this change would have healed
|
|
|
|
|
*nothing* on precisely the PRs it exists to fix, surfacing only as a log
|
|
|
|
|
line. The add side is now filtered against the repo's real label set, read
|
|
|
|
|
once per sweep. Removals need no filter (they are built from `has_label`, so
|
|
|
|
|
they provably exist), and an unreadable label set filters *nothing* rather
|
|
|
|
|
than everything — a failed read must not silently strip the board.
|
|
|
|
|
|
|
|
|
|
- **An unreadable check rollup is no longer read as "nothing is failing"** —
|
|
|
|
|
when `gh pr view` failed, the fallback left the `statusCheckRollup` key
|
|
|
|
|
absent, and `(.statusCheckRollup // [])` collapsed that into the same `NONE`
|
|
|
|
|
as a PR that genuinely has no checks. `NONE` blocks nothing, so an API
|
|
|
|
|
hiccup presented as mergeable-by-a-human — the unknown-certified-as-green
|
|
|
|
|
shape this machine exists to stop, surviving in the one place the #128 fix
|
|
|
|
|
never looked. `checks_state` now returns `UNREADABLE` for the absent key,
|
|
|
|
|
distinct from `NONE` for a present-but-empty array, and the sweep leaves
|
|
|
|
|
that PR exactly as it is rather than recomputing on facts it did not read.
|
|
|
|
|
Deliberately *not* a blocker: blocking would flap the whole board on one bad
|
|
|
|
|
call, and the next tick is 15 minutes away.
|
|
|
|
|
|
fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.
Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.
Closes #127
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
|
|
|
- **`state:needs-human` no longer appears on PRs a human cannot merge**
|
|
|
|
|
(#127, heavy-duty/box#136) — `decide_state()` derived state from three inputs
|
|
|
|
|
(draft flag, requested reviewers, submitted reviews) and read *nothing* about
|
|
|
|
|
mergeability or checks. Combined with the `if requested "$HUMAN"`
|
|
|
|
|
short-circuit at the top of its precedence, the label was **sticky**: once
|
|
|
|
|
the maintainer was requested, the PR read `state:needs-human` through
|
|
|
|
|
conflicts, through red CI, through a force-push that staled every approval.
|
|
|
|
|
Nothing demoted it.
|
|
|
|
|
|
|
|
|
|
In this repo the *second* half is the live one: three PRs currently sit at
|
|
|
|
|
`state:needs-human` simultaneously, with nothing saying which to merge first
|
|
|
|
|
— and they will conflict through `CHANGELOG.md` the moment one lands. The
|
|
|
|
|
stickiness has not bitten here yet only because nothing has conflicted; the
|
|
|
|
|
code carried it identically, so the first merge would have reproduced box's
|
|
|
|
|
situation exactly.
|
|
|
|
|
|
|
|
|
|
The rule the label now keeps is that **`state:needs-human` means a human
|
|
|
|
|
could merge this right now**, so anything making that false outranks the
|
|
|
|
|
request that put it there. A `CONFLICTING` branch or a failing check is the
|
|
|
|
|
agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean
|
|
|
|
|
nobody reviewed this tree: `state:addressing`, because the agent owes a
|
|
|
|
|
re-request. An *unfinished* round still yields to an explicit human request —
|
|
|
|
|
a maintainer pulling a PR to themselves early is deliberate, and `MISSING`
|
|
|
|
|
(nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed
|
fix(labels): unknown check outcomes and mixed rounds must not read green
Round 2 of #128. Two blockers from the bot panel, both real holes in the
invariant this PR exists to establish.
The check-rollup classifier enumerated the outcomes that block and
defaulted everything else to SUCCESS, so ERROR, CANCELLED and STALE fell
through to green. Inverted to an allow-list of the outcomes that do NOT
block — SUCCESS, NEUTRAL, SKIPPED and the pending set — with everything
else blocking. The rollup mixes two closed enums (CheckRun.conclusion and
StatusContext.state) and the costs are asymmetric: a false failure parks
the PR on the agent, who looks; a false success invites a human to merge a
tree that will not merge. Superseded runs are dropped first, each context
collapsing to its newest entry keyed on workflow + job name, so a re-run
does not strand its own PR in needs-rebase. The classifier also moved out
of main() into checks_state(), which is why no fixture caught this — it
was inline in the fetch loop and could only ever be injected pre-decided.
decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read, and a round that
was both unfinished and staled came out needs-human over a head nobody had
reviewed — the original bug wearing a different hat. The whole round is
now collected before any precedence is applied, STALE checked before
MISSING. The MISSING-yields-to-an-explicit-human-request rule is untouched.
Fixtures 29 -> 44, pinning the check-outcome enum, the supersede rule at
both orderings, and the mixed round at both ends of BOTS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:05:17 +00:00
|
|
|
something else). Precedence is applied to the round as a whole, after every
|
|
|
|
|
verdict is collected: deciding inside the loop let the order of `BOTS` pick
|
|
|
|
|
the answer, so a round that was *both* unfinished and staled returned on the
|
|
|
|
|
`MISSING` before any later bot's `STALE` was read — and came out
|
|
|
|
|
`needs-human` over a head nobody had reviewed, the original bug wearing a
|
|
|
|
|
different hat.
|
|
|
|
|
|
|
|
|
|
Whether a check blocks is judged by listing the outcomes that *don't* —
|
|
|
|
|
`SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the
|
|
|
|
|
outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion`
|
|
|
|
|
and `StatusContext.state`), and an outcome the list forgets is one the label
|
|
|
|
|
cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as
|
|
|
|
|
green under an allow-list of failures. The costs are not symmetric — a false
|
|
|
|
|
failure parks the PR on the agent, who looks; a false success invites a human
|
|
|
|
|
to merge a tree that will not merge. Superseded runs are dropped first, each
|
|
|
|
|
context collapsing to its newest entry: a re-run does not evict the run it
|
|
|
|
|
replaced, and the rollup keeps both. That shape is live on this board — this
|
|
|
|
|
PR's own tip carried two `scope` and two `reconcile` entries — and on
|
|
|
|
|
heavy-duty/box#137's tip the superseded half was `CANCELLED`, so once
|
|
|
|
|
`CANCELLED` blocks, judging every entry rather than the newest would strand
|
|
|
|
|
every re-run PR in `needs-rebase`.
|
fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.
Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.
Closes #127
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
|
|
|
|
fix(labels): date a check run by when it started, not when it finished
Round 3 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl
independently found the same regression in the round-2 supersede rule.
The collapse-to-newest step dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but gh does not omit the field — its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` only falls
through on null/false. So the sentinel was taken as the sort key and
sorted below every real timestamp: the live re-run went to the bottom of
its context and `last` discarded it, judging the very run it superseded.
That inverted the rule in both directions. A green context with a
replacement mid-flight read SUCCESS — #136 restored, needs-human pointing
a human at a disabled merge button — and a CANCELLED original whose
replacement was still running read FAILURE, the flap the collapse was
added to prevent.
A run is now dated by the newest timestamp it actually carries, with both
spellings of absent discarded (null, and the zero sentinel), rather than
by assuming which field is populated. An entry carrying no usable
timestamp sorts last rather than first: something undateable is most
likely the thing just created, so ambiguity resolves toward "not settled"
instead of toward a stale success.
Fixtures 44 -> 48. The gap was structural — the existing run_() helper
always sets a real completedAt, so every supersede fixture raced two
finished runs and none could express an in-flight one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:19:57 +00:00
|
|
|
Dating a run turned out to be the subtle half, and getting it wrong restored
|
|
|
|
|
the bug. A run still in flight has no completion, but `gh` does not omit the
|
|
|
|
|
field — its Go struct marshals the zero time as `"0001-01-01T00:00:00Z"`, a
|
|
|
|
|
string, which jq's `//` will not fall through. Ordering on completion
|
|
|
|
|
therefore sorted the *live* re-run below every finished one and let the
|
|
|
|
|
collapse discard it, judging the very run it superseded: a green context with
|
|
|
|
|
a replacement mid-flight read `SUCCESS` — the original bug restored, pointing
|
|
|
|
|
a human at a disabled merge button — and a `CANCELLED` original whose
|
|
|
|
|
replacement was still running read `FAILURE`, the flap the collapse exists to
|
fix(labels): date a run by when it began, not by its newest stamp
Round 4 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl again
converged on the same defect, in the round-3 dating expression itself.
`max` over [startedAt, createdAt, completedAt] resolves to completedAt
for a finished run and startedAt for a live one. Those are different
quantities, so the comparison was never an ordering on runs — it was
"newest stamp of any kind". A run cancelled by the concurrency group does
not stop the instant its replacement starts; the runner has to wind down,
so predecessor.completedAt > successor.startedAt is the ordinary case
rather than a corner. On box's aa5a6ba the superseding run started
15:19:38 and the run it cancelled did not finish until 15:19:51 —
thirteen seconds in which the dead predecessor out-dated the live run
that replaced it, and the collapse discarded the wrong one.
That narrowed round 3's two failures without closing them: a CANCELLED
predecessor read FAILURE and a SUCCESS predecessor read SUCCESS, where
both should be PENDING. The second is #136 restored — needs-human over a
tree whose merge button branch protection has disabled.
The list is already in preference order and the select leaves only stamps
the run actually carries, so `first` is exactly "date it by when it
began, falling back only if it never recorded a beginning".
Fixtures 49 -> 51. None of the existing 49 could see this: every one
spaces the predecessor's completion before the successor's start, and
run_() carries no startedAt at all, so the overlap needed explicit
payloads. Both new fixtures fail under `max` and the other 49 do not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:22 +00:00
|
|
|
prevent. So a run is dated by when it **began**, with both spellings of
|
|
|
|
|
absent discarded (`null`, and the zero sentinel) and a fallback only for a
|
|
|
|
|
run that never recorded a beginning — not by the newest stamp of any kind,
|
|
|
|
|
which compares the completion of a finished run against the start of a live
|
|
|
|
|
one. Those are different quantities, and a run cancelled by the concurrency
|
|
|
|
|
group does not stop the instant its replacement starts: the runner winds
|
|
|
|
|
down, so a predecessor routinely finishes *after* its successor began, and
|
|
|
|
|
dating by "newest stamp" let the dead run out-rank the live one that
|
|
|
|
|
replaced it. An entry carrying no usable timestamp at all sorts **last**
|
|
|
|
|
rather than first: something undateable is most likely the thing just
|
|
|
|
|
created, and every ambiguity here resolves toward "not settled" rather than
|
|
|
|
|
toward a stale success.
|
fix(labels): date a check run by when it started, not when it finished
Round 3 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl
independently found the same regression in the round-2 supersede rule.
The collapse-to-newest step dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but gh does not omit the field — its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` only falls
through on null/false. So the sentinel was taken as the sort key and
sorted below every real timestamp: the live re-run went to the bottom of
its context and `last` discarded it, judging the very run it superseded.
That inverted the rule in both directions. A green context with a
replacement mid-flight read SUCCESS — #136 restored, needs-human pointing
a human at a disabled merge button — and a CANCELLED original whose
replacement was still running read FAILURE, the flap the collapse was
added to prevent.
A run is now dated by the newest timestamp it actually carries, with both
spellings of absent discarded (null, and the zero sentinel), rather than
by assuming which field is populated. An entry carrying no usable
timestamp sorts last rather than first: something undateable is most
likely the thing just created, so ambiguity resolves toward "not settled"
instead of toward a stale success.
Fixtures 44 -> 48. The gap was structural — the existing run_() helper
always sets a real completedAt, so every supersede fixture raced two
finished runs and none could express an in-flight one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:19:57 +00:00
|
|
|
|
fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.
Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.
Closes #127
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
|
|
|
`UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub
|
|
|
|
|
reports it for about a minute after every merge while it recomputes, and
|
|
|
|
|
flapping every open PR through `needs-rebase` on each merge would be worse
|
|
|
|
|
than the bug. A failed read of either fact degrades to the same "do not know"
|
|
|
|
|
value, for the same reason.
|
|
|
|
|
|
|
|
|
|
Also adds `merge-next` — the label this repo needs most today, since a
|
|
|
|
|
correct `needs-human` still does not say *which* of three ready PRs to merge
|
|
|
|
|
first. Queue order is intent, so the reconciler never sets it; it only
|
|
|
|
|
**clears** it once the PR stops being mergeable-by-a-human. Ported from
|
fix(labels): unknown check outcomes and mixed rounds must not read green
Round 2 of #128. Two blockers from the bot panel, both real holes in the
invariant this PR exists to establish.
The check-rollup classifier enumerated the outcomes that block and
defaulted everything else to SUCCESS, so ERROR, CANCELLED and STALE fell
through to green. Inverted to an allow-list of the outcomes that do NOT
block — SUCCESS, NEUTRAL, SKIPPED and the pending set — with everything
else blocking. The rollup mixes two closed enums (CheckRun.conclusion and
StatusContext.state) and the costs are asymmetric: a false failure parks
the PR on the agent, who looks; a false success invites a human to merge a
tree that will not merge. Superseded runs are dropped first, each context
collapsing to its newest entry keyed on workflow + job name, so a re-run
does not strand its own PR in needs-rebase. The classifier also moved out
of main() into checks_state(), which is why no fixture caught this — it
was inline in the fetch loop and could only ever be injected pre-decided.
decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read, and a round that
was both unfinished and staled came out needs-human over a head nobody had
reviewed — the original bug wearing a different hat. The whole round is
now collected before any precedence is applied, STALE checked before
MISSING. The MISSING-yields-to-an-explicit-human-request rule is untouched.
Fixtures 29 -> 44, pinning the check-outcome enum, the supersede rule at
both orderings, and the mixed round at both ends of BOTS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:05:17 +00:00
|
|
|
heavy-duty/box#137 so the three repos' reconcilers stay byte-identical; both
|
fix(labels): date a check run by when it started, not when it finished
Round 3 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl
independently found the same regression in the round-2 supersede rule.
The collapse-to-newest step dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but gh does not omit the field — its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` only falls
through on null/false. So the sentinel was taken as the sort key and
sorted below every real timestamp: the live re-run went to the bottom of
its context and `last` discarded it, judging the very run it superseded.
That inverted the rule in both directions. A green context with a
replacement mid-flight read SUCCESS — #136 restored, needs-human pointing
a human at a disabled merge button — and a CANCELLED original whose
replacement was still running read FAILURE, the flap the collapse was
added to prevent.
A run is now dated by the newest timestamp it actually carries, with both
spellings of absent discarded (null, and the zero sentinel), rather than
by assuming which field is populated. An entry carrying no usable
timestamp sorts last rather than first: something undateable is most
likely the thing just created, so ambiguity resolves toward "not settled"
instead of toward a stale success.
Fixtures 44 -> 48. The gap was structural — the existing run_() helper
always sets a real completedAt, so every supersede fixture raced two
finished runs and none could express an in-flight one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:19:57 +00:00
|
|
|
live shapes, the mixed round, the in-flight run superseding a finished one —
|
fix(labels): date a run by when it began, not by its newest stamp
Round 4 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl again
converged on the same defect, in the round-3 dating expression itself.
`max` over [startedAt, createdAt, completedAt] resolves to completedAt
for a finished run and startedAt for a live one. Those are different
quantities, so the comparison was never an ordering on runs — it was
"newest stamp of any kind". A run cancelled by the concurrency group does
not stop the instant its replacement starts; the runner has to wind down,
so predecessor.completedAt > successor.startedAt is the ordinary case
rather than a corner. On box's aa5a6ba the superseding run started
15:19:38 and the run it cancelled did not finish until 15:19:51 —
thirteen seconds in which the dead predecessor out-dated the live run
that replaced it, and the collapse discarded the wrong one.
That narrowed round 3's two failures without closing them: a CANCELLED
predecessor read FAILURE and a SUCCESS predecessor read SUCCESS, where
both should be PENDING. The second is #136 restored — needs-human over a
tree whose merge button branch protection has disabled.
The list is already in preference order and the select leaves only stamps
the run actually carries, so `first` is exactly "date it by when it
began, falling back only if it never recorded a beginning".
Fixtures 49 -> 51. None of the existing 49 could see this: every one
spaces the predecessor's completion before the successor's start, and
run_() carries no startedAt at all, so the overlap needed explicit
payloads. Both new fixtures fail under `max` and the other 49 do not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:22 +00:00
|
|
|
in both spellings of an absent completion, in both directions, and across the
|
|
|
|
|
wind-down window where the two overlap — and the whole check-outcome enum are
|
|
|
|
|
pinned in `test/labels-reconcile.sh` (fixtures 19 → 51).
|
fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.
Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.
Closes #127
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
|
|
|
|
2026-07-19 21:32:51 +00:00
|
|
|
## 0.1.1 — 2026-07-19
|
|
|
|
|
|
2026-07-19 19:39:45 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
|
|
|
|
- **The release ceremony re-arms the changelog, and CI notices when it
|
|
|
|
|
doesn't** (#113) — stamping `## Unreleased` into `## X.Y.Z — DATE` is
|
|
|
|
|
done by hand in the ceremony PR; no workflow writes this file, and
|
|
|
|
|
nothing put the heading back. So `main` sat with the shipped section on
|
|
|
|
|
top and no `## Unreleased` above it — this repo's state from 0.1.0
|
|
|
|
|
until this entry. A PR authored before a release and merged after has
|
|
|
|
|
its entry land under whatever heading now occupies that position: the
|
|
|
|
|
release that already shipped. Git does that *cleanly*. The stamped
|
|
|
|
|
heading and the incoming entry never overlap textually, so the one
|
|
|
|
|
signal an author trusts — "git told me to look" — is missing exactly
|
|
|
|
|
when the result is wrong. rig watched it happen (heavy-duty/rig#66, the
|
|
|
|
|
origin of this fix): an entry landed inside published `## 0.1.0` an
|
|
|
|
|
hour after 0.1.0 shipped, and was caught only because someone was
|
|
|
|
|
reading. The published release body is never at risk — `release.yml`
|
|
|
|
|
extracts notes from the tree at the tag, before anything late can merge
|
|
|
|
|
— which is also why nobody notices: the file that drifts is the one
|
|
|
|
|
only maintainers read. Three moves. `## Unreleased` is back above
|
|
|
|
|
`## 0.1.0` (this entry re-creating it *is* the repair). CONTRIBUTING's
|
|
|
|
|
ceremony step now re-arms in the same diff that stamps. And
|
|
|
|
|
`test/release.test.ts` keys the rule to `package.json`: a stamped top
|
|
|
|
|
section is legal while the version is bare — the ceremony's own tree,
|
|
|
|
|
and main until the `-dev` bump — but once the version says `-dev`, the
|
|
|
|
|
top section must be `## Unreleased`. That is the distinction #108 had
|
|
|
|
|
to collapse to make the ceremony shippable at all, recovered rather
|
|
|
|
|
than reverted: the ceremony stays green at every step, and a disarmed
|
2026-07-19 20:33:49 +00:00
|
|
|
dev `main` goes red. The re-arm also forced the older extraction guard
|
|
|
|
|
to move. It asserted that the **top** section extracts non-empty, which
|
|
|
|
|
the re-armed ceremony tree — a deliberately empty `## Unreleased` above
|
|
|
|
|
the stamp — makes false by construction: the re-arm and the guard would
|
|
|
|
|
have contradicted each other, and the next release PR would have been
|
|
|
|
|
unshippable for a second time, the way #108 was. Keying to the top
|
|
|
|
|
section was only ever a stand-in for "the section `release.yml` will
|
|
|
|
|
publish", so the assert now names that section directly — on a bare
|
|
|
|
|
version the `## X.Y.Z` being shipped, on a `-dev` tree the newest
|
|
|
|
|
stamped one. Existence is checked with it: a bare version with no
|
|
|
|
|
matching section is a bump that never stamped, which used to pass every
|
|
|
|
|
test and fail only *after* the merge, in `release.yml`'s notes step,
|
|
|
|
|
past the ship decision and leaving `main` with a minted, unreleased
|
|
|
|
|
version to repair by hand. A double re-arm — two `## Unreleased`
|
|
|
|
|
headings, the extracted section silently the empty one — is red too.
|
|
|
|
|
box and rig carry the same fix (heavy-duty/box#110,
|
|
|
|
|
heavy-duty/rig#67); rig#67 retargeted the identical assert for the
|
|
|
|
|
identical reason.
|
2026-07-19 19:39:45 +00:00
|
|
|
|
2026-07-19 14:24:44 +00:00
|
|
|
## 0.1.0 — 2026-07-19
|
feat: merging a release-labeled PR is the release (#111)
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>
2026-07-19 15:21:07 +00:00
|
|
|
|
2026-07-19 12:26:35 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
2026-07-19 13:50:03 +00:00
|
|
|
- **The release suite accepts the ceremony's own tree** (#108) —
|
|
|
|
|
`test/release.test.ts` demanded the real `CHANGELOG.md`'s literal
|
|
|
|
|
`Unreleased` section extract non-empty and contain `#96`: false by
|
|
|
|
|
construction on the `release: X.Y.Z` tree the ceremony's own PR produces
|
|
|
|
|
(it stamps that heading into `## X.Y.Z — date`), so the first real
|
|
|
|
|
release PR turned CI red and the flow blocked itself — invisible to the
|
|
|
|
|
fork rehearsals, which tag a branch (`release.yml` runs; `ci.yml` never
|
|
|
|
|
does). The guard now asserts its actual purpose: whatever the TOP `## `
|
|
|
|
|
section is — `Unreleased` between releases, the stamped version on and
|
|
|
|
|
right after one — the exact `release-notes.sh` the workflow runs
|
|
|
|
|
extracts it non-empty. rig's twin is heavy-duty/rig#44.
|
|
|
|
|
|
2026-07-19 12:26:35 +00:00
|
|
|
- **`apply` no longer demands a GitHub App for a manifest that declares no
|
|
|
|
|
applications** (#103) — found live in the 2026-07-19 release drill, where a
|
|
|
|
|
databases-only manifest (`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, cast reads it in exactly one call (the application
|
|
|
|
|
create), and databases and services never touch it. That unconditional
|
|
|
|
|
resolution 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 only when the desired state actually
|
|
|
|
|
contains an application; a manifest that does declare one still refuses on
|
|
|
|
|
a missing binding exactly as before, clean plan or not, because that
|
|
|
|
|
binding is state the next create will need.
|
fix: a manifest with no ${…} refs applies without a store (#104)
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>
2026-07-19 12:26:15 +00:00
|
|
|
- **A manifest with no `${…}` refs applies without a store** (#104) — the
|
|
|
|
|
greenfield manifest-first bootstrap was a chicken-and-egg with no exit,
|
|
|
|
|
found by the 2026-07-19 release drill against two fresh Coolify 4.1.2
|
|
|
|
|
instances: a registered project whose manifest declared databases only
|
|
|
|
|
(zero `${…}` refs) could not take its first `apply` — apply refused with
|
|
|
|
|
`no secret store for <org>/<repo> in <env>`, and `capture`, the documented
|
|
|
|
|
way to get a store, rightly refuses a project that is absent on the box,
|
|
|
|
|
because apply is the verb that would create it. The drill unblocked with a
|
|
|
|
|
hand-rolled empty store (`printf '' | age -r … -o secrets/….env.age`),
|
|
|
|
|
documented nowhere. Now `diff`/`apply` gate that refusal on the manifest
|
|
|
|
|
actually *referencing* a secret, asked via the same parser resolution
|
|
|
|
|
uses: when the templates resolve zero `${…}` refs, an absent store is
|
|
|
|
|
treated as empty and the run proceeds, printing a loud one-line note
|
|
|
|
|
naming the path the store would live at — and since there is nothing to
|
|
|
|
|
decrypt, the age key is not demanded either. The moment any template
|
|
|
|
|
gains a `${…}` ref, the refusal returns byte-identical to before.
|
|
|
|
|
`capture` and `destroy` are untouched.
|
2026-07-19 12:17:33 +00:00
|
|
|
- **`CAST_AGE_KEY_FILE_<ENV>` is now settable for every environment name**
|
|
|
|
|
(#102) — `<ENV>` was the name uppercased verbatim, so env `drill-b`
|
|
|
|
|
advertised `CAST_AGE_KEY_FILE_DRILL-B`: a variable no POSIX shell can
|
|
|
|
|
export, which walled off the injected-key channel (and its
|
|
|
|
|
process-substitution trick) for every hyphenated environment. Found live
|
|
|
|
|
in the 2026-07-19 release drill. Characters outside `[A-Z0-9]` now map to
|
|
|
|
|
`_` — env `drill-b` reads `CAST_AGE_KEY_FILE_DRILL_B` — and the refusal
|
|
|
|
|
advertises the mapped name. The standing-key path keeps the exact
|
|
|
|
|
environment name, so two names that collide on the variable still resolve
|
|
|
|
|
their own keys on disk.
|
2026-07-19 12:26:35 +00:00
|
|
|
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-19 14:24:44 +00:00
|
|
|
- **Merging a release-labeled PR is the release — and the release re-arms
|
|
|
|
|
main itself** (#111; box#96's design) — `release.yml` now also fires on
|
|
|
|
|
pushes to main (not `pull_request` events: fork-sourced ceremony PRs get
|
|
|
|
|
a read-only token there — the round-1 catch). A decide step reads the
|
|
|
|
|
version transition from the push (`event.before` → the pushed head) and
|
|
|
|
|
answers four states: release-flow *work* merged under the `release`
|
|
|
|
|
label — `-dev` endstates, and the post-release window — no-ops green
|
|
|
|
|
with a NOTICE; the two genuinely ambiguous bare states refuse loudly;
|
|
|
|
|
a true transition then requires a merged, `release`-labeled PR behind
|
|
|
|
|
the commit (read via the API — the label is the operator's declared
|
|
|
|
|
intent) before the door opens. It then tags the merge commit, builds
|
|
|
|
|
the `cast-X.Y.Z.tgz` asset once, publishes — and bumps main to
|
|
|
|
|
`X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR fallback, so
|
|
|
|
|
no follow-up bump PR exists on the paved road. The tag-push path stays
|
|
|
|
|
as the documented fallback and backfill, and both paths run the same
|
|
|
|
|
steps so they cannot drift. First-release edge: 0.1.0 never carried
|
|
|
|
|
`-dev`, so its ceremony (#110) ships by manual tag; the automation
|
|
|
|
|
applies from 0.1.1 on.
|
|
|
|
|
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
- **Tagged releases with a prebuilt dist asset, and an installer that
|
|
|
|
|
installs them** (#96) — the cast half of the flow designed in
|
|
|
|
|
heavy-duty/box#83, plus the piece unique to cast: a **prebuilt asset**,
|
|
|
|
|
because cast is the one repo where the source tarball is *not* the
|
|
|
|
|
package. A release is a PR, then a tag: the `release: X.Y.Z` PR bumps
|
|
|
|
|
`package.json` (and `package-lock.json`) and stamps this file's Unreleased
|
|
|
|
|
section with version + date; the merge commit is tagged bare `X.Y.Z`
|
|
|
|
|
(box's tag scheme — no `v` prefix). `release.yml` turns the tag into the
|
|
|
|
|
GitHub release — after asserting tag == `package.json` version (a
|
|
|
|
|
mismatch fails loudly and creates nothing) — with that version's section
|
|
|
|
|
of this file as the body, extracted by the same
|
|
|
|
|
`.github/scripts/release-notes.sh` the test harness drives, and with the
|
|
|
|
|
runnable tree attached as `cast-X.Y.Z.tgz`: `bin/`, compiled `dist/`,
|
|
|
|
|
production `node_modules/`, `package.json`, built once in CI
|
|
|
|
|
(`npm ci && npm run build && npm prune --omit=dev`). `install.sh` now
|
|
|
|
|
defaults to the **latest release**: the tag is resolved by following the
|
|
|
|
|
`releases/latest` redirect and reading the `Location` header — no API, no
|
|
|
|
|
token — and the download is that release's asset, so **no `npm ci`, no
|
|
|
|
|
`tsc`, no devDependencies ever run on the operator's machine**. `CAST_REF`
|
|
|
|
|
picks the other two channels: a tag pins a release (its asset first,
|
|
|
|
|
source as the fallback for a ref that has none — `refs/tags` outranks a
|
|
|
|
|
same-named branch), a branch (`CAST_REF=main`) tracks the development
|
|
|
|
|
tree and is the one channel that still builds from source, the only place
|
|
|
|
|
`npm` is required. Until 0.1.0 is cut the default channel has nothing to
|
|
|
|
|
resolve and dies saying exactly that, naming `CAST_REF=main` as the way
|
|
|
|
|
to install today — it never falls back to main silently, because "I
|
|
|
|
|
installed the latest release" must not quietly mean "I installed whatever
|
|
|
|
|
main was that second". The channel only decides *which* tree arrives and
|
|
|
|
|
whether it is built here — whatever it fetched lands in the versioned
|
|
|
|
|
layout (`versions/<package.json version>`, `current` flipped atomically)
|
|
|
|
|
like any other install.
|