Merge remote-tracking branch 'origin/main' into flaky/2026-10-20-qa

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-20 15:03:38 +00:00
commit b8d5d555ef
8 files changed
+250 -6

No files matched your search

+128
View File
@@ -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()!); });
});