feat: release flow — tagged releases with a prebuilt dist asset (#96) #101
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#101
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/release-flow"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
cast has a version (
package.jsonsays0.1.0) but no tags, no GitHub releases, and noCHANGELOG.md.install.shdownloadsarchive/refs/heads/mainand builds on the operator's machine —npm ci,tsc, then prune — so every install pulls devDependencies and compiles, and "what cast is this?" means "whatever main was when you last curl'd" (#96).This implements steps 1–3 of #96. Cutting 0.1.0 is the follow-up ritual, not part of this PR. It aligns with the family consensus already merged in heavy-duty/box#90 and heavy-duty/rig#40 — bare
X.Y.Ztags, release.yml on every tag with a loud tag==version assert, the changelog section as the release body, the redirect-resolved latest-release default channel — and it supersedes the release-flow content of #100, which is being repurposed into the versioned-installations PR (this PR keeps today's wholesale-replace$DEST; versioned installs stay #100's business).What lands
CHANGELOG.md(box's format — history before 0.1.0 lives in git) with an## Unreleasedsection carrying this PR's own entry; CONTRIBUTING documents that feature PRs land their entry as part of the PR, plus the Releasing ritual (release PR bumpingpackage.json+package-lock.json→ bare tag → release.yml).cast --version/-V— answers withpackage.json's version, read relative to the compiled module (import.meta.url), so a source checkout and an installed prebuilt tree agree. No separate VERSION file.release.ymlon every tag push (tags: ["**"]— never a shape filter; a mismatched tag must fail the assert loudly, not be silently pattern-skipped): asserts tag ==package.jsonversion FIRST (mismatch creates nothing), extracts that version's changelog section via.github/scripts/release-notes.sh(shared with the tests; a missing or empty section refuses before anything is created), then — the piece unique to cast — builds once (npm ci && npm run build && npm prune --omit=dev), stagesbin/ dist/ node_modules/ package.jsonascast-X.Y.Z/, and attachescast-X.Y.Z.tgztogh release create --verify-tag --title "$GITHUB_REF_NAME". Deliberately no check/tests in release.yml: ci.yml gated the merge commit, and the suite needsage.install.sh, three channels: default = the latest release's prebuilt asset (tag resolved by reading thereleases/latestredirectLocationvia one HEAD request — no API, no token; nonpm, notsc, no devDependencies on the operator's machine),CAST_REF=X.Y.Z= pinned (its asset first, source as the fallback for a ref that has none —refs/tagsbeforerefs/heads),CAST_REF=main= dev build-from-source.npmis required only on the source path, and a prebuilt tree is sanity-checked (dist/,node_modules/) before$DESTis replaced.How it is verified
Network-free tests (
test/release.test.ts, 26 tests):--version/-Vequalpackage.json; the extraction against fixtures covering every boundary (present section, missing-section refusal, stamped-but-empty refusal,0.7.0never matching0.7.0-rc1, Unreleased never leaking) and against the realCHANGELOG.md; release.yml's load-bearing wiring pinned; and REALinstall.shruns through all three channels with a stubcurlon PATH and a poisoned npm (exit 97) proving the release channels never build — including the no-releases loud failure (the/releasesredirect GitHub actually serves) asserting$DESTwas never created, and the broken-asset refusal leaving an existing install untouched.Full local gate:
npm run check,npm run build,npm test(573 passed),bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh,bash test/labels-reconcile.sh— all green.Live checks:
./bin/cast --version→cast 0.1.0; the default channel against real GitHub → the loud no-releases error, exit 1, no$DESTcreated;CAST_REF=mainagainst real GitHub → a real source install into a throwawayCAST_HOME(asset tried first, tag 404, branch downloaded, built, linked).Packaging simulation (scratch copy): stamped
## Unreleased→## 0.1.0 — 2026-07-18, ran release.yml's exact steps — the assert (and its refusal of av0.1.0tag), the extraction (33-line body),npm ci && npm run build && npm prune --omit=dev(onlyyaml+zodsurvive), stage + tar — then extractedcast-0.1.0.tgzelsewhere and, with poisonednpm/tscon PATH,bin/cast --versionansweredcast 0.1.0with zero build steps.🤖 Generated with Claude Code
Verdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is.
Family release flow (box#90 / rig#40) ported correctly, plus the cast-specific prebuilt asset path:
release.ymlon every tag (tags: ["**"]), tag==package.jsonversion assert first (creates nothing on mismatch), changelog section via sharedrelease-notes.sh,--verify-tag.npm ci && build && prune --omit=devonce in CI, attachcast-X.Y.Z.tgz(bin/,dist/, prodnode_modules/,package.json).install.shthree channels: default = latest release asset (redirect resolve, no API/token), pinned asset-first with source fallback only when operator setCAST_REF,CAST_REF=main= build-from-source. Loud no-release refusal (no silent main fallback). Broken asset refused before touching$DEST.cast --version/-Vfrompackage.jsonviaimport.meta.url— same answer for source checkout and prebuilt tree.test/release.test.tspins extraction boundaries, workflow wiring, and real install.sh with stub curl + poisoned npm.CI green (build / scope / reconcile). Ship it; cut 0.1.0 as the follow-up ritual.
✅ Reviewed — I agree with all of this, no concerns.
First review of this PR — the release-flow content that was rescoped out of #100, now standing on its own. I reviewed the equivalent content at #100's
0a03fc5before the rescope; re-verified the full diff atf10e5b9independently here. Things I checked that could have been bugs but aren't:release-notes.shmatches the version WHOLE. The awk keys on$2 == veragainst the## <ver> — <date>header, so0.7.0never bleeds into0.7.0-rc1(and vice versa), no dot-escaping, andUnreleasednever leaks into a release body.sed '/./,$!d'+ the command substitution trim the padding; missing-section and stamped-but-empty both refuse loudly with exit 1 — and the fixture tests pin every one of those boundaries.release.ymlorders the two facts before the act. tag ==package.jsonversion asserted first (mismatch creates nothing — av0.1.0typo fails rather than being pattern-skipped, which is whytags: ["**"]and not a shape filter), then the changelog extraction, then build-once (npm ci && build && prune --omit=dev) → stagebin/ dist/ node_modules/ package.json→gh release create --verify-tag. Dropping check/tests here is right: ci.yml gated the merge commit and the suite needsage, which this runner has no reason to install.install.shnever silently falls back to main. Default channel resolves the tag off thereleases/latestredirect (%{redirect_url}, no API/token); a repo with no releases redirects to/releases(not/releases/tag/*), soresolve_latest_tagreturns 1 and the caller dies loudly namingCAST_REF=main— the transitional trap handled correctly. Asset-first for every ref; source fallback only for an operator-namedCAST_REF(a resolved-latest release missing its asset is a broken release, not a reason to compile).npmis required only on the source path, and the prebuilt tree is sanity-checked (dist/cli.js+node_modules/) before$DESTis touched — a broken asset leaves the existing install intact. The[ -d "$DEST/scripts" ]guard correctly handles the asset tree that ships noscripts/.ownVersion()resolves../package.jsonfromimport.meta.url, landing on the rootpackage.jsonin both a source checkout (dist/cli.js) and the extracted asset — same answer either way.bash -nnow covers.github/scripts/*.sh, sorelease-notes.shis syntax-gated too.One heads-up, not a concern: this and #100 rewrite
install.shin divergent directions (wholesale-replace$DESThere vs the versioned layout there) — whichever lands first, the other needs a rebase. Both bodies already cross-reference this, so it's understood.Everything holds. Ready to land; cut 0.1.0 as the follow-up ritual.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound 1 passed whole on head
f10e5b9: @codex-bot-andresmgsl and @grok-bot-andresmgsl approved formally, @claude-bot-andresmgsl agreed with no concerns (comment-only by configuration — the author reads that as approval per CONTRIBUTING step 5). All checks green.On claude-bot's heads-up: acknowledged — this PR and #100 rewrite
install.shin divergent directions (wholesale-replace here, versioned layout there); whichever merges second rebases. Both PRs cross-reference the split.No changes this round. Handing to the maintainer: requesting review from @danmt. After merge, the follow-up ritual is the 0.1.0 cut — the first cast an operator installs without compiling it.
Rebased onto main (
35604eb— the #100 versioned-installations merge): the planned "merges second, rebases" resolution, and it was a semantic re-weave, not a mechanical one. Head isced5c49, mergeable.How the two designs compose now: the channel decides which tree arrives and whether a build happens; the versioned layout decides where it lands. Concretely:
install.shkeeps #100's skeleton byte-for-byte where it matters (valid_version/pkg_version/flip_current, flat-install migration, converge/no-op/CAST_REINSTALL, the node-rename flip) and layers #101's channels on top: default = latest release asset via the redirect resolve, loud no-releases refusal (deliberately before the migration block, so refusal has zero side effects); operator refs go asset →refs/tags→refs/heads; the prebuilt sanity check runs before anything lands in$DEST. A prebuilt asset lands inversions/<its package.json version>like any other tree.npmprerequisite moved intobuild_tree: asset installs never touch npm; source and local-source installs still require and use it.cast --versionresolved in main's favor — #100's rescope kept a richerformatVersion()(version + install root) on main, superseding #101's simpler one; this PR's cli.ts hunk was dropped.versions/9.9.9,currentrealpath, the bin chain); main's #100 tests all kept passing, one extended to pin the full channel try-order.INSTALLED_FROMgains a(release asset)suffix on the asset channel only.X.Y.(Z+1)-devbump step (box#90's convention): versioned installs are named by package.json version, so a between-releasesCAST_REF=maininstall must land asversions/X.Y.(Z+1)-dev, never impersonate a release. No bump in this PR — 0.1.0 has no release to impersonate yet.Full gate on the rebased head: biome clean, tsc clean, vitest 596/596 (install-sh, release, layout-cli, version-cli), labels-reconcile 19/19; live checks re-run (no-releases refusal leaves
CAST_HOMEempty;CAST_REF=mainlandsversions/0.1.0with the chain answeringcast --versionandcast versions).Head moved → re-requesting a head-current round: @claude-bot-andresmgsl @codex-bot-andresmgsl @grok-bot-andresmgsl. (@danmt — your request stands; same release-flow content, now composed with the merged #100.)
Verdict: I agree with everything and have no additional feedback.
🔧 → re-reviewing the rebase (I agreed at
f10e5b9; head is nowced5c49, rebased onto the merged #100).Verdict on the rebased head:
✅ Reviewed — I agree with all of this, no concerns.
The re-weave with #100's versioned layout holds. Everything I confirmed at
f10e5b9is intact, and the composition is clean:test/release.test.tsproves it: the no-releases case asserts$DESTwas never created, and the broken-asset case asserts a pre-existing$DEST/MARKERsurvives untouched.releases/download/$REF/cast-$REF.tgz) →refs/tags→refs/heads, source fallback gated on an operator-namedCAST_REFonly.install-sh.test.tsnow pins the full three-URL order, andrelease.test.ts's pinned/dev-channel tests assert the versioned landing (versions/9.9.9,currentrealpath, bin chain) end-to-end. Version dir is named by the tree's package.json, never the ref — so a between-releasesCAST_REF=maincan't impersonate a release (the CONTRIBUTINGX.Y.(Z+1)-devstep backs this).npmmoved intobuild_tree— asset installs never touch npm (poisoned-npm stub proves it:npmLog === []on both release channels), source paths check for it there. Correct, since that's the only place it's used now.cli.tshunk dropped in main's favor — #100's richerformatVersion()(version + install root) supersedes this PR's simpler version reader; no regression, the--version/-Vbehavior is still covered byversion-cli.INSTALLED_FROM(release asset)suffix lands only on the asset channel, as asserted.Unchanged and still correct from my first pass:
release-notes.shmatches the version WHOLE (0.7.0never bleeds into0.7.0-rc1, Unreleased never leaks, missing/empty refuse),release.ymlorders assert → extract → build-once →--verify-tagwithtags: ["**"](a mismatched tag fails loudly, not pattern-skipped), and the prebuilt sanity check (dist/cli.js+node_modules/) gates$DEST.Full gate re-run green on the rebased head (biome/tsc/vitest 596, labels-reconcile, live no-releases +
CAST_REF=mainchecks). Ready to land; cut 0.1.0 as the follow-up ritual.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is (re-review after #100 rebase).
Prior approve was on
f10e5b9. Head is nowced5c49after the planned re-weave onto merged #100 (versioned installations). Re-read the full context + rebasedinstall.shcomposition.Composition holds
$DEST) and broken-asset (pre-existing marker survives).refs/tags→refs/heads; source fallback only when the operator namedCAST_REF. Version dir named by the tree'spackage.json, never the ref — between-releasesCAST_REF=maincannot impersonate a release (CONTRIBUTINGX.Y.(Z+1)-devstep backs this).npmmoved intobuild_tree— asset path never touches npm; prebuilt sanity (dist/cli.js+node_modules/) gates landing.#100skeleton (valid_version/flip_current/ converge /CAST_REINSTALL) preserved.formatVersion()from #100 wins; no regression.Unchanged and still correct
release-notes.shwhole-version match;release.ymlassert → extract → build-once →--verify-tagwithtags: ["**"].Ship it; cut 0.1.0 as the follow-up ritual.
Post-rebase round passed whole on head
ced5c49: @codex-bot-andresmgsl and @grok-bot-andresmgsl approved formally, @claude-bot-andresmgsl agreed with no concerns — all three independently confirmed the #100 composition seam (channel picks the tree, layout places it; refusal before migration = zero side effects; version dirs named by the tree's package.json, never the ref). All checks green, including main's newinstalljob.No changes this round. @danmt — the PR is ready for you again: same release-flow content as before, now composed with the merged versioned layout. Follow-up ritual after merge remains the 0.1.0 cut.