From 43d6ed28f2bf55acae23a13be3d1e053f8958d3b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 20 Oct 2026 08:01:46 -0700 Subject: [PATCH] v1.91.165.0 fix(version-gate): accept an unstamped READY head under merge trains (GSTA-49 S1) (#3323) * fix(version-gate): accept an unstamped READY head under merge trains (GSTA-49 S1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.165.0 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> --- .github/workflows/version-gate.yml | 16 +++- CHANGELOG.md | 17 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/version-gate-train.ts | 65 +++++++++++++++ package.json | 2 +- scripts/compare-pr-version.ts | 24 +++++- test/compare-pr-version.test.ts | 128 +++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 lib/version-gate-train.ts diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 4943a1f6e..5f8157d96 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -27,6 +27,16 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + # The gate's own code comes from the PR's merge commit (github.sha): a PR + # that edits the gate runs its edit, and an open PR picks up a gate fix + # on main at its next pull_request event (reopen or push), not on a re-run. + - name: Checkout gate code (merge commit) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: .version-gate + persist-credentials: false + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -38,7 +48,7 @@ jobs: set -euo pipefail PR_VERSION=$(cat VERSION | tr -d '[:space:]') BASE_REF="${{ github.event.pull_request.base.ref }}" - git fetch origin "$BASE_REF" --depth=1 --quiet || true + git fetch origin "$BASE_REF" --quiet || true BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0") { echo "pr_version=$PR_VERSION" @@ -76,7 +86,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_VERSION: ${{ steps.versions.outputs.pr_version }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} + BASE_REF: ${{ steps.versions.outputs.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | if ! PR_CHANGED_FILES=$(gh api --paginate "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --jq '.[].filename'); then PR_CHANGED_FILES=""; fi export PR_CHANGED_FILES - bun run scripts/compare-pr-version.ts next.json "${{ github.event.pull_request.number }}" + bun --no-env-file --config=/dev/null run .version-gate/scripts/compare-pr-version.ts next.json "${{ github.event.pull_request.number }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ca047b077..5dec15117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.165.0] - 2026-10-20 + +**The Version Gate accepts an unstamped READY head under merge trains.** Under the train policy a lane's head keeps VERSION, package.json and the agents digest at main's version and writes its CHANGELOG entry under `[Unreleased]`. The gate still demanded a bump, so every unstamped PR showed "VERSION not bumped", and `gstack-ship-queue ready` refused it with `QUEUE_NOT_READY` (#3320, #3321). That blocked every train. Plan GSTA-49, S1 for the queue. + +### Fixed +- **`scripts/compare-pr-version.ts`** passes an unstamped head when the base's `.gstack/ship-policy.json` declares `train`. A head counts as unstamped (`lib/version-gate-train.ts`) when, against its merge base with main, it changes neither `VERSION`, the `package.json` version nor any release output, and its CHANGELOG change is confined to one `[Unreleased]` section at the top. An older merge base is fine, because the train merges the head onto current main and rule 14 forbids lane main merges. +- **A stamped head under a train policy must carry exactly base + bump**, the version `gstack-restamp --next` allocates when the slot is called. Without a train block the gate is unchanged. +- **`.github/workflows/version-gate.yml`** runs the gate's code from the PR's merge commit (`github.sha`) rather than its head. A PR that edits the gate runs its own edit, and an open PR picks up a gate fix from main at its next `pull_request` event. It also fetches the base with full history so the merge base resolves. + +### Gate integrity +- The unstamped pass is new and applies only under a train policy. Negative controls in `test/compare-pr-version.test.ts` cover: + - a stamped head at any version other than base + bump; + - a changed `package.json` version or release output, which is treated as a stamp; + - a CHANGELOG edit to a released section; + - a second `[Unreleased]` section, or one that isn't at the top; + - an unstamped head without a train block, which still fails hard rule 1. + ## [1.91.164.0] - 2026-10-19 **Merge trains are on for gstack: `.gstack/ship-policy.json` now carries the `train` block.** READY PRs merge in trains of up to 5 (or once the oldest READY is 3 hours old) behind one full gate on the train head, instead of one full gate per PR. Plan GSTA-49, go-live after lanes T1 and T1b. Garry approved going live once one dry-run train passed. diff --git a/VERSION b/VERSION index e93777d2e..1fcb45616 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.164.0 +1.91.165.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2d905e5c2..6c984b2b3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.164.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.165.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/lib/version-gate-train.ts b/lib/version-gate-train.ts new file mode 100644 index 000000000..327cdfa1e --- /dev/null +++ b/lib/version-gate-train.ts @@ -0,0 +1,65 @@ +/** + * Version Gate under merge trains (docs/ship-policy.md#run-a-merge-train). + * When the base branch's .gstack/ship-policy.json declares a `train` block, a + * READY head is unstamped: the train merges it onto main and the lane stamps + * at its slot with `gstack-restamp --next`. This classifies the PR head + * against its merge base with the base branch, so + * scripts/compare-pr-version.ts can accept an unstamped head and hold a + * stamped one to the version the restamp allocates (base + the policy bump). + */ +import { spawnSync } from 'node:child_process'; +import { parseChangelog } from './changelog-check'; +import { matchesAny } from './ship-policy'; + +export type TrainHead = + | { kind: 'off' } + | { kind: 'unstamped' } + | { kind: 'stamped'; expected: string } + | { kind: 'invalid'; reason: string }; + +function git(repoRoot: string, args: string[]): string | null { + const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); + return r.status === 0 ? r.stdout : null; +} + +export function bumpVersion(version: string, level: string): string | null { + const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()); + if (!m) return null; + const v = m.slice(1).map(Number); + const at = { major: 0, minor: 1, patch: 2, micro: 3 }[level] ?? 2; + return v.map((n, i) => (i < at ? n : i === at ? n + 1 : 0)).join('.'); +} + +export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string }): TrainHead { + const base = `origin/${o.baseRef.replace(/^origin\//, '')}`; + const show = (rev: string, file: string) => git(o.repoRoot, ['show', `${rev}:${file}`]); + let policy: any = null; + try { policy = JSON.parse(show(base, '.gstack/ship-policy.json') ?? 'null'); } catch { policy = null; } + if (!policy || typeof policy.train !== 'object' || policy.train === null) return { kind: 'off' }; + const mergeBase = git(o.repoRoot, ['merge-base', base, o.head])?.trim(); + if (!mergeBase) return { kind: 'invalid', reason: `no merge base between ${base} and ${o.head}` }; + + const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } }; + const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean); + const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : []; + const touched = changed.filter(f => f === 'VERSION' || matchesAny(releaseOutputs, f)); + if (npmVersion(mergeBase) !== npmVersion(o.head)) touched.push('package.json version'); + if (touched.length) { + const expected = bumpVersion(show(base, 'VERSION') ?? '', policy.bump ?? 'patch'); + return expected ? { kind: 'stamped', expected } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` }; + } + + const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md'; + const before = parseChangelog(show(mergeBase, file) ?? ''); + const after = parseChangelog(show(o.head, file) ?? ''); + const unreleased = after.sections.filter(s => s.unreleased); + if (unreleased.length > 1) return { kind: 'invalid', reason: `${file} has ${unreleased.length} [Unreleased] sections` }; + if (unreleased.length === 1 && after.sections[0] !== unreleased[0]) return { kind: 'invalid', reason: `${file}: [Unreleased] must be the top section` }; + const released = (c: typeof after) => [c.header.join('\n'), ...c.sections.filter(s => !s.unreleased).map(s => s.lines.join('\n'))]; + const was = released(before); + const now = released(after); + if (was.length !== now.length || was.some((text, i) => text !== now[i])) { + return { kind: 'invalid', reason: `${file} changes text outside [Unreleased]; an unstamped head may only add or edit its [Unreleased] section` }; + } + return { kind: 'unstamped' }; +} diff --git a/package.json b/package.json index 9331fee0a..923c34ba4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.164", + "version": "1.91.165", "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/compare-pr-version.ts b/scripts/compare-pr-version.ts index 59b3d4740..6e6c324b4 100644 --- a/scripts/compare-pr-version.ts +++ b/scripts/compare-pr-version.ts @@ -16,6 +16,7 @@ // only blocks actual collisions. import { readFileSync } from "node:fs"; +import { classifyTrainHead } from "../lib/version-gate-train"; const [, , jsonPath, prNumber] = process.argv; if (!jsonPath) { @@ -31,6 +32,28 @@ try { process.exit(0); } +const tag = prNumber ? `PR #${prNumber}` : "this PR"; +const prVersionEnv = (process.env.PR_VERSION ?? "").trim(); + +// Merge trains: when the base's ship policy declares a `train` block, a READY +// head is unstamped and the lane stamps at its slot (docs/ship-policy.md#run-a-merge-train). +// The workflow passes BASE_REF and HEAD_SHA with the PR head checked out as cwd. +if (process.env.BASE_REF && process.env.HEAD_SHA) { + const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA }); + if (train.kind === "unstamped") { + console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION, package.json and the release outputs match its merge base, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`); + process.exit(0); + } + if (train.kind === "invalid") { + console.log(`::error::${tag}: ${train.reason}.`); + process.exit(1); + } + if (train.kind === "stamped" && prVersionEnv !== train.expected) { + console.log(`::error::${tag} claims v${prVersionEnv}, but under the base's train policy a stamped head must carry v${train.expected} (base + bump, what gstack-restamp --next allocates at its slot). Leave VERSION, package.json and the release outputs at the base's values until the slot is called.`); + process.exit(1); + } +} + if (parsed.offline === true) { console.log("::warning::workspace-aware-ship util offline; failing open (no collision check performed)"); console.log(`::notice::If you merge this PR and a queued PR landed ahead, CHANGELOG may need manual reconciliation.`); @@ -62,7 +85,6 @@ if (!pPR || !pNext) { process.exit(0); } -const tag = prNumber ? `PR #${prNumber}` : "this PR"; const claimed = (parsed.claimed ?? []) as Array<{ pr: number; branch: string; version: string; url?: string }>; // Emit a GitHub step summary (always helpful, even on pass). diff --git a/test/compare-pr-version.test.ts b/test/compare-pr-version.test.ts index e4a4d3890..b7355f28e 100644 --- a/test/compare-pr-version.test.ts +++ b/test/compare-pr-version.test.ts @@ -8,6 +8,8 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; +import { bumpVersion } from '../lib/version-gate-train'; const ROOT = path.resolve(import.meta.dir, '..'); const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts'); @@ -78,3 +80,129 @@ describe('version gate: dependabot dependency-manifest exemption', () => { expect(workflow.jobs.check.permissions).toEqual({ contents: 'read', 'pull-requests': 'read' }); }); }); + +/** + * Under merge trains (docs/ship-policy.md#run-a-merge-train) a READY head is + * unstamped: VERSION, package.json and the release outputs stay at the merge + * base and the CHANGELOG entry sits under [Unreleased]. The gate classifies + * the head against the base's policy in a real git repo. + */ +describe('version gate: unstamped heads under a train policy', () => { + const 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: 3, screen_selection: 'gstack-paid', trial_reservation_usd: 1.5 }; + const repos: FixtureRepo[] = []; + const fixture = (policy: Record = { ...BASE_POLICY, train: TRAIN }) => { const r = makeFixtureRepo({ policy, prefix: 'version-gate-train-' }); repos.push(r); return r; }; + const write = (r: FixtureRepo, rel: string, text: string) => fs.writeFileSync(path.join(r.work, rel), text); + const read = (r: FixtureRepo, rel: string) => fs.readFileSync(path.join(r.work, rel), 'utf8'); + const commit = (r: FixtureRepo, msg: string) => { r.git('add', '-A'); r.git('commit', '-q', '-m', msg); return r.git('rev-parse', 'HEAD'); }; + const withUnreleased = (r: FixtureRepo, entry = '- the PR entry') => read(r, 'CHANGELOG.md').replace('# Changelog\n\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n\n`); + const run = (r: FixtureRepo, head: string, prVersion = read(r, 'VERSION').trim()) => { + const next = path.join(r.root, 'next.json'); + fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed: [] })); + const out = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3320'], { + cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: prVersion, BASE_REF: 'main', HEAD_SHA: head }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe', + }); + return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() }; + }; + const branch = (r: FixtureRepo) => r.git('checkout', '-q', '-b', 'feature'); + + test('an unstamped head with only an [Unreleased] entry passes', () => { + const r = fixture(); + branch(r); + write(r, 'docs/feature.md', 'new\n'); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const res = run(r, commit(r, 'feature')); + expect(res.code).toBe(0); + expect(res.out).toContain('is unstamped under the base'); + }); + + test('an unstamped head on an older main still passes (the train merges it; no main merge needed)', () => { + const r = fixture(); + branch(r); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const head = commit(r, 'feature'); + r.git('checkout', '-q', 'main'); + write(r, 'VERSION', '1.2.4.0\n'); + write(r, 'CHANGELOG.md', read(r, 'CHANGELOG.md').replace('# Changelog\n\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-02\n\n- another release\n\n')); + commit(r, 'release 1.2.4.0'); + r.git('push', '-q', 'origin', 'main'); + r.git('fetch', '-q', 'origin'); + const res = run(r, head, '1.2.3.0'); + expect(res.code).toBe(0); + expect(res.out).toContain('is unstamped under the base'); + }); + + test('without a train block the unstamped head keeps hard rule 1', () => { + const r = fixture({ ...BASE_POLICY }); + branch(r); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const res = run(r, commit(r, 'feature')); + expect(res.code).toBe(1); + expect(res.out).toContain('::error::VERSION not bumped'); + }); + + test('a stamped head must carry base + bump: the slot-called stamp passes, any other version fails', () => { + const r = fixture(); + branch(r); + write(r, 'CHANGELOG.md', read(r, 'CHANGELOG.md').replace('# Changelog\n\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-02\n\n- the PR entry\n\n')); + write(r, 'VERSION', '1.2.4.0\n'); + const stamped = commit(r, 'stamp 1.2.4.0'); + expect(run(r, stamped).code).toBe(0); + write(r, 'VERSION', '1.2.7.0\n'); + const res = run(r, commit(r, 'wrong stamp')); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + }); + + test('negative controls: a changed package.json version or release output counts as a stamp and needs base + bump', () => { + for (const edit of [ + (r: FixtureRepo) => write(r, 'package.json', read(r, 'package.json').replace('"version": "1.2.3"', '"version": "1.2.9"')), + (r: FixtureRepo) => write(r, 'generated/digest.md', '# digest v1.2.9.0\nbody\n'), + ]) { + const r = fixture(); + branch(r); + write(r, 'CHANGELOG.md', withUnreleased(r)); + edit(r); + const res = run(r, commit(r, 'stray version edit')); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + } + }); + + test('negative controls: CHANGELOG edits outside [Unreleased] fail an unstamped head', () => { + const cases: Array<(r: FixtureRepo) => string> = [ + r => withUnreleased(r).replace('- older', '- older, rewritten'), + r => read(r, 'CHANGELOG.md').replace('- base release', '- base release\n- smuggled into a released section'), + r => withUnreleased(r).replace('## [1.2.2.0]', '## [Unreleased]\n\n- second\n\n## [1.2.2.0]'), + r => read(r, 'CHANGELOG.md').replace('## [1.2.2.0]', '## [Unreleased]\n\n- not on top\n\n## [1.2.2.0]'), + ]; + for (const changelog of cases) { + const r = fixture(); + branch(r); + write(r, 'CHANGELOG.md', changelog(r)); + const res = run(r, commit(r, 'bad changelog')); + expect(res.code).toBe(1); + expect(res.out).toContain('::error::PR #3320:'); + } + }); + + test('the workflow runs the gate from the merge commit with the base ref and head sha', () => { + const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any; + const steps = workflow.jobs.check.steps; + const gateCode = steps.find((s: any) => s.name === 'Checkout gate code (merge commit)'); + expect(gateCode.with).toMatchObject({ ref: '${{ github.sha }}', path: '.version-gate', 'persist-credentials': false }); + const step = steps.find((s: any) => s.name === 'Compare PR VERSION to next free slot'); + expect(step.env.BASE_REF).toBe('${{ steps.versions.outputs.base_ref }}'); + expect(step.env.HEAD_SHA).toBe('${{ github.event.pull_request.head.sha }}'); + expect(step.run).toContain('run .version-gate/scripts/compare-pr-version.ts'); + }); + + test('the stamped version is base + the policy bump', () => { + expect(bumpVersion('1.91.164.0', 'patch')).toBe('1.91.165.0'); + expect(bumpVersion('1.91.164.3', 'patch')).toBe('1.91.165.0'); + expect(bumpVersion('1.91.164.0', 'micro')).toBe('1.91.164.1'); + expect(bumpVersion('1.91.164.0', 'minor')).toBe('1.92.0.0'); + expect(bumpVersion('1.91.164', 'patch')).toBeNull(); + }); + + test('cleanup', () => { while (repos.length) cleanup(repos.pop()!); }); +});