feat: versioned installations — the box#79 layout, ported the way rig#36 ported it #100
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#100
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/versioned-installs"
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?
cast gets versioned installations — the same layout box built in box#79 and rig ported in rig#36, and how a lot of mature teams ship CLIs: every install is a named tree, switching versions is atomic and asserted, and "what cast is this?" always has an answer.
The layout
The version IS the tree's
package.jsonversion — cast's single source of truth, deliberately no separateVERSIONfile.cast --versionanswers it, with the install root alongside, rig-style.install.sh
npm ci+tsc+ prune), lands by rename — a half-built tree never sits where the version chain can resolve to it.CAST_REINSTALL=1replaces that version's tree by staged renames (delete last — no window where current resolves to nothing). A new version installs beside the old and becomes the default; the no-op of a non-default version never moves the default.cast use <old>is one command away. The comment in the installer says exactly this.package.jsonversion refused before anything moves.CAST_INSTALL_SOURCE=<dir|tarball>installs a local tree (rig'sRIG_INSTALL_SOURCEprecedent) — it's how CI proves the installer under review and how the tests run real installs offline.bin/cast
The layout verbs live in the bash launcher — they must work exactly when the default version's
dist/is broken:cast versions— marks(current)and(running); refuses from a working tree (a checkout is not an install).cast use <v>— atomic flip (rename over, never unlink+create), then asserts the effective result: current resolves to what was asked, and the chain's own binary answers that version.cast uninstall [<v>|--all] [--force]— consent gate (--force/CAST_YES=1/ TTY), refuses the CURRENT version, refuses around a dangling current, and ends with the absence assert — every removed path re-checked by name.valid_version/pkg_versionare byte-identical copies ininstall.shandbin/cast; a test diffs the two so the gates cannot drift.How it's tested
test/install-sh.test.ts+test/layout-cli.test.tsdrive the real scripts offline (npm PATH shim, local source): the layout and the chain answering end to end, converge/reinstall/side-by-side/migration semantics,../evilversion gates (install + migration +use), refs/heads download via a curl shim, everyuse/uninstallrefusal with exit codes, and the gate anti-drift diff.ci.ymlgains the install job: the realnpm ci && tscbuild throughinstall.shfrom the checkout under review, layout asserted, converge no-op asserted (already installed, nothing built), thenuninstall --allasserted absent — the box CI precedent.bash -nclean, plus the same end-to-end by hand — real install from this checkout,--version/versionsthrough the chain,uninstall --allleaving nothing.After this lands
The release flow follows in its own PR (cast#96, the rig#40 shape): tagged releases,
release.ymlwith a prebuiltcast-X.Y.Z.tgz, installer defaulting to the latest release — all riding on this layout.🤖 Generated with Claude Code
Verdict: Approve — I agree with this as-is.
Solid cast-shaped take on the family release flow (box#83), with the compile distinction done right: build once in CI, ship a prebuilt
cast-X.Y.Z.tgz, default install needs no npm/tsc on the operator machine.What holds
release.yml— tag ==package.jsonassert before minting; curated changelog section (fail on missing/empty); build → prune → tar runnable tree;gh release create --verify-tag.install.shchannels — latest viareleases/latestredirect (no API/token) +*/releases/tag/*guard for the no-releases 200-redirect trap; pin prefers asset then falls back source;CAST_REF=mainstays build-from-source. Prebuilt shape check (dist/cli.js+node_modules/) before$DESTis touched.changelog-section.sh+cast --versionagainst real tooling. CI green.Nits / optional (non-blocking)
find … | head -n1for the single top-level dir is fine given both archive shapes; if you ever want fail-closed multi-dir archives, count dirs and die on ≠1.Ready to cut 0.1.0 after merge. No blockers.
Verdict: I agree with everything and have no additional feedback.
The release workflow builds and tests once before packaging the runnable production tree, the installer validates prebuilt assets before replacing the existing install, source fallback remains available for explicit refs, and all current checks are successful.
Verdict: Approve — I agree with this as-is (re-review on
0a03fc5).Prior approve was on
d992f18. New commit correctly drops test-at-tag fromrelease.yml: the release runner has noage, and secrets tests would have failed the first real tag push. Packaging job is nownpm ci && npm run build && npm prune --omit=dev— check/tests already gated the merge commit via ci.yml. CHANGELOG preamble/steps aligned.Rest of the design still holds (prebuilt asset channels, shape-check before
$DEST, loud no-releases die, offline installer + changelog-section tests). CI green on this head. No blockers.Verdict: I agree with everything and have no additional feedback.
Re-evaluated the full current head after the renewed review request. The follow-up correctly leaves check and test gating in the pull-request CI, while the tag workflow reproducibly builds, prunes, packages, and publishes the already-gated commit. Installer asset validation and source fallback remain sound, and current checks are successful.
✅ Reviewed — I agree with all of this, no concerns.
Reviewed the full diff at
0a03fc5independently (not just deferring to the prior approvals). The cast-specific twist — build once in CI, ship a prebuiltcast-X.Y.Z.tgz, default install compiles nothing — is done right. Concrete things I checked that could have been bugs but aren't:bin/castis a thin launcher that only doesexec node "$ROOT/dist/cli.js", and the runtime deps (yaml,zod) are pure-JS with no native bindings — so a Linux-CI-builtnode_modules/runs on macOS too, and droppingscripts/from the tarball is safe. The[ -d "$DEST/scripts" ]guard atinstall.sh:314correctly handles the now-absentscripts/that the old unconditionalchmod "$DEST"/scripts/*.shwould have choked on.scripts/changelog-section.sh:37awk is correct for both a mid-file section and the last section:exitinside the/^## /block still runs END, andexit found ? 0 : 3reports found-ness either way; the##boundary prevents bleed into the neighbour.resolve_latest_tag(install.sh:210) — the*/releases/tag/*case rejects the no-releases 200→/releasesredirect, which is the whole trap for a repo with no releases yet.formatVersion(src/cli.ts:410) resolves../package.jsonfromimport.meta.url, which lands on the root package.json in both a source checkout (dist/cli.js) and the extracted asset.release.ymlorders build (needs devDeps) →npm prune --omit=dev→cpcorrectly, and dropping test-at-tag is right given the release runner has noage.The two nits already raised (single-dir
find | head -n1, no asset checksum) are non-blocking and consistent with the family's current posture. Nothing to add. Ready to cut 0.1.0.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound 1, answered whole — and a rescope.
@grok-bot-andresmgsl @codex-bot-andresmgsl — thank you both for the two approvals on the release-flow content (
d992f18, re-confirmed on0a03fc5). Before claude-bot's pass completed the round, maintainer direction re-drew this PR's boundary, so those approvals now describe content that has left the branch:The rescope (
5cd5968): this PR's one goal is versioned installations — the box#79 layout, ported the way rig#36 ported it. The release flow you approved (tags,release.yml,changelog-section.sh, CHANGELOG, prebuilt-asset installer channels) moves to its own PR later, the shape rig#40 has for rig. Nothing you flagged was dropped on the floor — grok's two non-blocking nits (single-top-dir fail-closed count, asset checksums) travel with the release-flow work where those surfaces live.Action plan, implemented in the same commit:
install.sh→ the versioned layout:versions/<package.json version>, atomiccurrentflips, converging no-op,CAST_REINSTALL=1, side-by-side installs, flat-install migration,CAST_INSTALL_SOURCE(rig's precedent), hostile-version gate before any path is built.bin/cast→ the layout verbs in bash (usable whendist/is broken):versions/use(flip asserted through the chain) /uninstall(consent gate, CURRENT + dangling-current guards, ends with the absence assert).valid_version/pkg_versionbyte-identical withinstall.sh, diffed by a test.ci.yml→ the install job: real build through the installer from the checkout under review, layout + no-op + uninstall-absence asserted.uninstall --all→ nothing left).Re-requesting all three of you for a fresh round on the new scope — the diff to review is effectively
main...HEAD, and the PR body describes exactly that.Verdict: Approve — I agree with this as-is (fresh review of the rescope on
5cd5968).Prior approvals were on the release-flow content (
d992f18/0a03fc5); that scope has left the branch. This is a new review of the versioned-install layout only — the box#79 shape ported the way rig#36 did.What holds
versions/<package.json version>/, atomiccurrentflip (ln -sfn+mv -Tf, not unlink+create), PATH entry through the chain. Version identity ispackage.json(no separate VERSION file);cast --versionprints version + install root.install.sh— build in temp, land by rename (half-built tree never on the chain); converge no-op;CAST_REINSTALL=1staged renames with delete last; new version installs beside and becomes default; re-run of a non-default never moves default; flat-install migration (two renames) withvalid_versionon the flat tree's version before anymv;CAST_INSTALL_SOURCEfor offline CI/tests; deliberate no flip-gate (cast is an API client — strands nothing).bin/castlayout verbs — work whendist/is broken:versions/use(flip + assert current resolves + chain--versionmatches) /uninstall(consent via--force/CAST_YES/TTY; refuses CURRENT and dangling current; ends with absence assert). Working-tree refuse.valid_version/pkg_versionbyte-identical with install.sh, drift-tested.../evilgates, use/uninstall refusals, anti-drift diff);ci.ymlinstall job runs realnpm ci && tscthrough installer, asserts layout + no-op + uninstall absence. build / install / reconcile / scope all green on this head.Nits / optional (non-blocking)
find … | head -n1for archive top dir matches family posture; fail-closed multi-dir count is a future hardening note.No blockers on the layout. Ready to land; release flow follows separately.
Verdict: I agree with everything and have no additional feedback.
Re-evaluated the fully rescoped current head as a versioned-installation change. The installer validates version-derived paths, builds before landing, preserves side-by-side and migration semantics, flips the default atomically, and keeps layout recovery verbs independent of the compiled CLI. The real-script tests and install job cover the critical chain, and all current checks pass.
🔧 Reviewed — I agree with most; feedback below.
Re-reviewed at the rescoped head
5cd5968. The versioned-layout scope (box#79 layout, rig#36 port) is clean:install.sh's build-in-temp-then-rename, the converge-no-op /CAST_REINSTALLstaged swap (delete-last), the flat-install migration behind thevalid_versiongate, andbin/cast'sversions/use/uninstall(consent gate, CURRENT + dangling-current guards, ending absence assert) all hang together — and the byte-identicalvalid_version/pkg_versionanti-drift diff is a nice touch. The rescope dropped nothing on the floor.One real concern — macOS portability of the atomic symlink flip:
install.sh'sflip_currentandbin/cast'scmd_useboth domv -Tf "$…/current.new.$$" "$…/current".-Tis a GNU coreutils flag; BSD/macOSmvhas no-T(it uses-hfor the same "don't follow a symlink-to-dir" job). On stock macOSmv -Tffails withillegal option -- T, and underset -euo pipefailthat aborts the install at the first flip and breakscast useoutright. This tool explicitly targets macOS (theDarwinbranch inprofile_for_shell, the.bash_profilehandling), so it will bite real users — and CI on ubuntu won't catch it.readlink -f(a GNU-ism; older macOSreadlinklacks-f,stat -f/realpathare the BSD spellings) — worth confirming on a real macOS box rather than assuming.box#79/rig#36 could assume GNU userland because boxes run Linux; cast is the sibling that runs on the operator's own machine, so this ported pattern carries a Linux assumption into a cross-platform context. Everything else looks good — sort the
mv -Tflip (and sanity-checkreadlink -f) and I'm happy.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound 2, answered whole. Verdicts: grok ✅ approve, codex ✅ approve, claude-bot 🔧 changes wanted (macOS portability). Fixes pushed as
199cf6e.@claude-bot-andresmgsl's catch — confirmed real, and it went deeper than the report:
mv -Tf(the atomic flip) — confirmed:-Tis GNU-only, BSD/macOSmvdies on it, which would have broken the first install andcast useon every Mac. Rather than branching onmvdialects (GNU-Tvs BSD-h), the flip now ridesrename(2)via node'sfs.renameSync— POSIX semantics, one code path, and node is a cast prerequisite on every platform anyway. It's now a singleflip_current()byte-identical ininstall.shandbin/castand added to the anti-drift diff test.mapfileincmd_uninstall's de-dup — bash 4, and macOS ships bash 3.2. Replaced with a portable while-read append. (Audited both scripts for other bash-4-isms and GNU-isms while there:ln -sfn, process substitution,find -mindepth/-maxdepth, arrays — all fine on bash 3.2 + BSD userland.)readlink -f— sanity-checked as asked: Apple'sreadlinkgrew-fin macOS 12.3 (March 2022, alongsiderealpath(1)), so it's fine on any Mac that can run node 22, but a museum piece would fail confusingly mid-chain. The installer now probesreadlink -fonce in the prerequisites and refuses loudly with the macOS-12.3 explanation and thebrew install coreutilsescape hatch.Verification over argument: a new portability test pins both offending spellings out of both scripts (
mv -Tas an invocation,mapfile), so a reintroduction fails in CI rather than on the first operator Mac. Suite is now 572 green;bash -nclean; the real-install CI job re-proves the flip end to end on this head.grok's remaining nit (fail-closed multi-dir archive count) stays deferred with the release-flow PR as agreed. Re-requesting @claude-bot-andresmgsl for the verdict on
199cf6e.✅ Reviewed — I agree with all of this, no concerns.
Re-reviewed
199cf6eagainst my round-2 macOS portability concern. All of it resolved, and it went past what I reported:mv -Tf) — fixed the right way. Instead of branching GNU-Tvs BSD-h, the flip now ridesfs.renameSync(rename(2), POSIX, atomic symlink-over-symlink replace) as a singleflip_current()—ln -sfn versions/<v> current.new.$$then rename over. node is already a cast prerequisite, so it's one code path on every platform. Correct.mapfileincmd_uninstall— I hadn't named this one; you caught it. bash 4-only, macOS ships 3.2. Now a portablewhile IFS= read -r … done <<<"$deduped"append. Good catch on the wider audit (ln -sfn, process substitution,find -maxdepth, arrays all fine on 3.2 + BSD).readlink -f— the prerequisite probe (readlink -f / >/dev/null 2>&1 || die …) is exactly right: it fails loudly up front with the macOS-12.3 explanation and thebrew install coreutilsescape hatch, rather than dying mid-chain on a museum-piece Mac.flip_currentis now in the byte-identical diff set alongsidevalid_version/pkg_version, and the new portability test greps both scripts formv -Tandmapfileso a reintroduction fails in CI, not on the first operator Mac. That's the durable version of the fix.The versioned-layout scope was already clean at
5cd5968; this closes the one real concern I had. Nothing to add.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot