cast/CHANGELOG.md
Daniel Marin 1acf172cad
Merge pull request #124 from dan-claude-bot/feat/github-app
feat: cast github-app create/register — run the App Manifest flow instead of transcribing it
2026-07-21 14:30:13 +01:00

39 KiB

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

Added

  • An application can declare HTTP basic auth, and apply sets it (#76) — UNCAPTURED.md has said for as long as it has existed that Basic Auth is "carried as raw container labels. cast's manifest has no 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:

    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

  • 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.

  • 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

  • scripts/register-github-app.sh — replaced by cast github-app register. Kept as a thin wrapper it would have preserved exactly the interface #5 catalogued as producing three live footguns, while adding a second surface to keep in step with the CLI.

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 itgh 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'tSUCCESS, 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 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 clones 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.