mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* test(ship): failing-first Step 12 keep-current refusal and unattended early bump write (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 12 keeps VERSION on --keep-current, adds bump never, and waits for the owner's answer before an unattended bump (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship SKILL goldens (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (answer gate covers ALREADY_BUMPED --version writes; typed session rows; never drift and --answer rejection cases) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (the answer gate names every flag that decides the write; Version Gate and validate understand bump never) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: under bump never a release PR that moves VERSION takes the Version Gate's ordinary rules Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fragment for GST-380 Step 12 keep-current and bump never 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>
167 lines
9.8 KiB
TypeScript
167 lines
9.8 KiB
TypeScript
/**
|
|
* Version Gate under changelog_fragments (docs/ship-policy.md#changelog-fragments): an unstamped head
|
|
* keeps its release-version fields at its merge base and carries a fragment (or a legacy [Unreleased]
|
|
* entry, or is a bot PR); other manifest edits pass; a merge-step stamp commit must carry base plus the
|
|
* effective bump of the fragments its pin folds.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { BUMP_NEVER_KEY_VERSION } from '../lib/ship-policy';
|
|
import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture';
|
|
|
|
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
|
|
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
|
const repos: FixtureRepo[] = [];
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; };
|
|
const write = (r: FixtureRepo, rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(r.work, rel)), { recursive: true }); fs.writeFileSync(path.join(r.work, rel), text); };
|
|
const commit = (r: FixtureRepo, msg: string) => { r.git('add', '-A'); r.git('commit', '-q', '-m', msg); return r.git('rev-parse', 'HEAD'); };
|
|
function run(r: FixtureRepo, head: string, extra: Record<string, string> = {}, claimed: unknown[] = []) {
|
|
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 prVersion = r.git('show', head + ':VERSION').trim();
|
|
const out = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3600'], { cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: prVersion, BASE_REF: 'main', HEAD_SHA: head, PR_AUTHOR: 'garrytan', ...extra }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe' });
|
|
return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() };
|
|
}
|
|
|
|
describe('version gate: bump never', () => {
|
|
// Value: protects=under bump never the Version Gate passes a head that keeps VERSION, and a release head that moves it goes through the ordinary bump and collision rules; fails_when=the gate rejects every kept-version PR as "VERSION not bumped", or rejects every release PR;
|
|
// why_new=GST-380 added bump never (outside-voice review, fix-check); seam=classifyTrainHead, scripts/compare-pr-version.ts; no_claim=who may open a release PR
|
|
test('a kept VERSION passes; a moved VERSION takes the ordinary rules', () => {
|
|
const r = makeFixtureRepo({ policy: { ...BASE_POLICY, queue_mode: 'claim-at-open', release_tool: null, release_outputs: [], bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }, prefix: 'version-gate-never-' });
|
|
repos.push(r);
|
|
r.git('checkout', '-q', '-b', 'k');
|
|
write(r, 'k.txt', 'k\n');
|
|
const kept = run(r, commit(r, 'k'));
|
|
expect([kept.code, kept.out.includes('as the base\'s ship policy (bump never) requires')]).toEqual([0, true]);
|
|
write(r, 'VERSION', '1.2.4.0\n');
|
|
const moved = run(r, commit(r, 'bump'));
|
|
expect([moved.code, moved.out.includes('bump never')]).toEqual([0, false]);
|
|
const collides = run(r, r.git('rev-parse', 'HEAD').trim(), {}, [{ pr: 7, branch: 'other', version: '1.2.4.0' }]);
|
|
expect([collides.code, collides.out.includes('VERSION collision')]).toEqual([1, true]);
|
|
});
|
|
});
|
|
|
|
describe('version gate: changelog fragments', () => {
|
|
test('a fragment head passes, a dependency or script edit beside it too', () => {
|
|
const r = fixture();
|
|
r.git('checkout', '-q', '-b', 'f');
|
|
write(r, 'changelog.d/f.md', '- f\n');
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8'));
|
|
pkg.dependencies['right-pad'] = '2.0.0'; pkg.scripts.build = 'node build.js';
|
|
write(r, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
const res = run(r, commit(r, 'f'));
|
|
expect(res.code).toBe(0);
|
|
expect(res.out).toContain('its entry is 1 CHANGELOG fragment(s)');
|
|
});
|
|
|
|
test('a premature version change fails; no entry fails for a person and passes for a bot', () => {
|
|
const r = fixture();
|
|
r.git('checkout', '-q', '-b', 'g');
|
|
write(r, 'changelog.d/g.md', '- g\n');
|
|
write(r, 'VERSION', '1.2.9.0\n');
|
|
const early = run(r, commit(r, 'g'));
|
|
expect(early.code).toBe(1);
|
|
expect(early.out).toContain('under changelog_fragments only the merge step stamps');
|
|
r.git('checkout', '-q', '-B', 'self', 'origin/main');
|
|
write(r, 'VERSION', '1.2.4.0\n'); write(r, 'self.txt', 'self\n');
|
|
const self = run(r, commit(r, 'a lane stamps the expected version itself, with no fragment'));
|
|
expect(self.code).toBe(1);
|
|
expect(self.out).toContain('under changelog_fragments only the merge step stamps');
|
|
r.git('checkout', '-q', '-B', 'h', 'origin/main');
|
|
write(r, 'h.txt', 'h\n');
|
|
const head = commit(r, 'h');
|
|
const none = run(r, head);
|
|
expect(none.code).toBe(1);
|
|
expect(none.out).toContain('add a CHANGELOG fragment: changelog.d/<slug>.md');
|
|
expect(run(r, head, { PR_AUTHOR: 'dependabot[bot]' }).code).toBe(0);
|
|
});
|
|
|
|
test('an unchanged branch on an advanced base passes; an invalid fragment fails', () => {
|
|
const r = fixture();
|
|
r.git('checkout', '-q', '-b', 'k');
|
|
write(r, 'changelog.d/k.md', '- k\n');
|
|
const head = commit(r, 'k');
|
|
r.git('checkout', '-q', 'main');
|
|
write(r, 'VERSION', '1.2.4.0\n'); write(r, 'other.txt', 'x\n');
|
|
commit(r, 'main moves'); r.git('push', '-q', 'origin', 'main');
|
|
expect(run(r, head).code).toBe(0);
|
|
r.git('checkout', '-q', '-B', 'bad', 'origin/main');
|
|
write(r, 'changelog.d/bad.md', '## [9.9.9.9]\n- bad\n');
|
|
const bad = run(r, commit(r, 'bad'));
|
|
expect(bad.code).toBe(1);
|
|
expect(bad.out).toContain('changelog.d/bad.md: a fragment carries no release heading');
|
|
});
|
|
|
|
test('a merge-step stamp commit [pin, base] carries base plus its fragments\' effective bump', () => {
|
|
const r = fixture();
|
|
r.git('checkout', '-q', '-b', 'm');
|
|
write(r, 'changelog.d/m.md', '---\nbump: minor\n---\n- m\n');
|
|
const pin = commit(r, 'm');
|
|
const stamp = (version: string) => {
|
|
r.git('checkout', '-q', '-f', '--detach', pin);
|
|
fs.rmSync(path.join(r.work, 'changelog.d', 'm.md'));
|
|
write(r, 'VERSION', version + '\n');
|
|
write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n- m\n\n'));
|
|
r.git('add', '-A');
|
|
return r.git('commit-tree', r.git('write-tree'), '-p', pin, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v' + version + ' stamp');
|
|
};
|
|
expect(run(r, stamp('1.3.0.0')).code).toBe(0);
|
|
const claimedToo = run(r, stamp('1.3.0.0'), {}, [{ pr: 77, branch: 'legacy', version: '1.3.0.0' }]);
|
|
expect(claimedToo.code).toBe(0);
|
|
expect(claimedToo.out).toContain('the land lock serializes allocation, so open claims do not apply');
|
|
const wrong = run(r, stamp('1.2.4.0'));
|
|
expect(wrong.code).toBe(1);
|
|
expect(wrong.out).toContain('must carry v1.3.0.0');
|
|
});
|
|
|
|
test('a stacked child\'s stamp counts only its own fragment once its parent has landed', () => {
|
|
const r = fixture();
|
|
const release = (version: string, line: string) => write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n' + line + '\n\n'));
|
|
r.git('checkout', '-q', '-b', 'p');
|
|
write(r, 'changelog.d/p.md', '---\nbump: minor\n---\n- p\n');
|
|
commit(r, 'p');
|
|
r.git('checkout', '-q', '-b', 'c');
|
|
write(r, 'changelog.d/c.md', '- c\n');
|
|
const child = commit(r, 'c');
|
|
r.git('checkout', '-q', '-B', 'main', 'origin/main');
|
|
write(r, 'VERSION', '1.3.0.0\n');
|
|
release('1.3.0.0', '- p');
|
|
commit(r, 'v1.3.0.0 (#p)');
|
|
r.git('push', '-q', 'origin', 'main');
|
|
r.git('checkout', '-q', '-f', '--detach', child);
|
|
fs.rmSync(path.join(r.work, 'changelog.d'), { recursive: true });
|
|
r.git('checkout', 'origin/main', '--', 'CHANGELOG.md');
|
|
write(r, 'VERSION', '1.3.1.0\n');
|
|
release('1.3.1.0', '- c');
|
|
r.git('add', '-A');
|
|
const stamp = r.git('commit-tree', r.git('write-tree'), '-p', child, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v1.3.1.0 stamp');
|
|
const res = run(r, stamp);
|
|
expect(res.code, res.out).toBe(0);
|
|
expect(res.out).toContain('carries v1.3.1.0');
|
|
});
|
|
|
|
test('the workflow runs on every PR (no path filter), so a PR without a release file still proves its fragment', () => {
|
|
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
|
|
expect(workflow.on.pull_request.paths).toBeUndefined();
|
|
expect(workflow.on.pull_request.branches).toEqual(['main']);
|
|
});
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1).
|
|
describe('version gate: legacy entries under changelog fragments', () => {
|
|
// Value: protects=a person's PR with a legacy [Unreleased] entry and no fragment still passes once fragments are on;
|
|
// fails_when=the [Unreleased] fallback in classifyTrainHead is dropped, so in-flight PRs fail with "add a CHANGELOG fragment";
|
|
// why_new=existing gate tests cover fragment, bot and no-entry heads only; seam=none; no_claim=GitHub workflow wiring (script run directly)
|
|
test('an [Unreleased] entry without a fragment passes and names that entry', () => {
|
|
const r = fixture();
|
|
r.git('checkout', '-q', '-b', 'legacy');
|
|
write(r, 'legacy.txt', 'l\n');
|
|
write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy entry\n'));
|
|
const res = run(r, commit(r, 'legacy'));
|
|
expect(res.code, res.out).toBe(0);
|
|
expect(res.out).toContain('the CHANGELOG change is confined to [Unreleased]');
|
|
});
|
|
});
|