mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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>
This commit is contained in:
1 parent
c580561e8a
commit
43d6ed28f2
8 files changed
+250
-6
No files matched your search
@@ -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 }}"
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<string, unknown> = { ...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()!); });
|
||||
});
|
||||
Reference in new issue
Block a user