From a101bf4093b797f42cc4d7fd377816efaaf121d5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 29 Aug 2026 05:42:57 +0000 Subject: [PATCH] fix(release): digest regen is an explicit --regen-digest opt-in, not presence-sniffed code exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (security) caught the cycle-1 fix executing any repo's scripts/gen-agents-digest.ts on plain 'write' — arbitrary code exec from a hostile clone on a routine bump, contradicting the binary's own containment posture. The regen still runs the TARGET repo's generator (a 'trusted' copy beside the binary would false-red the freshness gate on version drift), but only under the flag: /ship passes it deliberately, in a repo whose code the operator already executes (its test suite). Plain write is side-effect-free again. Also: uniform output shape (agentsDigest: null on the JSON-manifest branch), a REAL generator round-trip test replacing the misnamed lockstep check, and land-and-deploy's evidence gate gets the same digest allow-path as ship so the two grading surfaces agree. Co-Authored-By: Claude Fable 5 --- bin/gstack-version-bump | 43 ++++++++---- land-and-deploy/sections/readiness-gate.md | 2 +- .../sections/readiness-gate.md.tmpl | 2 +- ship/SKILL.md | 4 +- ship/SKILL.md.tmpl | 4 +- test/fixtures/golden/claude-ship-SKILL.md | 12 ++-- test/fixtures/golden/codex-ship-SKILL.md | 12 ++-- test/fixtures/golden/factory-ship-SKILL.md | 17 +++-- test/gstack-version-bump.test.ts | 67 ++++++++++++++----- 9 files changed, 111 insertions(+), 52 deletions(-) diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 5779bf311..a7de4e14c 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -20,8 +20,13 @@ // Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state // the caller must handle), exit 2 on bad args / unresolvable base. // -// write --version [--version-path

] +// write --version [--version-path

] [--regen-digest] // Validates the 4-digit pattern, writes VERSION + package.json.version. +// --regen-digest (opt-in): additionally run the target repo's own +// scripts/gen-agents-digest.ts when present, so the version-stamped +// committed digest stays freshness-green through the bump. Executes +// target-repo code — callers pass it only for repos whose code they +// already execute (/ship does). // Use for the FRESH bump (or an approved queue rebump). Exit 3 on a // half-write (VERSION written, package.json failed) so the caller knows // drift exists; the next classify() will report DRIFT_STALE_PKG. @@ -32,12 +37,12 @@ // // Contract: classify NEVER writes. write/repair mutate VERSION + the manifest // + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) — -// plus, in the gstack repo only, the committed agents digest (whose first -// line embeds VERSION and is byte-freshness-gated by test/agents-digest.test.ts -// and the Skill Docs Freshness CI check, so the write that changes VERSION -// must regenerate it or every release commit goes red). No git mutation, no -// network. Mirrors gstack-next-version's reader/writer split so /ship -// composes them. +// plus, under the explicit --regen-digest opt-in only, the committed agents +// digest (whose first line embeds VERSION and is byte-freshness-gated by +// test/agents-digest.test.ts and the Skill Docs Freshness CI check, so the +// write that changes VERSION must regenerate it or every release commit of +// the gstack repo goes red). No git mutation, no network. Mirrors +// gstack-next-version's reader/writer split so /ship composes them. // // Manifest resolution (all three subcommands accept --package-json-path): // --package-json-path

→ .gstack/package-json-path → ./package.json @@ -340,14 +345,22 @@ function cmdClassify(args: string[], cwd: string): void { } /** - * gstack-repo-only side effect: agents-digest/gstack-AGENTS.md embeds VERSION - * in its first line and is freshness-gated (committed bytes must equal the - * generated bytes), so the bump that changes VERSION regenerates it in the - * same write. Any repo without the generator + committed digest skips this. + * gstack-repo-only side effect, EXPLICITLY OPT-IN (--regen-digest): + * agents-digest/gstack-AGENTS.md embeds VERSION in its first line and is + * freshness-gated (committed bytes must equal the generated bytes), so the + * bump that changes VERSION regenerates it in the same write. The regen runs + * the TARGET repo's own generator (its bytes must match the target's + * freshness test, so a "trusted" copy beside this binary would false-red on + * version drift) — which is code execution from the bumped repo. That is why + * the flag exists: presence-sniffing alone would turn a routine bump in a + * hostile clone into arbitrary code exec. /ship passes the flag deliberately, + * in a repo whose code the operator is already executing (its test suite). + * Without the flag, write has NO side effects beyond VERSION/manifest/locks. * Regen failure warns rather than failing the bump — the deterministic * freshness test stays red until it's rerun, so nothing rots silently. */ -function regenAgentsDigest(versionPath: string): boolean | null { +function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | null { + if (!optedIn) return null; const root = dirname(versionPath); const gen = join(root, "scripts", "gen-agents-digest.ts"); if (!existsSync(gen) || !existsSync(join(root, "agents-digest", "gstack-AGENTS.md"))) return null; @@ -419,6 +432,10 @@ function cmdWrite(args: string[], cwd: string): void { versionPath: versionRel, packageJson: true, packageLock: lockSynced.length > 0, + // Uniform output shape with the plain-VERSION branch. Always null + // here: a JSON-manifest version source implies no plain VERSION file + // for the digest generator to stamp. + agentsDigest: null, }) + "\n", ); return; @@ -427,7 +444,7 @@ function cmdWrite(args: string[], cwd: string): void { const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); const hasPkg = existsSync(pkgPath); writeFileSync(versionPath, version + "\n"); - const digestRegen = regenAgentsDigest(versionPath); + const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest")); let lockSynced: string[] = []; // Decision 11: the manifest (and its lockfiles) carry the npm-valid // 3-digit translation — npm rejects a fourth component, so mirroring the diff --git a/land-and-deploy/sections/readiness-gate.md b/land-and-deploy/sections/readiness-gate.md index 4489107fb..7054accfd 100644 --- a/land-and-deploy/sections/readiness-gate.md +++ b/land-and-deploy/sections/readiness-gate.md @@ -97,7 +97,7 @@ and tell the user: "I found and fixed a few issues during the review. The fixes Check the evidence ledger first: ```bash -~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` (The `--expect-cmd` string must be the exact command the recorded run used — diff --git a/land-and-deploy/sections/readiness-gate.md.tmpl b/land-and-deploy/sections/readiness-gate.md.tmpl index 84a232891..694628096 100644 --- a/land-and-deploy/sections/readiness-gate.md.tmpl +++ b/land-and-deploy/sections/readiness-gate.md.tmpl @@ -95,7 +95,7 @@ and tell the user: "I found and fixed a few issues during the review. The fixes Check the evidence ledger first: ```bash -~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` (The `--expect-cmd` string must be the exact command the recorded run used — diff --git a/ship/SKILL.md b/ship/SKILL.md index ba352afeb..bb1799127 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -718,9 +718,9 @@ stay agent judgment; the slot pick stays `gstack-next-version`. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" + bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest ``` - The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. + The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. `--regen-digest` additionally reruns the repo's own `scripts/gen-agents-digest.ts` when present (gstack repo only — its committed digest embeds VERSION and is freshness-gated), a no-op everywhere else; /ship passes it deliberately because shipping a repo already executes that repo's code (its test suite). The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. 5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind: ```bash diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 0b4e93660..6fe515224 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -199,9 +199,9 @@ stay agent judgment; the slot pick stays `gstack-next-version`. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" + bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest ``` - The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. + The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. `--regen-digest` additionally reruns the repo's own `scripts/gen-agents-digest.ts` when present (gstack repo only — its committed digest embeds VERSION and is freshness-gated), a no-op everywhere else; /ship passes it deliberately because shipping a repo already executes that repo's code (its test suite). The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. 5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind: ```bash diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 6b4b6468b..bb1799127 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -718,9 +718,9 @@ stay agent judgment; the slot pick stays `gstack-next-version`. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" + bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest ``` - The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. + The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. `--regen-digest` additionally reruns the repo's own `scripts/gen-agents-digest.ts` when present (gstack repo only — its committed digest embeds VERSION and is freshness-gated), a no-op everywhere else; /ship passes it deliberately because shipping a repo already executes that repo's code (its test suite). The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. 5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind: ```bash @@ -898,15 +898,17 @@ EOF The evidence ledger is the mechanical arm of this law. Check it FIRST: ```bash -~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran — that binds FRESH to the real suite (a green `echo ok` recorded under the label can never satisfy the check). Residual risk, accepted: `package.json` sits on the allow-list because Step 12's version bump writes its version field between -the test run and this gate; a behavior-changing package.json edit in that -window would not invalidate evidence. The check is advisory either way. +the test run and this gate (and, in the gstack repo, regenerates the +version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing +package.json edit in that window would not invalidate evidence. The check is +advisory either way. - **Every line FRESH (exit 0):** the recorded runs were green and the working-tree content is identical to what was tested, modulo the allow-listed release files diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 53c704d90..8989ed954 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -1932,9 +1932,9 @@ stay agent judgment; the slot pick stays `gstack-next-version`. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" + bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest ``` - The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. + The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. `--regen-digest` additionally reruns the repo's own `scripts/gen-agents-digest.ts` when present (gstack repo only — its committed digest embeds VERSION and is freshness-gated), a no-op everywhere else; /ship passes it deliberately because shipping a repo already executes that repo's code (its test suite). The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. 5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind: ```bash @@ -2153,15 +2153,17 @@ EOF The evidence ledger is the mechanical arm of this law. Check it FIRST: ```bash -$GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +$GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran — that binds FRESH to the real suite (a green `echo ok` recorded under the label can never satisfy the check). Residual risk, accepted: `package.json` sits on the allow-list because Step 12's version bump writes its version field between -the test run and this gate; a behavior-changing package.json edit in that -window would not invalidate evidence. The check is advisory either way. +the test run and this gate (and, in the gstack repo, regenerates the +version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing +package.json edit in that window would not invalidate evidence. The check is +advisory either way. - **Every line FRESH (exit 0):** the recorded runs were green and the working-tree content is identical to what was tested, modulo the allow-listed release files diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 772ff69a5..8fa0d0a02 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -1959,6 +1959,11 @@ For each specialist (testing, maintainability, security, performance, data-migra - If skipped by gating: `{"dispatched": false, "reason": "gated"}` - If not applicable (e.g., red-team not activated): omit from the object +Advisory findings COUNT in the stats `findings` field — the advisory +carve-out governs the quality score and the findings-count header only. +Logging simplification's advisories as `findings: 0` would auto-gate the +lens into permanent silence after 10 dispatches. + Include the Design specialist even though it uses `design-checklist.md` instead of the specialist schema files. Remember these stats — you will need them for the review-log entry in Step 5.8. @@ -2354,9 +2359,9 @@ stay agent judgment; the slot pick stays `gstack-next-version`. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" + bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest ``` - The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. + The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. `--regen-digest` additionally reruns the repo's own `scripts/gen-agents-digest.ts` when present (gstack repo only — its committed digest embeds VERSION and is freshness-gated), a no-op everywhere else; /ship passes it deliberately because shipping a repo already executes that repo's code (its test suite). The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix. 5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind: ```bash @@ -2575,15 +2580,17 @@ EOF The evidence ledger is the mechanical arm of this law. Check it FIRST: ```bash -$GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +$GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran — that binds FRESH to the real suite (a green `echo ok` recorded under the label can never satisfy the check). Residual risk, accepted: `package.json` sits on the allow-list because Step 12's version bump writes its version field between -the test run and this gate; a behavior-changing package.json edit in that -window would not invalidate evidence. The check is advisory either way. +the test run and this gate (and, in the gstack repo, regenerates the +version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing +package.json edit in that window would not invalidate evidence. The check is +advisory either way. - **Every line FRESH (exit 0):** the recorded runs were green and the working-tree content is identical to what was tested, modulo the allow-listed release files diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index 920247447..89d525803 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -266,7 +266,7 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () => test('write updates the package.json in place and creates no VERSION file', () => { const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { cwd: dir }).toString(); - expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true, packageLock: false }); + expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true, packageLock: false, agentsDigest: null }); const pkg = JSON.parse(fs.readFileSync(pkgAbs, 'utf-8')); expect(pkg.version).toBe('0.99.3'); expect(pkg.scripts).toEqual({ dev: 'next dev' }); // rest of the file untouched @@ -363,7 +363,7 @@ describe('.gstack/version-path pin, no --version-path flag (#2462)', () => { const d = mkPinned(pinRel); fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n'); const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: d }).toString()); - expect(out).toEqual({ wrote: '0.99.3', versionPath: pinRel, packageJson: true, packageLock: false }); + expect(out).toEqual({ wrote: '0.99.3', versionPath: pinRel, packageJson: true, packageLock: false, agentsDigest: null }); expect(JSON.parse(fs.readFileSync(path.join(d, pinRel), 'utf-8')).version).toBe('0.99.3'); // Before the fix, write treated versionRel as "VERSION" and overwrote the // pinned JSON file with a bare "0.99.3\n", destroying the manifest. @@ -757,20 +757,19 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m }); }); -describe('write regenerates the gstack agents digest (gstack repo only)', () => { +describe('write --regen-digest regenerates the gstack agents digest (explicit opt-in)', () => { // The committed agents-digest/gstack-AGENTS.md embeds VERSION in its first // line and is byte-freshness-gated (test/agents-digest.test.ts + the Skill // Docs Freshness CI check). The write that changes VERSION must regenerate // it in the same mutation or every release commit of THIS repo goes red. - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-')); - afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); - - test('a repo with the generator + committed digest gets a fresh digest on write', () => { - fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + // The regen runs the TARGET repo's generator, which is code execution — + // hence the explicit flag: a plain `write` in a hostile clone must never + // execute repo files it merely finds on disk. + const stubGenerator = (dir: string) => { fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); fs.mkdirSync(path.join(dir, 'agents-digest'), { recursive: true }); - // Stub generator with the same shape as scripts/gen-agents-digest.ts: - // read VERSION, write the version-stamped digest. + // Stub with the same shape as scripts/gen-agents-digest.ts: read VERSION, + // write the version-stamped digest. fs.writeFileSync(path.join(dir, 'scripts', 'gen-agents-digest.ts'), [ "import * as fs from 'fs';", "import * as path from 'path';", @@ -779,11 +778,29 @@ describe('write regenerates the gstack agents digest (gstack repo only)', () => "fs.writeFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), `# gstack digest v${v}\\n`);", ].join('\n')); fs.writeFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n'); + }; - const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString()); + test('with the flag: a repo with the generator + committed digest gets a fresh digest', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-')); + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + stubGenerator(dir); + const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: dir }).toString()); expect(out.agentsDigest).toBe(true); expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8')) .toBe('# gstack digest v1.1.0.0\n'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('WITHOUT the flag: the generator is never executed, even when present (no presence-sniffed code exec)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-noflag-')); + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + stubGenerator(dir); + const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString()); + expect(out.agentsDigest).toBe(null); + // Digest untouched — the stub would have stamped v1.1.0.0 had it run. + expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8')) + .toBe('# gstack digest v1.0.0.0\n'); + fs.rmSync(dir, { recursive: true, force: true }); }); test('a generator failure warns and reports agentsDigest:false without failing the bump', () => { @@ -794,19 +811,33 @@ describe('write regenerates the gstack agents digest (gstack repo only)', () => fs.writeFileSync(path.join(d2, 'scripts', 'gen-agents-digest.ts'), 'process.exit(1);\n'); fs.writeFileSync(path.join(d2, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n'); - const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2, stdio: 'pipe' }); + const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0', '--regen-digest'], { cwd: d2, stdio: 'pipe' }); const out = JSON.parse(res.toString()); expect(out.wrote).toBe('1.1.0.0'); // the bump itself still lands expect(out.agentsDigest).toBe(false); fs.rmSync(d2, { recursive: true, force: true }); }); - test('the REAL generator + real digest stay in lockstep through a bump round-trip', () => { - // Belt and braces for the actual repo wiring (not a stub): bump a copy of - // the real VERSION in place, regen, and confirm the first line tracks it. + test('the REAL generator round-trips a bump: write --regen-digest restamps the digest first line', () => { + // Not a stub: copy the actual generator + digest into a temp repo, bump + // it, and confirm the regenerated first line tracks the new VERSION. const root = path.join(import.meta.dir, '..'); - const committed = fs.readFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8'); - const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf-8').trim(); - expect(committed.split('\n')[0]).toContain(`v${version}`); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-real-')); + fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'agents-digest'), { recursive: true }); + fs.copyFileSync( + path.join(root, 'scripts', 'gen-agents-digest.ts'), + path.join(dir, 'scripts', 'gen-agents-digest.ts'), + ); + fs.copyFileSync( + path.join(root, 'agents-digest', 'gstack-AGENTS.md'), + path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), + ); + fs.writeFileSync(path.join(dir, 'VERSION'), '9.9.9.9\n'); + const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '9.9.10.0', '--regen-digest'], { cwd: dir }).toString()); + expect(out.agentsDigest).toBe(true); + const first = fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8').split('\n')[0]; + expect(first).toContain('v9.9.10.0'); + fs.rmSync(dir, { recursive: true, force: true }); }); });