mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 09:48:20 +01:00
Merge origin/main (v1.91.278.0) into eval-select-review-floor-followups; stamp v1.91.279.0
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json MERGE_CONDITION: sha=64684de144e5c5b8934408514b44434559487b18 NEW_VERSION=1.91.279.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
c7f6daa7a4
30 files changed
+654
-85
No files matched your search
@@ -53,6 +53,25 @@ describe('readiness decision', () => {
|
||||
expect(freeTestsState([free(3, 'completed', 'cancelled')], HEAD)).toEqual({ state: 'failed', runId: 3, conclusion: 'cancelled' });
|
||||
});
|
||||
|
||||
// #3508: a head whose only conflict with main is the CHANGELOG gets no pull_request CI (GitHub has no merge ref to build);
|
||||
// the dispatch route runs Free Tests through workflow_dispatch on the branch, and readiness must read that run.
|
||||
test('a dispatched-only head: the workflow_dispatch Free Tests run at the exact head decides, green or red, newest first; a pull_request run outranks it', () => {
|
||||
const dispatched = (id: number, status: string, conclusion: string | null, over: Partial<FreeTestsRun> = {}) => free(id, status, conclusion, { event: 'workflow_dispatch', ...over });
|
||||
expect(freeTestsState([dispatched(40448356650, 'completed', 'success')], HEAD)).toEqual({ state: 'success', runId: 40448356650 });
|
||||
expect(freeTestsState([dispatched(5, 'completed', 'failure')], HEAD)).toEqual({ state: 'failed', runId: 5, conclusion: 'failure' });
|
||||
expect(freeTestsState([dispatched(5, 'in_progress', null)], HEAD)).toEqual({ state: 'running', runId: 5 });
|
||||
// A dispatched run on another head, or a push run on this one, is not this head's gate.
|
||||
expect(freeTestsState([dispatched(5, 'completed', 'success', { head_sha: NEWER })], HEAD)).toEqual({ state: 'missing' });
|
||||
expect(freeTestsState([free(5, 'completed', 'success', { event: 'push' })], HEAD)).toEqual({ state: 'missing' });
|
||||
// A pull_request run at the head decides when one exists, whatever its age: it built the merge ref, a dispatch built the
|
||||
// head alone, so a newer green dispatch never overrides a red pull_request run (rerun that run instead) and a red or
|
||||
// running extra dispatch never holds a head its pull_request CI cleared (pre-landing review, pass 1).
|
||||
expect(freeTestsState([free(1, 'completed', 'failure'), dispatched(2, 'completed', 'success')], HEAD)).toEqual({ state: 'failed', runId: 1, conclusion: 'failure' });
|
||||
expect(freeTestsState([dispatched(1, 'completed', 'success'), free(2, 'completed', 'failure')], HEAD)).toEqual({ state: 'failed', runId: 2, conclusion: 'failure' });
|
||||
expect(freeTestsState([free(1, 'completed', 'success'), dispatched(2, 'in_progress', null)], HEAD)).toEqual({ state: 'success', runId: 1 });
|
||||
expect(freeTestsState([free(1, 'completed', 'success'), dispatched(2, 'completed', 'failure')], HEAD)).toEqual({ state: 'success', runId: 1 });
|
||||
});
|
||||
|
||||
test('ready needs both a green Free Tests on the head and an elapsed window; a moved head is superseded', () => {
|
||||
const green = { state: 'success', runId: 7 } as const;
|
||||
expect(readinessDecision({ headSha: HEAD, prHead: HEAD, free: green, quietLeftMs: 0 }).kind).toBe('ready');
|
||||
@@ -80,6 +99,20 @@ describe('waitForReadiness', () => {
|
||||
expect(lines.join('\n')).toContain('Free Tests run 5 passed');
|
||||
});
|
||||
|
||||
test('a dispatched-only head starts the gate: the runs listing is not narrowed to pull_request (#3508)', () => {
|
||||
const { gh, calls } = fakeGh([{ free: [free(40448356650, 'completed', 'success', { event: 'workflow_dispatch' })] }]);
|
||||
const c = clock(CREATED + 3 * 3600_000);
|
||||
const argsSeen: string[][] = [];
|
||||
const spy: GhRunner = (args, timeout) => { argsSeen.push(args); return gh(args, timeout); };
|
||||
const { decision, lines } = waitForReadiness({ ...base, gh: spy, now: c.now, sleep: c.sleep });
|
||||
expect(decision.kind).toBe('ready');
|
||||
expect(lines.join('\n')).toContain('Free Tests run 40448356650 passed');
|
||||
const listing = argsSeen.find(args => args.some(a => a.endsWith('free-tests.yml/runs')))!;
|
||||
expect(listing).toContain(`head_sha=${HEAD}`);
|
||||
expect(listing.some(a => a.startsWith('event='))).toBe(false);
|
||||
expect(calls.filter(call => call.endsWith('free-tests.yml/runs'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a re-run keeps the original creation time, so an elapsed window is not waited again', () => {
|
||||
const { gh } = fakeGh([{ free: [free(5, 'completed', 'success')] }]);
|
||||
const c = clock(CREATED + 3 * 3600_000);
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
|
||||
const j = JSON.parse(r.out);
|
||||
expect(j).toMatchObject({ status: 'refused', code: 'QUEUE_STALE', expected: { base: assumed.slice(0, 12) } });
|
||||
expect(j.actual.base).toBe(repo.git('rev-parse', 'origin/main'));
|
||||
expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12}$/);
|
||||
expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12} --pr 2$/);
|
||||
expect(repo.git('status', '--porcelain')).toBe('');
|
||||
const text = restamp(repo, ['--next', '--pr', '2', '--expect-base', assumed.slice(0, 12)]);
|
||||
expect(text.err).toMatch(/base moved.*\(QUEUE_STALE\)\nrecompute: gstack-restamp --next/);
|
||||
@@ -495,9 +495,38 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
|
||||
const stale = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,2,3', '--json'], ghShim(repo, { '1': merged, '3': merged }));
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(JSON.parse(stale.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '1': 'merged', '3': 'merged' } } });
|
||||
expect(JSON.parse(stale.out).recompute).toContain('--expect-order 1,2,3');
|
||||
// The recompute is the corrected order (merged PRs dropped), never the stale list again.
|
||||
expect(JSON.parse(stale.out).recompute).toMatch(/ --pr 2 --expect-order 2$/);
|
||||
const unmerged = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,4,2', '--json'], ghShim(repo, { '1': merged, '4': open }));
|
||||
expect(JSON.parse(unmerged.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '4': 'open' } } });
|
||||
expect(JSON.parse(unmerged.out).message).toContain('#4 must merge before this PR stamps');
|
||||
expect(JSON.parse(unmerged.out).recompute).toMatch(/ --expect-order 4,2$/);
|
||||
// A predecessor GitHub calls merged that the local base does not carry yet (the slot call came before the lane fetched)
|
||||
// stays ahead in the recompute: dropping it would let --next allocate from the stale base the version it already took.
|
||||
const unfetched = { state: 'MERGED', mergeCommit: { oid: repo.git('rev-parse', 'pr-b') }, headRefOid: 'y' };
|
||||
const stale2 = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,4,2', '--json'], ghShim(repo, { '1': merged, '4': unfetched }));
|
||||
expect(JSON.parse(stale2.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '4': 'merged-not-in-base' } }, fix: 'git fetch origin main' });
|
||||
expect(JSON.parse(stale2.out).message).toContain('#4 merged but the local main does not carry the merge yet: git fetch origin main, then rerun');
|
||||
expect(JSON.parse(stale2.out).recompute).toMatch(/ --expect-order 4,2$/);
|
||||
});
|
||||
|
||||
// The slot-call lesson (GSTA-123, 2026-10-27): a lane given "remaining order 3487" ran --expect-order 3487 without its own
|
||||
// PR, so every entry read as an unmerged predecessor and the refusal repeated the same list as its recompute.
|
||||
test('--expect-order without the lane\'s own PR: the refusal says so and the recompute lists this PR first, then the remaining order', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-a');
|
||||
makePr(repo, 'pr-b');
|
||||
repo.git('checkout', '-q', 'pr-a');
|
||||
const open = { state: 'OPEN', mergeCommit: null, headRefOid: 'x' };
|
||||
const r = restamp(repo, ['--next', '--pr', '1', '--expect-order', '2,3', '--json'], ghShim(repo, { '2': open, '3': open }));
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
const j = JSON.parse(r.out);
|
||||
expect(j).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '2': 'open', '3': 'open' } } });
|
||||
expect(j.message).toContain('--pr 1 is not in --expect-order, so every listed PR read as a predecessor: list this PR first, then the remaining order');
|
||||
expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12} --pr 1 --expect-order 1,2,3$/);
|
||||
// The corrected command passes against the same gh answers.
|
||||
expect(restamp(repo, ['--next', '--pr', '1', '--expect-order', '1,2,3', '--dry-run'], ghShim(repo, { '2': open, '3': open })).status).toBe(EXIT.ok);
|
||||
expect(repo.git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -201,6 +201,23 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit
|
||||
expect(change('1.2.34\n', '1.2.44\n')).toBe(false);
|
||||
});
|
||||
|
||||
// #3512: the carry classifies a stamp path as metadata only while its change is a stamp; a tentative key version the slot
|
||||
// stamp moved (next minor -> released) was product, so `carry` refused RECEIPT_CARRY_PRODUCT_CHANGED on a stamp commit.
|
||||
test('stampOnlyText accepts a tentative key version moved to the new version, and refuses a released one moved by hand or any other edit', () => {
|
||||
const templates = ["_KEY_VERSION = '{version}'"];
|
||||
const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3.0', newVersion: '1.2.4.0', templates });
|
||||
const keys = (docs: string, old: string, extra = '') => `export const DOCS_KEY_VERSION = '${docs}';\nexport const OLD_KEY_VERSION = '${old}';\n${extra}`;
|
||||
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true);
|
||||
expect(change(keys('1.2.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true);
|
||||
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.4.0'))).toBe(false);
|
||||
expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0', 'export const EXTRA = 1;\n'))).toBe(false);
|
||||
// Without the template the constant is not a stamp form, so the move is a product change.
|
||||
expect(stampOnlyText({ before: keys('1.3.0.0', '1.2.2.0'), after: keys('1.2.4.0', '1.2.2.0'), oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false);
|
||||
// A mention of another width is never a stamp candidate (tentativeMentions), so moving it by hand is a product change.
|
||||
expect(change(keys('1.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(false);
|
||||
expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false);
|
||||
});
|
||||
|
||||
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
|
||||
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
|
||||
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
|
||||
|
||||
+42
-2
@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { GhResult } from '../lib/ci-gate';
|
||||
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
|
||||
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, DEFAULT_TIMEOUT_MINUTES, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
|
||||
import { GhError, runGh } from '../lib/pr-wait-github';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
|
||||
@@ -360,7 +360,9 @@ describe('arguments and the gh layer', () => {
|
||||
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
|
||||
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
|
||||
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
|
||||
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
|
||||
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60, excludes: [] });
|
||||
expect(parsePrWaitArgs(['42', '--exclude', 'quality-gate, canary', '--exclude', 'canary'])).toMatchObject({ excludes: ['quality-gate', 'canary'] });
|
||||
expect(parsePrWaitArgs(['42', '--exclude', ' , '])).toMatchObject({ error: expect.stringContaining('--exclude needs') });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
|
||||
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
|
||||
@@ -421,6 +423,44 @@ describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local f
|
||||
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
// #3495 follow-up (ruled by GStack Minder, 2026-10-26): gstack-ci-gate --exclude has a watcher equivalent, so a non-required
|
||||
// check stuck in pending (a canary, a deploy preview) no longer holds --until green for the whole timeout.
|
||||
test('--exclude leaves a named non-required check out of the verdict: pending does not hold green, failed does not block; a required name is refused and gates', () => {
|
||||
const excluded = (script: Script, ...names: string[]) => {
|
||||
const e = env(fakeGh(script));
|
||||
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), DEFAULT_TIMEOUT_MINUTES, names);
|
||||
};
|
||||
const checks = ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending']));
|
||||
const held = excluded({ rulesets: RULESETS, checks }, 'other-check');
|
||||
expect(pollUntilDone(held, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const w = excluded({ rulesets: RULESETS, checks }, 'quality-gate');
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 green on aaaaaaa: 2 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
|
||||
expect(stderr.join('')).toContain('excluded from the verdict: quality-gate');
|
||||
stdout = []; stderr = [];
|
||||
const red = excluded({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'quality-gate');
|
||||
expect(pollUntilDone(red)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 2 pass;/)]);
|
||||
stdout = []; stderr = [];
|
||||
const refused = excluded({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'free-tests');
|
||||
expect(refused.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
|
||||
expect(stderr.join('')).toContain('exclude refused for required check free-tests: a required check gates');
|
||||
stdout = []; stderr = [];
|
||||
// When a required-check source cannot be read the set may be short, so nothing is excluded (gstack-ci-gate refuses its
|
||||
// overrides the same way): the excluded failure gates and the first poll says why.
|
||||
const unknown = excluded({ rulesets: err('gh: HTTP 403: rate limited', 1), checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'quality-gate');
|
||||
expect(unknown.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 check failed: quality-gate https://ci.example/quality-gate');
|
||||
expect(stderr.join('')).toContain('exclude refused for quality-gate: the required checks could not be read, so every check gates');
|
||||
stdout = []; stderr = [];
|
||||
// Through main: the flag reaches the watcher.
|
||||
const e = env(fakeGh({ rulesets: RULESETS, checks }));
|
||||
expect(prWaitMain(['42', '--repo', REPO, '--until', 'green', '--exclude', 'quality-gate'], e)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 2 pass;/)]);
|
||||
});
|
||||
|
||||
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
|
||||
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
|
||||
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
|
||||
|
||||
@@ -157,6 +157,8 @@ describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge r
|
||||
expect([dry.status, dry.err]).toEqual([EXIT.ok, '']);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
expect(line(dry.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${head.slice(0, 12)} onto main@${main.slice(0, 12)} \\(refs/pull/1/merge [0-9a-f]{12} merges onto [0-9a-f]{12}, not main@${main.slice(0, 12)}\\); would push screen/1-${head.slice(0, 12)} \\(dry run\\)$`));
|
||||
// gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): main's stamp touched the CHANGELOG this PR also changes.
|
||||
expect(line(dry.out, 'BASE_OVERLAP:')).toBe(`BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: CHANGELOG.md; the train's merge onto main is clean`);
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
const real = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([real.status, real.err]).toEqual([EXIT.ok, '']);
|
||||
@@ -195,6 +197,15 @@ describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge r
|
||||
expect(conflict.err).toMatch(/#1 conflicts with main@[0-9a-f]{12} beyond the CHANGELOG and the stamp files \(pr-1\.txt\); GitHub's merge ref lags too \(origin has no refs\/pull\/1\/merge\).*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(next(conflict.out)).toBe('NEXT: resolve the conflict with main, push, run /ship, then gstack-ship-queue ready --pr 1 --screen');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
const main2 = repo.git('rev-parse', 'origin/main');
|
||||
expect(line(conflict.out, 'BASE_OVERLAP:')).toBe(`BASE_OVERLAP: main moved to ${main2.slice(0, 7)} and touches files in this PR: CHANGELOG.md, pr-1.txt; the train's merge onto main conflicts beyond the CHANGELOG and the stamp files (pr-1.txt)`);
|
||||
// Without a screen there is no scratch merge to refuse at, so READY itself refuses the conflict the train would stop at;
|
||||
// the fix follows the crew template's rule 14 (the base is merged on the controller's word, never by the lane alone).
|
||||
const noScreen = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(noScreen.status).toBe(EXIT.refused);
|
||||
expect(noScreen.err).toMatch(new RegExp(`#1 conflicts with main@${main2.slice(0, 12)} beyond the CHANGELOG and the stamp files \\(pr-1\\.txt\\); the train would stop at this PR.*\\(QUEUE_TRAIN_CONFLICT\\)`));
|
||||
expect(next(noScreen.out)).toBe('NEXT: report the conflict to the controller; on its word merge main into the pin (never rebase), resolve, push, run /ship, then gstack-ship-queue ready --pr 1 --screen|--no-screen');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`))).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { baseOverlap } from '../lib/ship-queue-train';
|
||||
import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
|
||||
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
@@ -352,6 +353,44 @@ describe('train build', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
// Handoff GSTA-119 / #3485: a pooled pin the base moved onto is named with the files, and the conflict is found while the
|
||||
// train is still filling instead of at dispatch time; a base move onto files the PR leaves alone is silent.
|
||||
test('a pin whose files main moved onto gets the base-overlap line; a conflict with main is QUEUE_TRAIN_CONFLICT at slot 1 even while the train fills', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { file: 'same.txt', text: 'one\n' });
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1, { at: '2026-10-18T11:00:00.000Z' });
|
||||
readyReceipt(fx, '2', h2, { at: '2026-10-18T11:00:00.000Z' });
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'same.txt'), 'main\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'unrelated.txt'), 'main\n');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', 'main moves onto same.txt');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('checkout', '-q', 'pr-1');
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
const r = queue(fx, ['train', 'build', '--prs', `2@${h2},1@${h1}`]);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
// At build the line is the files alone: the chain merges each pin onto its prefix, and that merge is the verdict.
|
||||
expect(line(r.out, '#1 BASE_OVERLAP:')).toBe(`#1 BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: same.txt`);
|
||||
expect(r.out).not.toContain('#2 BASE');
|
||||
expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toMatch(new RegExp(`stopped at #1 \\(conflicts: same\\.txt\\) after merging (\\(none\\)|#2); nothing dispatched, no branch pushed; fix: gstack-ship-queue train build --prs 2@${h2}$`));
|
||||
expect(r.out).not.toContain('MERGE_CHECK:');
|
||||
expect(remoteRefs(fx)).not.toContain('refs/heads/train/');
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
const without = queue(fx, ['train', 'build', '--prs', `2@${h2}`]);
|
||||
expect(without.status).toBe(EXIT.refused);
|
||||
expect(without.err).toContain('(QUEUE_TRAIN_WAITING)');
|
||||
expect(line(without.out, 'MERGE_CHECK:')).toBe(`MERGE_CHECK: T1..T1 merge clean onto main@${main.slice(0, 12)} (dry run in object storage; nothing pushed)`);
|
||||
// A git read that fails is never "no overlap" (pre-landing review, pass 1): the comparison is reported as unknown.
|
||||
const unknown = baseOverlap(repo.work, 'main', main, 'f'.repeat(40), TRAIN_POLICY as any);
|
||||
expect(unknown).toMatchObject({ unknown: true, touched: [], conflicts: null });
|
||||
expect(unknown.line).toMatch(new RegExp(`^BASE_OVERLAP: could not compare main@${main.slice(0, 12)} with ffffffffffff \\(.+\\); conflicts unknown$`));
|
||||
// At build the overlap line carries the files alone; the verdict is the chain's.
|
||||
expect(baseOverlap(repo.work, 'main', main, h1, TRAIN_POLICY as any, false).line).toBe(`BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: same.txt`);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -423,6 +462,8 @@ describe('train build', () => {
|
||||
expect(filling.status).toBe(EXIT.refused);
|
||||
expect(filling.err).toMatch(/1 of 5 PRs; the oldest READY dispatches at 2026-10-18T14:00:00\.000Z.*\(QUEUE_TRAIN_WAITING\)/);
|
||||
expect(result(filling.out)).toBe('GSTACK_RESULT: skill=ship-queue status=incomplete run=none');
|
||||
// #3485: the scratch merges run before the fill rule, so a WAITING result also proves the seated pins merge clean.
|
||||
expect(line(filling.out, 'MERGE_CHECK:')).toBe(`MERGE_CHECK: T1..T1 merge clean onto main@${fx.repo.git('rev-parse', 'origin/main').slice(0, 12)} (dry run in object storage; nothing pushed)`);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const urgent = queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--urgent']);
|
||||
expect(urgent.status).toBe(EXIT.ok);
|
||||
|
||||
@@ -48,6 +48,28 @@ describe('checkReceiptProse', () => {
|
||||
expect(bad.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 40, receipt: 41 }]);
|
||||
expect(checkReceiptProse(receipt, 'Gate passed: 41. CI passed=41.').mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
// #3513: the canonical REVIEW_LOOP line /ship and `gstack-ship-queue ready` print. Before the fix the `<n> <name>` branch
|
||||
// started on passes' value and read `2 blocking_found` as a blocking_found claim of 2 (PROSE: mismatch ... body=2 receipt=1).
|
||||
test('a key=value pair\'s value is that key\'s claim alone: the REVIEW_LOOP line parses as five claims that all match', () => {
|
||||
const loop = { ...receipt, review_loop: { passes: 2, blocking_found: 1, deferred: 0, noted: 4, fixed: 8, scope: 'frozen' } };
|
||||
const line = 'REVIEW_LOOP: passes=2 blocking_found=1 deferred=0 noted=4 fixed=8 scope=frozen';
|
||||
const ok = checkReceiptProse(loop, line);
|
||||
expect(ok).toEqual({ checked: 5, mismatches: [] });
|
||||
expect(proseLine(ok)).toBe('PROSE: 5 receipt counter claims in the body, all match');
|
||||
const one = { ...receipt, review_loop: { passes: 1, blocking_found: 0, deferred: 0, noted: 0, fixed: 0, scope: 'frozen' } };
|
||||
expect(checkReceiptProse(one, 'REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen')).toEqual({ checked: 5, mismatches: [] });
|
||||
// A real disagreement on the same line is still caught, and named for the key that carries it.
|
||||
expect(checkReceiptProse(loop, 'REVIEW_LOOP: passes=2 blocking_found=3 deferred=0 noted=4 fixed=8 scope=frozen').mismatches)
|
||||
.toEqual([{ field: 'review_loop.blocking_found', prose: 3, receipt: 1 }]);
|
||||
// Prose claims in the number-then-name spelling keep working when they do not follow a pair.
|
||||
expect(checkReceiptProse(loop, 'The loop ran 2 passes and found 1 blocking_found item.')).toEqual({ checked: 2, mismatches: [] });
|
||||
// The `key: value` spelling with whitespace is the same pair (pre-landing review, pass 1): the value never starts the
|
||||
// next key's claim, while a non-counter word before the number (`Review: 2 passes`) leaves the claim as it was.
|
||||
expect(checkReceiptProse(loop, 'REVIEW_LOOP: passes: 2 blocking_found: 1 deferred: 0 noted: 4 fixed: 8')).toEqual({ checked: 5, mismatches: [] });
|
||||
expect(checkReceiptProse(loop, 'Gate passed: 41 failed: 0')).toEqual({ checked: 2, mismatches: [] });
|
||||
expect(checkReceiptProse(loop, 'Review: 2 passes; passes = 2 fixed = 8.')).toEqual({ checked: 3, mismatches: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt read --check-prose', () => {
|
||||
|
||||
@@ -224,6 +224,40 @@ describe('prefix trees modulo stamps', () => {
|
||||
expect(stampFreeTree(git, 'f'.repeat(40), repo.work, policy)).toHaveProperty('error');
|
||||
});
|
||||
|
||||
// #3512: a stamp path carrying a tentative `*_KEY_VERSION = '<next minor>'` (gstack's own convention since #3511) digests the
|
||||
// same before and after the slot stamp moves it, so train-backstop finds the record that covers main; a base-version mention
|
||||
// on a line the base already carried digests the same at the prefix (where it equals VERSION) and on the stamped main.
|
||||
test('stampFreeTree collapses every stamp-form mention, not only the commit\'s version: a moved tentative key and a kept released one both digest equal', () => {
|
||||
const git = gitIn(repo.work);
|
||||
const policy = { ...loadPolicyFromHead(repo.work).policy, stamp_paths: ['docs/**/*.md', 'lib/policy.ts'], stamp_patterns: ["_KEY_VERSION = '{version}'"] };
|
||||
const policyFile = (text: string) => { fs.mkdirSync(path.join(repo.work, 'lib'), { recursive: true }); fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), text); };
|
||||
// The unstamped prefix: VERSION 1.2.3.0, a new key written as the tentative 1.3.0.0, a released key at 1.2.3.0 (the base's version).
|
||||
repo.git('checkout', '-q', '-B', 'key-prefix', sha.main!);
|
||||
policyFile("export const DOCS_KEY_VERSION = '1.3.0.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n");
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'prefix with a tentative key');
|
||||
const prefix = repo.git('rev-parse', 'HEAD');
|
||||
// Main after the slot stamp: VERSION 1.2.4.0, the tentative moved to the released version, the released key as written.
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n");
|
||||
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
expect(stampFreeTree(git, stamped, repo.work, policy)).toBe(stampFreeTree(git, prefix, repo.work, policy));
|
||||
// A byte beyond a stamp-form mention still changes the digest.
|
||||
policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const EXTRA = 1;\n");
|
||||
repo.git('commit', '-qam', 'a code byte');
|
||||
expect(stampFreeTree(git, repo.git('rev-parse', 'HEAD'), repo.work, policy)).not.toBe(stampFreeTree(git, prefix, repo.work, policy));
|
||||
// The receipt agrees with the digest on the stamp, and refuses a released key moved by hand (older than the gated version;
|
||||
// a mention equal to it is the base's own version, which the stamp_paths stage moves on a branch-added line).
|
||||
const tree = (current: string) => (buildReceipt({ repoRoot: repo.work, gated: prefix, current, policy, policyLabel: 'fixture' }) as { tree: string; residual: string[] }).tree;
|
||||
expect(tree(stamped)).toBe('same-modulo-stamps');
|
||||
repo.git('checkout', '-q', '-B', 'hand-move', prefix);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.4.0';\n");
|
||||
repo.git('commit', '-qam', 'stamp plus a released key moved by hand');
|
||||
expect(tree(repo.git('rev-parse', 'HEAD'))).toBe('changed');
|
||||
repo.git('checkout', '-q', 'squash');
|
||||
});
|
||||
|
||||
test('T1..Tk come from the train head by first parents; a head that is not a train is refused', () => {
|
||||
const git = gitIn(repo.work);
|
||||
const policy = loadPolicyFromHead(repo.work).policy;
|
||||
|
||||
@@ -8,7 +8,8 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { stampOnlyLine } from '../lib/restamp';
|
||||
import { BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo({ prefix: 'tree-receipt-', ...opts }); repos.push(r); return r; };
|
||||
@@ -55,6 +56,55 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', ()
|
||||
expect(r.out).not.toContain('pr-a.txt');
|
||||
});
|
||||
|
||||
// #3512: under the next-minor convention a new `*_KEY_VERSION` constant is written as a tentative future version, which the
|
||||
// stamp_paths stage moves to the released one at the slot. The receipt accepted a stamp_paths pair only as base -> new, so the
|
||||
// moved constant classified the file `changed` and the train refused the stamp commit (QUEUE_STAMP_NOT_STAMP_ONLY).
|
||||
test('a tentative key version a restamp moved is a stamp, like the base version; a line that changes more than the mention is not', () => {
|
||||
const repo = fixture({ policy: { ...BASE_POLICY, stamp_paths: ['docs/**/*.md', 'lib/policy.ts'], stamp_patterns: ["_KEY_VERSION = '{version}'"] } });
|
||||
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
|
||||
fs.mkdirSync(path.join(repo.work, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.3.0.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n");
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'a new policy key, tentative next minor');
|
||||
const bundle = path.join(repo.root, 'bundle.json');
|
||||
recordAndBundle(repo, repo.work, LANES, bundle);
|
||||
const gatedHead = repo.git('rev-parse', 'HEAD');
|
||||
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
|
||||
// The tentative 1.3.0.0 moved; the released 1.2.2.0 stayed.
|
||||
expect(readFile(repo, 'lib/policy.ts')).toBe("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n");
|
||||
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
||||
const r = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^version:\s+1\.2\.3\.0 -> 1\.2\.4\.0$/m);
|
||||
expect(r.out).toMatch(/^ stamp lib\/policy\.ts \(stamp_paths\)$/m);
|
||||
expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m);
|
||||
expect(r.out).toMatch(/^gate-reuse: eligible/m);
|
||||
// Negative: the version moves and the key is renamed on the same line; a tentative value moved by hand to the wrong version.
|
||||
fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_CAPS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n");
|
||||
repo.git('commit', '-qam', 'rename the key too');
|
||||
const renamed = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]);
|
||||
expect(renamed.status).toBe(EXIT.fail);
|
||||
expect(renamed.out).toMatch(/^ changed lib\/policy\.ts \(stamp_paths: "export const DOCS_KEY_VERSION = '1\.3\.0\.0';" -> "export const DOCS_CAPS_KEY_VERSION = '1\.2\.4\.0';"\)$/m);
|
||||
repo.git('reset', '-q', '--hard', 'HEAD~1');
|
||||
fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.4.0';\n");
|
||||
repo.git('commit', '-qam', 'move a released key by hand');
|
||||
const wrong = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]);
|
||||
expect(wrong.status).toBe(EXIT.fail);
|
||||
expect(wrong.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m);
|
||||
// A mention of another width (a 3-digit pin in a 4-digit repo) is never a stamp candidate, so moving it by hand to the
|
||||
// new version is a product change even though it reads newer than the gated head (pre-landing review, pass 1).
|
||||
repo.git('reset', '-q', '--hard', 'HEAD~1');
|
||||
fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const PIN_KEY_VERSION = '9.9.9';\n");
|
||||
repo.git('commit', '-qam', 'a 3-digit pin');
|
||||
const pinned = repo.git('rev-parse', 'HEAD');
|
||||
fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const PIN_KEY_VERSION = '1.2.4.0';\n");
|
||||
repo.git('commit', '-qam', 'move the 3-digit pin by hand');
|
||||
expect(stampOnlyLine("export const PIN_KEY_VERSION = '9.9.9';", "export const PIN_KEY_VERSION = '1.2.4.0';", '1.2.3.0', '1.2.4.0', ["_KEY_VERSION = '{version}'"])).toBe(false);
|
||||
expect(stampOnlyLine('left-pad since: 1.2.3', 'left-pad since: 1.2.4.0', '1.2.3.0', '1.2.4.0')).toBe(false);
|
||||
const width = receipt(repo, ['--gated', pinned, '--bundle', bundle]);
|
||||
expect(width.status).toBe(EXIT.fail);
|
||||
expect(width.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m);
|
||||
});
|
||||
|
||||
test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => {
|
||||
const repo = fixture();
|
||||
const g = gated(repo);
|
||||
|
||||
Reference in new issue
Block a user