From 87d7f0f54e5fc2fc01904e46153a0b379dd8dd87 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 4 Nov 2026 16:19:30 -0800 Subject: [PATCH] fix(ship-receipt): record the installed gstack VERSION, not the newest policy-key version (GST-1366) (#4002) * fix(ship-receipt): record the installed gstack VERSION, not the newest policy-key version (GST-1366) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fragment for GST-1366 receipt gstack_version 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> --- bin/gstack-ship-receipt | 4 +-- .../gsta-286-receipt-installed-version.md | 1 + docs/ship-policy.md | 5 ++-- lib/headless-artifacts.ts | 2 +- lib/ship-policy.ts | 11 ++++++-- test/ship-receipt-depth.test.ts | 28 ++++++++++++++++++- 6 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 changelog.d/gsta-286-receipt-installed-version.md diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index f22df5a64..86b5e96ca 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -41,7 +41,7 @@ import { branchSides, stampOnlyLine, type BranchSide } from '../lib/stamp-only'; import { runScriptSync } from '../lib/script-invocation'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; import { exceptionConsumerProblem } from '../lib/pregate/flaky-exception'; -import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; +import { installedGstackVersion, isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, @@ -305,7 +305,7 @@ function cmdWrite(rest: string[], json: boolean): number { policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product, - gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined, + gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined, base_red: pause ? receiptBaseRed(pause) : undefined, review_exclude: receiptExclude(review.exclude), no_stop: noStopArg(flag(rest, '--no-stop')), diff --git a/changelog.d/gsta-286-receipt-installed-version.md b/changelog.d/gsta-286-receipt-installed-version.md new file mode 100644 index 000000000..bd8bca06f --- /dev/null +++ b/changelog.d/gsta-286-receipt-installed-version.md @@ -0,0 +1 @@ +- **A /ship receipt now names the gstack release that wrote it.** `gstack_version` in the receipt used to record the version gstack reads policies as, which jumps to the newest policy key the checkout knows. On a 1.95.5.0 checkout that carries the unreleased 1.96.0.0 keys, every receipt said 1.96.0.0, a release that never ran. The receipt now records the checkout's VERSION file. Policy reading is unchanged, so a checkout that carries a new key still reads policies written for it. (GST-1366) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 6cf3a78a3..cb65139bd 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1279,8 +1279,9 @@ unreadable base policy is `POLICY_SOURCE_UNAVAILABLE`); one (`absent/ over` for a path the tree lacks), exit 1 on any `over`, whose 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_depth` and `gstack_version`.** `write` records the gstack release +that wrote the receipt (its `VERSION` file, never the newer policy-key version +an unstamped tree reads policies as) 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, 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)), diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 275ffa3c8..cb0937937 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -118,7 +118,7 @@ export interface ShipReceipt { carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ head_tree?: string; product_patch_id?: string; - /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ + /** The gstack release that wrote the receipt (its VERSION file, never the newer policy-key version it reads policies as), and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ gstack_version?: string; review_depth?: { depth: 'platform' | 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[]; mode?: 'lean' | 'panel' | 'platform'; platform_review?: { id: string; head: string; reviewer: string }; declared?: string[]; detected?: string[] }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 3a17009a5..7dd4d2688 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -231,14 +231,19 @@ export * from './ship-policy-keys'; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; +/** The release this checkout installs: its VERSION file, the version a ship receipt records (GST-1366). */ +export function installedGstackVersion(): string { + try { return fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch { return '0.0.0.0'; } +} + /** * The release this checkout reads policies as: its VERSION file, or the * newest key it knows when that is later (an unstamped tree that carries a - * new key already reads policies written for it). + * new key already reads policies written for it). Never a recorded version: + * that is installedGstackVersion(). */ export function runningGstackVersion(): string { - let own = '0.0.0.0'; - try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {} + const own = installedGstackVersion(); const known = [...Object.values(POLICY_KEY_INTRODUCED), BUMP_NEVER_KEY_VERSION, TRAIN_GATES_KEY_VERSION].reduce((a, b) => (newer(b, a) ? b : a)); return parseVersion(own) && !newer(known, own) ? own : known; } diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts index 8425d7a51..bd9403a7d 100644 --- a/test/ship-receipt-depth.test.ts +++ b/test/ship-receipt-depth.test.ts @@ -9,10 +9,15 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { EXIT } from '../lib/headless-artifacts'; import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; import { bindShippedReceipt, receiptDepthAtHead, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; -import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { runScriptSync } from '../lib/script-invocation'; +import { BUMP_NEVER_KEY_VERSION, POLICY_KEY_INTRODUCED, TRAIN_GATES_KEY_VERSION } from '../lib/ship-policy'; +import { cmpVersion, parseVersion } from '../lib/version-source'; +import { cleanup, commit, makeFixtureRepo, ROOT, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); @@ -54,6 +59,27 @@ describe('write records the review depth at the checked-out head', () => { expect(bad.err).toContain('(RECEIPT_INVALID)'); expect(fs.existsSync(other)).toBe(false); }, 60_000); + + // Value: protects=a receipt names the gstack release that wrote it, never a newer policy key its tree carries, so a + // version check against a receipt cannot be passed by an older build (GST-1366); fails_when=write records the + // key-inflated version policy reading uses, or policy reading stops seeing the newest key; seam=none + test('a gstack whose VERSION is older than its newest policy key records that VERSION; policy reading still reads at the key', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'lib/util.js': lines(3) }); + const installed = path.join(repo.root, 'installed-gstack'); + const sourceOnly = { recursive: true, filter: (file: string) => path.basename(file) !== 'node_modules' }; + for (const dir of ['bin', 'lib', 'scripts']) fs.cpSync(path.join(ROOT, dir), path.join(installed, dir), sourceOnly); + fs.writeFileSync(path.join(installed, 'VERSION'), '1.0.0.0\n'); + const body = path.join(repo.root, 'body.md'); + const write = runScriptSync(path.join(installed, 'bin', 'gstack-ship-receipt'), ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], + { cwd: repo.work, encoding: 'utf8', timeout: 180_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...repo.env } }); + expect(write.status, write.stderr).toBe(EXIT.ok); + expect(receiptIn(body).gstack_version).toBe('1.0.0.0'); + const newestKey = [...Object.values(POLICY_KEY_INTRODUCED), BUMP_NEVER_KEY_VERSION, TRAIN_GATES_KEY_VERSION].reduce((a, b) => (cmpVersion(parseVersion(b)!, parseVersion(a)!) > 0 ? b : a)); + const reader = spawnSync(process.execPath, [...bunIsolationArgs(), '-e', `console.log((await import(${JSON.stringify(path.join(installed, 'lib', 'ship-policy.ts'))})).runningGstackVersion())`], + { cwd: repo.work, encoding: 'utf8', timeout: 60_000 }); + expect(reader.stdout.trim(), reader.stderr).toBe(newestKey); + }, 120_000); }); describe('the queue refuses a receipt below the depth its head needs', () => {