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
477 lines
30 KiB
Markdown
477 lines
30 KiB
Markdown
# 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.
|
|
|
|
## Unreleased
|
|
|
|
### Changed
|
|
|
|
- **`state:needs-human` no longer waits on the cron to become true** (#131)
|
|
— the labels workflow now also wakes on `pull_request_target: labeled` and
|
|
`unlabeled`, and the author sets `state:needs-human` themselves when handing
|
|
a PR to the maintainer.
|
|
|
|
A review landing was never a trigger. There is no
|
|
`pull_request_review_target`, and on fork PRs — which is all of them here —
|
|
`pull_request_review` runs with a read-only token and cannot label anything.
|
|
So the exact moment the label became true, the third bot approving, fired
|
|
nothing at all: the label waited for the `*/15` cron, or for somebody to
|
|
touch an unrelated PR. And that cron does not run at its declared rate —
|
|
GitHub deprioritises short intervals hard enough that, measured across the
|
|
three repos over a two-hour window on 2026-07-20, each got **one** scheduled
|
|
run against the eight `*/15` implies. heavy-duty/rig#94 took its third
|
|
approval and sat on `state:bots-reviewing` for hours; box and cast happened
|
|
to catch a tick and flipped correctly, on byte-identical workflow files. The
|
|
lag was worst on the quietest repo, which depends on the cron most and
|
|
receives it least.
|
|
|
|
The two halves fix each other: the author's own label write is what fires
|
|
the sweep that validates it. That makes it an optimistic write rather than a
|
|
transfer of ownership — seconds later the reconciler either confirms the
|
|
label or corrects it, and the cron falls back to being a genuine last
|
|
resort, for the round an agent forgets to hand off. It cannot loop, because
|
|
the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create
|
|
workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a
|
|
PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every
|
|
open PR from scratch on every run, which is exactly what makes the
|
|
optimistic write safe. The `scope` job is skipped on the two new actions,
|
|
where no path can have changed and labeler has nothing to derive.
|
|
|
|
Landed in all three repos together (heavy-duty/box#142,
|
|
heavy-duty/rig#97) — `labels.yml` and the label taxonomy are shared.
|
|
|
|
- **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
|
|
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 → 72.
|
|
|
|
### Fixed
|
|
|
|
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
|
heavy-duty/box#122) — `.github/scripts/changelog-monotonic.sh` asserts that
|
|
the set of `## X.Y.Z` headings on HEAD is a superset of the set at the merge
|
|
base, and that no version heading appears twice. Wired into `ci.yml` on
|
|
every event, with `CHANGELOG_MONOTONIC_STRICT=1` and `fetch-depth: 0` so a
|
|
checkout that cannot reach the base ref fails loudly rather than skipping
|
|
quietly forever.
|
|
|
|
- **...and a duplicate heading no longer slips through on the paths where the
|
|
guard cannot see the base** (#133, heavy-duty/box#143) — the uniqueness half
|
|
is a property of HEAD alone, but it sat downstream of the base-ref,
|
|
merge-base and base-blob conditions, so each of those degradations returned
|
|
success on a tree with a duplicate in plain sight.
|
|
|
|
The base-blob case was the worst of the three because it was not a skip at
|
|
all: a branch that *introduces* `CHANGELOG.md` exited 0 through a bare
|
|
`exit 0`, on a message that was true about deletion and silent about the
|
|
duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the
|
|
two `skip()` calls, and that path is not one of them. Off CI the two skips
|
|
had the same shape, so a shallow clone or an unpacked tarball would not look
|
|
at a duplicate the author was about to push.
|
|
|
|
That inverted the two halves, and it inverted them hardest here. Deletion is
|
|
the failure that needs a diff to see; duplication is the one cast's
|
|
`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 (heavy-duty/box#118).
|
|
The half with the live extraction bug behind it was the half with the most
|
|
ways to silently not run.
|
|
|
|
Fixed by moving, not rewriting: uniqueness now runs directly after the file
|
|
exists, before any git access. The skip messages say *containment* skipped
|
|
and that uniqueness already passed, so a skip no longer claims nothing was
|
|
checked — and the success line got the same treatment, because dropping the
|
|
gate made `merge_base == HEAD` a routine path rather than a degradation. On a
|
|
push to main containment compares the file against itself and asserts
|
|
nothing, so the line now reports containment *vacuous* and names uniqueness
|
|
as the half that ran, instead of claiming N headings were verified present by
|
|
a comparison that could not have detected their absence.
|
|
|
|
The guard is also no longer gated to `pull_request` — deletion is
|
|
vacuous on a push to main, but duplication is vacuous on no tree, so a
|
|
duplicate reaching main by any other route went unasserted. That gate could
|
|
not simply be dropped: `github.base_ref` is empty on a push, and a bare
|
|
`origin/` under `STRICT=1` is a hard failure on every push to main, so the
|
|
base ref falls back to `github.ref_name`.
|
|
|
|
Found by `claude-bot-andresmgsl` and `codex-bot-andresmgsl` reviewing #134;
|
|
cast inherited the ordering from box, fixed there in heavy-duty/box#144.
|
|
|
|
The failure it catches leaves no trace. An author adding an entry under
|
|
`## Unreleased` types *over* the heading below it instead of inserting above
|
|
it — a one-line edit, in a file nobody touched concurrently, so git merges it
|
|
cleanly with no conflict and no signal. The arming rule stays green and is
|
|
not wrong to: the top section is still the right one for the version. But
|
|
the shipped section's body is now sitting under `## Unreleased`, and the
|
|
version it belonged to has no section at all. Nothing surfaces until the
|
|
*next* release, when `release-notes.sh` cannot find the section it extracts
|
|
by heading — or worse, republishes the absorbed prose as if it were new.
|
|
|
|
The uniqueness half matters more here than in box. `release-notes.sh`'s awk
|
|
has no `exit`, so `grab` re-arms on every matching `## ` line: two
|
|
`## 0.1.1` headings make the published body **absorb** whatever sits between
|
|
the copies, and an entry stranded there is dropped from the next release's
|
|
notes as well. Containment alone cannot see it — a duplicate is head-side
|
|
surplus, and base-minus-head is blind to extras on the head side — so
|
|
uniqueness on HEAD is asserted alongside it. `## Unreleased` is deliberately
|
|
outside the guarded set: the arming rule owns that heading, and the ceremony
|
|
legitimately consumes it.
|
|
|
|
- **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. A missing
|
|
*state* label skips only the label edit, not the rest of the PR: clearing a
|
|
stale `merge-next` and the staleness sweep depend on no part of the `state:*`
|
|
taxonomy, and a cold-start repo that skipped them would leave "merge this one
|
|
next" sitting on a PR the board had moved to the agent — the same false
|
|
invitation, one scope smaller.
|
|
|
|
- **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.
|
|
|
|
- **`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
|
|
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`.
|
|
|
|
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
|
|
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.
|
|
|
|
`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
|
|
heavy-duty/box#137 so the three repos' reconcilers stay byte-identical; both
|
|
live shapes, the mixed round, the in-flight run superseding a finished one —
|
|
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).
|
|
|
|
- **CI now lints every tracked shell script, and proves the set is
|
|
complete** (#118) — filed as cast's record of heavy-duty/box#116, whose
|
|
defect is a `shopt -s globstar; files=(bin/* **/*.sh)` sweep that skips
|
|
`.github/` because globs do not match dot-prefixed names without
|
|
`dotglob`. cast's CI turned out never to have had a shellcheck step at
|
|
all: the only shell gate was `bash -n install.sh bin/cast scripts/*.sh
|
|
.github/scripts/*.sh`, a syntax check over a hand-maintained list. So the
|
|
reported symptom was right — `release-notes.sh` and
|
|
`labels-reconcile.sh` shipped unlinted — but so did every other script in
|
|
the repo, including `install.sh` and `bin/cast`, and `bash -n` would not
|
|
have caught a quoting or unset-variable bug in any of them.
|
|
[.github/scripts/shellcheck-all.sh](.github/scripts/shellcheck-all.sh)
|
|
now runs `shellcheck -x` over the tracked tree, from CI and from
|
|
`npm run check:shell`. It takes its file list from `git ls-files` rather
|
|
than from a glob. `shopt -s globstar dotglob` was measured and does work
|
|
here — cast's dependency tree ships no `.sh` files, so sweeping after
|
|
`npm ci` pulls in nothing — but that is a property of somebody else's
|
|
package tree, re-decided by every install; `git ls-files` does not depend
|
|
on it. Extensionless scripts are found by shebang, which is how
|
|
`bin/cast` is covered without being named. All seven scripts passed as
|
|
they stood — the three findings were intentional (`$PATH` written
|
|
literally into a profile, advice text in backticks) or a false positive,
|
|
and are annotated as such, so this lands as a no-op on behavior. It ships
|
|
with a class check in box#112's shape: the sweep asserts its own list
|
|
covers `git ls-files '*.sh'` and fails naming the strays otherwise, so a
|
|
future sweep that quietly narrows is red rather than green over nothing.
|
|
|
|
- **`cast` no longer leaves a full repo clone in the temp dir on every run**
|
|
(#117) — `resolveCheckout()` mkdtemps an `infra-checkout-` directory and
|
|
`git clone`s the infra repo into it, and nothing ever removed it. Every
|
|
`cast apply`, `diff`, or `capture` invoked *without* `--path` — the normal
|
|
way to run all three — left a shallow clone behind permanently. This is a
|
|
runtime leak, not a test one: #117 was filed as test-suite hygiene and
|
|
explicitly scoped the runtime out ("`cast` itself does not leak"), but the
|
|
box that found it was also holding 602 `infra-checkout-*` directories,
|
|
73 MB of real `.git` trees, from the same day. The leak fires on the
|
|
failure path too, since the directory is created before the clone runs.
|
|
Ephemeral checkouts are now registered and removed on process exit, which
|
|
is the lifetime that fits: the tree has to outlive `resolveCheckout`'s
|
|
return — every caller reads it — so a `finally` would delete the checkout
|
|
out from under the command that asked for it. A `--path` checkout is the
|
|
operator's own working tree and is never registered.
|
|
|
|
- **The test suite reaps its temp directories** (#117) — 68 `mkdtempSync`
|
|
call sites across 21 files, zero cleanups, accumulating ~6700 directories
|
|
and 189 MB per machine-day, some holding age keys. All 68 now go through a
|
|
single `tmp()` helper (`test/helpers/tmp.ts`) that allocates inside a
|
|
per-run root, which vitest's `globalSetup` teardown removes wholesale. A
|
|
class-guard test (`test/tmp-guard.test.ts`) fails if `mkdtempSync` appears
|
|
anywhere under `test/` outside those helpers, so the next raw call is
|
|
caught at review rather than after a day of accumulation.
|
|
|
|
## 0.1.1 — 2026-07-19
|
|
|
|
### 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
|
|
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.
|
|
|
|
## 0.1.0 — 2026-07-19
|
|
|
|
### Fixed
|
|
|
|
- **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.
|
|
|
|
- **`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.
|
|
- **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.
|
|
- **`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.
|
|
|
|
### Added
|
|
|
|
- **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.
|
|
|
|
- **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.
|