cast/CHANGELOG.md

72 lines
2.5 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
feat: an application can declare HTTP basic auth, and apply sets it UNCAPTURED.md has said since it 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." For applications 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). An application now declares `basic_auth: { enabled, username, password }`, with the password a store ${REF} and only a ${REF} — the schema refuses a literal, because a manifest is a committed file. 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 before anything is written. Managing it is opt-in (the is_static rule): an unconditional `false` would have the first apply after this ships strip protection off every app enabled by hand in the UI. Enabling without both credentials is refused at parse time and again at the wire — Coolify's own rule (:2446-2463), enforced before the request rather than discovered as a mid-run 422. The read side is fail-honest. The toggle and username are plain columns and are compared, so a UI flip is caught. The password is gated behind a sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have to be printed as a field diff, so it is never projected into the comparison vocabulary on any box — every diff of an app declaring basic_auth says the password was NOT compared, in the backup schedule's voice: reported, not drift. custom_labels stays deliberately unwired: enabling basic auth or changing domains regenerates labels and overwrites it unless is_container_label_readonly_enabled, which is not API-settable until v4.2. The NO_API_COVERAGE row narrows to services, where it is a real API gap on both releases, plus a separate row for custom_labels on applications. Closes #76 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
### Added
refactor: one drill record per version, in drills/ Drill records move from sections inside drill/RUNS.md to one file per version: drills/<version>.md. The old guard parsed headings — em-dash field matching, an optional ' — DATE' tail, a whole-version comparison so 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail constraint to match box, and a non-blank body rule. All of that existed only because records shared one file, and this repo shipped two defects out of the complexity in review: the sed '/./,$!d' whitespace bypass, and heading-grammar drift from box's stricter form. One file per version makes nearly all of it unrepresentable — 0.2.0.md and 0.2.0-rc1.md are simply different files, so the whole-version rule is the filesystem's rather than a comparison anyone can get wrong. One rule survives: a file of only whitespace is not a record. Plain drills/, not .drills/ — dot-directories are invisible to globs without dotglob, the cause of #118/#121 here and box#116. drill/RUNS.md is deleted. It was created in this same unmerged PR and held only format documentation, no real records; the useful reasoning moves to drills/README.md. (box keeps ITS drill/RUNS.md, a genuine harness log with real run history.) The docs also drop an over-constrained ordering claim: the three repos' drills are INDEPENDENT, run in any order and any sitting. What makes that safe is that each pins the same fixed set of candidate refs — and that pinning, not sequencing, is what dissolves the box/rig recursion, since refs are static identifiers that exist as soon as the release branches do. Each repo also drills a different thing: box the isolation contract, rig convergence, cast promotion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:27:09 +00:00
- CI refuses a release PR with no drill record at `drills/<version>.md`
- An application can declare HTTP basic auth, and `apply` sets it (#76)
- `cast github-app create` / `cast github-app register` run the App Manifest
flow (#7)
feat: cast github-app create/register — run the App Manifest flow instead of transcribing it The GitHub App was the one piece of a Coolify instance cast could not reproduce. There is no REST endpoint that creates one — 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 the operator's own browser session authenticates, followed by an unauthenticated code exchange. That exchange is the only moment GitHub yields the private key, the client secret and the webhook secret together; all three are persisted to <state>/github-apps/ at 0600 under a .gitignore of `*`. `create` does not reimplement `register`: it obtains credentials and then calls exactly that path. Both verbs end at GET /github-apps/{id}/repositories, asserting the repo is actually reachable — the check that turns a silent misconfiguration into an error next to the thing that caused it. github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent entry, and is refused when it disagrees), the client secret is stdin-only, and --webhook-secret is optional. scripts/register-github-app.sh is deleted. No new dependencies: node:http for the callback, node:crypto's createSign("RSA-SHA256") for the App JWT that recovers the installation id from the App's own key rather than from a spoofable redirect parameter. Closes #7 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:46:43 +00:00
### Changed
- `state:needs-human` is set at handoff, not by the cron (#131)
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
is in the way); `state:needs-rebase` is retired (heavy-duty/box#138)
- 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
(#136)
feat: an application can declare HTTP basic auth, and apply sets it UNCAPTURED.md has said since it 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." For applications 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). An application now declares `basic_auth: { enabled, username, password }`, with the password a store ${REF} and only a ${REF} — the schema refuses a literal, because a manifest is a committed file. 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 before anything is written. Managing it is opt-in (the is_static rule): an unconditional `false` would have the first apply after this ships strip protection off every app enabled by hand in the UI. Enabling without both credentials is refused at parse time and again at the wire — Coolify's own rule (:2446-2463), enforced before the request rather than discovered as a mid-run 422. The read side is fail-honest. The toggle and username are plain columns and are compared, so a UI flip is caught. The password is gated behind a sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have to be printed as a field diff, so it is never projected into the comparison vocabulary on any box — every diff of an app declaring basic_auth says the password was NOT compared, in the backup schedule's voice: reported, not drift. custom_labels stays deliberately unwired: enabling basic auth or changing domains regenerates labels and overwrites it unless is_container_label_readonly_enabled, which is not API-settable until v4.2. The NO_API_COVERAGE row narrows to services, where it is a real API gap on both releases, plus a separate row for custom_labels on applications. Closes #76 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
feat: cast github-app create/register — run the App Manifest flow instead of transcribing it The GitHub App was the one piece of a Coolify instance cast could not reproduce. There is no REST endpoint that creates one — 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 the operator's own browser session authenticates, followed by an unauthenticated code exchange. That exchange is the only moment GitHub yields the private key, the client secret and the webhook secret together; all three are persisted to <state>/github-apps/ at 0600 under a .gitignore of `*`. `create` does not reimplement `register`: it obtains credentials and then calls exactly that path. Both verbs end at GET /github-apps/{id}/repositories, asserting the repo is actually reachable — the check that turns a silent misconfiguration into an error next to the thing that caused it. github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent entry, and is refused when it disagrees), the client secret is stdin-only, and --webhook-secret is optional. scripts/register-github-app.sh is deleted. No new dependencies: node:http for the callback, node:crypto's createSign("RSA-SHA256") for the App JWT that recovers the installation id from the App's own key rather than from a spoofable redirect parameter. Closes #7 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:46:43 +00:00
### Removed
- BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app
register` (#7)
feat: cast github-app create/register — run the App Manifest flow instead of transcribing it The GitHub App was the one piece of a Coolify instance cast could not reproduce. There is no REST endpoint that creates one — 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 the operator's own browser session authenticates, followed by an unauthenticated code exchange. That exchange is the only moment GitHub yields the private key, the client secret and the webhook secret together; all three are persisted to <state>/github-apps/ at 0600 under a .gitignore of `*`. `create` does not reimplement `register`: it obtains credentials and then calls exactly that path. Both verbs end at GET /github-apps/{id}/repositories, asserting the repo is actually reachable — the check that turns a silent misconfiguration into an error next to the thing that caused it. github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent entry, and is refused when it disagrees), the client secret is stdin-only, and --webhook-secret is optional. scripts/register-github-app.sh is deleted. No new dependencies: node:http for the callback, node:crypto's createSign("RSA-SHA256") for the App JWT that recovers the installation id from the App's own key rather than from a spoofable redirect parameter. Closes #7 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:46:43 +00:00
fix(labels): state:needs-human means a human could merge it right now Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos' reconcilers stay byte-identical. The state machine here was byte-identical to box's before this change and remains so after -- only the scope:* taxonomy differs, correctly. decide_state() derived state from three inputs -- draft flag, requested reviewers, submitted reviews -- and read NOTHING about mergeability or checks. With the `if requested "$HUMAN"` short-circuit at the top of its precedence, the label was sticky: once the maintainer was requested, a PR read state:needs-human through conflicts, through red CI, through a force-push that staled every approval. In this repo the SECOND half is the live one: three PRs 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. The rule the label now keeps: state:needs-human means a human could merge this RIGHT NOW, so anything making that false outranks the request that put it there. CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix) approvals staled by a push -> state:addressing (nobody reviewed this tree) An UNFINISHED round still yields to an explicit human request -- MISSING (nobody has reviewed yet) is a different fact from STALE (everyone reviewed something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub reports it for about a minute after every merge, and flapping every open PR through needs-rebase on each merge would be worse than the bug. A failed read degrades to the same "do not know" value. 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, only CLEARS it. Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the correct result: every open PR here is currently mergeable, so the new precedence is a no-op on a healthy board and fires only when something is actually wrong. npm test 623 passed. Closes #127 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
### Fixed
- 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,
heavy-duty/box#122)
- A duplicate release heading is caught even where the guard cannot see the
base (#133, heavy-duty/box#143)
- A label the repo does not have no longer takes the whole label edit down
with it
- An unreadable check rollup no longer reads as "nothing is failing"
- `state:needs-human` no longer appears on PRs a human cannot merge (#127,
heavy-duty/box#136)
- CI lints every tracked shell script, and proves the set is complete (#118)
- The shellcheck sweep covers extensionless scripts such as `bin/cast` (#121)
- `cast` no longer leaves a full repo clone in the temp dir on every run (#117)
- The test suite reaps its temp directories (#117)
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
## 0.1.1 — 2026-07-19
### Fixed
- The release ceremony re-arms `## Unreleased`, and CI is red when it does not
(#113)
## 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)
- `apply` no longer demands a GitHub App for a manifest that declares no
applications (#103)
- A manifest with no `${…}` refs applies without a secret store (#104)
- `CAST_AGE_KEY_FILE_<ENV>` is settable for every environment name (#102)
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)
- Tagged releases with a prebuilt dist asset, and an installer that installs
them (#96)