docs(changelog): one line per entry, and a pass over the whole file

This commit is contained in:
dan-claude-bot 2026-07-21 13:51:01 +00:00
parent bf0dfd8fca
commit 79f754549b
2 changed files with 69 additions and 604 deletions

View file

@ -9,629 +9,62 @@ actually cutting it, and this file starts there.
### Added ### Added
- **An application can declare HTTP basic auth, and `apply` sets it** - An application can declare HTTP basic auth, and `apply` sets it (#76)
(#76) — `UNCAPTURED.md` has said for as long as it has existed that - `cast github-app create` / `cast github-app register` run the App Manifest
Basic Auth is "carried as raw container labels. cast's manifest has no flow (#7)
field for them, so a rebuilt resource is UNPROTECTED where the original
was not." The #72 audit found that half of that is a **cast vocabulary
gap, not a Coolify one**: `is_http_basic_auth_enabled`,
`http_basic_auth_username` and `http_basic_auth_password` are in both
the create and the PATCH allowlists at v4.1.2
(`ApplicationsController.php:914`, `:2368`). So an application now
declares it:
```yaml
admin:
basic_auth:
enabled: true
username: ops
password: ${ADMIN_BASIC_AUTH_PROD}
```
The password is a **store ref and only a store ref** — the schema
refuses a literal rather than warning about one, because a manifest is
a reviewed, committed artifact and a literal there is a live password
in git forever. It resolves out of the environment's age store through
the same mechanism every env-template `${REF}` uses, and a missing or
empty entry fails the run before anything is written.
Managing it is **opt-in**, the `is_static` rule for the same reason one
notch sharper: an unconditional `is_http_basic_auth_enabled: false`
would make the first apply after this ships strip the protection off
every app somebody enabled by hand in the UI. Omit the block to say
nothing, `enabled: false` to assert it is off. Enabling without both
credentials is refused at parse time *and* at the wire — Coolify's own
rule (`:2446-2463`), enforced before the request rather than discovered
as a 422 halfway through a run.
**The read side is fail-honest, because the three fields do not read
back alike.** The toggle and the username are plain columns and are
compared like anything else — somebody turning basic auth off in the UI
*is* caught, which is most of the value. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and behind the `read:sensitive`
ability on v4.2 (#77), so whether it arrives depends on the token, the
route and the release — and it would have to be *printed*, since a
field diff renders as `field: <live> → <desired>`. So cast never
projects it into the comparison vocabulary on any box, and every diff
of an app declaring `basic_auth:` prints `http_basic_auth_password NOT
compared — verify in the Coolify UI`. Same disposition as an
unverifiable backup schedule: reported, not counted as drift. A read
returning none of the three names all three on that line instead, and
claims nothing at all.
The limit that follows is stated rather than hidden: rotating *only*
the password in the store produces no field diff and therefore no
write. It lands on the next apply that touches basic auth for any other
reason — Coolify requires both credentials on any write that enables
it, so cast completes the whole triple whenever it sends one.
`custom_labels` is **deliberately still absent**, though it is equally
settable: enabling basic auth or changing domains makes Coolify
regenerate an application's labels and overwrite `custom_labels` unless
`is_container_label_readonly_enabled`, which is itself not API-settable
until v4.2. Declaring both on one app would have cast silently destroy
what it was told to write. Basic-auth-only is the safe slice until
then.
- **`cast github-app create` / `cast github-app register`** (#7, superseding
#5) — the GitHub App was the one piece of a Coolify instance cast could
not reproduce: made by hand in a browser, its four identifiers copied out
of the UI by eye, its private key downloaded to `~/Downloads`, its details
fed to `scripts/register-github-app.sh` as a six-variable env pile.
Nothing about that survived in state. There is no REST endpoint that
creates a GitHub App — no `POST /apps`, no GraphQL mutation, no `gh app`
subcommand, no PAT scope — so `create` runs the only programmatic path
there is, GitHub's App Manifest flow: a one-shot page served on
`127.0.0.1` whose form POST your own browser session authenticates,
followed by an unauthenticated code exchange. That exchange is the only
moment GitHub ever yields the private key, the client secret and the
webhook secret together, and cast now persists all three to
`<state>/github-apps/` at 0600 under a `.gitignore` of `*`, so `git add
-A` in the state repo cannot commit them by accident. `register` adopts
credentials you already hold (an App made by hand, or a DR restore from a
stored PEM) and reads the client secret from **stdin only** — argv is
visible in `ps`. `create` does not reimplement it: it obtains credentials
and then calls exactly the `register` path.
#5's three footguns are gone structurally rather than by validation. The
Coolify-facing name is resolved from `github_apps.<org>/<repo>` in
`environments.yaml` — the value every later `cast apply` resolves the App
by — and `--name` only *seeds* an absent entry (keyed by full slug, #6),
and is refused outright when it disagrees with one that exists. The state
file is written only after the App is registered *and* verified, because a
state file naming an App that does not work is worse than one naming none.
`--webhook-secret` is optional: a webhook-inactive App is the correct shape
for a tailnet-only Coolify, and nobody has to invent a placeholder.
The one-shot secrets are written the instant GitHub yields them, *before*
`create` waits ~5 minutes for you to install the App — so a timeout, a
dropped connection or a `Ctrl-C` during that wait cannot destroy a private
key GitHub will never re-show. Until the install lands the record carries
`"installation_id": null`, the single field GitHub will answer again, and
it is backfilled on success. A name collision is refused *before* the
browser flow starts, when no App exists yet and nothing can be lost.
Both verbs end at the step that matters most: `GET
/github-apps/{id}/repositories`, asserting the repo is actually reachable.
Until now a misconfigured App failed silently and surfaced hours later, in
a different command, as an unresolvable source at `cast apply` time. When
that check fails its advice is to re-run `register` — which now re-verifies
the existing Coolify Source instead of registering a second one, because
Coolify does not enforce unique Source names (`GithubController@create`
validates `name` without `unique` and calls a plain `GithubApp::create`).
No new dependencies — `node:http` serves the callback, `node:crypto`'s
`createSign("RSA-SHA256")` mints the App JWT that recovers the installation
id from the App's own key (never from the `installation_id` GitHub appends
to a redirect, which GitHub documents as a spoofable hint).
**Unvalidated, and load-bearing**: that GitHub accepts a `redirect_url` on
`http://127.0.0.1:<port>` at all. The manifest docs are silent on the
scheme, the precedent (Probot's setup flow) is strong, and validating it
requires a logged-in GitHub session — so the first real run is an
operator's, and README keeps the manual browser path documented until
`create` has succeeded once.
### Changed ### Changed
- **`state:needs-human` no longer waits on the cron to become true** (#131) - `state:needs-human` is set at handoff, not by the cron (#131)
— the labels workflow now also wakes on `pull_request_target: labeled` and - PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
`unlabeled`, and the author sets `state:needs-human` themselves when handing is in the way); `state:needs-rebase` is retired (heavy-duty/box#138)
a PR to the maintainer. - The `NO_API_COVERAGE` row for Basic Auth now says services (#76)
- Changelog entries are one line each, and the whole file now follows the rule
A review landing was never a trigger. There is no (#136)
`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.
- **The `NO_API_COVERAGE` row for Basic Auth now says *services*** (#76)
— the blanket row covered applications and services alike, and became
wrong for half of them the moment applications could say `basic_auth:`.
What survives is a real API gap rather than a vocabulary one:
`ServicesController` carries no basic-auth fields and no
`custom_labels`, on v4.1.2 or on the v4.2 train, so no manifest field
could ever set them. A separate row now covers `custom_labels` on
applications — writable, deliberately unwired, with the overwrite
caveat spelled out — so neither row implies cast can express something
it cannot. `inventory --emit-draft` also reports, per application, an
app whose basic auth is enabled on the box: it emits no `basic_auth:`
block, because the password cannot be read and a block a rebuild cannot
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
### Removed ### Removed
- **`scripts/register-github-app.sh`** — replaced by `cast github-app - BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app
register`. Kept as a thin wrapper it would have preserved exactly the register` (#7)
interface #5 catalogued as producing three live footguns, while adding a
second surface to keep in step with the CLI.
### Fixed ### Fixed
- `tmp-guard` is green again: three test files from #124/#125 used raw `mkdtempSync` - Three test files from #124/#125 allocate through `tmp()`, not raw
`mkdtempSync` (#135)
- **A PR that deletes a shipped release heading is now CI-red** (#133, - A PR that deletes a shipped release heading is now CI-red (#133,
heavy-duty/box#122) — `.github/scripts/changelog-monotonic.sh` asserts that heavy-duty/box#122)
the set of `## X.Y.Z` headings on HEAD is a superset of the set at the merge - A duplicate release heading is caught even where the guard cannot see the
base, and that no version heading appears twice. Wired into `ci.yml` on base (#133, heavy-duty/box#143)
every event, with `CHANGELOG_MONOTONIC_STRICT=1` and `fetch-depth: 0` so a - A label the repo does not have no longer takes the whole label edit down
checkout that cannot reach the base ref fails loudly rather than skipping with it
quietly forever. - An unreadable check rollup no longer reads as "nothing is failing"
- `state:needs-human` no longer appears on PRs a human cannot merge (#127,
- **...and a duplicate heading no longer slips through on the paths where the heavy-duty/box#136)
guard cannot see the base** (#133, heavy-duty/box#143) — the uniqueness half - CI lints every tracked shell script, and proves the set is complete (#118)
is a property of HEAD alone, but it sat downstream of the base-ref, - The shellcheck sweep covers extensionless scripts such as `bin/cast` (#121)
merge-base and base-blob conditions, so each of those degradations returned - `cast` no longer leaves a full repo clone in the temp dir on every run (#117)
success on a tree with a duplicate in plain sight. - The test suite reaps its temp directories (#117)
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.
- **The shellcheck sweep's own blind spot: extensionless scripts** (#121) —
the class check above asserts the swept set covers `git ls-files '*.sh'`,
which says nothing about scripts with no `.sh` extension. `bin/cast` is
one, and it enters the set only through the shebang scan — covered by the
derivation, not by the assertion. Break that scan and the shipped
entrypoint drops out of the lint while the check still exits 0: #118's
failure mode one level in. The sweep now also asserts a named floor of
known extensionless scripts, verified to go red when the shebang branch is
broken. Fixed alongside it: `IFS= read -r line <"$f" || continue` skipped
any file whose first line had no trailing newline, because `read` returns
1 at EOF even having populated `line` — a shebang-only file with no final
newline was silently unswept.
- **`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 ## 0.1.1 — 2026-07-19
### Fixed ### Fixed
- **The release ceremony re-arms the changelog, and CI notices when it - The release ceremony re-arms `## Unreleased`, and CI is red when it does not
doesn't** (#113) — stamping `## Unreleased` into `## X.Y.Z — DATE` is (#113)
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 ## 0.1.0 — 2026-07-19
### Fixed ### Fixed
- **The release suite accepts the ceremony's own tree** (#108) — - The release suite accepts the ceremony's own tree (#108)
`test/release.test.ts` demanded the real `CHANGELOG.md`'s literal - `apply` no longer demands a GitHub App for a manifest that declares no
`Unreleased` section extract non-empty and contain `#96`: false by applications (#103)
construction on the `release: X.Y.Z` tree the ceremony's own PR produces - A manifest with no `${…}` refs applies without a secret store (#104)
(it stamps that heading into `## X.Y.Z — date`), so the first real - `CAST_AGE_KEY_FILE_<ENV>` is settable for every environment name (#102)
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 ### Added
- **Merging a release-labeled PR is the release — and the release re-arms - Merging a release-labeled PR is the release, and the release re-arms main
main itself** (#111; box#96's design) — `release.yml` now also fires on itself (#111)
pushes to main (not `pull_request` events: fork-sourced ceremony PRs get - Tagged releases with a prebuilt dist asset, and an installer that installs
a read-only token there — the round-1 catch). A decide step reads the them (#96)
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.

View file

@ -55,6 +55,38 @@ labels tell you where everything is without opening anything.
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading — convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut. that section becomes the release notes verbatim when a release is cut.
## Changelog entries
Every PR that changes behaviour adds one line to `## Unreleased`. One line is
the whole rule — if it wraps more than twice in your editor, cut it down.
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
cost, what it implies — that belongs in the PR body and the commit message,
which is where anyone chasing the reasoning already goes. This file answers
one question: what is different in this version.
- **Any word that can be removed, is removed.**
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
handoff" beats "the labels workflow now also wakes on `labeled`".
- **Cite the issue or PR**`(#131)` — and let the reader follow it for the
rest.
- **Mark a breaking change** with a leading `BREAKING:`.
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
Good:
- `state:needs-human` is set at handoff, not by the cron (#131)
- An unreadable check rollup no longer reads as "nothing is failing" (#128)
- BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app` (#7)
Not an entry — that is a PR body:
- **`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 it themselves when handing a PR over. A
review landing was never a trigger. There is no `pull_request_review_target`,
and on fork PRs — which is all of them here — ...
## Releasing ## Releasing
A release is a PR, and merging it IS the release A release is a PR, and merging it IS the release