From ef8dce11dfae80d38ab87bf168ed61e5a483f1f3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 15 Oct 2026 00:41:43 +0000 Subject: [PATCH] docs/capy-install: resolver, versioned store, bun shim, trailer notes; wrapper minimum 2 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- capy/support.env | 2 +- docs/capy-install.md | 103 +++++++++++++++++++++++++++++-------- test/gstack-doctor.test.ts | 5 +- 3 files changed, 85 insertions(+), 25 deletions(-) diff --git a/capy/support.env b/capy/support.env index 01fd89653..37d453d0b 100644 --- a/capy/support.env +++ b/capy/support.env @@ -16,4 +16,4 @@ GSTACK_MIN_REVISION=1.91.96.0 # GSTACK_CAPY_MIN_WRAPPER: the oldest bootstrapper revision (GSTACK_CAPY_WRAPPER in # capy/skill/gstack/scripts/install.sh) the owner should run. An older copy still # works and prints an owner action (CAPY_WRAPPER_STALE). -GSTACK_CAPY_MIN_WRAPPER=1 +GSTACK_CAPY_MIN_WRAPPER=2 diff --git a/docs/capy-install.md b/docs/capy-install.md index 61258437c..df9ef044e 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -82,26 +82,39 @@ whose pid is alive and outside its own process chain exists; 1. Reuses the checkout it lives in (or clones into `--root `), under the lock. It never resets or pulls; `--revision ` pins a fresh clone, and an existing checkout at another revision is reused with the trailer note - `(requested ; run /bin/gstack-upgrade to move)`. -2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. A supported Bun already in - `$BUN_INSTALL/bin` (default `~/.bun/bin`) is reused. The project's pins + `requested: (run /bin/gstack-upgrade to move)`. A fresh + clone prints `cloned at ()` (the bootstrapper's + clone too, through `GSTACK_CAPY_CLONED`). +2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. The project's pins (`--project `, default the git toplevel of the current directory) are read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes - for this platform). When neither the Bun on PATH nor the installed one - satisfies gstack's minimum and the pins, the installer downloads one exact + for this platform). One resolver picks the Bun (`gstack_bun_resolve` in + `bin/gstack-bun-version.sh`; `resolveBun` in `lib/pregate/runtime.ts` is + its TypeScript twin), and the installer, `--check`, the doctor, the bun + shim and install admission all use it. Candidates, first eligible wins: + the store copy of the project's exact pin (rule `pin`), the store copy of + the install default (`default`), `$BUN_INSTALL/bin/bun`, `~/.bun/bin/bun` + (`home`), then every `bun` on PATH. Eligible means it runs, reports a + version at or above the supported minimum (1.4.2) and satisfies the + project's pins. When nothing qualifies, the installer downloads one exact release archive, verifies its SHA-256 against `bin/gstack-bun-version.sh` - and installs it into `$BUN_INSTALL/bin`, beside any different system Bun (no - symlink into a shared bin directory). That archive is the supported minimum - (1.4.2), or the project's exact pin when it is above the minimum and gstack - carries a verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3), so a - repo whose CI pins `bun-version: 1.4.3` gets 1.4.3. With an exact pin, only - that version satisfies it. Any other exact pin (below the minimum, two - different ones, or one without a verified archive), or a range gstack's - minimum does not satisfy, stops with both + and unpacks it into the versioned store + `/runtimes/bun//bun`, under `~/.gstack-runtime.lock`. + A store entry is never replaced in place, and `~/.bun/bin` is never + written. The archive is the project's exact pin when gstack carries a + verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3), else the + install default `GSTACK_BUN_DEFAULT` (1.4.3, above the 1.4.2 minimum; one + CI job runs the quick free tier on it). + `GSTACK_BUN_INSTALL_VERSION=` overrides the default; a version without a + verified archive or below the minimum stops the install + ([BUN_INSTALL_VERSION_INVALID](troubleshooting.md#bun-install-version-invalid)). + With an exact pin, only that version satisfies it. Any other exact pin + (below the minimum, two different ones, or one without a verified + archive), or a range gstack's minimum does not satisfy, stops with both constraints ([RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict), exit 3) before `./setup`. With no Bun at or above the security floor, the - verified one is installed first so the pins can be read. + default is installed first so the pins can be read. 3. `./setup --host claude --no-prefix`, then `./setup --host codex --model gpt-6-astra`, both with `--no-browser`. Skipped when this exact HEAD was set up with the same hosts, model and browser flag (recorded in the git dir @@ -110,13 +123,46 @@ whose pid is alive and outside its own process chain exists; 4. `gstack-doctor --check` for the requested workflow (`--for `, `--only `, or `--require `; default `claude,codex,patch`), run with the launch PATH, so the trailer certifies the Bun workflows run. -5. The install verdict, written atomically to - `/install-verdicts/.json`: - checkout, HEAD, the workflow and required components it certified, whether - pins were skipped as planning-only, the trailer, the remote minimum, the - launch PATH and the time. +5. The install verdict (schema 2), written atomically to + `/install-verdicts//.json`: + checkout, project, HEAD, the workflow and required components it + certified, whether pins were skipped as planning-only, the pin it + honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and + `bun_install` when this run downloaded it), the trailer, the remote + minimum, the launch PATH and the time. Admission refuses a workflow whose + running Bun is not the one the verdict recorded + ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)); the + doctor's `runtime` row reports the same drift. A schema 1 verdict (keyed + by checkout only) is still read. +6. On a Capy machine (`CAPY_MACHINE_CONFIG` set or `~/.capy/system` present), + install mode writes the bun shims described below. -`--check` runs only steps 4 and 5 (no clone, Bun install or setup). +`--check` runs only steps 4 and 5 (no clone, Bun install, setup or shim). + +## The bun shim + +A fresh shell on Capy finds the image's `/usr/local/bin/bun` (1.3.14) first, +so a `bun test` typed without the `GSTACK_LAUNCH` prefix used to run the wrong +Bun. Install mode therefore writes `~/.local/bin/bun` and `~/.local/bin/bunx` +(`~/.local/bin` precedes `/usr/local/bin` on the Capy image's PATH). Each is a +small POSIX script whose second line is the marker +`# gstack-bun-shim: gstack-owned; delete this file to remove it`. At every +call the `bun` shim finds the git toplevel around the working directory and +runs, in order: the Bun that project's install verdict recorded, the store +copy of the project's exact pin, the store copy of the install default, then +the first non-shim `bun` on PATH. A project with no verdict prints +`gstack bun shim: has no recorded Bun; run ... --check here` to stderr +and falls through. `bunx` runs `bun x` through the `bun` shim beside it. + +- `GSTACK_BUN_SHIM_EXPLAIN=1 bun --version` prints + `gstack bun shim: project= record= chose= rule=`. +- `GSTACK_BUN_SHIM=0` at install time writes no shim. +- Removing it: delete `~/.local/bin/bun` and `~/.local/bin/bunx`. gstack + replaces only a missing file or a file carrying the marker; a foreign file + or a dangling link there is left untouched with a note, and the + `GSTACK_LAUNCH` prefix stays the way to reach the certified Bun. +- The doctor's `runtime` row notes the shim; the resolver never treats a + shim-marked file as a Bun candidate. ## The one line to grep @@ -124,9 +170,19 @@ The last line is the trailer: ``` gstack: ok 1.91.96.0 for=autoplan +gstack: ok 1.91.96.0 for=review (requested: v1.91.90.0 (run /bin/gstack-upgrade to move); latest: 1.91.101.0 available) +gstack: ok 1.91.96.0 for=autoplan (pins: skipped (planning-only)) gstack: fail runtime,pins ``` +An ok trailer carries at most one parenthesised note group, `; `-separated, +in this order: `requested: …` (an existing checkout at another revision than +`--revision`), `pins: skipped (planning-only|no project)`, and `latest: +available` (origin's default branch has a newer VERSION; the bootstrapper +exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded +in this run). A fail trailer never carries notes. Match `^gstack: ok ` or +`^gstack: fail `; never parse past the first space after the version. + Exit 0 only with `gstack: ok`. Read the exit status and the trailer directly: ```bash @@ -144,7 +200,8 @@ A parent agent confirms each subagent machine with this grep; one machine's install never covers another, and a subagent's first report carries its own `gstack:` line. -`SKIP pins (planning-only; repo pins differ: ..., launch bun )` is not a +`SKIP pins (planning-only; repo pins differ: ..., launch bun )` (trailer +note `pins: skipped (planning-only)`) is not a failure: planning workflows (`--for autoplan`, the plan reviews, `office-hours`) never run the target repo's tests. Workflows that do (`review`, `ship`, `qa`) and `--require` keep `pins` required. @@ -232,7 +289,9 @@ floor is not what keeps machines current). It never exceeds VERSION (test/capy-skill-wrapper.test.ts). Raise `GSTACK_CAPY_MIN_WRAPPER` together with `GSTACK_CAPY_WRAPPER` in the bootstrapper when the bootstrapper itself changes; older Drive copies keep working and print an owner action -([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)). +([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)), and the +doctor's `wrapper` row warns. Wrapper 2 adds the trailer's `latest:` note +and the `cloned` line. ## Exit codes diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 62109098d..60dd79fe6 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -801,6 +801,7 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { const drive = (f: Fixture, scope: string, text: string) => { const file = path.join(f.home, '.capy/drive', scope, 'skills/gstack/scripts/install.sh'); write(file, text); return file; }; const withSupport = (f: Fixture, min = 1) => write(path.join(f.root, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.0.0\nGSTACK_CAPY_MIN_WRAPPER=${min}\n`); const current = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/scripts/install.sh'), 'utf8'); + const currentRev = current.match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)![1]; test('direct doctor run: a pre-bootstrapper Drive copy (no revision line) fails wrapper with both fixes; a current copy is not flagged', () => { const f = makeFixture(); @@ -814,7 +815,7 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { expect(row).toContain('(CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)'); write(stale, current); const fresh = parse(f, []); - expect(fresh.line('wrapper')).toBe(`PASS wrapper Drive copy ${stale} (wrapper 1) (minimum 1)`); + expect(fresh.line('wrapper')).toBe(`PASS wrapper Drive copy ${stale} (wrapper ${currentRev}) (minimum 1)`); expect(fresh.status, fresh.out).toBe(0); }); @@ -824,7 +825,7 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { const stale = drive(f, 'org', PRE_BOOTSTRAPPER); const good = drive(f, 'user', current); const r = parse(f, []); - expect(r.line('wrapper')).toBe(`PASS wrapper Drive copy ${good} (wrapper 1) (minimum 1); stale copies: ${stale} (wrapper 0)`); + expect(r.line('wrapper')).toBe(`PASS wrapper Drive copy ${good} (wrapper ${currentRev}) (minimum 1); stale copies: ${stale} (wrapper 0)`); expect(parse(f, [], { GSTACK_CAPY_WRAPPER_PATH: stale }).trailer).toBe('gstack: fail wrapper'); });