feat: release flow — tagged releases with a prebuilt dist asset (#96)
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.
- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
relative to the compiled module so a source checkout and an installed
prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
must fail the assert loudly, not be pattern-skipped): asserts tag ==
package.json version FIRST, extracts that version's changelog section
(.github/scripts/release-notes.sh, shared with the tests; missing or
empty refuses), builds once (npm ci && npm run build && npm prune
--omit=dev), stages bin/ dist/ node_modules/ package.json as
cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
--verify-tag`. No tests here — ci.yml gated the merge commit, and the
suite needs age.
- install.sh grows the three channels: default = the latest release's
asset (tag resolved off the releases/latest redirect Location — no
API, no token; failure dies loudly naming CAST_REF=main, never a
silent fallback), CAST_REF=<tag> = pinned (asset first, source
fallback), CAST_REF=main = dev build-from-source. npm is required
only on the source path, and a prebuilt tree is sanity-checked
(dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
changelog, and REAL install.sh runs through all three channels with a
stub curl and a poisoned npm — including the loud no-releases refusal
with no $DEST side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
|
|
|
# Changelog
|
|
|
|
|
|
|
|
|
|
History before 0.1.0 lives in git — cast has said `0.1.0` in `package.json`
|
|
|
|
|
since its first commit, but grew its release surface (this file,
|
|
|
|
|
`cast --version`, tagged releases with a prebuilt asset) on the way to
|
|
|
|
|
actually cutting it, and this file starts there.
|
|
|
|
|
|
2026-07-19 19:39:45 +00:00
|
|
|
## Unreleased
|
|
|
|
|
|
|
|
|
|
### Fixed
|
|
|
|
|
|
|
|
|
|
- **The release ceremony re-arms the changelog, and CI notices when it
|
|
|
|
|
doesn't** (#113) — stamping `## Unreleased` into `## X.Y.Z — DATE` is
|
|
|
|
|
done by hand in the ceremony PR; no workflow writes this file, and
|
|
|
|
|
nothing put the heading back. So `main` sat with the shipped section on
|
|
|
|
|
top and no `## Unreleased` above it — this repo's state from 0.1.0
|
|
|
|
|
until this entry. A PR authored before a release and merged after has
|
|
|
|
|
its entry land under whatever heading now occupies that position: the
|
|
|
|
|
release that already shipped. Git does that *cleanly*. The stamped
|
|
|
|
|
heading and the incoming entry never overlap textually, so the one
|
|
|
|
|
signal an author trusts — "git told me to look" — is missing exactly
|
|
|
|
|
when the result is wrong. rig watched it happen (heavy-duty/rig#66, the
|
|
|
|
|
origin of this fix): an entry landed inside published `## 0.1.0` an
|
|
|
|
|
hour after 0.1.0 shipped, and was caught only because someone was
|
|
|
|
|
reading. The published release body is never at risk — `release.yml`
|
|
|
|
|
extracts notes from the tree at the tag, before anything late can merge
|
|
|
|
|
— which is also why nobody notices: the file that drifts is the one
|
|
|
|
|
only maintainers read. Three moves. `## Unreleased` is back above
|
|
|
|
|
`## 0.1.0` (this entry re-creating it *is* the repair). CONTRIBUTING's
|
|
|
|
|
ceremony step now re-arms in the same diff that stamps. And
|
|
|
|
|
`test/release.test.ts` keys the rule to `package.json`: a stamped top
|
|
|
|
|
section is legal while the version is bare — the ceremony's own tree,
|
|
|
|
|
and main until the `-dev` bump — but once the version says `-dev`, the
|
|
|
|
|
top section must be `## Unreleased`. That is the distinction #108 had
|
|
|
|
|
to collapse to make the ceremony shippable at all, recovered rather
|
|
|
|
|
than reverted: the ceremony stays green at every step, and a disarmed
|
2026-07-19 20:33:49 +00:00
|
|
|
dev `main` goes red. The re-arm also forced the older extraction guard
|
|
|
|
|
to move. It asserted that the **top** section extracts non-empty, which
|
|
|
|
|
the re-armed ceremony tree — a deliberately empty `## Unreleased` above
|
|
|
|
|
the stamp — makes false by construction: the re-arm and the guard would
|
|
|
|
|
have contradicted each other, and the next release PR would have been
|
|
|
|
|
unshippable for a second time, the way #108 was. Keying to the top
|
|
|
|
|
section was only ever a stand-in for "the section `release.yml` will
|
|
|
|
|
publish", so the assert now names that section directly — on a bare
|
|
|
|
|
version the `## X.Y.Z` being shipped, on a `-dev` tree the newest
|
|
|
|
|
stamped one. Existence is checked with it: a bare version with no
|
|
|
|
|
matching section is a bump that never stamped, which used to pass every
|
|
|
|
|
test and fail only *after* the merge, in `release.yml`'s notes step,
|
|
|
|
|
past the ship decision and leaving `main` with a minted, unreleased
|
|
|
|
|
version to repair by hand. A double re-arm — two `## Unreleased`
|
|
|
|
|
headings, the extracted section silently the empty one — is red too.
|
|
|
|
|
box and rig carry the same fix (heavy-duty/box#110,
|
|
|
|
|
heavy-duty/rig#67); rig#67 retargeted the identical assert for the
|
|
|
|
|
identical reason.
|
2026-07-19 19:39:45 +00:00
|
|
|
|
2026-07-19 14:24:44 +00:00
|
|
|
## 0.1.0 — 2026-07-19
|
feat: merging a release-labeled PR is the release (#111)
box#95 taught the family that a forgotten manual tag is the worst
failure shape: silent, no red X, a release that simply doesn't happen.
The ship decision already lives in the ceremony PR — the one whose whole
diff is the version leaving -dev, carrying the reviews and the
maintainer's merge — so tagging after it is transcription, and
transcription belongs to the machine (box#96's design; this is cast's
twin).
release.yml now also triggers on pull_request closed against main,
gated on merged == true AND the hand-set release label. The merge path
asserts four facts in order, each fail-loud and creating nothing: the
merged package.json version is non--dev (read via node, never regex —
the pkg_version discipline); the version CHANGED in this PR (base vs
merge — the -dev interlock, so a mislabeled ordinary PR fails loudly);
the version's changelog section extracts non-empty via the existing
release-notes.sh; and no tag or release exists yet (idempotent re-runs,
and the loud answer to a manual-tag race). Then, in the same job, it
tags the merge commit via the API and publishes. Same-job is
load-bearing: a GITHUB_TOKEN-created tag triggers no workflows, so the
tag-push path cannot fire on it and double-publish.
Both trigger paths converge on literally the same steps — each entry
step exports RELEASE_VERSION, and the notes extraction, the exact
existing asset build (npm ci, npm run build, npm prune --omit=dev,
staged as cast-X.Y.Z/), and the gh release create read only that — so
the paths cannot drift and the installer keeps finding the one asset
name it knows, cast-X.Y.Z.tgz. The tag-push path survives as the
documented manual fallback and backfill, and it matters immediately:
0.1.0 never carried -dev (cast predates the ritual), so the interlock
correctly does not fire for #110's ceremony — that one ships by manual
tag, and the automation applies from 0.1.1 on.
test/release.test.ts pins the new wiring in the house grep style,
fail-closed: the merged+labeled gate, the four asserts strictly ordered
ahead of tag/build/publish, the single job, the anti-recursion comment,
and that no per-path asset name exists. CONTRIBUTING.md's Releasing now
says it plainly: merge is the ship decision; the tag is the fallback.
Fixes #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:21:07 +00:00
|
|
|
|
2026-07-19 12:26:35 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
2026-07-19 13:50:03 +00:00
|
|
|
- **The release suite accepts the ceremony's own tree** (#108) —
|
|
|
|
|
`test/release.test.ts` demanded the real `CHANGELOG.md`'s literal
|
|
|
|
|
`Unreleased` section extract non-empty and contain `#96`: false by
|
|
|
|
|
construction on the `release: X.Y.Z` tree the ceremony's own PR produces
|
|
|
|
|
(it stamps that heading into `## X.Y.Z — date`), so the first real
|
|
|
|
|
release PR turned CI red and the flow blocked itself — invisible to the
|
|
|
|
|
fork rehearsals, which tag a branch (`release.yml` runs; `ci.yml` never
|
|
|
|
|
does). The guard now asserts its actual purpose: whatever the TOP `## `
|
|
|
|
|
section is — `Unreleased` between releases, the stamped version on and
|
|
|
|
|
right after one — the exact `release-notes.sh` the workflow runs
|
|
|
|
|
extracts it non-empty. rig's twin is heavy-duty/rig#44.
|
|
|
|
|
|
2026-07-19 12:26:35 +00:00
|
|
|
- **`apply` no longer demands a GitHub App for a manifest that declares no
|
|
|
|
|
applications** (#103) — found live in the 2026-07-19 release drill, where a
|
|
|
|
|
databases-only manifest (`applications: {}`) rendered its plan of two
|
|
|
|
|
creates and then died in preflight on `no GitHub App bound`, over a binding
|
|
|
|
|
nothing in the run would ever have used: a GitHub App exists to clone
|
|
|
|
|
application source, cast reads it in exactly one call (the application
|
|
|
|
|
create), and databases and services never touch it. That unconditional
|
|
|
|
|
resolution gated infra-only projects — the databases a fleet's other
|
|
|
|
|
projects share — behind the GitHub-App browser-registration ceremony for no
|
|
|
|
|
reason. `apply` now resolves the App only when the desired state actually
|
|
|
|
|
contains an application; a manifest that does declare one still refuses on
|
|
|
|
|
a missing binding exactly as before, clean plan or not, because that
|
|
|
|
|
binding is state the next create will need.
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
- **A manifest with no `${…}` refs applies without a store** (#104) — the
|
|
|
|
|
greenfield manifest-first bootstrap was a chicken-and-egg with no exit,
|
|
|
|
|
found by the 2026-07-19 release drill against two fresh Coolify 4.1.2
|
|
|
|
|
instances: a registered project whose manifest declared databases only
|
|
|
|
|
(zero `${…}` refs) could not take its first `apply` — apply refused with
|
|
|
|
|
`no secret store for <org>/<repo> in <env>`, and `capture`, the documented
|
|
|
|
|
way to get a store, rightly refuses a project that is absent on the box,
|
|
|
|
|
because apply is the verb that would create it. The drill unblocked with a
|
|
|
|
|
hand-rolled empty store (`printf '' | age -r … -o secrets/….env.age`),
|
|
|
|
|
documented nowhere. Now `diff`/`apply` gate that refusal on the manifest
|
|
|
|
|
actually *referencing* a secret, asked via the same parser resolution
|
|
|
|
|
uses: when the templates resolve zero `${…}` refs, an absent store is
|
|
|
|
|
treated as empty and the run proceeds, printing a loud one-line note
|
|
|
|
|
naming the path the store would live at — and since there is nothing to
|
|
|
|
|
decrypt, the age key is not demanded either. The moment any template
|
|
|
|
|
gains a `${…}` ref, the refusal returns byte-identical to before.
|
|
|
|
|
`capture` and `destroy` are untouched.
|
2026-07-19 12:17:33 +00:00
|
|
|
- **`CAST_AGE_KEY_FILE_<ENV>` is now settable for every environment name**
|
|
|
|
|
(#102) — `<ENV>` was the name uppercased verbatim, so env `drill-b`
|
|
|
|
|
advertised `CAST_AGE_KEY_FILE_DRILL-B`: a variable no POSIX shell can
|
|
|
|
|
export, which walled off the injected-key channel (and its
|
|
|
|
|
process-substitution trick) for every hyphenated environment. Found live
|
|
|
|
|
in the 2026-07-19 release drill. Characters outside `[A-Z0-9]` now map to
|
|
|
|
|
`_` — env `drill-b` reads `CAST_AGE_KEY_FILE_DRILL_B` — and the refusal
|
|
|
|
|
advertises the mapped name. The standing-key path keeps the exact
|
|
|
|
|
environment name, so two names that collide on the variable still resolve
|
|
|
|
|
their own keys on disk.
|
2026-07-19 12:26:35 +00:00
|
|
|
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.
- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
relative to the compiled module so a source checkout and an installed
prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
must fail the assert loudly, not be pattern-skipped): asserts tag ==
package.json version FIRST, extracts that version's changelog section
(.github/scripts/release-notes.sh, shared with the tests; missing or
empty refuses), builds once (npm ci && npm run build && npm prune
--omit=dev), stages bin/ dist/ node_modules/ package.json as
cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
--verify-tag`. No tests here — ci.yml gated the merge commit, and the
suite needs age.
- install.sh grows the three channels: default = the latest release's
asset (tag resolved off the releases/latest redirect Location — no
API, no token; failure dies loudly naming CAST_REF=main, never a
silent fallback), CAST_REF=<tag> = pinned (asset first, source
fallback), CAST_REF=main = dev build-from-source. npm is required
only on the source path, and a prebuilt tree is sanity-checked
(dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
changelog, and REAL install.sh runs through all three channels with a
stub curl and a poisoned npm — including the loud no-releases refusal
with no $DEST side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-19 14:24:44 +00:00
|
|
|
- **Merging a release-labeled PR is the release — and the release re-arms
|
|
|
|
|
main itself** (#111; box#96's design) — `release.yml` now also fires on
|
|
|
|
|
pushes to main (not `pull_request` events: fork-sourced ceremony PRs get
|
|
|
|
|
a read-only token there — the round-1 catch). A decide step reads the
|
|
|
|
|
version transition from the push (`event.before` → the pushed head) and
|
|
|
|
|
answers four states: release-flow *work* merged under the `release`
|
|
|
|
|
label — `-dev` endstates, and the post-release window — no-ops green
|
|
|
|
|
with a NOTICE; the two genuinely ambiguous bare states refuse loudly;
|
|
|
|
|
a true transition then requires a merged, `release`-labeled PR behind
|
|
|
|
|
the commit (read via the API — the label is the operator's declared
|
|
|
|
|
intent) before the door opens. It then tags the merge commit, builds
|
|
|
|
|
the `cast-X.Y.Z.tgz` asset once, publishes — and bumps main to
|
|
|
|
|
`X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR fallback, so
|
|
|
|
|
no follow-up bump PR exists on the paved road. The tag-push path stays
|
|
|
|
|
as the documented fallback and backfill, and both paths run the same
|
|
|
|
|
steps so they cannot drift. First-release edge: 0.1.0 never carried
|
|
|
|
|
`-dev`, so its ceremony (#110) ships by manual tag; the automation
|
|
|
|
|
applies from 0.1.1 on.
|
|
|
|
|
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.
- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
relative to the compiled module so a source checkout and an installed
prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
must fail the assert loudly, not be pattern-skipped): asserts tag ==
package.json version FIRST, extracts that version's changelog section
(.github/scripts/release-notes.sh, shared with the tests; missing or
empty refuses), builds once (npm ci && npm run build && npm prune
--omit=dev), stages bin/ dist/ node_modules/ package.json as
cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
--verify-tag`. No tests here — ci.yml gated the merge commit, and the
suite needs age.
- install.sh grows the three channels: default = the latest release's
asset (tag resolved off the releases/latest redirect Location — no
API, no token; failure dies loudly naming CAST_REF=main, never a
silent fallback), CAST_REF=<tag> = pinned (asset first, source
fallback), CAST_REF=main = dev build-from-source. npm is required
only on the source path, and a prebuilt tree is sanity-checked
(dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
changelog, and REAL install.sh runs through all three channels with a
stub curl and a poisoned npm — including the loud no-releases refusal
with no $DEST side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
|
|
|
- **Tagged releases with a prebuilt dist asset, and an installer that
|
|
|
|
|
installs them** (#96) — the cast half of the flow designed in
|
|
|
|
|
heavy-duty/box#83, plus the piece unique to cast: a **prebuilt asset**,
|
|
|
|
|
because cast is the one repo where the source tarball is *not* the
|
|
|
|
|
package. A release is a PR, then a tag: the `release: X.Y.Z` PR bumps
|
|
|
|
|
`package.json` (and `package-lock.json`) and stamps this file's Unreleased
|
|
|
|
|
section with version + date; the merge commit is tagged bare `X.Y.Z`
|
|
|
|
|
(box's tag scheme — no `v` prefix). `release.yml` turns the tag into the
|
|
|
|
|
GitHub release — after asserting tag == `package.json` version (a
|
|
|
|
|
mismatch fails loudly and creates nothing) — with that version's section
|
|
|
|
|
of this file as the body, extracted by the same
|
|
|
|
|
`.github/scripts/release-notes.sh` the test harness drives, and with the
|
|
|
|
|
runnable tree attached as `cast-X.Y.Z.tgz`: `bin/`, compiled `dist/`,
|
|
|
|
|
production `node_modules/`, `package.json`, built once in CI
|
|
|
|
|
(`npm ci && npm run build && npm prune --omit=dev`). `install.sh` now
|
|
|
|
|
defaults to the **latest release**: the tag is resolved by following the
|
|
|
|
|
`releases/latest` redirect and reading the `Location` header — no API, no
|
|
|
|
|
token — and the download is that release's asset, so **no `npm ci`, no
|
|
|
|
|
`tsc`, no devDependencies ever run on the operator's machine**. `CAST_REF`
|
|
|
|
|
picks the other two channels: a tag pins a release (its asset first,
|
|
|
|
|
source as the fallback for a ref that has none — `refs/tags` outranks a
|
|
|
|
|
same-named branch), a branch (`CAST_REF=main`) tracks the development
|
|
|
|
|
tree and is the one channel that still builds from source, the only place
|
|
|
|
|
`npm` is required. Until 0.1.0 is cut the default channel has nothing to
|
|
|
|
|
resolve and dies saying exactly that, naming `CAST_REF=main` as the way
|
|
|
|
|
to install today — it never falls back to main silently, because "I
|
|
|
|
|
installed the latest release" must not quietly mean "I installed whatever
|
|
|
|
|
main was that second". The channel only decides *which* tree arrives and
|
|
|
|
|
whether it is built here — whatever it fetched lands in the versioned
|
|
|
|
|
layout (`versions/<package.json version>`, `current` flipped atomically)
|
|
|
|
|
like any other install.
|