cast/CHANGELOG.md

165 lines
10 KiB
Markdown
Raw Normal View History

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.
## 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
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
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
fix: resolve the GitHub App only when the manifest declares applications (#103) Found live in the 2026-07-19 release drill: a manifest declaring only databases (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, and cast reads it in exactly one call, the application create (POST /applications/private-github-app); databases and services never touch it. Resolving it unconditionally 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 (binding lookup and uuid resolution both) only when the desired state contains at least one application. The executor's githubAppUuid field is typed string | null, and its single consumer guards the null with cast's own internal error — unreachable by construction, since a plan can only create resources the desired state holds, but a null slipping onto the wire would otherwise surface as a Coolify 422 about somebody else's field. Keyed off desired rather than the plan's changes, deliberately: a manifest that declares an application keeps the missing-binding refusal even on a clean plan, byte-identical to before — that binding is state the next create will need, and the operator should hear about it now, not mid-bootstrap. Fixes #103 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:35 +00:00
### 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.
fix: resolve the GitHub App only when the manifest declares applications (#103) Found live in the 2026-07-19 release drill: a manifest declaring only databases (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, and cast reads it in exactly one call, the application create (POST /applications/private-github-app); databases and services never touch it. Resolving it unconditionally 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 (binding lookup and uuid resolution both) only when the desired state contains at least one application. The executor's githubAppUuid field is typed string | null, and its single consumer guards the null with cast's own internal error — unreachable by construction, since a plan can only create resources the desired state holds, but a null slipping onto the wire would otherwise surface as a Coolify 422 about somebody else's field. Keyed off desired rather than the plan's changes, deliberately: a manifest that declares an application keeps the missing-binding refusal even on a clean plan, byte-identical to before — that binding is state the next create will need, and the operator should hear about it now, not mid-bootstrap. Fixes #103 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
- **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.
fix: resolve the GitHub App only when the manifest declares applications (#103) Found live in the 2026-07-19 release drill: a manifest declaring only databases (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, and cast reads it in exactly one call, the application create (POST /applications/private-github-app); databases and services never touch it. Resolving it unconditionally 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 (binding lookup and uuid resolution both) only when the desired state contains at least one application. The executor's githubAppUuid field is typed string | null, and its single consumer guards the null with cast's own internal error — unreachable by construction, since a plan can only create resources the desired state holds, but a null slipping onto the wire would otherwise surface as a Coolify 422 about somebody else's field. Keyed off desired rather than the plan's changes, deliberately: a manifest that declares an application keeps the missing-binding refusal even on a clean plan, byte-identical to before — that binding is state the next create will need, and the operator should hear about it now, not mid-bootstrap. Fixes #103 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
- **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.