From e9b40bbd82aa7e53e973f2790aa485a0d4debd3b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 14:44:59 -0800 Subject: [PATCH 01/21] v1.94.42.0 test: red-team retry, docsync replay and plan-seed fixture hold up on loaded runners (GSTA-262) (#3954) * test: red-team retry, docsync replay and plan-seed fixture hold up on loaded runners (GSTA-262) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: mark the two intended timing bounds with gstack-timing-ok (GSTA-262) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/fixtures/plan-seed-cli.ts | 10 +++++----- test/helpers/docsync-fault-callbacks.ts | 10 ++++++---- test/review-army-budget.test.ts | 6 +++++- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 041b9fb7d..54a206b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.94.42.0] - 2026-11-03 + +### Tests +- Three free tests that failed only on a busy remote test machine (remote pregate run 20261103T015240Z-11756-6ff6, PR #3892) now hold up under load: + - `test/review-army-budget.test.ts`: the red-team retry fixture spent a real 80 ms sleep inside its 200 ms deadline, so a pause of more than 120 ms failed attempt 2. Attempt 1 now waits for the cancellation and then drains, and attempt 2 doesn't sleep. With random 150 ms pauses injected, 6 of 10 runs passed before and 20 of 20 pass now. + - `test/helpers/docsync-fault-callbacks.ts`: the docsync replay assumed fixture setup took less than 5 seconds, and setup takes 5 to 12 seconds on a busy machine. The replay now checks that the session timeout equals the budget minus 15 seconds minus the time already spent, which is how the code computes it. Under 16 busy loops on 4 cores, 0 of 4 runs passed before and 4 of 4 pass now. + - `test/fixtures/plan-seed-cli.ts`: the fake CLI drew its screen and its startup question in two separate writes, so a screen read between them saw an empty input and pasted the plan. It now draws both in one write. Under load, 31 of 36 runs passed before and 36 of 36 pass now. + ## [1.94.41.0] - 2026-11-03 **/ship's receipt counts its review findings by id, keeps every run, and a small change after review gets one reader instead of a second /ship (GSTA-258 L4; GST-1136, GST-1168, GST-1179).** The receipt's `REVIEW_LOOP` counts were wrong in a repeatable way: gbrain-evals#310's receipt said 3 blockers found and 2 fixed when 2 were found, 1 fixed and 1 deferred; gstack-evals#174 counted 16 blocking with 8 unrecorded against 9 found and 9 fixed; #3906 and #3911 showed `fixed=0` after real fixes. The summary paired findings by path:line only, so a native finding with no path:line, a row logged after its fix moved the line, or a late defer re-logged under another fingerprint all broke the count, and rows it couldn't place were dropped without a word. diff --git a/VERSION b/VERSION index d6379acf4..670aa09c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.41.0 +1.94.42.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f65ec891c..1fb823e36 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.41.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.42.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index cd92e14d6..7d3d680df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.41", + "version": "1.94.42", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/plan-seed-cli.ts b/test/fixtures/plan-seed-cli.ts index 9323d5749..f740abe21 100644 --- a/test/fixtures/plan-seed-cli.ts +++ b/test/fixtures/plan-seed-cli.ts @@ -22,10 +22,10 @@ const row=(type,content,stop)=>JSON.stringify({type,sessionId:sid,cwd,message:{r const text=s=>[{type:'text',text:s}]; const append=(type,content,stop)=>fs.appendFileSync(file,row(type,content,stop)); const rule='─'.repeat(120); -const frame=(s,history='',top=rule,bottom=rule)=>{ +const frame=(s,history='',top=rule,bottom=rule,tail='')=>{ const inputRows=1+(s.match(/\r\n/g)||[]).length; const topRow=(process.stdout.rows||40)-inputRows-2; - process.stdout.write('\x1b[2J\x1b[H'+history+'\x1b['+topRow+';1H'+top+'\r\n❯ '+s+'\r\n'+bottom+'\r\npaste again to expand'); + process.stdout.write('\x1b[2J\x1b[H'+history+'\x1b['+topRow+';1H'+top+'\r\n❯ '+s+'\r\n'+bottom+'\r\npaste again to expand'+tail); }; let input='',seed='',submitted=false; process.stdin.setRawMode(true);process.stdin.resume(); @@ -40,12 +40,12 @@ else if(scenario==='startup-ci-placeholder')frame(process.env.CI==='true'&&proce else if(scenario==='startup-ci-typed-hint')frame(hint); else if(scenario==='startup-placeholder-cursor')frame('\x1b[7mT\x1b[27m\x1b[2m'+hint.slice(1)+'\x1b[22m'); else if(scenario==='startup-placeholder-unicode')frame('\x1b[2mTry "refactor src/設定.ts"\x1b[22m'); -else if(['startup-placeholder','startup-prior-conversation','startup-missing-styles','startup-waiting','startup-prose-question','startup-permission','startup-fresh-waiting'].includes(scenario))frame('\x1b[2m'+hint+'\x1b[22m'); +else if(['startup-placeholder','startup-prior-conversation','startup-missing-styles','startup-waiting','startup-prose-question','startup-permission','startup-fresh-waiting'].includes(scenario))frame('\x1b[2m'+hint+'\x1b[22m','',rule,rule, + scenario==='startup-prose-question'?'Which option do you prefer?\r\nA) Full review (recommended)\r\nB) Skip review\r\n' + :scenario==='startup-permission'?'Bash command run checks requires permission\r\n':''); else if(scenario==='startup-typed-hint')frame(hint); else if(scenario==='startup-partial-dim')frame('\x1b[2mTry \x1b[22m"refactor "'); else frame(''); -if(scenario==='startup-prose-question')process.stdout.write('Which option do you prefer?\r\nA) Full review (recommended)\r\nB) Skip review\r\n'); -if(scenario==='startup-permission')process.stdout.write('Bash command run checks requires permission\r\n'); process.stdin.on('data',chunk=>{ input+=chunk.toString(); if(input.startsWith('\x1b[200~')&&input.endsWith('\x1b[201~')){ diff --git a/test/helpers/docsync-fault-callbacks.ts b/test/helpers/docsync-fault-callbacks.ts index 31a158557..65c4b6e14 100644 --- a/test/helpers/docsync-fault-callbacks.ts +++ b/test/helpers/docsync-fault-callbacks.ts @@ -25,7 +25,7 @@ const { parseDocsCompletion } = await import(path.join(root, 'test/helpers/docsy const { docsActorSeeded, DOCS_SEEDED_AUDIT_ID } = await import(path.join(root, 'test/helpers/docsync-fault-actor.ts')); const CALLBACK_TEST_MS = ${CALLBACK_TEST_MS}; const callbacks = new Map(); -let fixture, control = '', launches = 0, recorded, legacyReaudit = false; +let fixture, control = '', launches = 0, recorded, legacyReaudit = false, bodyStartedAt = 0; let returnedResult: SkillTestResult | undefined; let consumers: string[] = []; function expectResult(result: SkillTestResult) { @@ -47,7 +47,7 @@ mock.module(observerModule, () => ({ ...observers, mock.module(path.join(root, 'test/helpers/e2e-gate.ts'), () => ({ describeE2ETier: () => (_name, body) => body() })); mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({ runId: 'free-docsync-faults', describeIfSelected: (_name, _names, body) => body(), - testConcurrentIfSelected: (name, body, budget) => callbacks.set(name, { body, budget }), + testConcurrentIfSelected: (name, body, budget) => callbacks.set(name, { body: () => { bodyStartedAt = Date.now(); return body(); }, budget }), createEvalCollector: () => ({}), finalizeEvalCollector() {}, logCost(_name, result) { expectResult(result); @@ -65,8 +65,10 @@ mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({ async ru const legacy = options.testName === 'ship-docsync-failure'; const scenario = legacy ? 'legacy-completion' : options.testName.slice('ship-docsync-'.length); expect(options.maxTurns).toBe(legacy ? 30 : 24); - expect(options.timeout).toBeGreaterThan((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 20_000); - expect(options.timeout).toBeLessThanOrEqual((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 15_000); + const remaining = (legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 15_000; + // gstack-timing-ok: runShipDocsFault derives the timeout from elapsed time, so the bound measures the same elapsed time + expect(options.timeout).toBeGreaterThanOrEqual(remaining - (Date.now() - bodyStartedAt)); + expect(options.timeout).toBeLessThanOrEqual(remaining); expect(options.tools).toEqual(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep']); expect(options.allowedTools).toEqual(options.tools); expect(options.prompt).toContain('BOUNDED_CALLBACK_USED'); diff --git a/test/review-army-budget.test.ts b/test/review-army-budget.test.ts index db3c5b808..dd077fe2e 100644 --- a/test/review-army-budget.test.ts +++ b/test/review-army-budget.test.ts @@ -108,7 +108,11 @@ const run = async opts => { fixturePaths.push(opts.workingDirectory); captureOptions.push({ timeout: opts.timeout, maxTurns: opts.maxTurns, model: opts.model ?? null, signal: !!opts.signal }); opts.signal?.addEventListener('abort', () => events.push('abort-' + attempt), { once: true }); - await Bun.sleep(attempt === 1 ? captureMs + drainMs : 80); + if (attempt === 1) { + await new Promise(resolve => opts.signal.addEventListener('abort', resolve, { once: true })); + // gstack-timing-ok: the drain after cancellation is the behavior under test; no deadline remains during it + await Bun.sleep(drainMs); + } expect(fs.existsSync(opts.workingDirectory)).toBe(true); events.push('capture-done-' + attempt); if (attempt === 2) fs.writeFileSync(path.join(opts.workingDirectory, 'review-output.md'), ${JSON.stringify(validReport ? 'RED TEAM REVIEW' : 'ordinary unrelated prose')}); From 913aca6ac098a9dcf2587ac0f08dbfd4e1b44abf Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 14:51:42 -0800 Subject: [PATCH 02/21] v1.94.43.0 fix(pregate): gstack-evidence passed skips the tree hash with no ledger row; cheaper setup for two 5 s test cases (#3952) * fix: gstack-evidence passed skips the tree hash with no ledger row; two local 5 s test cases get cheaper setup (GSTA-262) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog and TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-evidence | 8 +++++--- package.json | 2 +- test/pregate-guards.test.ts | 17 ++++++----------- test/spec-quality-gate-secret-sink.test.ts | 10 ++++++---- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a206b47..073804273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.94.43.0] - 2026-11-03 + +### Fixed + +- **`gstack-evidence passed` skips the tree hash when the ledger has nothing to match.** Before pregate runs a guard, it asks `gstack-evidence passed` whether that exact command already passed at this tree (#3929). Each check hashed the working tree (`gstack-wtree`) before it looked at the ledger. Usually the ledger has no row for the command at all, so that hash was wasted. Each check took 0.19 s idle and 0.74 to 0.94 s on a busy 4-core machine, once per guard. Now the ledger is read first, and the tree is hashed only when a local row names the command. The answer is the same. Only the no-row message drops its "at tree …" suffix. +- **Two local test cases no longer run out of time at Bun's default 5 s.** `test/pregate-guards.test.ts`'s guard-timeout case ran two full pregates, each waiting out a real 1 s guard timeout. It is now two cases, one with several guards together and one with a lone timed-out guard. Its fixture also writes the manifest into the base commit instead of making a second commit. On a 4-core machine it went from 4.1 s to 2.0 s and 1.75 s idle. With every core busy (busy loops at nice 10), it went from 0 of 3 runs passing to 10 of 10. `test/spec-quality-gate-secret-sink.test.ts` rendered every host's skill docs in `beforeAll` but reads only the claude and codex output. It now renders just those two, which are byte-identical to the full render: 1.38 s down to 0.37 s. With 16 busy loops, that hook went from timing out in 3 of 3 runs to passing 3 of 3. That is the hook timeout the macOS named-regressions job hit on #3926. + ## [1.94.42.0] - 2026-11-03 ### Tests diff --git a/VERSION b/VERSION index 670aa09c5..a01fb7593 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.42.0 +1.94.43.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1fb823e36..83c7bff9e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.42.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.43.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-evidence b/bin/gstack-evidence index d87f76e28..54d0acd73 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -653,12 +653,14 @@ function cmdPassed(argv: string[]): number { const sep = argv.indexOf("--"); const command = sep < 0 ? "" : argv.slice(sep + 1).join(" "); if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- "); return 2; } - const wtree = currentWtree(); - if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; } let records: EvidenceRecord[] = []; try { records = readJsonl(ledgerPath().file); } catch { records = []; } const cmdSha = sha256(command); - const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined); + const local = records.filter((r) => r.cmd_sha256 === cmdSha && r.source === undefined); + if (!local.length) { console.log("NOT_PASSED: no local ledger row for this command"); return 1; } + const wtree = currentWtree(); + if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; } + const newest = local.findLast((r) => r.wtree === wtree); if (!newest) { console.log(`NOT_PASSED: no local ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; } if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; } console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`); diff --git a/package.json b/package.json index 7d3d680df..ace2477a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.42", + "version": "1.94.43", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 6bf2153b9..3edc4595e 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -30,7 +30,7 @@ import * as path from 'node:path'; import { afterEach } from 'bun:test'; import { EXIT } from '../lib/headless-artifacts'; import { capyHost, type CapyHost } from './helpers/capy-host'; -import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; const hosts: CapyHost[] = []; @@ -50,16 +50,8 @@ const SERIAL_TEST = "const test = require('node:test');\ntest('serial', () => {} /** The base branch gains the manifest, its check script and the given pregate config. */ function guardedFixture(pregate: Record): FixtureRepo { - const repo = makeFixtureRepo({ pregate }); + const repo = makeFixtureRepo({ pregate, baseFiles: { 'scripts/check-serial.js': CHECK_SERIAL, 'scripts/serial-files.tsv': 'path\treason\n' } }); repos.push(repo); - repo.git('checkout', '-q', 'main'); - write(repo.work, 'scripts/check-serial.js', CHECK_SERIAL); - write(repo.work, 'scripts/serial-files.tsv', 'path\treason\n'); - repo.git('add', '-A'); - repo.git('commit', '-q', '-m', 'serial manifest'); - repo.git('push', '-q', 'origin', 'main'); - repo.git('checkout', '-q', 'feature'); - repo.git('merge', '-q', '--ff-only', 'main'); return repo; } const pregate = (repo: FixtureRepo, args: string[], env: Record = {}) => runBin(repo, 'gstack-pregate', ['--base', 'main', ...args], env); @@ -92,7 +84,7 @@ describe('guards (CD1)', () => { expect(r.out).toContain('GSTACK_RESULT: skill=pregate status=incomplete'); }); - test('each guard runs in its own process with its timeout: a failing guard does not stop the next, a timed-out guard is incomplete, never pass', () => { + test('each guard runs in its own process with its timeout: a failing guard does not stop the next, a timed-out guard never passes', () => { const repo = guardedFixture({ ...BASE_PREGATE, guards: [ { id: 'first', when: ['lib/**'], command: 'echo first-ran; exit 3' }, { id: 'second', when: ['lib/**'], command: 'touch ../second-ran' }, @@ -108,6 +100,9 @@ describe('guards (CD1)', () => { expect(row.status).toBe('fail'); expect(row.detail).toContain('first'); expect(row.detail).not.toContain('second'); + }); + + test('a timed-out guard with no failing guard beside it is incomplete, never pass', () => { const slowOnly = guardedFixture({ ...BASE_PREGATE, guards: [{ id: 'slow', when: ['lib/**'], command: 'sleep 20', timeout_s: 1 }] }); commit(slowOnly, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); expect(pregate(slowOnly, ['--allow-repo-commands', '--stage', 'preflight', '--only', 'guards']).status).toBe(EXIT.fail); diff --git a/test/spec-quality-gate-secret-sink.test.ts b/test/spec-quality-gate-secret-sink.test.ts index 011c604ab..eef39b142 100644 --- a/test/spec-quality-gate-secret-sink.test.ts +++ b/test/spec-quality-gate-secret-sink.test.ts @@ -9,10 +9,12 @@ let output: string; beforeAll(() => { output = mkdtempSync(join(tmpdir(), 'gstack-spec-gate-render-')); - const rendered = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', output], { - cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000, - }); - if (rendered.exitCode !== 0) throw new Error(rendered.stderr.toString()); + for (const host of ['claude', 'codex']) { + const rendered = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', output], { + cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000, + }); + if (rendered.exitCode !== 0) throw new Error(rendered.stderr.toString()); + } }); afterAll(() => { if (output) rmSync(output, { recursive: true, force: true }); }); From 10cdd0ec5a5deba6fac4711545aae85357fb9823 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 14:58:53 -0800 Subject: [PATCH 03/21] v1.94.44.0 fix(install): a child's install for another workflow no longer drops /ship's install verdict (GST-1233) (#3960) * fix(install): a child's install for another workflow no longer drops /ship's install verdict (GST-1233) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): keep each workflow's Bun on --check, refuse after a failed setup, launch only the admitting record's Bun (GST-1233 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-1233 fragment names the pregate launch-dir check precisely Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-capy-install | 12 +++- bin/gstack-install-verdict.sh | 113 +++++++++++++++++++------------ docs/capy-install.md | 14 +++- lib/install-verdict.ts | 19 +++++- lib/pregate/runtime.ts | 14 ++-- package.json | 2 +- test/bun-resolve.test.ts | 29 ++++++++ test/gstack-capy-install.test.ts | 38 +++++++++++ test/install-verdict.test.ts | 24 +++++++ 12 files changed, 216 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073804273..1d4aff92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.94.44.0] - 2026-11-03 + +### Fixed + +- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install check failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD or a failed `./setup` still invalidates every record, `--check --for ship` keeps ship's own recorded Bun, and the pregate skips a launch directory whose `bun` resolves to a Bun other than the one its admitting record certified. The shared launch file is a separate follow-up (GST-1239). + ## [1.94.43.0] - 2026-11-03 ### Fixed diff --git a/VERSION b/VERSION index a01fb7593..d647e7a0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.43.0 +1.94.44.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 83c7bff9e..21012e84c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.43.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.44.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 4ad259627..26b9e6668 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -163,8 +163,10 @@ PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) && VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL") REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR="" if [ -f "$VERDICT" ]; then - REC_BUN=$(gstack_install_verdict_field "$VERDICT" bun_path) REC_VER=$(gstack_install_verdict_field "$VERDICT" bun_version) - REC_RULE=$(gstack_install_verdict_field "$VERDICT" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$VERDICT" bun_install) + _rec=$VERDICT + case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 0 ] && [ "$(gstack_install_verdict_field "${VERDICT%.json}.for/$FOR.json" ok 2>/dev/null)" = true ] && _rec="${VERDICT%.json}.for/$FOR.json" ;; esac + REC_BUN=$(gstack_install_verdict_field "$_rec" bun_path) REC_VER=$(gstack_install_verdict_field "$_rec" bun_version) + REC_RULE=$(gstack_install_verdict_field "$_rec" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$_rec" bun_install) REC_FOR=$(gstack_install_verdict_field "$VERDICT" for) fi # A reviewer's machine (--for review-voice) asked to run a workflow upgrades @@ -429,7 +431,9 @@ verdict_kept() { # An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun, # so one machine serving /ship and its docs step stays admitted for both (#3561): required is # the union, pins count as planning-only only when both runs skipped them, and for= lists every -# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict. +# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict, so +# each run also records its own workflow's verdict at .for/.json, which admission +# falls back to: a later install for another workflow never drops this one (GST-1233). record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) local v_for="${FOR:-}" v_req v_plan e_for id if verdict_kept; then @@ -440,6 +444,8 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1) v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0) + case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 1 ] || gstack_install_verdict_write "${VERDICT%.json}.for/$FOR.json" "$ROOT_REAL" "$HEAD_SHA" "$FOR" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ + "$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" || say "could not write the install verdict ${VERDICT%.json}.for/$FOR.json" ;; esac if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \ && [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \ diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 7477dd56d..5714bf60a 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -21,6 +21,9 @@ # /install-verdicts/.json. An install # profile's verdict (for=review-voice) at the keyed path yields to the launched # GSTACK_INSTALL_VERDICT, so a reviewer's install never shadows it (GST-1161). +# Each workflow install also writes its own record, .for/.json, +# which admission tries when the shared verdict does not admit that workflow, so +# another workflow's later install never drops it (GST-1233). # # Admission (gstack-skill-start, after SESSION_KIND): an unattended session, # or an explicit GSTACK_SESSION_KIND=spawned session with GSTACK_CAPY=1, @@ -109,12 +112,62 @@ gstack_install_verdict_field() { esac } +# _gstack_ia_check — the verdict checks gstack_install_admission runs on $file +# for $skill (its locals: src, project, where, found): sets detail (empty when +# the verdict admits), fix, code, trailer and, for a Bun drift, _ia_status. +_gstack_ia_check() { + detail="" + code=INSTALL_UNVERIFIED + fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints" + if [ ! -f "$file" ]; then + detail="no install verdict at $file for $src" + fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints" + else + trailer=$(gstack_install_verdict_field "$file" trailer) + v_head=$(gstack_install_verdict_field "$file" head) + head=$(git -C "$src" rev-parse HEAD 2>/dev/null) + v_schema=$(gstack_install_verdict_field "$file" schema_version) + if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then + detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer" + elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then + detail="the install verdict says: $trailer" + elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then + detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src" + elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then + detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})" + elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then + detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests" + elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \ + && v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then + detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests" + fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints" + else + for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do + case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac + done + if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then + detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)" + fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints" + fi + fi + v_bun=$(gstack_install_verdict_field "$file" bun_path) + if [ -z "$detail" ] && [ -n "$v_bun" ]; then + running=$(gstack_realpath "$where" 2>/dev/null) + _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath /dev/null | head -1) + if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then + _ia_status=runtime code=BUN_RESOLUTION_DRIFT + detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" + fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed" + fi + fi + fi +} # gstack_install_admission BIN_DIR SKILL KIND — sets _ia_applies (0|1), # _ia_refuse (0|1), _ia_warn (0|1), _ia_status (ok|runtime|verdict), # _ia_line (the INSTALL: or INSTALL_VERDICT: line to print, may be empty). # Needs gstack-bun-version.sh and gstack-doctor-components.sh sourced. gstack_install_admission() { - local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun + local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun v_schema _ia_own _ia_m_file _ia_m_detail _ia_m_fix _ia_m_code _ia_m_status _ia_m_trailer _ia_applies=0 _ia_refuse=0 _ia_warn=0 _ia_status=ok _ia_line="" case "$kind" in unattended|spawned) _ia_applies=1 ;; *) return 0 ;; esac [ "${GSTACK_CAPY:-}" = 1 ] && capy=1 @@ -136,51 +189,21 @@ gstack_install_admission() { esac [ -f "$file" ] || [ ! -f "${GSTACK_INSTALL_VERDICT:-}" ] || file="$GSTACK_INSTALL_VERDICT" [ -f "$file" ] || file=$(gstack_install_verdict_legacy_path "${_GH:-$HOME/.gstack}" "$src") - code=INSTALL_UNVERIFIED - fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints" - if [ ! -f "$file" ]; then - detail="no install verdict at $file for $src" - fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints" - else - trailer=$(gstack_install_verdict_field "$file" trailer) - v_head=$(gstack_install_verdict_field "$file" head) - head=$(git -C "$src" rev-parse HEAD 2>/dev/null) - v_schema=$(gstack_install_verdict_field "$file" schema_version) - if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then - detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer" - elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then - detail="the install verdict says: $trailer" - elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then - detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src" - elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then - detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})" - elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then - detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests" - elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \ - && v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then - detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests" - fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints" - else - for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do - case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac - done - if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then - detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)" - fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints" - fi - fi - v_bun=$(gstack_install_verdict_field "$file" bun_path) - if [ -z "$detail" ] && [ -n "$v_bun" ]; then - running=$(gstack_realpath "$where" 2>/dev/null) - _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath /dev/null | head -1) - if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then - _ia_status=runtime code=BUN_RESOLUTION_DRIFT - detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" - fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed" - fi - fi - [ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; } + _gstack_ia_check + # GST-1233: each install also keeps its own verdict per workflow, so when a + # later install for another workflow replaced the shared one, this + # workflow's own record still admits it; never after a failed ./setup, which + # changed the checkout every record certified. + _ia_own="${file%.json}.for/$skill.json" + case "$skill" in ''|*[!A-Za-z0-9._-]*) _ia_own="" ;; esac + case "$trailer" in "gstack: fail setup"*) _ia_own="" ;; esac + if [ -n "$detail" ] && [ -n "$_ia_own" ] && [ -f "$_ia_own" ]; then + _ia_m_file=$file _ia_m_detail=$detail _ia_m_fix=$fix _ia_m_code=$code _ia_m_status=$_ia_status _ia_m_trailer=$trailer + file=$_ia_own _ia_status=ok + _gstack_ia_check + [ -z "$detail" ] || { file=$_ia_m_file detail=$_ia_m_detail fix=$_ia_m_fix code=$_ia_m_code _ia_status=$_ia_m_status trailer=$_ia_m_trailer; } fi + [ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; } [ "$_ia_status" = runtime ] || _ia_status=verdict else return 0 diff --git a/docs/capy-install.md b/docs/capy-install.md index 6f0feef46..a1da5fd13 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -330,7 +330,19 @@ keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and the installer prints `the install verdict at keeps for= and adds (for=)`, and the trailer still names this run's workflow). A failing run, a new HEAD or another Bun replaces the verdict, and so does any - workflow install over a review-voice verdict. Admission refuses a workflow whose + workflow install over a review-voice verdict. Each workflow install (and + `--check`) also writes that workflow's own verdict beside it, at + `.for/.json`, with only this run's result. When the shared + verdict does not admit a workflow, admission and the pregate try the + workflow's own record with the same checks, so a later install for another + workflow on a shared machine (a /ship docs child resolving another Bun, or a + failed child install) never drops /ship's admission (GST-1233). A new HEAD + still invalidates both, and a failed `./setup` (`gstack: fail setup`) + invalidates every workflow's record. `--check --for ` keeps the + Bun that workflow's own record holds. The launch file and launch directory + stay shared per checkout and project (GST-1239), so the pregate launches + from the recorded Bun's own directory when the launch directory's `bun` + resolves to another one. Admission refuses a workflow whose running Bun is not the one the verdict recorded ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so do the pregate's lanes and guards, which also refuse an invalid verdict diff --git a/lib/install-verdict.ts b/lib/install-verdict.ts index f9599a477..60f787cd2 100644 --- a/lib/install-verdict.ts +++ b/lib/install-verdict.ts @@ -142,7 +142,24 @@ export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: st export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null { const src = sourceRoot(gstackRoot); const project = installProject(src, cwd); - const file = findInstallVerdict(src, project, env); + return admittedVerdict(skill, src, findInstallVerdict(src, project, env), project, bunPath, bunVersion).problem; +} + +/** + * The verdict file that admits `skill`: `file` itself, else the workflow's own record + * .for/.json that every install also writes, so a later install for another + * workflow never drops this one (GST-1233), except after a failed ./setup, which changed the + * checkout every record certified. When neither admits, `file` with its problem. + */ +export function admittedVerdict(skill: string, src: string, file: string, project: string | null, bunPath: string, bunVersion: string): { file: string; problem: VerdictProblem | null } { + const problem = verdictFileProblem(file, skill, src, project, bunPath, bunVersion); + const own = file.replace(/\.json$/, `.for/${skill}.json`); + if (problem && /^[A-Za-z0-9._-]+$/.test(skill) && !readInstallVerdict(file)?.trailer?.startsWith('gstack: fail setup') && fs.existsSync(own) && !verdictFileProblem(own, skill, src, project, bunPath, bunVersion)) return { file: own, problem: null }; + return { file, problem }; +} + +/** Why the verdict in `file` does not admit `skill`; null when it does. */ +function verdictFileProblem(file: string, skill: string, src: string, project: string | null, bunPath: string, bunVersion: string): VerdictProblem | null { const v = readInstallVerdict(file); const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' }); if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`); diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index e0a8d6af8..9985bafc8 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { findInstallVerdict, installProject, readInstallVerdict, sourceRoot, verdictProblem } from '../install-verdict'; +import { admittedVerdict, findInstallVerdict, installProject, readInstallVerdict, sourceRoot } from '../install-verdict'; import { resultCodeSuffix } from '../result-codes'; import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins'; import { resolveStateRoot } from '../state-root'; @@ -152,15 +152,15 @@ export interface LaunchRuntime { * The Bun the lanes and guards run under, its dir first on PATH; the env is * unchanged when nothing qualifies. Under the admission condition the install * verdict for (checkout, project) is validated as workflow admission does - * (verdictProblem: ok, HEAD, pins, project, coverage, for /ship) and its + * (admittedVerdict: ok, HEAD, pins, project, coverage, for /ship; else /ship's own record) and its * recorded Bun is the one launched; the gstack checkout itself binds the * 'none' verdict (installProject). */ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchRuntime { const checkout = sourceRoot(GSTACK_ROOT); const project = installProject(checkout, repoRoot); - const file = findInstallVerdict(checkout, project, env); - const recorded = readInstallVerdict(file); + const admitted = admittedVerdict('ship', checkout, findInstallVerdict(checkout, project, env), project, '', ''); + const recorded = readInstallVerdict(admitted.file); let pinsRoot: string | null = null; try { pinsRoot = fs.realpathSync(repoRoot); } catch { /* unresolvable: no pins */ } const { pinExact, accept } = projectBunPins(pinsRoot, env); @@ -174,14 +174,16 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR if (env.GSTACK_CAPY !== '1' && !recorded) { verdictLine = 'install verdict: not checked (no GSTACK_CAPY=1 and no verdict for this checkout and project)'; } else { - const problem = verdictProblem('ship', GSTACK_ROOT, env, '', '', repoRoot); + const { problem } = admitted; if (problem) return refuse('INSTALL_UNVERIFIED', problem.detail, problem.fix ?? installer); verdict = 'ok'; if (recorded!.bun_path) { const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : ''; const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : ''; if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix'); - const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path); + let launchBun = ''; + try { launchBun = fs.realpathSync(path.join(recorded!.launch_path, 'bun')); } catch { /* no Bun in the launch dir */ } + const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) && (!launchBun || launchBun === recorded!.bun_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path); return result(launch, now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', ''); } } diff --git a/package.json b/package.json index ace2477a6..db6a64a8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.43", + "version": "1.94.44", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/bun-resolve.test.ts b/test/bun-resolve.test.ts index 9af139cd9..ade1fe074 100644 --- a/test/bun-resolve.test.ts +++ b/test/bun-resolve.test.ts @@ -138,4 +138,33 @@ describe('one Bun resolution contract, two twins', () => { expect(viaLaunch.dir).toBe(launch); expect(viaLaunch.env.PATH?.split(path.delimiter)[0]).toBe(launch); }); + + // GST-1233: a docs child's install replaced the shared verdict and republished the shared launch + // directory with its own Bun; the finish pregate admits from ship's own record and launches ship's Bun. + test("launchRuntime admits /ship from its own record when another workflow's install replaced the shared verdict, and never launches that install's Bun", () => { + const l = layout({ storeVersions: ['1.4.4'] }); + const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict'); + const checkout = fs.realpathSync(ROOT); + const file = installVerdictPath(checkout, l.state, null); + const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const childBun = path.join(bunStore(l.state), '1.4.4/bun'); + const launch = file.replace(/\.json$/, '.launch'); + fs.mkdirSync(launch, { recursive: true }); + fs.symlinkSync(childBun, path.join(launch, 'bun')); + const record = { schema_version: 2, checkout, project: '', head, pins_planning_only: false, ok: true, min_revision: '', launch_path: launch, time: '' }; + fs.writeFileSync(file, JSON.stringify({ ...record, for: 'document-release', required: ['claude'], trailer: 'gstack: ok for=document-release', bun_path: fs.realpathSync(childBun), bun_version: '1.4.4' })); + const refused = launchRuntime(ROOT, l.env); + expect(refused.refusal).toMatchObject({ code: 'INSTALL_UNVERIFIED' }); + expect(refused.refusal?.detail).toContain('certified for=document-release'); + const own = file.replace(/\.json$/, '.for/ship.json'); + fs.mkdirSync(path.dirname(own), { recursive: true }); + fs.writeFileSync(own, JSON.stringify({ ...record, for: 'ship', required: ['claude', 'codex', 'patch'], trailer: 'gstack: ok for=ship', bun_path: REAL_BUN, bun_version: Bun.version })); + const r = launchRuntime(ROOT, l.env); + expect(r.refusal).toBeNull(); + expect(r.verdict).toBe('ok'); + expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`); + expect(r.dir).toBe(path.dirname(REAL_BUN)); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), ok: false, trailer: 'gstack: fail setup — ./setup failed' })); + expect(launchRuntime(ROOT, l.env).refusal?.detail).toBe('install verdict: the install verdict says: gstack: fail setup — ./setup failed'); + }); }); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 405458549..9809cae64 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -406,6 +406,44 @@ describe('gstack-capy-install', () => { expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs'); }); + // GST-1233 (TRUT-26): the union above needs the same Bun and an ok run, so a child whose shell + // resolves another Bun, or whose install fails, still replaced the shared verdict and /ship's + // finish pregate reported INSTALL_UNVERIFIED. Each workflow keeps its own verdict as well. + test('--for ship then --for document-release with another Bun or a failed run: ship stays admitted; another HEAD invalidates both', () => { + const f = fixture(); + const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') }; + const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home); + const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(ship.status, ship.stdout + ship.stderr).toBe(0); + const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!; + const childBin = path.join(f.base, 'child-bin'); + script(path.join(childBin, 'bun'), ['[ "$1" = --version ] && { echo 1.4.2; exit 0; }', 'exit 0']); + const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime', PATH: `${childBin}${path.delimiter}${f.env.PATH}` }); + expect(docs.status, docs.stdout + docs.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', bun_path: path.join(childBin, 'bun') }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')).toBeNull(); + const shipBun = JSON.parse(fs.readFileSync(file.replace(/\.json$/, '.for/ship.json'), 'utf8')).bun_path; + expect(shipBun).not.toBe(path.join(childBin, 'bun')); + const check = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(check.status, check.stdout + check.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(file.replace(/\.json$/, '.for/ship.json'), 'utf8')).bun_path).toBe(shipBun); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship', bun_path: shipBun }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')).toBeNull(); + const failed = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime', STUB_DOCTOR_FAIL: '1' }); + expect(failed.status).not.toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', ok: false }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')?.detail).toContain('the install verdict says: gstack: fail'); + sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root); + expect(admits('ship')?.detail).toMatch(/^the install verdict says: gstack: fail/); + const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime' }); + expect(moved.status, moved.stdout + moved.stderr).toBe(0); + expect(admits('document-release')).toBeNull(); + expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone'); diff --git a/test/install-verdict.test.ts b/test/install-verdict.test.ts index 98aa8ed14..3c4bb0050 100644 --- a/test/install-verdict.test.ts +++ b/test/install-verdict.test.ts @@ -152,6 +152,30 @@ describe('install verdict keyed by (checkout, project): both twins', () => { expect(both('autoplan', proj, env).detail).toContain('unsupported schema_version'); }); + test("GST-1233: a later install for another workflow replaced the shared verdict; both twins admit from the workflow's own record, never from a stale one", () => { + const sr = tmp(); + const proj = project('truther'); + const shared = writeVerdict(sr, { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', + required: ['claude', 'runtime'], trailer: 'gstack: ok 9.9.9.9 for=document-release' }); + const own = shared.replace(/\.json$/, '.for/ship.json'); + const env = { GSTACK_STATE_ROOT: sr }; + const without = both('ship', proj, env); + expect(without).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED', detail: 'the install verdict certified for=document-release, which does not cover codex that ship needs' }); + writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', trailer: 'gstack: ok 9.9.9.9 for=ship' }, own); + expect(both('ship', proj, env)).toMatchObject({ refuse: false, status: 'ok' }); + expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toBe('INSTALL_VERDICT: ok gstack: ok 9.9.9.9 for=ship'); + const setupFailed = { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', ok: false, trailer: 'gstack: fail setup' }; + writeVerdict(sr, setupFailed); + expect(both('ship', proj, env)).toMatchObject({ refuse: true, status: 'verdict', detail: 'the install verdict says: gstack: fail setup' }); + writeVerdict(sr, { ...setupFailed, trailer: 'gstack: fail codex' }); + expect(both('ship', proj, env)).toMatchObject({ refuse: false, status: 'ok' }); + writeVerdict(sr, { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', required: ['claude', 'runtime'], trailer: 'gstack: ok 9.9.9.9 for=document-release' }); + writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', head: 'f'.repeat(40) }, own); + const stale = both('ship', proj, env); + expect(stale).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED', detail: without.detail }); + expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toBe(without.line); + }); + test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => { const sr = tmp(); const a = project('a'); From 26a4e591654bc2b17a1d4949ca387120ea842fe3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 16:03:30 -0800 Subject: [PATCH 04/21] v1.95.0.0 feat(ship-queue): land --pin merges a READY PR at its own green head (merge: direct, GSTA-258 L2 PR 1) (#3973) * feat(ship-queue): land --pin merges a READY PR at its own green head under merge: direct (GSTA-258 L2 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-policy): keep gstack's policy on train until the merge key's release exists; regenerate doctor components; floor test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): direct land fails closed without a merge base; stamping land refuses under merge: direct; Windows curation for the gh shim test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-queue): land --pin reads policy after fetching the base, fetches the pin before verifying, and tells cannot-verify apart from a mismatch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): land --pin STAMPED row names .gstack/version-path Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-queue): the merge-key floor test simulates its restamp on a tentative constant, so it holds after the stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor-components.sh | 5 + bin/gstack-ship-queue | 12 +- docs/captain.md | 27 ++ docs/ship-policy.md | 37 +++ docs/troubleshooting.md | 53 ++++ lib/result-codes-release.ts | 26 ++ lib/ship-policy.ts | 8 +- lib/ship-queue-land-direct.ts | 136 +++++++++ lib/ship-queue-land.ts | 1 + lib/ship-queue-train-record.ts | 8 +- package.json | 2 +- scripts/lib/windows-curation.ts | 1 + test/ship-queue-land-direct.test.ts | 419 ++++++++++++++++++++++++++++ 16 files changed, 738 insertions(+), 7 deletions(-) create mode 100644 lib/ship-queue-land-direct.ts create mode 100644 test/ship-queue-land-direct.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4aff92a..e911301a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.95.0.0] - 2026-11-03 + +### Added + +- **Merge each PR at its own green head (`merge: direct`).** `gstack-ship-queue land --pr --pin ` merges one READY PR with `--squash --match-head-commit`, with no train, no stamp commit and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the pin, every check there is green with none pending, GitHub reports the PR mergeable and clean, and the PR leaves VERSION, the `package.json` version and the CHANGELOG to the release. After the merge it checks that main's tree is the pin merged onto its parent and writes a land note. A new ship-policy key, `merge: train | direct`, turns it on, and `train build` refuses under `direct`. gstack switches its own policy to `direct` in a follow-up once this release exists, so `min_gstack_version` names a version that was actually published. Each refusal has its own code and fix in docs/troubleshooting.md, and docs/captain.md has the new runbook. + ## [1.94.44.0] - 2026-11-03 ### Fixed diff --git a/VERSION b/VERSION index d647e7a0c..8e95121d8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.44.0 +1.95.0.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 21012e84c..344e451e5 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.44.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.0.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 4ec7b9a61..ce33b2005 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -347,6 +347,11 @@ gstack_result_anchor() { QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;; QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;; QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;; + QUEUE_LAND_STAMPED) printf '%s' 'queue-land-stamped' ;; + QUEUE_LAND_PIN_MOVED) printf '%s' 'queue-land-pin-moved' ;; + QUEUE_LAND_CHECKS) printf '%s' 'queue-land-checks' ;; + QUEUE_LAND_NOT_CLEAN) printf '%s' 'queue-land-not-clean' ;; + QUEUE_LAND_UNVERIFIED) printf '%s' 'queue-land-unverified' ;; PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;; PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;; PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;; diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index 2b503b0fe..30c1c051c 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -14,6 +14,7 @@ * gstack-ship-queue gate-ahead --list --pr * gstack-ship-queue verify --pr --screen (train policy: the head's screen verdict from its run) * gstack-ship-queue verify --post-merge [--timeout ] (after a merge: main's push workflows at that commit until they settle) + * gstack-ship-queue land --pr --pin [--dry-run] (merge: direct; lib/ship-queue-land-direct.ts) * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts) * gstack-ship-queue land --pr --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR) * gstack-ship-queue land --census @@ -57,6 +58,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc verify --pr --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT) verify --post-merge [--timeout ] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending and one CHECK: line per workflow; resumable, default timeout 2700 s + land --pr --pin [--dry-run] merge: direct: merge the PR at its own green head (receipt and READY at the pin, no stamp + files, checks green, mergeable clean), verify the tree, write a land note land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock [--break-train-lock]] [--allow-repo-commands] the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit, wait for its checks, squash with --match-head-commit, confirm the base tree; resumable @@ -86,7 +89,7 @@ function finish(cmd: string, out: StepOutput): number { } process.stdout.write(formatResult('ship-queue', out.ok ? 'complete' : 'refused', out.run) + '\n'); if (out.ok) return EXIT.ok; - return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused; + return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY', 'QUEUE_LAND_UNVERIFIED'].includes(out.failure.code) ? EXIT.fail : EXIT.refused; } const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; @@ -163,6 +166,8 @@ async function main(argv: string[]): Promise { if (cmd === 'train') { const train = await import('../lib/ship-queue-train-cli'); if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; } + const loaded = args[0] === 'build' || args[0] === 'land' ? loadBasePolicy(ctx) : null; + if (loaded && !('message' in loaded) && loaded.policy.merge === 'direct') return finish(`train ${args[0]}`, { ok: false, lines: [], run: 'none', failure: { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, so trains are off', fix: 'land each READY PR with gstack-ship-queue land --pr --pin (docs/ship-policy.md#land-direct)' } }); return train.trainMain(ctx, args); } if (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args); @@ -171,6 +176,11 @@ async function main(argv: string[]): Promise { const l = await import('../lib/ship-queue-land'); if (args.includes('--census')) return finish('land', l.census(ctx)); if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr (or --census)'); + if (args.includes('--pin')) { + const pin = flag(args, '--pin') ?? ''; + if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names'); + return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, dryRun: args.includes('--dry-run') })); + } const timeout = flag(args, '--timeout'); return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); } diff --git a/docs/captain.md b/docs/captain.md index f7127194f..adf478e48 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -271,6 +271,33 @@ the same product diff; any other rebase is a full /ship ([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to rebase a reviewed branch. +## Direct land (`merge: direct`) + +When the base policy sets `"merge": "direct"`, there are no trains, slot calls or stamp +commits. Each PR merges at its own green head, and versions are set at release +([ship-policy.md](ship-policy.md#land-direct)): + +1. Lane: /ship, then `gstack-ship-queue ready --pr --no-screen` at the pinned head, + then READY to the Captain with that pin. The PR carries a `changelog.d` fragment and + leaves VERSION, the `package.json` version and CHANGELOG.md alone. +2. Captain: `gstack-ship-queue land --pr --pin `. It merges with + `--match-head-commit`, checks main's tree and writes a land note. +3. Release: the release gate stamps the version and folds the fragments. + +What to do with each refusal (nothing was merged unless the code says so): + +| Code | Who acts | Action | +|---|---|---| +| `QUEUE_LAND_STAMPED` | lane | run the printed `git checkout $(git merge-base origin/main HEAD) -- …`, keep the fragment, commit, /ship and `ready` again | +| `QUEUE_LAND_PIN_MOVED` | lane | /ship and `ready` at the new head, new READY | +| `QUEUE_RECEIPT_MISSING` / `QUEUE_RECEIPT_STALE` | lane | /ship at the pin | +| `QUEUE_NOT_READY` | lane, or Captain | no READY note: `ready --pr --no-screen` at the pin; `merge: train`: use the train; closed or retargeted PR: as named | +| `QUEUE_LAND_CHECKS` | Captain, then lane | pending: `gstack-pr-wait --until green` and rerun; red: back to the lane (rule 16 rerun for a flaky case); missing: dispatch the workflow on the branch | +| `QUEUE_LAND_NOT_CLEAN` | lane, or Captain | `CONFLICTING`: the lane merges main (rule 19), /ship, `ready`; `UNKNOWN`: rerun in a minute | +| `QUEUE_TRAIN_FROZEN` / `QUEUE_LAND_LOCKED` | Captain | finish the repair or the holder first | +| `QUEUE_TRAIN_UNTRUSTED` | Captain | a fork head; rewrite on a crew branch | +| `QUEUE_LAND_UNVERIFIED` | Captain | merged, but main isn't the pin on its parent: revert the stray change, report it | + ## Merge trains gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 966f8e6d8..f0683967c 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -63,6 +63,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | +| `merge` | `train` \| `direct` | `train` | `direct` lands each READY PR at its own green head with [`land --pr --pin `](#land-direct): no train and no stamp commit, and `train build` refuses. A PR then leaves VERSION, the `package.json` version and the CHANGELOG at its merge base, and the release sets them. Needs `changelog_fragments`, and the stamping `land --pr ` refuses under it. `train` keeps merge trains. Read from the base revision only. Needs `min_gstack_version`. | | `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. | | `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | @@ -1897,6 +1898,42 @@ command: `--dry-run` merges and stamps in a scratch worktree and prints the version and tree, taking no lock and pushing nothing. + +### `land --pin`: merge at the PR's own green head (`merge: direct`) + +Under `"merge": "direct"` in the base policy, the Captain merges one READY PR +at a time with `gstack-ship-queue land --pr --pin `, where `` +is the head the READY names. There's no train, no stamp commit, no base merge +and no extra CI round. GitHub's required checks plus `--match-head-commit` +stay the gate. Versions aren't set per PR: a PR carries only its +`changelog.d` fragment, VERSION stays where it is, and the release stamps the +version and folds the fragments. Land refuses, and merges nothing, unless all +of these hold: + +| Check | Refusal | +|---|---| +| the base policy sets `merge: direct` | `QUEUE_NOT_READY` | +| the PR is open, on the base, from this repository | `QUEUE_NOT_READY`, `QUEUE_TRAIN_UNTRUSTED` | +| the PR head is the pin | [`QUEUE_LAND_PIN_MOVED`](troubleshooting.md#queue-land-pin-moved) | +| the PR body's /ship receipt is bound to the pin | `QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE` | +| a READY note from `ready` exists at the pin | `QUEUE_NOT_READY` | +| no freeze is active and no land lock is held | `QUEUE_TRAIN_FROZEN`, `QUEUE_LAND_LOCKED` | +| VERSION, the `package.json` version, the CHANGELOG and `.gstack/version-path` are unchanged since the merge base | [`QUEUE_LAND_STAMPED`](troubleshooting.md#queue-land-stamped) | +| every check at the pin is green with none pending, and each `changelog_fragments.required_checks` entry passed there | [`QUEUE_LAND_CHECKS`](troubleshooting.md#queue-land-checks) | +| GitHub reports the PR `MERGEABLE` and `CLEAN` | [`QUEUE_LAND_NOT_CLEAN`](troubleshooting.md#queue-land-not-clean) | + +Release outputs (the agents digest) are checked by the Version Gate, which must +be green at the pin: it accepts an output only when it equals what the release +tool generates at the merge base's VERSION. If the base moved and GitHub still +reports the PR clean, land merges it as it is. Then it runs `gh pr merge +--squash --match-head-commit ` (`--merge` under `history: merge-only`), +reads the merge commit, and checks that its tree is the pin merged onto the +merge commit's parent ([`QUEUE_LAND_UNVERIFIED`](troubleshooting.md#queue-land-unverified) +if not). Last, it writes a `land` note on `refs/notes/gstack-trains`. A rerun +after GitHub merged goes straight to the tree check. `--dry-run` runs every +check and merges nothing. Under `merge: direct`, `train build` refuses and +names this command. + ### Migrating to fragments diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ceedfe854..382d7daf3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5073,6 +5073,59 @@ the two allowed restamps, so `land` stopped instead of restamping again. it), then rerun `gstack-ship-queue land --pr `; the lock was released and the rerun restamps from the pin. + +### `QUEUE_LAND_STAMPED` + +**Meaning.** Under `merge: direct`, the PR changes VERSION, the +`package.json` version or the CHANGELOG. Versions and the CHANGELOG are set at +release, so a PR carries only its `changelog.d` fragment. Nothing was merged. + +**Fix.** The lane runs the command the refusal prints, for example +`git checkout $(git merge-base origin/main HEAD) -- VERSION CHANGELOG.md` +(and sets the `package.json` version back by hand when it's named), keeps or +adds the fragment with the entry text, commits, then runs /ship and +`gstack-ship-queue ready --pr --no-screen` at the new head. + + +### `QUEUE_LAND_PIN_MOVED` + +**Meaning.** The PR head isn't the pin `land --pin` was given, so the READY +doesn't describe what would merge. Nothing was merged. + +**Fix.** The lane runs /ship and `ready --pr --no-screen` at the new head +and sends a new READY; land that pin. + + +### `QUEUE_LAND_CHECKS` + +**Meaning.** A check at the pin is red or pending, or a required check never +ran there. Nothing was merged. + +**Fix.** Pending: `gstack-pr-wait --until green`, then rerun land. Red: +the lane fixes it at a new head (a flaky case gets its one rerun under rule +16). Missing: dispatch that workflow on the branch, then rerun land. + + +### `QUEUE_LAND_NOT_CLEAN` + +**Meaning.** GitHub doesn't report the PR `MERGEABLE` and `CLEAN`. Nothing +was merged. + +**Fix.** `CONFLICTING`: the lane merges the base into its branch, resolves, +runs /ship and `ready` at the new head. `UNKNOWN`: GitHub is still +computing, so rerun land in a minute. `BLOCKED` or `BEHIND`: meet the branch +rule it names. + + +### `QUEUE_LAND_UNVERIFIED` + +**Meaning.** GitHub merged the PR, but the base branch's merge commit tree +isn't the pin merged onto that commit's parent, so something besides the +reviewed pin reached main. No land note was written. + +**Fix.** Compare the merge commit with `git merge-tree --write-tree +`, revert what doesn't belong to the PR, and report it to the Captain. + ### `QUEUE_TRAIN_STAMP_MISMATCH` diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 6c209131a..065645bc9 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -472,6 +472,32 @@ export const RELEASE_RESULT_CODES = { summary: 'the base branch moved during the stamp commit\'s CI on each of the allowed restamp attempts, so land stopped instead of restamping again', fix: 'find what is merging outside land (every merge should go through it), then rerun gstack-ship-queue land --pr ; it resumes from its journal', }, + // gstack-ship-queue land --pin (lib/ship-queue-land-direct.ts): merge: direct refusals and its post-merge check. + QUEUE_LAND_STAMPED: { + anchor: 'queue-land-stamped', + summary: 'under merge: direct the PR changes VERSION, the package.json version or the CHANGELOG; versions and the CHANGELOG are set at release, so a PR carries only its changelog fragment', + fix: 'put the named files back to the merge base (git checkout $(git merge-base origin/main HEAD) -- ), keep or add the changelog.d fragment, commit, then /ship and ready at the new head', + }, + QUEUE_LAND_PIN_MOVED: { + anchor: 'queue-land-pin-moved', + summary: 'the PR head is not the pin land was given, so the READY it was given does not describe what would merge', + fix: 'the lane runs /ship and gstack-ship-queue ready --pr --no-screen at the new head and reports READY with it; land that pin', + }, + QUEUE_LAND_CHECKS: { + anchor: 'queue-land-checks', + summary: 'a check at the pin is red or pending, or a required check never ran there', + fix: 'wait for pending checks (gstack-pr-wait --until green), fix a red one at a new head, or dispatch a missing required workflow on the branch; then rerun land', + }, + QUEUE_LAND_NOT_CLEAN: { + anchor: 'queue-land-not-clean', + summary: 'GitHub does not report the PR mergeable and clean (a conflict, a branch rule, or a state GitHub is still computing)', + fix: 'CONFLICTING: the lane merges the base into the branch, resolves, runs /ship and ready at the new head; UNKNOWN: rerun land in a minute; BLOCKED or BEHIND: meet the branch rule it names', + }, + QUEUE_LAND_UNVERIFIED: { + anchor: 'queue-land-unverified', + summary: 'after the merge, the base branch tree is not the pin merged onto the merge commit\'s parent, so something besides the reviewed pin reached main', + fix: 'compare the merge commit with the pin merged onto its parent, revert what does not belong to the PR, and report it to the Captain', + }, // gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes. PR_WAIT_BLOCKED: { anchor: 'pr-wait-blocked', diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 2901db257..d6ebc4d14 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -86,6 +86,7 @@ export interface ShipPolicy { docs: { caps: Array<{ path: string; max_bytes: number }> } | null; /** How /ship reproduces a Step 5 failure on the base branch before calling it base-red (docs/ship-policy.md#base-red); null never calls one base-red. */ base_red: BaseRedPolicy | null; + /** `direct` lands each READY PR at its own green head (`land --pin`, docs/ship-policy.md#land-direct); `train` keeps merge trains. */ merge: 'direct' | 'train'; } /** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */ @@ -176,7 +177,7 @@ export const DEFAULT_POLICY: ShipPolicy = { min_gstack_version: null, train: null, docs: null, - base_red: null, + base_red: null, merge: 'train', }; export type PolicyKey = Exclude; @@ -210,6 +211,7 @@ export const POLICY_EXPLANATIONS: Record = { train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? } (the gstack-evals gate) or { gates, land, max_size, fill_hours } (any repo: gates lists { kind: workflows, workflows, required_jobs, record?, post_merge_checks? } and { kind: command, command, timeout_sec, spend_cap_usd, pass_env? }, each GREEN on every prefix; land is { merge_method: squash|merge|rebase, required_checks? }) or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|land|watch|init|doctor|prune, ready --screen|--no-screen, verify --train). gates and land need min_gstack_version at the release that reads them (validate names it). null keeps today\'s per-PR queue unchanged.', docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.', base_red: '{ setup, select, timeout_sec } or null: how /ship Step 5 proves a failure is the base branch\'s own (docs/ship-policy.md#base-red). gstack-base-red checks out the base head and the PR head in scratch worktrees, runs setup (dependency install, or null), then select with the failing test files in its {files} slot; only the same failures in both, in files the PR does not touch, are base-red, and /ship then pauses before READY instead of failing. Read from the base branch only. null keeps every failure in-branch.', + merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr --pin ` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).', min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).', }; @@ -249,6 +251,7 @@ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0'; export const REVIEW_POLICY_KEY_VERSION = '1.94.30.0'; +export const MERGE_KEY_VERSION = '1.95.0.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', @@ -257,7 +260,7 @@ export const POLICY_KEY_INTRODUCED: Record = { train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, - stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, + stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -481,6 +484,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']); oneOf('history', ['merge-only', 'squash-ok']); oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']); + oneOf('merge', ['direct', 'train']); if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors); if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError)); if ('release_tool' in obj && obj.release_tool !== null) { diff --git a/lib/ship-queue-land-direct.ts b/lib/ship-queue-land-direct.ts new file mode 100644 index 000000000..dcc08dc98 --- /dev/null +++ b/lib/ship-queue-land-direct.ts @@ -0,0 +1,136 @@ +/** + * ship-queue-land-direct — B1's merge step, behind `gstack-ship-queue land --pr --pin ` under the base + * policy's merge: direct (docs/ship-policy.md#land-direct). The PR merges at its own green head: no train, no stamp + * commit, no base merge and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the + * pin, the pin is the PR's head, the diff leaves VERSION, the manifest version and the CHANGELOG at its merge base + * (versions are set at release), every check at the pin is green with none pending and each required check present, + * and GitHub reports the PR mergeable and clean. Then gh pr merge --squash --match-head-commit , a check that the + * merge commit's tree is the pin merged onto that commit's parent, and a land note on refs/notes/gstack-trains. A rerun + * after GitHub merged goes straight to the tree check. + */ +import { versionAtRef } from './restamp'; +import type { ShipPolicy } from './ship-policy'; +import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; +import { git, type QueueFailure } from './ship-queue-gate'; +import { lockLabel, readLock } from './ship-queue-land-lock'; +import { activeFreeze, loadReady, persist, rebuildFromNotes, repoIdentity } from './ship-queue-train-record'; +import { bindShippedReceipt } from './ship-receipt-depth'; + +interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null } +export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; now?: () => Date } + +const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? ''); + +function view(ctx: QueueContext, pr: string): DirectPr | QueueFailure { + const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,mergeable,mergeStateStatus,mergeCommit']); + try { return JSON.parse(r.out) as DirectPr; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; } +} + +/** Release files the pin changed since its merge base: the version file, the manifest version and the CHANGELOG; an unreadable version is an error, never a pass. */ +export function stampedFiles(root: string, policy: ShipPolicy, mergeBase: string, pin: string): string[] | { error: string } { + const out: string[] = []; + const [was, now] = [versionAtRef(root, mergeBase), versionAtRef(root, pin)]; + if ('error' in was || 'error' in now) return { error: 'error' in was ? was.error : (now as { error: string }).error }; + if (was.version !== now.version) out.push(now.rel); + const npm = (rev: string) => { const r = git(root, ['show', `${rev}:package.json`]); try { return r.ok ? JSON.parse(r.out).version ?? null : null; } catch { return null; } }; + if (!('rel' in now && now.rel === 'package.json') && npm(mergeBase) !== npm(pin)) out.push('package.json version'); + if (policy.changelog && git(root, ['diff', '--quiet', mergeBase, pin, '--', policy.changelog]).ok === false) out.push(policy.changelog); + if (!git(root, ['diff', '--quiet', mergeBase, pin, '--', '.gstack/version-path']).ok) out.push('.gstack/version-path'); + return out; +} + +/** The pin's commit is local, fetched from its branch or GitHub's refs/pull//head (which outlives a deleted branch). */ +function havePin(root: string, pr: string, pin: string, branch?: string): boolean { + const have = () => git(root, ['cat-file', '-e', `${pin}^{commit}`]).ok; + if (have()) return true; + if (branch) git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]); + if (!have()) git(root, ['fetch', '-q', 'origin', `refs/pull/${pr}/head`]); + return have(); +} + +/** The merge commit's tree is the pin merged onto the merge commit's first parent: GitHub merged exactly the pin. A read that fails is "cannot verify", never a mismatch. */ +function verifyMerge(ctx: QueueContext, root: string, pr: string, pin: string, merge: string, lines: string[], run: string, now: Date): StepOutput { + const cannot = (why: string): StepOutput => ({ ok: false, run, lines, failure: { code: 'QUEUE_NOT_READY', message: `#${pr} merged as ${merge.slice(0, 12)}, but land cannot verify it: ${why}`, fix: `fix the read named, then rerun gstack-ship-queue land --pr ${pr} --pin ${pin}; it goes straight to the tree check` } }); + if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return cannot(`git fetch origin ${ctx.base} failed`); + if (!havePin(root, pr, pin)) return cannot(`the pin ${pin.slice(0, 12)} is not in this clone and refs/pull/${pr}/head did not fetch it`); + if (!git(root, ['merge-base', '--is-ancestor', merge, `origin/${ctx.base}`]).ok) return { ok: false, run, lines, failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `the merge commit ${merge.slice(0, 12)} is not on origin/${ctx.base}` } }; + const parent = git(root, ['rev-parse', `${merge}^1`]).out; + const tree = git(root, ['rev-parse', `${merge}^{tree}`]).out; + const mt = git(root, ['merge-tree', '--write-tree', parent, pin]); + const expected = mt.out.split('\n')[0] ?? ''; + if (!isSha(expected)) return cannot(`git merge-tree ${parent.slice(0, 12)} ${pin.slice(0, 12)} failed: ${mt.err.split('\n')[0]}`); + if (!mt.ok || tree !== expected) return { ok: false, run, lines: [...lines, `MAIN: ${merge} tree=${tree} parent=${parent} expected=${expected || 'conflict'}`], failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `origin/${ctx.base} ${merge.slice(0, 12)} is not the pin ${pin.slice(0, 12)} merged onto ${parent.slice(0, 12)}`, fix: `compare git diff ${expected || parent} ${merge} and revert what does not belong to #${pr}; report it to the Captain` } }; + const keys = repoIdentity(root); + const at = now.toISOString(); + const note = persist(root, [{ schema: 1, kind: 'land', repo_key: keys.key, pr, pin, base_sha: parent, merge_sha: merge, tree, landed_at: at, updated_at: at }], { remote: ctx.remote }); + return { ok: true, run, lines: [...lines, `MERGED: #${pr} as ${merge}`, `LANDED: origin/${ctx.base} tree ${tree} equals the pin merged onto ${parent}`, `NOTE: land #${pr}@${pin.slice(0, 12)} (${note.detail})`] }; +} + +/** `gstack-ship-queue land --pr --pin `: one pass; a refusal merges nothing. */ +export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput { + const root = ctx.repoRoot; + const run = 'none'; + const now = o.now ?? (() => new Date()); + const fail = (lines: string[], failure: QueueFailure): StepOutput => ({ ok: false, lines, failure, run }); + if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return fail([], { code: 'QUEUE_NOT_READY', message: `cannot fetch origin/${ctx.base}; land reads the policy and the required checks from the current base`, fix: 'fix the fetch, then rerun land' }); + const loaded = loadBasePolicy(ctx); + if ('message' in loaded) return fail([], loaded); + const policy = loaded.policy; + if (policy.merge === 'direct' && !policy.changelog_fragments) return fail([], { code: 'QUEUE_NOT_READY', message: 'merge: direct needs changelog_fragments: a PR carries its entry as a fragment, and ready records READY notes only under that key', fix: 'add "changelog_fragments": { "dir": "changelog.d" } to .gstack/ship-policy.json on the base' }); + if (policy.merge !== 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: `the base policy sets merge: ${policy.merge}; land --pin merges only under merge: direct`, fix: 'land through the train (gstack-ship-queue train build), or set "merge": "direct" in .gstack/ship-policy.json on the base' }); + const info = view(ctx, o.pr); + if ('code' in info) return fail([], info); + const lines = [`LAND: #${o.pr} pin=${o.pin} direct`]; + if (info.isCrossRepository) return fail(lines, { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land merges only crew branches` }); + if (info.baseRefName && info.baseRefName !== ctx.base) return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}`, fix: `retarget it to ${ctx.base}, or land its parent first` }); + if (info.state === 'MERGED' && info.headRefOid === o.pin && isSha(info.mergeCommit?.oid)) { + lines.push(`#${o.pr} is already merged as ${info.mergeCommit!.oid.slice(0, 12)}; checking the tree`); + return verifyMerge(ctx, root, o.pr, o.pin, info.mergeCommit!.oid, lines, run, now()); + } + if (info.state !== 'OPEN') return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }); + if (info.headRefOid !== o.pin) return fail(lines, { code: 'QUEUE_LAND_PIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs /ship and gstack-ship-queue ready --pr ${o.pr} at the new head and reports READY with it` }); + + if (!havePin(root, o.pr, o.pin, info.headRefName)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot fetch the pin ${o.pin.slice(0, 12)} from ${info.headRefName} or refs/pull/${o.pr}/head`, fix: 'fix the fetch, then rerun land' }); + const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [o.pin]); + lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : [])); + if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: `run /ship at the pin, then gstack-ship-queue ready --pr ${o.pr}` }); + const keys = repoIdentity(root).keys; + rebuildFromNotes(root, ctx.remote ?? 'origin'); + if (!loadReady(keys, o.pr, o.pin)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY note for #${o.pr} at the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs gstack-ship-queue ready --pr ${o.pr} --no-screen at the pin` }); + const freeze = activeFreeze(keys); + if (freeze) return fail(lines, { code: 'QUEUE_TRAIN_FROZEN', message: `main is frozen by train ${freeze.train} (${freeze.reason})` }); + const lock = readLock(root, ctx.remote ?? 'origin'); + if (lock.state === 'held') return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `${lockLabel(lock.lock)} holds the land lock; a direct land would race it onto main` }); + + const mergeBase = git(root, ['merge-base', `origin/${ctx.base}`, o.pin]).out; + if (!isSha(mergeBase)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot compute the merge base of the pin ${o.pin.slice(0, 12)} and origin/${ctx.base}, so the release-file check cannot run`, fix: `git fetch origin ${ctx.base} and the PR head (an unshallow clone), then rerun land` }); + const stamped = stampedFiles(root, policy, mergeBase, o.pin); + if ('error' in stamped) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot read the version at the pin or its merge base: ${stamped.error}` }); + if (stamped.length) { + const files = stamped.filter(f => f !== 'package.json version'); + const restore = files.length ? `git checkout $(git merge-base origin/${ctx.base} HEAD) -- ${files.join(' ')}` : ''; + const npm = stamped.includes('package.json version') ? 'set package.json "version" back to its merge base value' : ''; + return fail(lines, { code: 'QUEUE_LAND_STAMPED', message: `#${o.pr} changes ${stamped.join(', ')}; under merge: direct the release sets versions and the CHANGELOG, a PR carries only its changelog fragment`, fix: `${[restore, npm].filter(Boolean).join(' and ')}, keep (or add) the changelog.d fragment, commit, then /ship and ready at the new head` }); + } + + const ci = ciAtHead(ctx, o.pin); + lines.push(`CI: ${ci.line}`); + if (!ci.green) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `checks are ${ci.line}`, fix: `wait for pending checks (gstack-pr-wait ${o.pr} --until green), or fix the red one at a new head; then rerun land` }); + const passed = new Set([...ci.checks.filter(c => c.conclusion === 'success').map(c => c.name), ...ci.contexts.filter(c => c.state === 'success').map(c => c.name)]); + const missing = (policy.changelog_fragments?.required_checks ?? []).filter(n => !passed.has(n)); + if (missing.length) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `required check(s) ${missing.join(', ')} did not pass at the pin ${o.pin.slice(0, 12)}`, fix: `dispatch them on the branch (gh workflow run --ref ${info.headRefName}) and rerun land once they pass` }); + if (info.mergeable !== 'MERGEABLE' || info.mergeStateStatus !== 'CLEAN') return fail(lines, { code: 'QUEUE_LAND_NOT_CLEAN', message: `GitHub reports #${o.pr} mergeable=${info.mergeable ?? 'unknown'} mergeStateStatus=${info.mergeStateStatus ?? 'unknown'}`, fix: info.mergeable === 'CONFLICTING' ? `the lane merges origin/${ctx.base} into the branch, resolves, runs /ship and ready at the new head` : 'UNKNOWN means GitHub is still computing: rerun land in a minute; BLOCKED or BEHIND names the branch rule to meet first' }); + if (o.dryRun) return { ok: true, run, lines: [...lines, `DRY-RUN: nothing merged (would run gh pr merge ${o.pr} --squash --match-head-commit ${o.pin})`] }; + + const method = policy.history === 'merge-only' ? '--merge' : '--squash'; + const m = ctx.gh(['pr', 'merge', o.pr, method, '--match-head-commit', o.pin]); + let merge: string | undefined; + for (let i = 0; i < (m.ok ? 4 : 1) && !isSha(merge); i++) { + if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3_000); + const after = view(ctx, o.pr); + merge = 'code' in after || after.state !== 'MERGED' ? undefined : after.mergeCommit?.oid; + } + if (!isSha(merge) && m.ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge accepted #${o.pr}, but GitHub does not show it merged yet; nothing is verified`, fix: `rerun gstack-ship-queue land --pr ${o.pr} --pin ${o.pin} in a minute; once GitHub shows the merge it goes straight to the tree check` }); + if (!isSha(merge)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200) || 'the PR is not merged'}`, fix: `read the refusal; rerun land --pr ${o.pr} --pin ${o.pin} once it is fixed` }); + return verifyMerge(ctx, root, o.pr, o.pin, merge!, lines, run, now()); +} diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 38ef98ca9..a564072ee 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -325,6 +325,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { if ('message' in loaded) return fail([], loaded, run); const policy = loaded.policy; if (o.repair) return repair(ctx, policy, readJson(file), file, run); + if (policy.merge === 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, where land never stamps', fix: `gstack-ship-queue land --pr ${o.pr} --pin ` }, run); const info = prInfo(ctx, o.pr); if ('code' in info) return fail([], info, run); if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run); diff --git a/lib/ship-queue-train-record.ts b/lib/ship-queue-train-record.ts index 5d8f7df97..7ba2cf9b4 100644 --- a/lib/ship-queue-train-record.ts +++ b/lib/ship-queue-train-record.ts @@ -117,7 +117,9 @@ export interface ReadyReceipt { schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string; base_red?: BaseRedCondition; fixes_base_red?: string[]; } -export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord; +/** What `land --pin` merged (docs/ship-policy.md#land-direct): the pin, the base it landed on and the verified merge commit. */ +export interface LandNote { schema: 1; kind: 'land'; repo_key: string; pr: string; pin: string; base_sha: string; merge_sha: string; tree: string; landed_at: string; updated_at: string } +export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord | LandNote; /** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */ export function nowIso(): string { @@ -141,6 +143,7 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json'); const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`); export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`); export const wavePath = (nonce: string) => path.join(queueRoot(), 'waves', `${nonce}.json`); +export const landPath = (repoKey: string, pr: string, pin: string) => path.join(queueRoot(), 'landed', repoKey, `${pr}-${pin}.json`); /** Hosts that serve GitHub repositories at // (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */ const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai']; @@ -226,6 +229,7 @@ export function recordPath(r: AnyRecord): string { if (r.kind === 'train-bisect') return bisectPath(r.train); if (r.kind === 'freeze') return freezePath(r.repo_key); if (r.kind === 'wave') return wavePath(r.nonce); + if (r.kind === 'land') return landPath(r.repo_key, r.pr, r.pin); return readyPath(r.repo_key, r.pr, r.head); } @@ -235,6 +239,7 @@ export function noteName(r: AnyRecord): string { if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`; if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`; if (r.kind === 'wave') return `gstack-trains:wave:${r.nonce}`; + if (r.kind === 'land') return `gstack-trains:land:${r.repo_key}:${r.pr}:${r.pin}`; return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`; } @@ -299,6 +304,7 @@ function parseNote(text: string): AnyRecord | null { if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r; if (r.kind === 'freeze' && ids.test(r.repo_key)) return r; if (r.kind === 'wave' && ids.test(r.nonce) && ids.test(r.repo_key) && Array.isArray(r.constituents)) return r; + if (r.kind === 'land' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.pin)) return r; if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r; } catch {} return null; diff --git a/package.json b/package.json index db6a64a8f..02a02701f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.44", + "version": "1.95.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 569a09462..dc82a023c 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -51,6 +51,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ // when possible; this list is for environment-/runtime-specific tests where // the failure mode is structural rather than detectable via source-file scan. export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [ + { file: 'test/ship-queue-land-direct.test.ts', reason: 'the same extensionless node gh shim as test/ship-queue-land.test.ts; windows-latest launches the runner\'s gh.exe instead (no GH_TOKEN), so every land case fails (windows-scoped-shard, run 41720760327); land is a controller command run on Linux and macOS' }, { file: 'test/ship-queue-land.test.ts', reason: 'its gh shim is an extensionless #!/usr/bin/env node script on a colon-joined PATH, which windows-latest cannot launch, so land reaches the real gh (no GH_TOKEN) and every case fails (windows-free-shard 6, run 40824965988); land is a controller command run on Linux and macOS' }, // Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI. { file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' }, diff --git a/test/ship-queue-land-direct.test.ts b/test/ship-queue-land-direct.test.ts new file mode 100644 index 000000000..0657f0717 --- /dev/null +++ b/test/ship-queue-land-direct.test.ts @@ -0,0 +1,419 @@ +/** + * gstack-ship-queue land --pr --pin (docs/ship-policy.md#land-direct): B1's direct land over real fixture + * repos (bare origin, lane clone) and a gh shim that answers pr view (with mergeable, mergeStateStatus and the merge + * commit), check runs and a squash merge against the bare origin. Covers the happy path for an unstamped, + * fragment-only head, a base that moved but still merges clean, a refusal for each condition, the post-merge tree + * check, the land note, train build under merge: direct, and the policy key's validation. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { MERGE_KEY_VERSION, validatePolicy } from '../lib/ship-policy'; +import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; +import { shippedBody } from './helpers/ship-queue-train-fixture'; +import { persist, repoIdentity } from '../lib/ship-queue-train-record'; +import { rewriteStamps, tentativeMentions } from '../lib/restamp'; +import { bumpVersion } from '../lib/version-gate-train'; + +const REQUIRED = ['free-tests', 'Check VERSION is not stale vs queue']; +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d', required_checks: REQUIRED }, merge: 'direct', min_gstack_version: MERGE_KEY_VERSION }; +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +interface Fx { repo: FixtureRepo; env: Record; write: (fn: (d: any) => void) => void; read: () => any } +function setup(policy: Record = POLICY): Fx { + const repo = makeFixtureRepo({ policy, prefix: 'gstack-land-direct-fx-' }); + repos.push(repo); + const dir = path.join(repo.root, 'gh-shim'); + fs.mkdirSync(dir, { recursive: true }); + const data = path.join(dir, 'data.json'); + fs.writeFileSync(data, JSON.stringify({ prs: {}, checks: {}, defaultChecks: REQUIRED })); + fs.writeFileSync(path.join(dir, 'gh'), [ + '#!/usr/bin/env node', + "const fs=require('fs');const {execFileSync}=require('child_process');", + 'const D=process.env.LAND_SHIM_DATA, O=process.env.LAND_SHIM_ORIGIN;', + "const d=JSON.parse(fs.readFileSync(D,'utf8'));const a=process.argv.slice(2);", + "const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8',timeout:30000}).trim();", + "const save=()=>fs.writeFileSync(D,JSON.stringify(d));", + "if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}", + " let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};", + " console.log(JSON.stringify({mergeable:'MERGEABLE',mergeStateStatus:'CLEAN',...p,headRefOid:p.headRefOid??head,mergeCommit:p.mergeSha?{oid:p.mergeSha}:null}));process.exit(0)}", + "if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);", + " if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}", + " const set=d.checks[m[1]]??Object.fromEntries(d.defaultChecks.map(n=>[n,'success']));", + " const runs=Object.entries(set).map(([name,c],i)=>({id:i+1,name,status:c==='pending'?'in_progress':'completed',conclusion:c==='pending'?null:c,completed_at:'2026-11-03T00:00:00Z',app:{id:1}}));", + " console.log(JSON.stringify({total_count:runs.length,check_runs:runs}));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);", + " if(head!==want){console.error('Head branch was modified');process.exit(1)}", + " const main=g('rev-parse','refs/heads/main');let tree=g('merge-tree','--write-tree',main,want).split('\\n')[0];", + " if(d.strayOnMerge){tree=g('rev-parse',main+'^{tree}')}", + " const sq=g('commit-tree',tree,'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);", + " d.mergeArgs=a.slice(2);p.state='MERGED';p.mergeSha=sq;save();process.exit(0)}", + "console.error('gh shim: unsupported '+a.join(' '));process.exit(1);", + '', + ].join('\n'), { mode: 0o755 }); + const env = { PATH: dir + ':' + (process.env.PATH ?? ''), LAND_SHIM_DATA: data, LAND_SHIM_ORIGIN: repo.origin }; + const read = () => JSON.parse(fs.readFileSync(data, 'utf8')); + return { repo, env, read, write: fn => { const d = read(); fn(d); fs.writeFileSync(data, JSON.stringify(d)); } }; +} + +/** The READY receipt gstack-ship-queue ready records (a note on origin refs/notes/gstack-trains), written from another state root. */ +function markReady(fx: Fx, pr: string, head: string): void { + const prior = process.env.GSTACK_STATE_ROOT; + process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state'); + try { + const now = new Date().toISOString(); + persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr, head, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }]); + } finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; } +} + +/** A PR branch from origin/main with one product file and a fragment, pushed, shipped (receipt at its head) and READY. */ +function openPr(fx: Fx, pr: string, opts: { edit?: (work: string) => void; shipped?: boolean; ready?: boolean; fork?: boolean } = {}): string { + const { repo } = fx; + repo.git('fetch', '-q', 'origin'); + repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr); + fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n'); + fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'pr-' + pr + '.md'), '- #' + pr + ' change\n'); + opts.edit?.(repo.work); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'pr ' + pr); + repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr); + const head = repo.git('rev-parse', 'HEAD'); + fx.write(d => { d.prs[pr] = { state: 'OPEN', baseRefName: 'main', headRefName: 'pr-' + pr, isCrossRepository: !!opts.fork, title: 'PR ' + pr, body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); + if (opts.ready !== false) markReady(fx, pr, head); + repo.git('checkout', '-q', '--detach', 'origin/main'); + return head; +} +const land = (fx: Fx, pr: string, pin: string, extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', pr, '--pin', pin, '--base', 'main', ...extra], fx.env); +const remote = (fx: Fx, ref: string) => { fx.repo.git('fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); return fx.repo.git('rev-parse', 'origin/' + ref); }; +const show = (fx: Fx, rev: string) => fx.repo.git('show', rev); +const landNotes = (fx: Fx) => { + const notes = (...a: string[]) => fx.repo.git('--git-dir', fx.repo.origin, 'notes', '--ref=refs/notes/gstack-trains', ...a); + let list = ''; + try { list = notes('list'); } catch { return []; } + return list.split('\n').filter(Boolean).map(l => JSON.parse(notes('show', l.split(' ')[1]!))).filter(r => r.kind === 'land'); +}; +/** Whether a clone holds a commit. */ +const spawnCheck = (dir: string, sha: string) => spawnSync('git', ['-C', dir, 'cat-file', '-e', `${sha}^{commit}`], { timeout: 30_000 }).status === 0; +/** Commit a file straight onto origin/main (another PR that landed first). */ +function moveMain(fx: Fx, file: string, text: string): string { + const { repo } = fx; + repo.git('fetch', '-q', 'origin'); + repo.git('checkout', '-q', '--detach', 'origin/main'); + fs.writeFileSync(path.join(repo.work, file), text); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'other change on main'); + repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + return repo.git('rev-parse', 'HEAD'); +} + +describe('land --pin: direct merge at the PR\'s own green head', () => { + test('happy path: an unstamped, fragment-only head merges with --squash --match-head-commit, main keeps its VERSION, and a land note is written', () => { + const fx = setup(); + const base = remote(fx, 'main'); + const pin = openPr(fx, '7'); + const r = land(fx, '7', pin); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin]); + const main = remote(fx, 'main'); + expect(fx.repo.git('rev-parse', main + '^1')).toBe(base); + expect(r.out).toContain('MERGED: #7 as ' + main); + expect(r.out).toMatch(/^LANDED: origin\/main tree [0-9a-f]+ equals the pin merged onto [0-9a-f]+$/m); + expect(show(fx, main + ':VERSION')).toBe(show(fx, base + ':VERSION')); + expect(show(fx, main + ':changelog.d/pr-7.md')).toBe('- #7 change'); + const notes = landNotes(fx); + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ kind: 'land', pr: '7', pin, merge_sha: main, base_sha: base }); + }, 90_000); + + test('the base moved and the PR still merges clean: it lands as it is, on the new base, with no restamp or extra CI round', () => { + const fx = setup(); + const pin = openPr(fx, '8'); + const moved = moveMain(fx, 'other.txt', 'other\n'); + const r = land(fx, '8', pin); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + const main = remote(fx, 'main'); + expect(fx.repo.git('rev-parse', main + '^1')).toBe(moved); + expect(show(fx, main + ':other.txt')).toBe('other'); + expect(show(fx, main + ':pr-8.txt')).toBe('8'); + expect(fx.repo.git('rev-parse', 'origin/pr-8')).toBe(pin); + }, 90_000); + + test('a rerun after GitHub merged the PR goes straight to the tree check and lands', () => { + const fx = setup(); + const pin = openPr(fx, '9'); + expect(land(fx, '9', pin).status).toBe(EXIT.ok); + const again = land(fx, '9', pin); + expect(again.err).toBe(''); + expect(again.status).toBe(EXIT.ok); + expect(again.out).toContain('already merged'); + expect(again.out).toMatch(/^LANDED: /m); + }, 90_000); + + test('--dry-run checks everything and merges nothing', () => { + const fx = setup(); + const base = remote(fx, 'main'); + const pin = openPr(fx, '10'); + const r = land(fx, '10', pin, ['--dry-run']); + expect(r.err).toBe(''); + expect(r.out).toContain('DRY-RUN: nothing merged'); + expect(remote(fx, 'main')).toBe(base); + }, 90_000); +}); + +describe('land --pin: refusals (nothing merged)', () => { + const refused = (fx: Fx, r: { status: number | null; err: string }, code: string) => { + expect(r.status).not.toBe(EXIT.ok); + expect(r.err).toContain('(' + code + ')'); + expect(fx.read().mergeArgs).toBeUndefined(); + }; + + test('QUEUE_LAND_STAMPED: a head that bumps VERSION names the one-command fix', () => { + const fx = setup(); + const pin = openPr(fx, '11', { edit: w => fs.writeFileSync(path.join(w, 'VERSION'), '1.2.4.0\n') }); + const r = land(fx, '11', pin); + refused(fx, r, 'QUEUE_LAND_STAMPED'); + expect(r.err).toContain('git checkout $(git merge-base origin/main HEAD) -- VERSION'); + }, 90_000); + + test('QUEUE_LAND_STAMPED: a head that edits the CHANGELOG or the package.json version', () => { + const fx = setup(); + const pin = openPr(fx, '12', { + edit: w => { + fs.appendFileSync(path.join(w, 'CHANGELOG.md'), '\n- stray\n'); + const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.version = '9.9.9'; + fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + }, + }); + const r = land(fx, '12', pin); + refused(fx, r, 'QUEUE_LAND_STAMPED'); + expect(r.err).toContain('CHANGELOG.md'); + expect(r.err).toContain('package.json version'); + }, 90_000); + + test('a package.json dependency edit is not a stamp', () => { + const fx = setup(); + const pin = openPr(fx, '13', { + edit: w => { const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.dependencies = { ...(pkg.dependencies ?? {}), 'left-pad': '1.3.0' }; fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); }, + }); + const r = land(fx, '13', pin); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + }, 90_000); + + test('QUEUE_LAND_PIN_MOVED: the PR head is not the pin', () => { + const fx = setup(); + const pin = openPr(fx, '14'); + fx.write(d => { d.prs['14'].headRefOid = 'a'.repeat(40); }); + refused(fx, land(fx, '14', pin), 'QUEUE_LAND_PIN_MOVED'); + }, 90_000); + + test('QUEUE_RECEIPT_MISSING: no /ship receipt in the PR body', () => { + const fx = setup(); + const pin = openPr(fx, '15', { shipped: false }); + refused(fx, land(fx, '15', pin), 'QUEUE_RECEIPT_MISSING'); + }, 90_000); + + test('QUEUE_NOT_READY: no READY note at the pin', () => { + const fx = setup(); + const pin = openPr(fx, '16', { ready: false }); + const r = land(fx, '16', pin); + refused(fx, r, 'QUEUE_NOT_READY'); + expect(r.err).toContain('gstack-ship-queue ready --pr 16'); + }, 90_000); + + test('QUEUE_LAND_CHECKS: a red check, a pending check, and a required check GitHub never scheduled', () => { + const fx = setup(); + const pin = openPr(fx, '17'); + fx.write(d => { d.checks[pin] = { 'free-tests': 'success', 'Check VERSION is not stale vs queue': 'success', other: 'failure' }; }); + expect(land(fx, '17', pin).err).toMatch(/red at .*\(QUEUE_LAND_CHECKS\)/); + fx.write(d => { d.checks[pin] = { 'free-tests': 'pending', 'Check VERSION is not stale vs queue': 'success' }; }); + expect(land(fx, '17', pin).err).toMatch(/pending at .*\(QUEUE_LAND_CHECKS\)/); + fx.write(d => { d.checks[pin] = { 'free-tests': 'success' }; }); + const missing = land(fx, '17', pin); + refused(fx, missing, 'QUEUE_LAND_CHECKS'); + expect(missing.err).toContain('Check VERSION is not stale vs queue'); + }, 90_000); + + test('QUEUE_LAND_NOT_CLEAN: GitHub does not report the PR mergeable and clean', () => { + const fx = setup(); + const pin = openPr(fx, '18'); + fx.write(d => { d.prs['18'].mergeable = 'CONFLICTING'; d.prs['18'].mergeStateStatus = 'DIRTY'; }); + const r = land(fx, '18', pin); + refused(fx, r, 'QUEUE_LAND_NOT_CLEAN'); + expect(r.err).toContain('DIRTY'); + }, 90_000); + + test('QUEUE_TRAIN_UNTRUSTED: a fork head', () => { + const fx = setup(); + const pin = openPr(fx, '19', { fork: true }); + refused(fx, land(fx, '19', pin), 'QUEUE_TRAIN_UNTRUSTED'); + }, 90_000); + + test('QUEUE_NOT_READY: the base policy merges through trains', () => { + const fx = setup({ ...POLICY, merge: 'train' }); + const pin = openPr(fx, '20'); + const r = land(fx, '20', pin); + refused(fx, r, 'QUEUE_NOT_READY'); + expect(r.err).toContain('merge: train'); + }, 90_000); + + test('QUEUE_LAND_UNVERIFIED: main\'s tree after the merge is not the pin merged onto its parent', () => { + const fx = setup(); + const pin = openPr(fx, '21'); + fx.write(d => { d.strayOnMerge = true; }); + const r = land(fx, '21', pin); + expect(r.status).not.toBe(EXIT.ok); + expect(r.err).toContain('(QUEUE_LAND_UNVERIFIED)'); + expect(landNotes(fx)).toHaveLength(0); + }, 90_000); +}); + +describe('land --pin: fail closed and merge methods', () => { + test('the old stamping land refuses under merge: direct, and a --pin with no sha is a usage error', () => { + const fx = setup(); + const pin = openPr(fx, '23'); + const old = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '23', '--base', 'main', '--no-wait'], fx.env); + expect(old.status).not.toBe(EXIT.ok); + expect(old.err).toContain('merge: direct'); + expect(old.err).toContain('land --pr 23 --pin'); + expect(remote(fx, 'pr-23')).toBe(pin); + const dangling = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '23', '--base', 'main', '--pin'], fx.env); + expect(dangling.status).toBe(EXIT.usage); + expect(fx.read().mergeArgs).toBeUndefined(); + }, 90_000); + + test('a pin with no merge base on the base branch refuses instead of skipping the release-file check', () => { + const fx = setup(); + const { repo } = fx; + repo.git('checkout', '-q', '--orphan', 'pr-24'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '9.9.9.9\n'); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'unrelated history'); + repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-24'); + const pin = repo.git('rev-parse', 'HEAD'); + fx.write(d => { d.prs['24'] = { state: 'OPEN', baseRefName: 'main', headRefName: 'pr-24', isCrossRepository: false, title: 'PR 24', body: shippedBody('24', pin) }; }); + markReady(fx, '24', pin); + repo.git('checkout', '-q', '--detach', 'origin/main'); + const r = land(fx, '24', pin); + expect(r.status).not.toBe(EXIT.ok); + expect(r.err).toContain('cannot compute the merge base'); + expect(fx.read().mergeArgs).toBeUndefined(); + }, 90_000); + + test('history: merge-only merges with --merge; a PR on another base refuses', () => { + const fx = setup({ ...POLICY, history: 'merge-only' }); + const pin = openPr(fx, '25'); + fx.write(d => { d.prs['25'].baseRefName = 'dev'; }); + const other = land(fx, '25', pin); + expect(other.err).toContain('targets dev, not main'); + expect(fx.read().mergeArgs).toBeUndefined(); + fx.write(d => { d.prs['25'].baseRefName = 'main'; }); + const r = land(fx, '25', pin); + expect(r.err).toBe(''); + expect(fx.read().mergeArgs).toEqual(['25', '--merge', '--match-head-commit', pin]); + }, 90_000); + + test('land reads the policy after fetching the base: a required check main added since the last fetch is enforced', () => { + const fx = setup(); + const pin = openPr(fx, '27'); + const other = fx.repo.root + '/other'; + fx.repo.git('clone', '-q', fx.repo.origin, other); + fs.writeFileSync(path.join(other, '.gstack', 'ship-policy.json'), JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d', required_checks: [...REQUIRED, 'security-scan'] } }, null, 2) + '\n'); + fx.repo.git('-C', other, 'commit', '-qam', 'require security-scan'); + fx.repo.git('-C', other, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + const r = land(fx, '27', pin, ['--dry-run']); + expect(r.err).toContain('security-scan'); + expect(r.err).toContain('(QUEUE_LAND_CHECKS)'); + }, 90_000); + + test('a rerun from a fresh clone after the branch was deleted fetches the pin from refs/pull//head and lands', () => { + const fx = setup(); + const pin = openPr(fx, '28'); + expect(land(fx, '28', pin).status).toBe(EXIT.ok); + const g = (...a: string[]) => fx.repo.git('--git-dir', fx.repo.origin, ...a); + g('update-ref', 'refs/pull/28/head', pin); + fx.write(d => { d.prs['28'].headRefOid = pin; }); + g('update-ref', '-d', 'refs/heads/pr-28'); + const fresh = fx.repo.root + '/fresh'; + fx.repo.git('clone', '-q', '--no-local', '--single-branch', '--branch', 'main', fx.repo.origin, fresh); + expect(spawnCheck(fresh, pin)).toBe(false); + const r = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '28', '--pin', pin, '--base', 'main'], fx.env, fresh); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toMatch(/^LANDED: /m); + }, 90_000); + + test('QUEUE_LAND_STAMPED: a head that repoints .gstack/version-path', () => { + const fx = setup(); + const pin = openPr(fx, '29', { edit: w => { fs.mkdirSync(path.join(w, '.gstack'), { recursive: true }); fs.writeFileSync(path.join(w, 'OTHER_VERSION'), fs.readFileSync(path.join(w, 'VERSION'))); fs.writeFileSync(path.join(w, '.gstack', 'version-path'), 'OTHER_VERSION\n'); fs.writeFileSync(path.join(w, 'VERSION'), '9.9.9.9\n'); } }); + const r = land(fx, '29', pin); + expect(r.err).toContain('(QUEUE_LAND_STAMPED)'); + expect(r.err).toContain('.gstack/version-path'); + expect(fx.read().mergeArgs).toBeUndefined(); + }, 90_000); + + test('merge: direct without changelog_fragments refuses with the key to add', () => { + const fx = setup({ ...POLICY, changelog_fragments: null }); + const pin = openPr(fx, '26'); + const r = land(fx, '26', pin); + expect(r.err).toContain('merge: direct needs changelog_fragments'); + expect(fx.read().mergeArgs).toBeUndefined(); + }, 90_000); +}); + +describe('merge: direct | train', () => { + test('train build refuses under merge: direct and names land', () => { + const fx = setup(); + const pin = openPr(fx, '22'); + const r = runBin(fx.repo, 'gstack-ship-queue', ['train', 'build', '--prs', '22@' + pin, '--base', 'main'], fx.env); + expect(r.status).not.toBe(EXIT.ok); + expect(r.err).toContain('merge: direct'); + expect(r.err).toContain('gstack-ship-queue land --pr --pin '); + }, 90_000); + + test('the key validates its values and needs min_gstack_version', () => { + const errs = (o: Record) => validatePolicy(o).map(e => e.path + ': ' + e.message).join('\n'); + expect(errs({ merge: 'direct', min_gstack_version: MERGE_KEY_VERSION })).toBe(''); + expect(errs({ merge: 'train' })).toBe(''); + expect(errs({ merge: 'sideways', min_gstack_version: MERGE_KEY_VERSION })).toContain('merge: must be one of direct | train'); + expect(errs({ merge: 'direct' })).toContain('introduced in gstack ' + MERGE_KEY_VERSION); + }); + + test('the key floor follows the stamp: the train restamp moves MERGE_KEY_VERSION to the stamped 1.x.N, an N install reads a policy with min N, an N-1 install gets the upgrade message', () => { + const root = path.resolve(import.meta.dir, '..'); + const fork = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); + const stamped = bumpVersion(fork, 'patch')!; + const tentative = bumpVersion(fork, 'minor')!; + const policy = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'ship-policy.json'), 'utf8')); + const templates: string[] = policy.stamp_patterns; + expect(policy.stamp_paths).toContain('lib/ship-policy.ts'); + expect(templates).toContain("_KEY_VERSION = '{version}'"); + expect(fs.readFileSync(path.join(root, 'lib', 'ship-policy.ts'), 'utf8')).toContain(`export const MERGE_KEY_VERSION = '${MERGE_KEY_VERSION}';`); + const line = `export const MERGE_KEY_VERSION = '${tentative}';`; + expect(tentativeMentions(new Map([['lib/ship-policy.ts', new Set([line])]]), fork, stamped, templates)).toEqual([tentative]); + const out = rewriteStamps(line, tentative, stamped, templates); + expect(out.count).toBe(1); + expect(out.text).toBe(`export const MERGE_KEY_VERSION = '${stamped}';`); + const errs = (min: string, running: string) => validatePolicy({ merge: 'direct', min_gstack_version: min }, { gstackVersion: running }); + expect(errs(MERGE_KEY_VERSION, MERGE_KEY_VERSION)).toEqual([]); + const parts = MERGE_KEY_VERSION.split('.').map(Number); + const at = parts.findLastIndex(n => n > 0); + parts[at] = parts[at]! - 1; + const older = parts.join('.'); + const tooOld = errs(MERGE_KEY_VERSION, older); + expect(tooOld.map(e => e.code)).toContain('POLICY_GSTACK_TOO_OLD'); + expect(tooOld.map(e => e.message).join('\n')).toContain(`this policy needs gstack ${MERGE_KEY_VERSION} or later; this gstack is ${older}`); + }); + + test("gstack's own policy does not set merge before the key's release exists (min_gstack_version must name a published version)", () => { + const raw = JSON.parse(fs.readFileSync(path.resolve(import.meta.dir, '..', '.gstack', 'ship-policy.json'), 'utf8')); + const fork = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); + if ('merge' in raw) expect(validatePolicy(raw, { gstackVersion: fork })).toEqual([]); + }); +}); From 20645bce84dad2deddaeace036e873283ca69ee4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 16:11:07 -0800 Subject: [PATCH 05/21] v1.95.1.0 review-depth: executables stay code, sealed custody and egress are security, quoted names in slices (GST-1177, GSTA-258 L7) (#3972) * review-depth: an exec bit or shebang keeps logs/ and .out paths code (GSTA-258 L7 Addendum 1, 7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-brief: sliceDiff splits and names git-quoted and spaced filenames (GSTA-258 L7 Addendum 1.2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-risk: sealed custody, redaction and outside-model egress code class as security (GST-1177) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: review key row, review_depth receipt fields, executable and egress classing (GSTA-258 L7 Addendum 2, GST-1177) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review pins: GSTA-258-L7 planner no-loss replay admits the new depth rules; changelog fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (deleted paths are not executables, read two bytes of candidates only, wider egress line rules) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (executables and symlinks are code with their lines read whatever their docs, data or config name; never open a symlink) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: second review exception (docs credential check reads every docs path whatever its class; the live SVG reader never opens a symlink or non-regular file) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: L7 fragment matches the final change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-plan | 2 +- docs/review-policy.md | 8 ++- docs/ship-policy.md | 4 +- lib/review-brief.ts | 11 +++- lib/review-collect.ts | 52 ++++++++++++++-- lib/review-depth.ts | 12 ++-- lib/review-risk.ts | 12 +++- lib/ship-preflight.ts | 2 +- package.json | 2 +- .../files/lib/review-depth.ts.json | 2 +- .../review-pins/files/lib/review-risk.ts.json | 2 +- .../review-pins/replays/GSTA-258-L7.json | 1 + test/review-collect.test.ts | 61 +++++++++++++++++++ test/review-depth-lean.test.ts | 40 ++++++++++++ 17 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 test/fixtures/review-pins/replays/GSTA-258-L7.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e911301a3..b98f91f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.95.1.0] - 2026-11-04 + +**/ship's review depth now follows what the code does, not what a file is named (GST-1177).** An executable file stays code wherever it sits and whatever its name, and code that seals eval custody or decides what reaches an outside model now gets full review. Over the last 200 commits on main, no commit's review depth went down. Six gained a risk class, and three of those went from light to full, including the secret-egress fix in #3921 that had been planned light. + +### Changed +- **An executable is code whatever its name or folder.** A file with the exec bit, a symlink, or a file whose first line is `#!` counts as code. Examples are `scripts/logs/prune`, a runnable `*.out`, or an executable `.txt` or `.json` script. It no longer counts as a run log, data, config or docs, and its changed lines are read for risk. If gstack can't read a file's mode or first bytes, it treats the file as executable. A deleted file is never counted as executable. +- **Sealed custody and outside-model egress are security risk.** Paths naming seal, sealed, custody or egress now plan full depth (harness-evals#237, #3921). So do code lines that call a redaction function, import a redaction or outside-voice module, or set a `SECRET` or `CREDENTIAL` constant to a regex. +- **The docs credential check reads every docs path**, including docs-named executables, so a script that prints an `Authorization:` header still plans full depth. + +### Fixed +- **The outside voice's path-scoped slice no longer drops files with quoted or spaced names.** Git prints a quoted header when a name has a tab or non-ASCII characters. Such a file was either glued onto the file before it or left out of a diff over 2 MB without being listed. Now each one is its own file, and the brief names it when it's left out. +- **`gstack-review-plan` never opens a tracked symlink** to check for `#!` or to read an SVG drawing, so a link to a FIFO can't hang it. +- **docs/ship-policy.md describes the `review` policy key** and the `risk` and `mode` fields of the receipt's `review_depth` record. + ## [1.95.0.0] - 2026-11-03 ### Added diff --git a/VERSION b/VERSION index 8e95121d8..b28cd731d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.95.0.0 +1.95.1.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 344e451e5..aa2782464 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.95.0.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.1.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index ab4cd4eec..fa4a161be 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -216,7 +216,7 @@ function main(): number { const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings, + process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings, executables: collected.executables, ...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n'); return EXIT.ok; } diff --git a/docs/review-policy.md b/docs/review-policy.md index 98ef7c7c5..3f7411490 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -210,7 +210,9 @@ content rules: data and config files (JSON, YAML, TOML, CSV and the like) and an scripts under `docs/`, `results/`, `benchmarks/`, `analysis/` or `notebooks/` are judged by their path, so a `lease_usd` value or an `os.path.join(sys.argv[1], …)` in a stats script names no class (HARN-1, harness-evals#231), and binary data such as -`*.sqlite` is data wherever it sits; a docs line that can print a credential (an +`*.sqlite` is data wherever it sits, unless the file has the exec bit or starts with +`#!`: an executable stays code whatever its name or directory (`scripts/logs/prune`, +a runnable `*.out`); a docs line that can print a credential (an `Authorization:` header, a bearer or key-shaped value, an environment key set to a value, a get or show command for auth config such as `mcp get`) is security (gbrain#8417); code that reserves, binds, leases or settles spend against a ledger, budget or cap (`bindCheck(ledger, …)`, @@ -281,7 +283,9 @@ pairs: usage beside a token or cost word, proxy beside billing, and a client beside a cost or charge word, so a CLI's usage text and a plain proxy stay out (GST-850, from gbrain-evals #289, whose metering proxy and billing clients read `required=none`). **security** is a trust-boundary path (trust, redact, sanitize, neutralize, -consent, escape) or line (`shell: true`, `exec(` and `execSync(`, `eval`, `new Function(`, +consent, escape, and sealed custody or egress: seal, sealed, custody, egress) or line (a +redaction call, an import of a redaction or outside-voice module, a `SECRET` or `CREDENTIAL` +constant set to a regex, since that code decides what reaches an outside model, GST-1177; `shell: true`, `exec(` and `execSync(`, `eval`, `new Function(`, raw HTML sinks, a shell run through argv, recursive deletes), any call to an outbound HTTP client (`fetch`, `http(s).get` or `.request`, axios, got, undici, `requests`, `httpx`, urllib, with no exemption for a literal URL), `new URL` from diff --git a/docs/ship-policy.md b/docs/ship-policy.md index f0683967c..571e775b0 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -60,6 +60,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. | | `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. | | `review_exclude_paths` | git glob pathspecs | `[]` | Content files `/ship`'s reviewers leave out: data, docs, `.log`, and binaries under a content extension (images, fonts, archives and the like) that do not start with executable magic. Read from the base only. Code (native libraries and every other binary outside the content extensions included), gate paths, agent prose, config, executables, links, `.gstack/` and `.github/` files, gate-tagged, secret-line and named-by-code files stay in review. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. | +| `review` | `{ panel?, reader_model?, platform?, risk_paths? }` | `{ all, lane, off, [] }` | Who does `/ship`'s review, read from the base only; see [Who reads](review-policy.md#who-reads-r65-the-base-policys-review-key). `panel`: `all` (today's panel on every class) \| `risk` \| `off` (the opt-in one-reader shapes); `reader_model`: `lane`, `cross-family` or a model id; `platform`: `off` \| `on` (a docs or tests diff with no numeric, results or prereg claims whose PR carries capy-ai[bot]'s review at the exact head skips the readers); `risk_paths`: globs of code paths that count as risk class `policy`. Introduced at 1.94.30.0; needs `min_gstack_version` when set to a non-default value. | | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | @@ -1189,7 +1190,8 @@ repair is trimming the file, never raising the cap in the same PR. **`review_depth` and `gstack_version`.** `write` records the gstack that wrote the receipt and the review depth at the checked-out head ([review-policy.md#review-depth](review-policy.md#review-depth)): -`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`, +`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons, risk, mode }` +(`mode` is lean, panel or platform; `declared` and `detected` appear with `write --declare-risk`, and the platform depth adds `platform_review`, see [review-policy.md](review-policy.md#review-depth)), computed from git objects for the range against `origin/` (`gstack-diff-scope --head ` for the scope signals), with the gate scan run on that head. `--head` must be the checked-out HEAD diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 698715c1d..aadc15e36 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -43,14 +43,21 @@ export class BriefError extends Error {} */ export function sliceDiff(text: string, cap: number): { text: string; sliced: boolean; files: number; kept: number; bytes: number; total: number; left: string[] } { const total = Buffer.byteLength(text); - const starts = [...text.matchAll(/^diff --git a\/(\S+)/gm)]; + const starts = [...text.matchAll(/^diff --git (.*)$/gm)]; + const name = (rest: string) => { + const quoted = /^"a\/((?:[^"\\]|\\.)*)"/.exec(rest); + if (quoted) return `"${quoted[1]}"`; + const half = (rest.length - 1) / 2; + if (rest.startsWith('a/') && rest.slice(half) === ` b/${rest.slice(2, half)}`) return rest.slice(2, half); + return rest.startsWith('a/') ? rest.slice(2).split(' b/')[0]! : rest; + }; if (total <= cap || !starts.length) return { text, sliced: false, files: starts.length, kept: starts.length, bytes: total, total, left: [] }; let out = '', bytes = 0; const left: string[] = []; starts.forEach((m, k) => { const section = text.slice(m.index!, k + 1 < starts.length ? starts[k + 1]!.index! : text.length); const n = Buffer.byteLength(section); - if (bytes + n <= cap) { out += section; bytes += n; } else left.push(m[1]!); + if (bytes + n <= cap) { out += section; bytes += n; } else left.push(name(m[1]!)); }); return { text: out, sliced: true, files: starts.length, kept: starts.length - left.length, bytes, total, left }; } diff --git a/lib/review-collect.ts b/lib/review-collect.ts index f5e10709f..087059a65 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -13,7 +13,7 @@ import * as path from 'node:path'; import { resolveBase, type ResolvedBase } from './gate-diff/git'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; -import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; +import { BINARY_DATA_PATH, classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; export interface Collected extends ClassInput { perPath: PathLines[]; @@ -32,6 +32,7 @@ export interface Collected extends ClassInput { untracked: string[]; consumed: string[] | 'all'; drawings: string[]; + executables: string[]; /** collectLive only: the ref resolveBase read for the base name (GST-1036), null when nothing resolved. */ base?: ResolvedBase | null; } @@ -232,9 +233,36 @@ function drawingsAmong(perPath: readonly PathLines[], classRoot: string | readon }).sort(); } -function result(diff: ReturnType, extra: Pick & { drawings?: string[] }): Collected { +/** + * The changed paths a docs, data or run-log rule could move out of code that are executables: the exec bit, a symlink, or text + * that starts with "#!" (GSTA-258 L7). `isExec` answers for one path and fails closed (true) when it cannot read it. + */ +function executablesAmong(perPath: readonly PathLines[], classRoot: string | readonly string[], isExec: (e: PathLines) => boolean): string[] { + return perPath.filter(e => (BINARY_DATA_PATH.test(e.path) && classifyPath(e.path, classRoot) === 'code') || dataCandidate(e.path, classRoot) || svgCandidate(e.path, classRoot) || classifyPath(e.path, classRoot) === 'docs').filter(isExec).map(e => e.path).sort(); +} + +/** + * The executables among the changed paths at `headSha`: tree mode 100755 or 120000 (a symlink, as collectLive sees it), or a text + * blob whose first two bytes are "#!". A path the head tree lacks is a deletion, never an executable; an unreadable tree or blob counts as one. + */ +function executablesAt(root: string, headSha: string, perPath: readonly PathLines[], classRoot: readonly string[]): string[] { + const tree = git(root, ['ls-tree', '-r', '-z', headSha]); + const entries = new Map(tree.out.split('\0').flatMap(rec => { const m = /^(\d+) blob ([0-9a-f]+)\t(.*)$/s.exec(rec); return m ? [[m[3]!, { mode: m[1]!, sha: m[2]! }] as const] : []; })); + return executablesAmong(perPath, classRoot, e => { + if (!tree.ok) return true; + const t = entries.get(e.path); + if (!t) return false; + if (t.mode === '100755' || t.mode === '120000') return true; + if (e.binary) return false; + const r = spawnSync('git', ['cat-file', 'blob', t.sha], { cwd: root, timeout: 30_000, maxBuffer: 2 }); + const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS'; + return !read || (r.stdout ?? Buffer.alloc(0)).subarray(0, 2).toString('latin1') === '#!'; + }); +} + +function result(diff: ReturnType, extra: Pick & { drawings?: string[]; executables?: string[] }): Collected { const paths = diff.perPath.map(e => e.path).sort(); - return { ...diff, drawings: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; + return { ...diff, drawings: [], executables: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; } /** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */ @@ -255,7 +283,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C const blob = spawnSync('git', ['cat-file', 'blob', `${headSha}:${p}`], { cwd: root, timeout: 60_000, maxBuffer: DRAWING_READ_CAP + 1 }); return blob.status === 0 && !blob.error ? blob.stdout : null; }); - return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings }); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings, executables: executablesAt(root, headSha, diff.perPath, skillDirs) }); } /** @@ -289,7 +317,19 @@ export function collectLive(root: string, base: string): Collected { } const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors); const drawings = drawingsAmong(diff.perPath, root, p => { - try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; } + try { const st = fs.lstatSync(path.join(root, p)); return st.isFile() && st.size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; } }); - return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }), base: resolved }; + const executables = executablesAmong(diff.perPath, root, e => { + try { + const f = path.join(root, e.path); + const st = fs.lstatSync(f); + if (!st.isFile() || st.mode & 0o111) return true; + if (e.binary) return false; + const fd = fs.openSync(f, 'r'); + const buf = Buffer.alloc(2); + try { fs.readSync(fd, buf, 0, 2, 0); } finally { fs.closeSync(fd); } + return buf.toString('latin1') === '#!'; + } catch (err) { return (err as NodeJS.ErrnoException).code !== 'ENOENT'; } + }); + return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings, executables }), base: resolved }; } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 666768fd3..11c568501 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -237,6 +237,8 @@ export interface ClassInput { created?: readonly string[]; /** The SVG candidates (svgCandidate) whose whole text the collector read as a drawing (svgDrawing); any other SVG stays code. */ drawings?: readonly string[]; + /** Docs-, data- or log-named paths the collector found executable (exec bit, a symlink or a "#!" first line): code whatever their name or directory, never judged by path alone. */ + executables?: readonly string[]; /** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */ added?: Record; removed?: Record; @@ -252,9 +254,9 @@ export interface ClassResult { class: DiffClass; reasons: string[]; errors: Dept const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); -const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root))); -const binaryData = (i: ClassInput, p: string) => BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); -const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root)); +const unconsumedData = (i: ClassInput, p: string) => !i.executables?.includes(p) && Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root))); +const binaryData = (i: ClassInput, p: string) => !i.executables?.includes(p) && BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); +const pathClass = (i: ClassInput, p: string): PathClass => i.executables?.includes(p) && !i.excluded?.has(p) ? 'code' : (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root)); /** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { @@ -285,8 +287,8 @@ export function classifyDiff(i: ClassInput): ClassResult { for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; } if (i.registry?.error) errors.push(i.registry.error); } - const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map(); - const docSecret = i.paths.find(p => pathClass(i, p) === 'docs' && DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l)))); + const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), executables: i.executables, added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map(); + const docSecret = i.paths.find(p => DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l)))); if (docSecret && !riskHits.has('security')) riskHits.set('security', `docs that can print a credential: ${docSecret}`); const byRank = (a: RiskClass, b: RiskClass) => RISK_CLASSES.indexOf(a) - RISK_CLASSES.indexOf(b); const detected = [...riskHits.keys()].sort(byRank); diff --git a/lib/review-risk.ts b/lib/review-risk.ts index 522f6fcb3..ba8cbb845 100644 --- a/lib/review-risk.ts +++ b/lib/review-risk.ts @@ -51,7 +51,8 @@ export const SECRET_LINE = [ /\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/, ]; -const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape']; +/** Sealed custody (harness-evals#237: scripts/sealed/seal.ts) and egress code are trust boundaries too (GST-1177). */ +const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape', 'seal', 'sealed', 'custody', 'egress']; /** Coarse, linear-time rules (GST-850 B+): any changed line naming the child_process module, any outbound HTTP client call; quantifiers bounded. */ export const SECURITY_LINE = [ /\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?; /** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */ scopeAuth: boolean; + /** Executables (lib/review-collect.ts): never a config file or results script, so their lines are always read. */ + executables?: readonly string[]; /** The base policy's review.risk_paths matcher: a code path it matches is risk class `policy`. */ isRiskPath?: (p: string) => boolean; } @@ -141,9 +146,10 @@ export function matchRisk(i: RiskInput): Map { const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; for (const p of i.codePaths) { if (i.isRiskPath?.(p)) hit('policy', 'risk_paths: ' + p); - const stats = STATS_SCRIPT.test(p); + const exe = !!i.executables?.includes(p); + const stats = !exe && STATS_SCRIPT.test(p); const t = stats ? [] : tokens(p); - const config = CONFIG_DATA_PATH.test(p); + const config = !exe && CONFIG_DATA_PATH.test(p); const pathOnly = config || stats; const prose = PROSE_PATH.test(p); if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p); diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index 966cec822..203df22c9 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -152,7 +152,7 @@ function merge(cwd: string, base: string, r: Report): boolean { let runtime = 'unknown'; try { const j = JSON.parse(plan.stdout); - const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, drawings: j.drawings, root: cwd, gateTagged: 0 }); + const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, drawings: j.drawings, executables: j.executables, root: cwd, gateTagged: 0 }); runtime = String(c.runtimeLines ?? 'unknown'); } catch { /* unknown */ } r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`); diff --git a/package.json b/package.json index 02a02701f..61e20b310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.95.0", + "version": "1.95.1", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/review-pins/files/lib/review-depth.ts.json b/test/fixtures/review-pins/files/lib/review-depth.ts.json index 1ed4cf629..a77572084 100644 --- a/test/fixtures/review-pins/files/lib/review-depth.ts.json +++ b/test/fixtures/review-pins/files/lib/review-depth.ts.json @@ -1 +1 @@ -{"sha":"fb21c0b80e98685ca8c5192aeaa217cfffe9fbdaa8890455c97e01be1b044a53","replay":"GSTA-258-C+GST-940"} +{"sha":"6688963750c14f03cdbb249dcaa27ebd9e9c2c62170f4e142ec8ded28af010a1","replay":"GSTA-258-L7"} diff --git a/test/fixtures/review-pins/files/lib/review-risk.ts.json b/test/fixtures/review-pins/files/lib/review-risk.ts.json index 1528c69f7..e160b17bb 100644 --- a/test/fixtures/review-pins/files/lib/review-risk.ts.json +++ b/test/fixtures/review-pins/files/lib/review-risk.ts.json @@ -1 +1 @@ -{"sha":"e2a4d2aabe02e28140b3b8f3d5288bf0eac1872b5cb375eb011e4f0cde2ad67a","replay":"GSTA-258-C+GST-942"} +{"sha":"1d03d5cd8f0cae5f01af85502004849c9fa2545009957f2c4c44a1359d9d0fc0","replay":"GSTA-258-L7"} diff --git a/test/fixtures/review-pins/replays/GSTA-258-L7.json b/test/fixtures/review-pins/replays/GSTA-258-L7.json new file mode 100644 index 000000000..0066b5951 --- /dev/null +++ b/test/fixtures/review-pins/replays/GSTA-258-L7.json @@ -0,0 +1 @@ +{"replay":"GSTA-258-L7","justification":"GSTA-258 L7 planner no-loss check (GST-1177, Addenda 1 and 7): the change only adds triggers (executables stay code; seal, sealed, custody and egress path tokens; redaction calls (camelCase or snake_case), redaction or outside-voice imports (from, side-effect, require, dynamic and Python forms) and SECRET or CREDENTIAL constants set to a regex literal, a regex list or new RegExp are security; the docs credential check reads every docs path whatever its class; a deleted file is never an executable, and an executable or symlink is code with its lines read whatever its docs, data or config name) and removes none; over the last 200 first-parent main commits to bf2cab34c5, planReview and collectCommit from origin/main and from this branch give the same depth, class, risk classes, next step and dispatches on 194, and the other 6 only raise: ed5c948eb6 (#3921, the GST-1164 secret-egress fix) and c197a139b7 (#3918, approval text in public PR bodies) and 514daebf5f (#3569, lib/sealed-guard.ts) go light to full on security, while 90a170c941, 71acd83aa6 and 216493fb32 gain security at unchanged full depth and reviewers; no commit's depth drops ; reruns after the pass 1 and Step 11 fixes give the same rows, and after the second-exception fixes 216493fb32 also gains security at unchanged depth and readers (GStack Minder drive handoffs/GSTA-258/L7-planner-noloss/, l7-200-v2.tsv, l7-200-v3.tsv, l7-200-v4.tsv)"} diff --git a/test/review-collect.test.ts b/test/review-collect.test.ts index 3db4a3665..74440e333 100644 --- a/test/review-collect.test.ts +++ b/test/review-collect.test.ts @@ -100,6 +100,67 @@ describe('collectCommit: git objects only', () => { }); }); +describe('GSTA-258 L7 Addendum 1 and 7: executables among data-named paths', () => { + // Value: protects=an exec bit or a shebang keeps a logs/ or .out path code in both collectors; fails_when=scripts/logs/prune + // or an executable .out reaches classifyDiff as data (docs, light); seam=collectCommit/collectLive -> depthOfCommitInput + test('the exec bit or a shebang lists the path as executable; a plain log does not', () => { + const r = repo({}); + r.write({ 'scripts/logs/prune': '#!/bin/sh\nrm -rf "$1"\n', 'bin/tool.out': 'ELF-ish\n', 'logs/run.txt': 'ok\n', 'docs/r/logs/ready': 'yes\n' }); + fs.chmodSync(path.join(r.dir, 'bin/tool.out'), 0o755); + const head = r.commit({}); + const c = collectCommit(r.dir, r.baseSha, head); + expect(c.executables).toEqual(['bin/tool.out', 'scripts/logs/prune']); + expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code'); + r.write({ 'docs/r/logs/later': '#!/usr/bin/env bash\necho\n' }); + expect(collectLive(r.dir, 'main').executables).toEqual(['bin/tool.out', 'docs/r/logs/later', 'scripts/logs/prune']); + const plain = repo({}); + const ph = plain.commit({ 'logs/run.txt': 'ok\n', 'docs/r/logs/ready': 'yes\n', 'docs/r/agent.out': 'done\n' }); + const pc = collectCommit(plain.dir, plain.baseSha, ph); + expect(pc.executables).toEqual([]); + expect(depthOfCommitInput({ ...pc, gateTagged: 0 }).class).toBe('docs'); + }); + // Value: protects=a deleted log is absent, not unreadable, and an unrelated large blob never forces the fail-closed default; + // fails_when=removing logs/run classes as gate, or a big text file in the diff makes r.log executable; seam=executablesAt + test('a deleted log is not executable, and only candidates are read (two bytes each)', () => { + const r = repo({ 'logs/run': 'ok\n', 'r.log': 'ok\n', 'big.txt': 'a'.repeat(1024 * 1024) }); + const del = r.commit({ 'logs/run': null, 'r.log': 'ok2\n', 'big.txt': 'b' + 'a'.repeat(1024 * 1024) }); + const c = collectCommit(r.dir, r.baseSha, del); + expect(c.executables).toEqual([]); + expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('docs'); + r.write({ 'r.log': null }); + expect(collectLive(r.dir, 'main').executables).toEqual([]); + }); + // Value: protects=a docs or config name never hides an executable's lines, and a symlink is never opened (outside voice on #3972); + // fails_when=a 755 prune.txt or prune.json with rm -rf classes light with no security, or a logs/ symlink is read through; + // seam=collectors -> classifyDiff -> matchRisk + test('executable .txt and .json scripts are code with security lines; a symlink counts as executable without being read', () => { + const r = repo({}); + const script = '#!/bin/sh\nrm -rf "$1"\n'; + r.write({ 'scripts/logs/prune.txt': script, 'tools/prune.json': script }); + for (const p of ['scripts/logs/prune.txt', 'tools/prune.json']) fs.chmodSync(path.join(r.dir, p), 0o755); + fs.mkdirSync(path.join(r.dir, 'logs')); + fs.symlinkSync('/dev/null', path.join(r.dir, 'logs/run')); + const head = r.commit({}); + for (const c of [collectCommit(r.dir, r.baseSha, head), collectLive(r.dir, 'main')]) { + expect(c.executables).toEqual(['logs/run', 'scripts/logs/prune.txt', 'tools/prune.json']); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + expect([d.class, d.risk.includes('security'), d.depth]).toEqual(['code', true, 'full']); + } + }); + // Value: protects=collectLive never opens a symlinked SVG (GStack Minder second exception on #3972); fails_when=a tracked + // art.svg linked to a FIFO blocks the drawing read; seam=drawingsAmong's live reader + test('a symlinked SVG is not read as a drawing in collectLive', () => { + const r = repo({}); + const fifo = path.join(r.dir, '.git', 'fifo'); + spawnSync('mkfifo', [fifo], { timeout: 5_000 }); + fs.symlinkSync(fifo, path.join(r.dir, 'art.svg')); + r.commit({}); + const t0 = Date.now(); + expect(collectLive(r.dir, 'main').drawings).toEqual([]); + expect(Date.now() - t0).toBeLessThan(20_000); + }, 30_000); +}); + describe('collectLive: the working tree', () => { test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => { const r = repo({ 'src/a.ts': lines(1) }); diff --git a/test/review-depth-lean.test.ts b/test/review-depth-lean.test.ts index 6fd77c49d..885269fd8 100644 --- a/test/review-depth-lean.test.ts +++ b/test/review-depth-lean.test.ts @@ -28,6 +28,35 @@ describe('HARN-1 false triggers (harness-evals#231 on 1.93.7.0): data, config an expect([c.class, c.risk]).toEqual(['docs', []]); expect(classifyDiff({ paths: ['src/logs/rotate.ts'], gateTagged: 0 }).class).toBe('code'); }); + // Value: protects=a directory name or a data extension never downgrades an executable (GSTA-258 L7 Addendum 1 and 7); + // fails_when=scripts/logs/prune or an executable .out classes as docs; seam=collector executables -> classifyDiff + test('an executable (exec bit or shebang) under logs/ or named .out stays code; plain logs stay docs', () => { + const exe = ['scripts/logs/prune', 'tools/build/runner.out']; + const c = classifyDiff({ paths: [...exe, 'logs/run.txt'], gateTagged: 0, executables: exe }); + expect(c.class).toBe('code'); + expect(c.reasons).toEqual(expect.arrayContaining(['code: scripts/logs/prune'])); + for (const p of exe) expect(classifyDiff({ paths: [p], gateTagged: 0, executables: [p] }).class).toBe('code'); + const txt = 'scripts/logs/prune.txt'; + const printsKey = classifyDiff({ paths: [txt], gateTagged: 0, executables: [txt], added: { [txt]: ['curl -H "Authorization: Bearer abcdefgh12345678"'] } }); + expect([printsKey.class, printsKey.risk]).toEqual(['code', ['security']]); + expect(classifyDiff({ paths: ['logs/run.txt', 'docs/r/logs/ready'], gateTagged: 0, executables: [] }).class).toBe('docs'); + }); + // Value: protects=sealed custody and outside-model prompt egress plan full depth (GST-1177: harness-evals#237, gstack #3921); + // fails_when=scripts/sealed/seal.ts or the #3921 secret-egress lines class light with no risk; seam=matchRisk path and line rules + test('sealed custody and code that redacts or ships repo content to an outside model are security', () => { + const sec = (p: string, lines: string[] = ['export const x = 1;']) => matchRisk({ codePaths: [p], added: { [p]: lines }, scopeAuth: false }).has('security'); + expect(sec('scripts/sealed/seal.ts')).toBe(true); + expect(sec('lib/eval-custody.ts')).toBe(true); + expect(sec('lib/autoplan-lean.ts', ["import { redactFindingSpans } from './redact-engine';"])).toBe(true); + expect(sec('lib/autoplan-lean.ts', [' const redacted = (patch: string) => redactFindingSpans(patch) ?? "";'])).toBe(true); + expect(sec('lib/autoplan-lean.ts', ['const SECRET_NAME = /(^|\\/)(\\.env[^/]*)$/i;'])).toBe(true); + expect(sec('lib/review-x.ts', ["import { runOutsideVoice } from './outside-voice-runner';"])).toBe(true); + for (const l of ['out = redact_secrets(text)', 'from gstack.redact import scrub', "import './outside-voice';", 'SECRET_PATTERNS = [/sk-[a-z]+/]', 'const CREDENTIAL_RE = new RegExp(src);']) expect(sec('lib/x.ts', [l])).toBe(true); + expect(sec('lib/autoplan-lean.ts', ['const INLINE_DRAFT_BYTES = 64_000;'])).toBe(false); + expect(sec('lib/x.ts', ['const SECRET_COUNT = total / 2;', "import { readFile } from 'node:fs';"])).toBe(false); + expect(sec('lib/seasonal.ts')).toBe(false); + expect(classifyDiff({ paths: ['scripts/sealed/seal.ts'], gateTagged: 0 }).risk).toContain('security'); + }); test('a sqlite file is data even when a script names it', () => { const c = classifyDiff({ paths: [sqlite, 'scripts/load.py'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'scripts/load.py', lines: 4, binary: false }], consumed: [sqlite] }); expect([c.class, c.errors.map(e => e.code), c.runtimeLines]).toEqual(['code', [], 4]); @@ -222,6 +251,17 @@ describe('C7 merged reader brief and the outside voice reading its diff from dis expect(sliceNote(s, 100)).toMatch(/2 of 3 files.*big\.json/); expect(sliceDiff(sec('a.ts', 10), 100).sliced).toBe(false); }); + // Value: protects=no file leaves the outside voice's slice unnamed (GSTA-258 L7 Addendum 1.2); fails_when=a git-quoted header + // before the first plain one is dropped unlisted, or glued onto the file before it; seam=sliceDiff headers + test('a git-quoted or spaced filename is its own file in the slice and is named when left out', () => { + const quoted = (p: string, n: number) => `diff --git "a/${p}" "b/${p}"\n+${'x'.repeat(n)}\n`; + const sec = (p: string, n: number) => `diff --git a/${p} b/${p}\n+${'x'.repeat(n)}\n`; + const s = sliceDiff(quoted('t\\303\\251st.sql', 500) + sec('a.ts', 10) + quoted('tab\\there.ts', 10), 100); + expect([s.files, s.kept, s.left]).toEqual([3, 2, ['"t\\303\\251st.sql"']]); + expect(s.text).toContain('tab\\there.ts'); + const spaced = sliceDiff(sec('my notes/big data.csv', 500) + sec('a.ts', 10), 100); + expect(spaced.left).toEqual(['my notes/big data.csv']); + }); test('the outside voice gets a path and a hash, not the pasted diff', () => { const text = renderBrief('outside-voice', { diff_base: sha, diff_file: '/tmp/o/outside-voice.diff', diff_sha256: 'd'.repeat(64), diff_bytes: '1234' }, t); expect(text).toContain('/tmp/o/outside-voice.diff'); From f557988ea1b41a51dbcf66ce4dc21e0296784c2f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 16:18:18 -0800 Subject: [PATCH 06/21] v1.95.2.0 pregate: conflict-markers guard refuses merge markers on added lines (GST-1190) (#3965) * pregate: a conflict-markers guard refuses merge markers on the lines a branch adds (GST-1190) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): conflict-markers reads the pushed commit, fixed git prefixes and real declarations Review fixes for GST-1190: read HEAD and the working tree, force a/ b/ prefixes and unquoted paths, skip untracked files, require the fixture declaration on its own line near the top, refuse conflicts inside code fences in Markdown, run the test spawn through bunIsolationArgs for Windows. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(conflict-markers): type the git config env rows Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .gstack/pregate.json | 3 +- CHANGELOG.md | 7 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/pregate.md | 18 ++++- package.json | 2 +- scripts/check-conflict-markers.ts | 105 ++++++++++++++++++++++++++++++ test/conflict-markers.test.ts | 86 ++++++++++++++++++++++++ test/pregate-guards.test.ts | 6 +- 9 files changed, 222 insertions(+), 9 deletions(-) create mode 100755 scripts/check-conflict-markers.ts create mode 100644 test/conflict-markers.test.ts diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 737703bbe..6f86797c9 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -39,7 +39,8 @@ { "id": "context-budget", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/context-budget-ratchet.test.ts", "test/fixtures/context-budget*.json"], "command": "bun test test/context-budget-ratchet.test.ts", "fix": "shorten the skill under its token ceiling, or lower the ceiling when the skill shrank" }, { "id": "module-size", "when": ["lib/**/*.ts", "bin/**", "scripts/**/*.ts", "browse/src/**/*.ts", "test/helpers/**/*.ts", "test/module-size-ratchet.test.ts", "test/fixtures/module-size-ratchet.json"], "command": "bun test test/module-size-ratchet.test.ts", "fix": "split the module or function the failure names; it may not grow past its recorded size" }, { "id": "goldens", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "ship/**", "scripts/gen-ship-goldens.ts", "test/host-config.test.ts", "test/fixtures/golden/**"], "command": "bun test test/host-config.test.ts", "fix": "when the render change is intended, run bun scripts/gen-ship-goldens.ts and commit the updated goldens" }, - { "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" } + { "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" }, + { "id": "conflict-markers", "when": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs", "**/*.cjs", "**/*.sh", "**/*.json", "**/*.yml", "**/*.yaml", "**/*.tmpl", "bin/**"], "command": "bun scripts/check-conflict-markers.ts", "fix": "finish the merge in each file it names, delete the marker lines and commit; a fixture that shows markers on purpose declares itself as docs/pregate.md describes" } ], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b98f91f8f..fdb4f2f1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.95.2.0] - 2026-11-04 + +- Pushes from /ship now stop on leftover merge conflict markers. A new `conflict-markers` guard reads every line the branch adds, in the commit being pushed and in the working tree, and refuses one that starts with `<<<<<<<` or `>>>>>>>`, or is exactly `=======`, even inside a template literal that still parses and passes its tests (GST-1190, seen on #3927). Markdown code blocks that show git output (an unlabeled, `text` or `diff` fence) are skipped, while a conflict inside a `bash` or `ts` fence is still refused. A fixture that shows markers on purpose declares `gstack-conflict-markers: fixture` on a line of its own near the top. + +### Tests +- `test/conflict-markers.test.ts`: a marker inside a template literal is refused (it failed before the guard existed); a Markdown code block that shows a marker passes while the same lines outside a fence or in a bash fence are refused; unchanged lines, declared fixtures, untracked files, quoted file names, git prefix settings and a committed conflict hidden by an unstaged repair are handled. + ## [1.95.1.0] - 2026-11-04 **/ship's review depth now follows what the code does, not what a file is named (GST-1177).** An executable file stays code wherever it sits and whatever its name, and code that seals eval custody or decides what reaches an outside model now gets full review. Over the last 200 commits on main, no commit's review depth went down. Six gained a risk class, and three of those went from light to full, including the secret-egress fix in #3921 that had been planned light. diff --git a/VERSION b/VERSION index b28cd731d..936be4c1b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.95.1.0 +1.95.2.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index aa2782464..6ff7813de 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.95.1.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.2.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/pregate.md b/docs/pregate.md index 50d96c7e3..5ca21a0b9 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1402,8 +1402,22 @@ being pushed skips the guards, as `GUARDS: not run`. Outside /ship, run the command above yourself before `git push`. gstack declares its own list in `.gstack/pregate.json`: `typecheck`, -`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens` and -`doc-caps`. Generated SKILL.md freshness is the `regen` check's job. Other +`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens`, +`doc-caps` and `conflict-markers`. Generated SKILL.md freshness is the `regen` +check's job. `conflict-markers` runs `scripts/check-conflict-markers.ts` on a +change to code, templates or config, and reads every line the branch adds +against its merge base with `origin/main`, both in the committed HEAD (what the +push publishes) and in the working tree's tracked files. A line starting with +`<<<<<<<` or `>>>>>>>` (then a space or the line end), or a line that is exactly +`=======`, stops the push, even inside a template literal the parser accepts. +In Markdown (`.md`, `.mdx`, `.markdown`, `.md.tmpl`) it skips fences whose info +string is empty, `text`, `txt`, `plaintext`, `diff` or `console`, the fences a +doc uses to show git output, and counts `=======` only between a `<<<<<<<` and +its close, so such a code block and a heading underline both pass; a conflict +inside a `bash` or `ts` fence is still refused. A fixture that shows markers on +purpose puts a line that is only `gstack-conflict-markers: fixture` (a comment +leader such as `#` or `//` is fine) in its first five lines; the phrase +anywhere else exempts nothing. Other repos add their own `guards` the same way; the key needs no newer gstack than the one that already reads `guards`. diff --git a/package.json b/package.json index 61e20b310..25c309d38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.95.1", + "version": "1.95.2", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/check-conflict-markers.ts b/scripts/check-conflict-markers.ts new file mode 100755 index 000000000..4389ef0e6 --- /dev/null +++ b/scripts/check-conflict-markers.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * check-conflict-markers — refuse merge conflict markers on the lines a branch changed (GST-1190). + * + * bun scripts/check-conflict-markers.ts [--base ] + * + * Reads every line the branch adds against its merge base with (default origin/main) twice: in the + * committed HEAD, which is what a push publishes, and in the working tree's tracked files (deleted files are + * skipped; untracked files are never pushed, so they are not read). A line starting with `<<<<<<<` or + * `>>>>>>>` followed by a space or the line end, or a line that is exactly `=======`, is a marker. This + * catches a marker the parser accepts, such as one inside a template literal (#3927). Markdown files + * (`.md`, `.mdx`, `.markdown`, `.md.tmpl`) skip lines inside a ``` or ~~~ fence whose info string is empty, + * text, txt, plaintext, diff or console, the fences a doc uses to show git output; a conflict inside a bash, + * ts or other code fence is still refused. They count `=======` only between a `<<<<<<<` and its close, so a + * setext heading underline passes. A file whose first five lines include one that is only the declaration + * `gstack-conflict-markers: fixture` (after an optional comment leader such as `#`, `//` or `)?\s*$/; +const GIT = ['-c', 'core.quotePath=false', '-c', 'diff.noprefix=false', '-c', 'diff.mnemonicPrefix=false']; + +function git(args: string[], allowFail = false): string | null { + const r = spawnSync('git', [...GIT, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 120_000 }); + if (r.status === 0) return r.stdout; + if (allowFail) return null; + console.error(`check-conflict-markers: git ${args.join(' ')} failed: ${(r.stderr || r.error?.message || '').trim()}`); + process.exit(2); +} + +/** The added line numbers per path, from a zero-context diff whose headers name paths with fixed a/ and b/ prefixes. */ +function addedLines(diff: string): Map> { + const added = new Map>(); + let file = '', header = false; + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git ')) { header = true; file = ''; continue; } + if (header && line.startsWith('+++ ')) { file = line.startsWith('+++ b/') ? line.slice(6).replace(/\t$/, '') : ''; header = false; continue; } + if (header) continue; + const hunk = /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!hunk || !file) continue; + const start = Number(hunk[1]), count = hunk[2] === undefined ? 1 : Number(hunk[2]); + const set = added.get(file) ?? new Set(); + for (let n = start; n < start + count; n++) set.add(n); + added.set(file, set); + } + return added; +} + +const seen = new Set(); +function scan(f: string, text: string, lines: Set, where: string, hits: Set): void { + const all = text.split('\n'); + if (text.includes('\0') || all.slice(0, 5).some(l => DECLARATION.test(l))) return; + const md = MARKDOWN.test(f); + let fence = '', shown = false, open = false; + all.forEach((raw, idx) => { + const l = raw.replace(/\r$/, ''); + if (md) { + const m = FENCE.exec(l); + if (m && !fence) { fence = m[1]!; shown = SHOWN.test(m[2]!); return; } + if (m && !m[2] && m[1]![0] === fence[0] && m[1]!.length >= fence.length) { fence = ''; shown = false; return; } + if (shown) return; + } + const marker = OPEN.test(l) ? '<'.repeat(7) : CLOSE.test(l) ? '>'.repeat(7) : MID.test(l) && (!md || open) ? '='.repeat(7) : ''; + if (OPEN.test(l)) open = true; + if (CLOSE.test(l)) open = false; + if (!marker || !lines.has(idx + 1) || seen.has(`${f}:${idx + 1}`)) return; + seen.add(`${f}:${idx + 1}`); + hits.add(`CONFLICT_MARKER: ${f}:${idx + 1} ${marker}${where}`); + }); +} + +const i = process.argv.indexOf('--base'); +const base = i > 0 ? process.argv[i + 1]! : 'origin/main'; +const root = git(['rev-parse', '--show-toplevel'])!.trim(); +process.chdir(root); +const fork = git(['merge-base', base, 'HEAD'])!.trim(); +const DIFF = ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--diff-filter=AM']; + +const hits = new Set(); +const committed = addedLines(git([...DIFF, fork, 'HEAD', '--'])!); +for (const [f, lines] of committed) { + const text = git(['show', `HEAD:${f}`], true); + if (text !== null) scan(f, text, lines, '', hits); +} +const working = addedLines(git([...DIFF, fork, '--'])!); +for (const [f, lines] of working) { + let text: string; + try { text = fs.readFileSync(path.join(root, f), 'utf8'); } catch { continue; } + scan(f, text, lines, ' (working tree)', hits); +} +const files = new Set([...committed.keys(), ...working.keys()]).size; +if (hits.size) { + console.log([...hits].sort().join('\n')); + console.log('fix: finish the merge in each file above (keep the right side, delete the marker lines) and commit; a fixture that shows markers on purpose declares the fixture line documented in docs/pregate.md#prepush in its first five lines'); + process.exit(1); +} +console.log(`CONFLICT_MARKERS: none (${files} changed file(s) against ${fork.slice(0, 12)})`); diff --git a/test/conflict-markers.test.ts b/test/conflict-markers.test.ts new file mode 100644 index 000000000..14e285e91 --- /dev/null +++ b/test/conflict-markers.test.ts @@ -0,0 +1,86 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const SCRIPT = path.resolve(import.meta.dir, '..', 'scripts/check-conflict-markers.ts'); +const OPEN = '<'.repeat(7), MID = '='.repeat(7), CLOSE = '>'.repeat(7); +const dirs: string[] = []; +afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); }); + +function repo(base: Record = { 'README.md': 'base\n' }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-markers-')); + dirs.push(root); + const git = (...args: string[]) => { + const r = spawnSync('git', ['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-c', 'user.email=test@example.com', '-c', 'user.name=test', ...args], { cwd: root, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; + }; + git('init', '-q'); + git('symbolic-ref', 'HEAD', 'refs/heads/main'); + const write = (files: Record) => { for (const [p, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true }); fs.writeFileSync(path.join(root, p), c); } }; + write(base); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + git('checkout', '-q', '-b', 'feat'); + const change = (files: Record) => { write(files); git('add', '-A'); git('commit', '-q', '-m', 'change'); }; + const run = (env: Record = {}) => spawnSync('bun', [...bunIsolationArgs(), SCRIPT, '--base', 'main'], { cwd: root, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...env } }); + return { root, git, change, write, run }; +} + +describe('check-conflict-markers (GST-1190: #3927 kept markers inside a template literal and its tests passed)', () => { + // Value: protects=a merge that leaves conflict markers in a changed file is refused before the push, even where the + // markers sit inside a string the parser accepts; fails_when=a marker line in a template literal passes, a markdown + // code block that shows a marker is refused, or a git display setting hides a file; why_new=GSTA-234 #3927; + // seam=a real git repository and the script + test('a marker inside a template literal is refused', () => { + const r = repo(); + r.change({ 'lib/prompt.ts': `export const PROMPT = \`\n${OPEN} HEAD\nkeep this\n${MID}\nor this\n${CLOSE} origin/main\n\`;\n` }); + const out = r.run(); + expect(out.status).toBe(1); + expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:2 ${OPEN}\n`); + expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:4 ${MID}\n`); + expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:6 ${CLOSE}\n`); + }); + + test('a markdown code block that shows a marker is not refused; the same lines outside a fence, or in a bash fence, are', () => { + const r = repo(); + const block = `${OPEN} HEAD\nours\n${MID}\ntheirs\n${CLOSE} main\n`; + r.change({ 'docs/merging.md': `# Merging\n\nGit writes this:\n\n\`\`\`\n${block}\`\`\`\n\nA heading\n${MID}\n` }); + const fenced = r.run(); + expect([fenced.status, fenced.stdout]).toEqual([0, expect.stringContaining('CONFLICT_MARKERS: none')]); + r.change({ 'docs/merging.md': `# Merging\n\n${block}` }); + const bare = r.run(); + expect(bare.status).toBe(1); + expect(bare.stdout).toContain(`CONFLICT_MARKER: docs/merging.md:3 ${OPEN}`); + expect(bare.stdout).toContain(`CONFLICT_MARKER: docs/merging.md:5 ${MID}`); + r.change({ 'skill/sections/x.md': `# X\n\n\`\`\`bash\n${OPEN} HEAD\necho a\n${MID}\necho b\n${CLOSE} feat\n\`\`\`\n` }); + expect(r.run().stdout).toContain(`CONFLICT_MARKER: skill/sections/x.md:4 ${OPEN}`); + }); + + test('only changed lines count; a fixture declares itself on a line of its own, a mention elsewhere does not exempt a file', () => { + const r = repo({ 'old.txt': `${OPEN} kept on main\n` }); + r.change({ 'old.txt': `${OPEN} kept on main\nnew line\n`, 'test/fixtures/merge.txt': `# gstack-conflict-markers: fixture\n${OPEN} HEAD\n${CLOSE} main\n` }); + expect(r.run()).toMatchObject({ status: 0 }); + r.change({ 'cfg.json': `{"fix": "declare gstack-conflict-markers: fixture"}\n${OPEN} HEAD\n` }); + expect(r.run().stdout).toContain(`CONFLICT_MARKER: cfg.json:2 ${OPEN}`); + }); + + test('the pushed commit is read, not only the working tree; untracked files are not; git display settings change nothing', () => { + const r = repo(); + r.change({ 'src/a b/ünï.ts': `const s = \`\n${CLOSE} theirs\n\`;\n` }); + for (const env of []>[{}, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.noprefix', GIT_CONFIG_VALUE_0: 'true' }, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.mnemonicPrefix', GIT_CONFIG_VALUE_0: 'true' }]) { + expect(r.run(env).stdout, JSON.stringify(env)).toContain(`CONFLICT_MARKER: src/a b/ünï.ts:2 ${CLOSE}`); + } + r.write({ 'src/a b/ünï.ts': 'const s = 1;\n', 'merge.ts.orig': `${OPEN} HEAD\n` }); + const repaired = r.run(); + expect([repaired.status, repaired.stdout]).toEqual([1, expect.stringContaining(`CONFLICT_MARKER: src/a b/ünï.ts:2 ${CLOSE}`)]); + expect(repaired.stdout).not.toContain('merge.ts.orig'); + r.write({ 'README.md': `base\n${MID}\n` }); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'repair'); + r.write({ 'lib/wip.ts': `${CLOSE} uncommitted\n` }); r.git('add', 'lib/wip.ts'); r.git('commit', '-q', '-m', 'wip'); r.write({ 'lib/wip.ts': `ok\n${OPEN} x\n` }); + expect(r.run().stdout).toContain(`CONFLICT_MARKER: lib/wip.ts:2 ${OPEN} (working tree)`); + }); +}); diff --git a/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 3edc4595e..88b7055c8 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -275,7 +275,7 @@ describe("gstack's own pre-push guards (GST-1138)", () => { test('the list validates, every guard has a fix, and every test file it names exists', () => { expect(codes(policy)).toEqual([]); - expect(guards.map(g => g.id).sort()).toEqual(['context-budget', 'doc-caps', 'goldens', 'module-size', 'parity-suite', 'skill-size', 'typecheck']); + expect(guards.map(g => g.id).sort()).toEqual(['conflict-markers', 'context-budget', 'doc-caps', 'goldens', 'module-size', 'parity-suite', 'skill-size', 'typecheck']); for (const g of guards) { expect(g.fix, g.id).toBeTruthy(); for (const f of g.command.match(/test\/[\w./-]+\.test\.ts/g) ?? []) expect(fs.existsSync(path.join(GSTACK, f)), `${g.id}: ${f}`).toBe(true); @@ -283,8 +283,8 @@ describe("gstack's own pre-push guards (GST-1138)", () => { }); test('each guard fires on its own inputs and none fires on a docs-only change', () => { - expect(selected('ship/SKILL.md.tmpl')).toEqual(['context-budget', 'goldens', 'parity-suite', 'skill-size']); - expect(selected('lib/pregate/index.ts')).toEqual(['module-size', 'typecheck']); + expect(selected('ship/SKILL.md.tmpl')).toEqual(['conflict-markers', 'context-budget', 'goldens', 'parity-suite', 'skill-size']); + expect(selected('lib/pregate/index.ts')).toEqual(['conflict-markers', 'module-size', 'typecheck']); expect(selected('test/fixtures/golden/claude-ship-SKILL.md')).toEqual(['goldens']); expect(selected('CLAUDE.md')).toEqual(['doc-caps']); expect(selected('docs/pregate.md')).toEqual([]); From b1e9f156296218acede29be3b0daaa9e3d2034ca Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 16:24:35 -0800 Subject: [PATCH 07/21] v1.95.3.0 docs(captain): crew threads ask their Captain for new threads (R68); Capy read returns a view (#3971) * docs(captain): crew threads ask their Captain for new threads (R68); capy read is a view Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the capy skill's context budget for the read-view line (5315 -> 5425) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: plain conjunction (prose lint) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): read-view note moves to docs/captain.md (capy skill budget is fixed); adopt already-created cross-project threads (A1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): keep issues tidy (standing rule) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): issue sweep exemptions (R69) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/factory.md | 2 +- docs/captain.md | 44 +++++++++++++++++++++++++++++++++ package.json | 2 +- test/capy-skill-wrapper.test.ts | 6 +++-- 7 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb4f2f1b..5cf730125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.95.3.0] - 2026-11-04 + +**A crew thread no longer creates threads itself. It asks its Captain, so every new thread joins a crew from the start (R68). docs/captain.md also warns that `tools.capy.read` returns a cut-down view of long files.** + +### Changed +- **docs/captain.md** has a new section, "New threads join the crew". A crew thread asks its Captain for a new thread, and the Captain creates it in the crew. A thread the crew thread already made gets reported by code in that turn, so the Captain can add it with `crew.add`. A thread in another project goes Captain to Captain, and the receiving Captain creates it in its own crew as work, briefs it and returns the result. Only a project with no Captain lets the crew thread create the thread itself, with the code recorded on the work's issue. A cross-project thread the crew thread already made goes to that project's Captain to adopt, with its code on the issue. The cause: GBRA-240 created GBEV-33 directly in another project, and that thread ran outside every crew. +- **docs/captain.md** also has a new section, "Keep issues tidy" (Garry, 2026-11-03: "can we tell all the captains to audit their issues and clean them up. and have a standing rule that that is kept tiday"). Before archiving a thread, close or backlog its issues. On each wake, keep every touched issue accurate. Unassigned Todos nobody will start go to Backlog. A daily sweep closes, re-assigns or backlogs open issues untouched for 2 days whose assignee is archived, or idle with no open PR (R69). The sweep skips issues assigned to a person, and Waiting issues that name what they wait on while it is still open. The target is an In progress count close to working threads plus PRs waiting to merge. +- **The Capy factory context (`factory.md` point 12)** now has the short version and cites that section. Its byte budget in `test/capy-skill-wrapper.test.ts` goes from 3,300 to 3,450: the page's new size rounded up to the next 25 bytes, and no other point was trimmed. +- **docs/captain.md, Drive layout** says that `tools.capy.read` returns about the first 38,000 characters, with each line cut at 2,000. So a read, change and `tools.capy.write` program saves a truncated file, Drive files included. Edit long files with `tools.capy.edit`, or read and write the whole file in bash or Python and check the line count. `tools.drive.history` restores a recent version. This is how the org knowledge base's rules.md lost 37 rules. It lives in the docs page rather than the Capy skill because the skill sits at its fixed 5,315-token context budget. + ## [1.95.2.0] - 2026-11-04 - Pushes from /ship now stop on leftover merge conflict markers. A new `conflict-markers` guard reads every line the branch adds, in the commit being pushed and in the working tree, and refuses one that starts with `<<<<<<<` or `>>>>>>>`, or is exactly `=======`, even inside a template literal that still parses and passes its tests (GST-1190, seen on #3927). Markdown code blocks that show git output (an unlabeled, `text` or `diff` fence) are skipped, while a conflict inside a `bash` or `ts` fence is still refused. A fixture that shows markers on purpose declares `gstack-conflict-markers: fixture` on a line of its own near the top. diff --git a/VERSION b/VERSION index 936be4c1b..d1317fde6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.95.2.0 +1.95.3.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 6ff7813de..00803de87 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.95.2.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.3.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/capy/skill/gstack/factory.md b/capy/skill/gstack/factory.md index 3a334a6bd..5e7c8f077 100644 --- a/capy/skill/gstack/factory.md +++ b/capy/skill/gstack/factory.md @@ -13,5 +13,5 @@ What seven Captain crews agreed on. Your crew's CREW.md and recorded approvals d 9. **Flakes: one rerun, a ledger, a daily fix.** Only the policy's rerun, never for a harness or infra red; a second red is real; never loosen a test. [docs/captain.md: The daily flaky pass] 10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast needing no reply goes in the Captain's notes; never reply just to acknowledge. 11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered steps, the reply word. Never re-ask a standing rule; check what you can yourself. [docs/captain.md: The ask format] -12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain); past the context limit, hand off. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md] +12. **Fresh threads, closed out.** One thread per unit of work; past the context limit, hand off. Crew threads never create threads: ask your Captain (Captain to Captain for another project), or report one you made by code for crew.add. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md; docs/captain.md: New threads join the crew] 13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference] diff --git a/docs/captain.md b/docs/captain.md index adf478e48..62651c89d 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -66,6 +66,13 @@ surface. Keep it small and predictable: Scope instructions in the drive root (`AGENTS.md`, `CREW.md`) apply to every thread in the crew, so a rule written there needs no relaying. +On Capy, `tools.capy.read` returns a view of a file, not the whole file: roughly the first +38,000 characters, with each line cut at 2,000. A program that reads a file, changes it +and writes it back with `tools.capy.write` therefore saves a truncated copy, and drive +files are no exception. Edit long files with `tools.capy.edit`, or read and write the +whole file in bash or Python and check the line count afterward. `tools.drive.history` +restores a recent version of a drive file. + ## CREW.md: the lanes' contract gstack holds how the work is done; a crew's `CREW.md` holds only what is specific to that @@ -131,6 +138,43 @@ such decision with a plain-language explanation of every option: what it is, why matters, what each answer does, and what it costs. A gate code or a tool's result line alone is not an ask. +## New threads join the crew + +A crew thread never creates a thread itself. It asks its Captain, and the Captain creates +the thread, which puts it in the crew from the start: the Captain briefs it, reads its +reports and archives it when its work is done. When a crew thread has already made a +thread, it reports the new thread's code to its Captain in that same turn, and the Captain +adds it to the crew (`crew.add` on Capy). + +A thread in another project goes Captain to Captain, because `crew.add` refuses a thread +from another project and a Captain creates threads only in its own project. The crew +thread asks its Captain, who asks that project's Captain to create and own the thread, and +the results come back the same way. For the receiving Captain, that ask is work: it +creates the thread in its own crew, briefs it and returns the result. Only when the other +project has no Captain does the crew thread create the thread itself, and then it records +the new thread's code on the work's issue so the thread can still be found. A thread the +crew thread already created in another project goes the same way: it reports the code to +its Captain, who asks that project's Captain to adopt the thread, and the code goes on +the work's issue. + +Why: a crew thread once created a thread in another project directly. That thread ran +outside every crew, so no Captain briefed it, tracked it or could archive it. + +## Keep issues tidy + +Issues show what the crew is really doing, so keep them true: + +- Before archiving a thread, close or move to Backlog every issue assigned to it. +- On each wake, leave every issue you touched accurate: its status, assignee and links. +- An unassigned Todo that nobody will start goes to Backlog. +- A daily sweep closes, re-assigns or backlogs any open issue outside Backlog that nobody + has touched for 2 days and whose assignee is archived, or idle with no open PR. +- The sweep skips an issue assigned to a person (an ask waiting on the owner, say) and a + Waiting issue that names what it waits on, by a blocked-by link or a dated comment, while that is still open. + +The target: the Overview's In progress count is about the number of working threads plus +the PRs waiting to merge. + ## Plan before build A lane that builds something plans first. Its first act after the session-start install diff --git a/package.json b/package.json index 25c309d38..3e84d6815 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.95.2", + "version": "1.95.3", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 759ce57b3..21e8e6661 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -587,9 +587,11 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => { return [...new Set(problems)]; } - test('it fits the 3,300-byte budget, and every citation, heading and skill it names resolves', () => { + test('it fits the 3,450-byte budget, and every citation, heading and skill it names resolves', () => { const text = fs.readFileSync(FACTORY, 'utf8'); - expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3300); + // 3,300 since #3634; raised to 3,450 (the page's new size, rounded up to the next 25 bytes) for point 12's + // R68 clause (Garry, 2026-11-03: crew threads ask their Captain for new threads). Raise it only by what a rule needs. + expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3450); expect(text).toMatch(/^# How we work/); expect(citationProblems(text)).toEqual([]); }); From 1cf7835e62096ceec1d76fc1e9275ff50f15ce8c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 16:31:14 -0800 Subject: [PATCH 08/21] v1.95.4.0 fix(autoplan): lean draft capture finds the committed draft on a PR-branch checkout (GST-1241) (#3969) * fix(autoplan): lean draft capture resolves its base like gstack-diff-scope (GST-1241) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): the lean draft base default is gstack-diff-scope's base (GST-1241) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/DRIVER.md | 2 +- autoplan/DRIVER.md.tmpl | 2 +- lib/autoplan-lean.ts | 6 +++-- package.json | 2 +- test/autoplan-lean.test.ts | 40 ++++++++++++++++++++++++++++++++++ 8 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf730125..7dc2e26fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.95.4.0] - 2026-11-04 + +### Fixed +- **The lean `/autoplan` reviewer sees the committed draft on a PR-branch checkout** (GST-1241). Draft capture took `origin/HEAD` as its base, and a Capy machine started on a PR branch points `origin/HEAD` at that branch, so the reviewer got `DRAFT: none (no code changes against origin/)`. It now resolves the base through `lib/default-base.ts`, the same resolver `bin/gstack-diff-scope` uses, which skips an `origin/HEAD` naming the checked-out branch and falls back to the PR base, the remote's live HEAD, then `origin/main`. + ## [1.95.3.0] - 2026-11-04 **A crew thread no longer creates threads itself. It asks its Captain, so every new thread joins a crew from the start (R68). docs/captain.md also warns that `tools.capy.read` returns a cut-down view of long files.** diff --git a/VERSION b/VERSION index d1317fde6..61868db17 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.95.3.0 +1.95.4.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 00803de87..640cf2e75 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.95.3.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.4.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a2aef2c50..47d27398d 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -20,7 +20,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add the `SCOPE_PIN:` marker it prints (``) as the plan's first -line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. +line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. ## The loop diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 2831814f7..a5e67ebe2 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -17,7 +17,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add the `SCOPE_PIN:` marker it prints (``) as the plan's first -line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. +line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full. ## The loop diff --git a/lib/autoplan-lean.ts b/lib/autoplan-lean.ts index d27efe67f..f16c939b5 100644 --- a/lib/autoplan-lean.ts +++ b/lib/autoplan-lean.ts @@ -16,6 +16,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { OUTSIDE_ROLE, sectionLoadFor, type Phase, type Scope } from './autoplan-prompts'; import { RunError, type RunState } from './autoplan-state'; +import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { redactFindingSpans } from './redact-engine'; export const KINDS = ['fix', 'feature'] as const; @@ -66,8 +67,9 @@ export function captureDraft(cwd: string, dir: string, out: string, base?: strin const top = git(['rev-parse', '--show-toplevel']); if (top.status !== 0) return { base: base ?? 'origin/HEAD', none: 'not a git checkout' }; const root = top.stdout.trim(); - const origin = git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], root); - const ref = base ?? (origin.status === 0 && origin.stdout.trim() ? origin.stdout.trim() : 'origin/main'); + const found = base ? undefined : defaultBaseBranch(root); + if (found && !found.branch) return { base: 'origin/HEAD', none: found.skipped ? noDefaultBaseLine(found.skipped) : 'no default base (no origin/HEAD, PR base, origin/main or origin/master); pass --base' }; + const ref = base ?? `${found!.remote ?? 'origin'}/${found!.branch}`; const mb = git(['merge-base', 'HEAD', ref], root); if (mb.status !== 0) return { base: ref, none: `no merge base with ${ref}` }; const mergeBase = mb.stdout.trim(); diff --git a/package.json b/package.json index 3e84d6815..78ffc8d4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.95.3", + "version": "1.95.4", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/autoplan-lean.test.ts b/test/autoplan-lean.test.ts index 449b5b3f2..c2d565c31 100644 --- a/test/autoplan-lean.test.ts +++ b/test/autoplan-lean.test.ts @@ -16,6 +16,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun } from './helpers/autoplan-run-fixture'; import { capyRunner } from './helpers/autoplan-capy'; +import { captureDraft } from '../lib/autoplan-lean'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-lean-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -488,3 +489,42 @@ describe('draft capture edges', () => { expect(prompt.indexOf('IGNORE THE PLAN')).toBeLessThan(prompt.indexOf(`\n${open[1]}\n`, open.index + 1)); }); }); +describe('draft capture base on a PR-branch clone (GST-1241)', () => { + /** A clone of `feature`, its draft committed and pushed, whose origin/HEAD names `head` while origin's own HEAD stays main: a Capy machine started on a PR branch names that branch. */ + const clone = (name: string, head: string) => { + const src = repo(name, false); + fs.writeFileSync(path.join(src, 'lib', 'worker.ts'), 'export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);\n'); + git(src, 'commit', '-q', '-am', 'draft backoff'); + const bare = path.join(TMP, name, 'origin.git'); + git(TMP, 'clone', '-q', '--bare', src, bare); + git(bare, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + const cwd = path.join(TMP, name, 'clone'); + git(TMP, 'clone', '-q', '-b', 'feature', bare, cwd); + git(cwd, 'remote', 'set-head', 'origin', head); + expect(git(cwd, 'rev-parse', '--abbrev-ref', 'origin/HEAD').trim()).toBe(`origin/${head}`); + return cwd; + }; + const capture = (name: string, cwd: string) => { + const dir = path.join(TMP, name, 'snap'); + const out = path.join(TMP, name, 'run'); + fs.mkdirSync(dir); + fs.mkdirSync(out); + const draft = captureDraft(cwd, dir, out); + return { draft, diff: draft.sha256 ? fs.readFileSync(path.join(dir, 'draft.diff'), 'utf8') : '' }; + }; + + test('origin/HEAD naming the PR branch still captures the committed draft against main', () => { + const { draft, diff } = capture('pr-head', clone('pr-head', 'feature')); + expect(draft.none).toBeUndefined(); + expect(draft.base).toBe('origin/main'); + expect(draft.files).toEqual(['lib/worker.ts']); + expect(diff).toContain('+export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);'); + }); + + test('control: origin/HEAD naming main captures the same draft', () => { + const main = capture('main-head', clone('main-head', 'main')); + expect(main.draft.base).toBe('origin/main'); + expect(main.draft.files).toEqual(['lib/worker.ts']); + expect(main.diff).toContain('+export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);'); + }); +}); From fa1e4268ab014d38abe66bc730086e09a7f001a4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 17:21:36 -0800 Subject: [PATCH 09/21] chore(ship-policy): gstack lands its own PRs with merge: direct (min_gstack_version 1.95.0.0) (#3977) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .gstack/ship-policy.json | 3 ++- CHANGELOG.md | 4 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index a37b11ca4..48dc10cb5 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -15,5 +15,6 @@ "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, - "min_gstack_version": "1.91.340.0" + "merge": "direct", + "min_gstack_version": "1.95.0.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc2e26fd..16e005ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.95.5.0] - 2026-11-04 + +- **gstack's own PRs now merge at their own green head.** Its ship policy sets `merge: direct`, so the Captain lands each READY PR with `gstack-ship-queue land --pr --pin ` instead of a train, and VERSION waits for the release stamp. The policy's `min_gstack_version` rises to 1.95.0.0, the release that introduced the key, so an older gstack refuses it by name (`POLICY_GSTACK_TOO_OLD`) and asks for an upgrade. + ## [1.95.4.0] - 2026-11-04 ### Fixed diff --git a/VERSION b/VERSION index 61868db17..8b0810197 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.95.4.0 +1.95.5.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 640cf2e75..e27e6664f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.95.4.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.95.5.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index 78ffc8d4d..2827b63e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.95.4", + "version": "1.95.5", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", From cff1de203943195ccfa1c6ebbe58cf9488d14408 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 17:22:38 -0800 Subject: [PATCH 10/21] v1.95.0.0 feat(release-gate): gate main every 8 hours and feed the stable release (inert until adopted) (#3917) * feat(release-gate): gate main every 8 hours and feed the stable release (inert until adopted) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(release-gate): exit codes match the bin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(release-gate): blobless checkout and free-only workflow classification Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (finish an open gate before a new head; neutral check for an unfinished gate) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, pass 2 (the workflow reads main head itself; an open gate on stable or older than three cadences) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (no paid dispatch without the record on origin; held or failed leaves the gate job green) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(release-gate): re-pin the key to 1.95.0.0 above main; move assertInsideRoot to lib/path-containment.ts to keep ship-policy.ts under 800 lines Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(release-gate): type the narrow fixture rows (test typecheck ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(release-gate): charge the trial reservation for a null or missing bisect prefix cost so the cap holds across rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(capy-install): wrapper 4 follows the update channel (Step 14.5 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate-selection): mark the fixture wrapper bump as a deliberate old literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate-selection): put the literal marker on its own comment line Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(release-gate-workflow): run policy steps with Git Bash argument conversion off, as on the Linux runners (windows-scoped-shard) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: release gate evidence and hold in releases.md, release_gate row in the policy key table (Step 14.5 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): defer the release_gate key-table row to the adoption PR (named documentation risk, GSTA-258) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/evals-main-backstop.yml | 8 + .github/workflows/evals.yml | 16 +- .github/workflows/release-gate.yml | 125 ++++++ bin/gstack-release-gate | 62 +++ capy/skill/gstack/scripts/install.sh | 39 +- changelog.d/gsta258-l1-release-gate.md | 17 + docs/captain.md | 5 +- docs/capy-install.md | 7 +- docs/releases.md | 22 +- docs/ship-policy.md | 82 ++++ lib/path-containment.ts | 23 + lib/release-gate-policy.ts | 57 +++ lib/release-gate.ts | 413 ++++++++++++++++++ lib/ship-policy.ts | 29 +- lib/ship-queue-train-record.ts | 19 +- .../scripts/test-pr-profile.ts.json | 2 +- scripts/release-cut.ts | 33 +- scripts/test-pr-profile.ts | 1 + scripts/train-gate.ts | 16 +- test/capy-skill-wrapper.test.ts | 70 ++- test/pregate-selection.test.ts | 1 + test/release-cut.test.ts | 44 ++ test/release-gate-workflow.test.ts | 139 ++++++ test/release-gate.test.ts | 396 +++++++++++++++++ test/train-gate.test.ts | 14 + 25 files changed, 1585 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/release-gate.yml create mode 100755 bin/gstack-release-gate create mode 100644 changelog.d/gsta258-l1-release-gate.md create mode 100644 lib/path-containment.ts create mode 100644 lib/release-gate-policy.ts create mode 100644 lib/release-gate.ts create mode 100644 test/release-gate-workflow.test.ts create mode 100644 test/release-gate.test.ts diff --git a/.github/workflows/evals-main-backstop.yml b/.github/workflows/evals-main-backstop.yml index 2c3f30303..a864b8edf 100644 --- a/.github/workflows/evals-main-backstop.yml +++ b/.github/workflows/evals-main-backstop.yml @@ -12,6 +12,10 @@ name: Main backstop gate # (success when a green train covers the tree or the backstop gated it green, # neutral when the push was superseded or deferred, failure when it froze the # queue or could not finish); the stable release reads it (docs/releases.md). +# +# Once main's policy declares a `release_gate` block, the release gate +# (release-gate.yml, every 8 hours) gates main instead and this backstop stands +# down: no gate, no freeze and no check run on a push. on: push: branches: [main] @@ -60,9 +64,13 @@ jobs: done fi train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } + release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy") if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY" + elif [ "$release" = true ]; then + echo "train=false" >> "$GITHUB_OUTPUT" + echo "- Backstop stands down: main's .gstack/ship-policy.json declares a release_gate block; the scheduled release gate gates main (release-gate.yml, docs/ship-policy.md#release-gate)" >> "$GITHUB_STEP_SUMMARY" else echo "train=true" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index fd5ed557c..b662c8867 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -166,7 +166,8 @@ jobs: pull-requests: read outputs: mode: ${{ steps.route.outputs.mode }} - record_artifact: ${{ steps.policy.outputs.record_artifact }} + # A release bisect (gstack-release-gate) names its own prefix-record artifact; everything else uses the train block's. + record_artifact: ${{ steps.route.outputs.record_artifact || steps.policy.outputs.record_artifact }} full_gate_check: ${{ steps.policy.outputs.full_gate_check }} screen_head: ${{ steps.screen.outputs.head_sha }} screen_base: ${{ steps.screen.outputs.base_sha }} @@ -218,10 +219,23 @@ jobs: echo "::warning::the train block at ${base} names no valid record_artifact and full_gate_check; this run routes as before" fi + # The release gate's bisect (gstack-release-gate) routes only under a + # release_gate block at the same base revision; git and jq only. + - name: Read the release gate policy (base revision) + id: release_policy + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + policy=$(git show "${BASE_SHA:-origin/${DEFAULT_BRANCH}}:.gstack/ship-policy.json" 2>/dev/null || echo null) + release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy" 2>/dev/null || echo false) + echo "release=${release}" >> "$GITHUB_OUTPUT" + - name: Route this run id: route env: TRAIN_POLICY: ${{ steps.policy.outputs.train }} + RELEASE_POLICY: ${{ steps.release_policy.outputs.release }} NONCE: ${{ inputs.nonce }} EXPECTED_SHA: ${{ inputs.expected_sha }} EVALS_ALL: ${{ inputs.evals_all }} diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 000000000..c696e2793 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,125 @@ +name: Release gate +# The release gate (docs/ship-policy.md#release-gate; bin/gstack-release-gate, +# lib/release-gate.ts): the full paid gate on main's head every 8 hours, only +# when main moved since the last gate. It records the verdict as a release +# note in refs/notes/gstack-trains, publishes the main-backstop check run on +# the gated commit (what scripts/release-cut.ts reads as gated), and bisects +# a red gate's cases. It never writes the stable branch: release-cut is its +# only writer, and refuses every cut while the newest gate is red. Inert (no +# paid work, nothing written) unless main's .gstack/ship-policy.json declares +# a release_gate block; with one, the per-push main backstop +# (evals-main-backstop.yml) stands down and this replaces it. +on: + schedule: + # Every 8 hours (RELEASE_GATE_SCHEDULE_HOURS in lib/release-gate-policy.ts; + # test/release-gate-workflow.test.ts pins the two together), off the hour. + - cron: '17 */8 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Print what the gate would test and dispatch nothing' + type: boolean + default: false + ignore_cadence: + description: 'Gate now even if the last gate is younger than cadence_hours (main must still have moved)' + type: boolean + default: true + +# One gate at a time; a second run waits for the first. +concurrency: + group: release-gate + cancel-in-progress: false + +permissions: {} + +jobs: + release-gate: + runs-on: ubuntu-24.04 + # A dispatch gates only from the default branch, with its tooling. + if: github.ref_name == github.event.repository.default_branch + # The full gate (270) + a bisect round + margin; past --budget-minutes the + # record stays resumable by the next run. + timeout-minutes: 345 + outputs: + action: ${{ steps.gate.outputs.action }} + sha: ${{ steps.gate.outputs.sha }} + check: ${{ steps.gate.outputs.check }} + permissions: + # Pushes the release record to refs/notes/gstack-trains and a bisect's + # release-gate/ branch (deleted after its round). + contents: write + # Dispatches the gate and reads its runs and artifacts. + actions: write + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + filter: blob:none + + # Read with git and jq only: without a release_gate block nothing below runs. + - name: Read the release gate policy (main) + id: policy + run: | + file=.gstack/ship-policy.json + policy=$(git show "${GITHUB_SHA}:${file}" 2>/dev/null || echo null) + gate=$(jq -c 'if (.release_gate | type) == "object" then .release_gate else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:${file} is not valid JSON; fix the policy file" >&2; exit 1; } + if [ -z "$gate" ] || [ "$gate" = null ]; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "- Release gate inert: main's ${file} declares no release_gate block; the per-push backstop gates main" >> "$GITHUB_STEP_SUMMARY" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.policy.outputs.release == 'true' + with: + bun-version: 1.4.2 + + - name: Gate main's head when it moved + id: gate + if: steps.policy.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state + DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + IGNORE_CADENCE: ${{ inputs.ignore_cadence && '1' || '' }} + # held and failed are verdicts the release-check job publishes on the gated commit. Failing this job would + # leave a failed check on main's head, which release-cut reads as red even after that head gates green, so + # only a run that recorded no action (a crash) fails it. + run: | + rc=0 + ./bin/gstack-release-gate run --repo "$GITHUB_REPOSITORY" --budget-minutes 330 \ + --summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT" ${DRY_RUN:+--dry-run} ${IGNORE_CADENCE:+--ignore-cadence} || rc=$? + if [ "$rc" != 0 ] && ! grep -qE '^action=(held|failed)$' "$GITHUB_OUTPUT"; then exit "$rc"; fi + + # The gated commit's check run (release_gate.check_name, main-backstop), the + # evidence scripts/release-cut.ts reads. Its own job with checks: write and + # no repository code, as for backstop-check: only the recorded action word. + release-check: + runs-on: ubuntu-24.04 + needs: release-gate + if: ${{ !cancelled() && contains(fromJSON('["gated","held","failed"]'), needs.release-gate.outputs.action) && needs.release-gate.outputs.sha != '' }} + timeout-minutes: 5 + permissions: + checks: write + steps: + - name: Publish the release gate's check run on the gated commit + env: + GH_TOKEN: ${{ github.token }} + ACTION: ${{ needs.release-gate.outputs.action }} + SHA: ${{ needs.release-gate.outputs.sha }} + CHECK: ${{ needs.release-gate.outputs.check }} + run: | + case "$ACTION" in + gated) conclusion=success ;; + held) conclusion=failure ;; + failed) conclusion=neutral ;; + *) echo "::error::unknown release gate action '$ACTION'"; exit 1 ;; + esac + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::'$SHA' is not a commit SHA"; exit 1; } + [[ "$CHECK" =~ ^[A-Za-z0-9_./-]{1,64}$ ]] || { echo "::error::'$CHECK' is not a check-run name"; exit 1; } + gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name="$CHECK" -f head_sha="$SHA" -f status=completed -f conclusion="$conclusion" \ + -f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + -f "output[title]=Release gate: ${ACTION}" -f "output[summary]=${ACTION}: see the release-gate job summary of run ${GITHUB_RUN_ID}" >/dev/null + echo "- ${CHECK}: ${conclusion} (release gate ${ACTION}) on ${SHA}" >> "$GITHUB_STEP_SUMMARY" diff --git a/bin/gstack-release-gate b/bin/gstack-release-gate new file mode 100755 index 000000000..f523a0fd1 --- /dev/null +++ b/bin/gstack-release-gate @@ -0,0 +1,62 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-release-gate — the full paid gate on main every cadence_hours, only + * when main moved since the last gate (docs/ship-policy.md#release-gate; + * logic: lib/release-gate.ts). The gate never moves the stable branch: + * scripts/release-cut.ts is its only writer and holds every cut while the + * newest gate is red. + * + * gstack-release-gate run [--dry-run] [--sha
] [--ignore-cadence] + * gstack-release-gate status + * gstack-release-gate bisect [--sha ] [--spend-cap ] [--dry-run] + * + * Every subcommand takes --repo (default $GITHUB_REPOSITORY, else + * gh repo view), --policy (a whole ship policy, validated; default the + * reviewed .gstack/ship-policy.json on origin/main), --remote (origin), + * --summary (appends each line as a bullet) and --outputs + * (appends action= and sha= for the workflow). run and bisect + * take --poll-seconds (60) and --budget-minutes (330): past the budget nothing + * waits, and the record stays resumable by the next run or bisect. + * Exit 0: inert, skipped, dry run, gated green, or status with no hold; + * 1: red (held), incomplete or could not finish; 2: usage. + */ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import { bisectGate, gateStatus, loadReleaseGate, runGate, type GateEnv, type GateOut } from '../lib/release-gate'; +import { runGh } from '../scripts/recover-receipts'; +import { gitIn } from '../scripts/train-gate'; + +const USAGE = 'usage: gstack-release-gate run [--dry-run] [--sha ] [--ignore-cadence] | status | bisect [--sha ] [--spend-cap ] [--dry-run]\n' + + ' common: [--repo ] [--policy ] [--remote ] [--summary ] [--outputs ] [--poll-seconds ] [--budget-minutes ]'; +const [command, ...rest] = process.argv.slice(2); +const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; }; +const has = (name: string) => rest.includes(`--${name}`); +const usage = (why?: string): never => { console.error(why ? `${why}\n${USAGE}` : USAGE); process.exit(2); }; +if (command === '--help' || command === '-h') { console.log(USAGE); process.exit(0); } +if (!['run', 'status', 'bisect'].includes(command ?? '')) usage(); +const sha = flag('sha'); +if (sha !== undefined && !/^[0-9a-f]{40}$/.test(sha)) usage('--sha needs a full 40-hex commit'); +const spendCap = flag('spend-cap') === undefined ? undefined : Number(flag('spend-cap')); +if (spendCap !== undefined && !(spendCap >= 0)) usage('--spend-cap needs dollars'); +const repoRoot = process.cwd(); +const repo = flag('repo') ?? process.env.GITHUB_REPOSITORY ?? runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], 60_000).stdout.trim(); +if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) usage(`cannot name the repository (${JSON.stringify(repo)}); pass --repo `); +const loaded = loadReleaseGate(repoRoot, { policyFile: flag('policy') }); +const env: GateEnv = { + repo, repoRoot, remote: flag('remote') ?? 'origin', gh: runGh, git: gitIn(repoRoot), now: Date.now, sleep: ms => Bun.sleepSync(ms), + random: () => randomBytes(3).toString('hex'), pollMs: Number(flag('poll-seconds') ?? 60) * 1000, + deadline: Date.now() + Number(flag('budget-minutes') ?? 330) * 60_000, +}; +let out: GateOut; +if ('error' in loaded) out = { action: 'failed', sha: null, lines: [`RELEASE: policy refused: ${loaded.error}`] }; +else if (command === 'run') out = runGate(env, { ...loaded, sha, dryRun: has('dry-run'), ignoreCadence: has('ignore-cadence') }); +else if (command === 'bisect') out = bisectGate(env, { ...loaded, sha, spendCap, dryRun: has('dry-run') }); +else out = gateStatus(env, loaded); +const summary = flag('summary'); +for (const line of out.lines) { + console.log(line); + if (summary) fs.appendFileSync(summary, `- ${line}\n`); +} +const outputs = flag('outputs'); +if (outputs) fs.appendFileSync(outputs, `action=${out.action}\nsha=${out.sha ?? ''}\ncheck=${'gate' in loaded && loaded.gate ? loaded.gate.check_name : ''}\n`); +process.exit(out.action === 'held' || out.action === 'failed' ? 1 : 0); diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh index 2cb8ae4c2..f7f2eaa1b 100755 --- a/capy/skill/gstack/scripts/install.sh +++ b/capy/skill/gstack/scripts/install.sh @@ -17,6 +17,12 @@ # checkout it prints `gstack: ok not-needed for=none (...)` and exits 0; with # one it prints the same trailer with the checkout's version, running nothing # from the checkout. That ok admits no workflow (no verdict is written). +# From revision 4 the clone and the floor fast-forward follow the update +# channel (docs/releases.md#channels): GSTACK_UPDATE_CHANNEL=stable|main, else +# the checkout's bin/gstack-update-channel.sh (auto: stable in team mode), +# else main. On stable, a fresh clone checks out origin's stable branch and +# the floor fast-forward goes to stable, each only when stable exists and +# meets the supported minimum; otherwise main, as before. --revision wins. # Everything else (Bun, setup, the doctor, the verdict, the launch line) is # the checkout's. The last line is the `gstack:` trailer; exit 0 only on # `gstack: ok`. Contract: docs/capy-install.md; tests: @@ -27,9 +33,10 @@ # bash install.sh --check --for none this machine runs no workflow: ok not-needed, nothing installed # bash install.sh [--for | --require ] [--with-browser] [--revision ] [--project ] # Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO, -# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s). +# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s), +# GSTACK_UPDATE_CHANNEL=main (follow every merge even in team mode) or stable. set -u -GSTACK_CAPY_WRAPPER=3 +GSTACK_CAPY_WRAPPER=4 self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}" @@ -74,6 +81,24 @@ below() { return 1 } support_get() { printf '%s\n' "$1" | sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" | head -1; } +# channel_stable ROOT MIN [FETCH_FLAG] — true, with origin's stable commit in $stable_sha, when +# this install follows stable and origin's stable exists and meets MIN; says why not otherwise. +# FETCH_FLAG is --depth=1 for a fresh clone only (never --depth into an existing checkout). +channel_stable() { + local ch="${GSTACK_UPDATE_CHANNEL:-}" ver + case "$ch" in stable|main) ;; *) ch=$(bash "$1/bin/gstack-update-channel.sh" "$1" 2>/dev/null | head -1) ;; esac + [ "$ch" = stable ] || return 1 + if ! bounded git -C "$1" fetch --quiet ${3:+"$3"} origin stable 2>/dev/null; then + echo "gstack-capy-skill: channel stable, but origin has no stable branch yet (or the fetch failed); following main" + return 1 + fi + stable_sha=$(git -C "$1" rev-parse FETCH_HEAD) ver=$(git -C "$1" show FETCH_HEAD:VERSION 2>/dev/null | head -1) + if [ -n "$2" ] && below "${ver:-0}" "$2"; then + echo "gstack-capy-skill: channel stable is at ${ver:-unknown}, below the supported minimum $2; following main" + return 1 + fi + echo "gstack-capy-skill: channel stable ${ver:-unknown} (GSTACK_UPDATE_CHANNEL=main follows every merge)" +} mkdir -p "$(dirname "$root")" if [ "$check" -eq 0 ]; then @@ -100,6 +125,10 @@ if [ ! -f "$root/setup" ]; then fi remote_min=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_MIN_REVISION) remote_version=$(head -1 "$root/VERSION" 2>/dev/null) + if [ -z "$revision" ] && channel_stable "$root" "$remote_min" --depth=1; then + git -C "$root" reset --quiet --hard "$stable_sha" \ + || { echo "gstack: fail installer — could not check out origin/stable in $root; fix: GSTACK_UPDATE_CHANNEL=main $fix_cmd"; exit 1; } + fi export GSTACK_CAPY_CLONED="$repo" else # The remote's rules: fetch the default branch into FETCH_HEAD (never --depth @@ -140,9 +169,11 @@ else echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min ($cause); fix: $fix (REVISION_BELOW_MINIMUM)" exit 1 fi - if ! git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null; then + target=$(git -C "$root" rev-parse FETCH_HEAD) + if channel_stable "$root" "$remote_min" && git -C "$root" merge-base --is-ancestor HEAD "$stable_sha" 2>/dev/null; then target=$stable_sha; fi + if ! git -C "$root" merge --ff-only -q "$target" 2>/dev/null; then git -C "$root" fetch --quiet --unshallow origin "$default" 2>/dev/null - git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null \ + git -C "$root" merge --ff-only -q "$target" 2>/dev/null \ || { echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min (fast-forward failed); fix: git -C $root status, then $fix_cmd (REVISION_BELOW_MINIMUM)"; exit 1; } fi echo "gstack-capy-skill: upgraded $installed -> $(head -1 "$root/VERSION")" diff --git a/changelog.d/gsta258-l1-release-gate.md b/changelog.d/gsta258-l1-release-gate.md new file mode 100644 index 000000000..19c22ce53 --- /dev/null +++ b/changelog.d/gsta258-l1-release-gate.md @@ -0,0 +1,17 @@ +--- +bump: minor +--- +**A release gate can now gate main every 8 hours instead of on every push, and it feeds the stable release.** It is off until main's `.gstack/ship-policy.json` declares a `release_gate` block, so this release changes nothing on its own. With the block, `.github/workflows/release-gate.yml` runs `gstack-release-gate run` every 8 hours. When main moved since the last gate, it runs the full paid gate on main's head once, records the verdict as a `release` note in `refs/notes/gstack-trains`, and publishes the `main-backstop` check run on that commit. A red gate bisects its red cases within `bisect_spend_cap_usd` and names the first bad commit. The per-push main backstop stands down while the block is set, so main is never gated twice. `scripts/release-cut.ts` is still the only thing that moves `stable`, and its 72-hour floor and exceptions are unchanged. It now reads a green release record as proof the commit was gated, and it refuses every cut while the newest release gate is red with no green gate on a later commit. That hold is the freeze. See docs/ship-policy.md#release-gate. + +### Added +- **`release_gate` policy key** (`lib/release-gate-policy.ts`, which needs `min_gstack_version` at this release): `cadence_hours` (8 to 168), `gate_workflow`, `branch`, `stable_ref`, `check_name`, `on_red: hold-release` and `bisect_spend_cap_usd`, every field required. +- **`bin/gstack-release-gate`** (`lib/release-gate.ts`): `run [--dry-run] [--sha] [--ignore-cadence]`, `status` and `bisect`. `--policy ` reads the block from a file, for a dry run or a proof before adoption. Exit 0 means gated, skipped, inert or a dry run; 1 means held, failed or a refused policy; 2 is a usage error. +- **`.github/workflows/release-gate.yml`**: an 8-hour schedule and a manual dispatch. The check run comes from a separate job that holds `checks: write` and runs no repository code. +- **Capy skill installer follows the update channel** (`capy/skill/gstack/scripts/install.sh`, wrapper revision 4). With `GSTACK_UPDATE_CHANNEL=stable` or a stable `update_channel`, a fresh clone or a floor upgrade goes to `stable` when it meets the support floor, and to main otherwise. `--revision` still wins. + +### Changed +- **`evals-main-backstop.yml`** stands down (no gate, no freeze, no check run) when main's policy declares `release_gate`. +- **`evals.yml`** routes a release gate bisect (`release-gate/` refs) only under a `release_gate` block at the base revision, and that bisect publishes its prefix records as `gstack-release-record`. + +### Tests +- `test/release-gate.test.ts` drives run, bisect and status against a fake `gh` and a scratch remote. `test/release-gate-workflow.test.ts` runs the workflows' policy steps on scratch repositories and pins the cron to the cadence floor. `test/release-cut.test.ts` proves a red release record refuses the cut and a green one on a later commit releases it. `test/train-gate.test.ts` covers the bisect route, and `test/capy-skill-wrapper.test.ts` covers the stable channel and its fallback to main. diff --git a/docs/captain.md b/docs/captain.md index 62651c89d..bf9cae04a 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -370,7 +370,10 @@ through an urgent one-PR train (or `train build --repair` while the queue is fro refuses `--urgent`); all prefixes green earns exactly one unchanged rebuild; anything else goes to a repair lane. An infra-only census earns one re-dispatch. A backstop gate runs on every push to the base branch and freezes the queue when it finds -a tree no green train covered; one repair PR at its exact head clears the freeze. +a tree no green train covered; one repair PR at its exact head clears the freeze. A +policy with a `release_gate` block swaps that per-push backstop for one full gate on main +every 8 hours, whose red verdict holds the stable release instead of freezing the queue +([ship-policy.md#release-gate](ship-policy.md#release-gate)). **Wave PRs when the queue is long.** Every slot pays its own stamp, its own round of required checks and its own version, one after another. When more diff --git a/docs/capy-install.md b/docs/capy-install.md index a1da5fd13..8e4075529 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -4,7 +4,7 @@ Three layers, each owned by gstack: | Layer | Where | Job | |---|---|---| -| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | +| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env` (from wrapper 4 a fresh clone or the floor fast-forward follows `stable` instead when the update channel resolves to it and `stable` meets that minimum, see [releases.md](releases.md#channels)); in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | | Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair | | Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer | @@ -606,7 +606,10 @@ Wrapper 2 only added optional exports (the trailer's `latest:` note and the `cloned` line). Wrapper 3 answers `--check --for none` itself (a copy at 1 or 2 prints `fail not-installed` for a coordinator machine and still works for every other call), so the minimum is 3: every older Drive copy prints the owner -action until the owner recopies `capy/skill/gstack/`. +action until the owner recopies `capy/skill/gstack/`. Wrapper 4 only adds the +update-channel choice for the clone and the floor fast-forward +(`GSTACK_UPDATE_CHANNEL=stable|main`, else the checkout's channel, else main); +the minimum stays 3, so a copy at 3 keeps working. ## Exit codes diff --git a/docs/releases.md b/docs/releases.md index de38b8ff4..520a22987 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,11 +29,12 @@ it has completed and none failed, and `refs/notes/gstack-trains` holds no active freeze. A cancelled check run counts as red. A commit with a check run still going is skipped and the next older one is considered, back through 7 days or 200 first-parent commits. The workflow runs no tests and no paid evals -of its own; it only reads what the trains and the backstop already recorded. +of its own; it only reads what the trains, the backstop and the release gate +already recorded. With nothing green in the window it fails and lists why each commit was skipped. -**Gated** means one of two pieces of evidence. The first is the +**Gated** means one of three pieces of evidence. The first is the `main-backstop` check run: the Main backstop gate workflow publishes it on each push to main from a job of its own, success when a green train record covers the tree or the backstop gated it green, neutral when main moved past @@ -45,11 +46,17 @@ its tree: those entries are the main commits `gstack-ship-queue verify --train` confirmed, so a commit a green train merged is gated even when trains merged faster than the backstop could settle and its own backstop run ended neutral or never ran. The `CANDIDATE` line says which: `gated by -main-backstop` or `gated by train `. Train evidence relaxes nothing +main-backstop` or `gated by train `. The third is a GREEN release +record (kind `release`) on the commit, written by the release gate +([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) when the +policy declares `release_gate`; the line then says `gated by release gate run +`. A RED release record with no GREEN one on a later commit that +contains it refuses the cut like a freeze. Train evidence relaxes nothing else: a red backstop run, a failed or missing `free-tests` run, any other red check run or a run still going keeps the commit out, and a record in any other state (`red`, `superseded`, `free-green`, …) is not evidence. A commit with -neither a successful backstop run nor a train record is not released: a +none of the three (no successful backstop run, train record or GREEN release +record) is not released: a dependabot push, a policy without a train block, or a tree that was never gated. @@ -240,6 +247,10 @@ overrides the key for one command. The resolution lives in one helper, - `bin/gstack-upgrade` and `/gstack-upgrade`: they fast-forward to the channel ref (`UPSTREAM_REF=origin/stable` in the skill's output) and say which channel they followed. +- `capy/skill/gstack/scripts/install.sh`, the Capy skill's installer (wrapper + revision 4): a fresh clone or a floor upgrade follows `stable` when the channel + resolves to it and `stable` meets the support floor, and follows main + otherwise. `--revision` still pins an exact commit. Two edge cases are handled on purpose: @@ -302,11 +313,12 @@ is exercised against real check runs before it merges. | Line | Meaning | |---|---| -| `CANDIDATE: () gated by main-backstop|train ` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) | +| `CANDIDATE: () gated by main-backstop|train |release gate run ` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) | | `NOTHING_NEW: origin/main is already released as v` | nothing merged since the last release; exit 0 | | `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) | | `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward | | `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) | +| `REFUSED: release gate red on () and no gate on a later commit is green; ...` | the release gate's hold: land the revert or fix-forward, then `gstack-release-gate run --ignore-cadence` ([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) | | `REFUSED: tag v already names ` | a version was stamped twice; needs a look before anything is cut | | `REFUSED_FLOOR: stable moved to v at