fix(ship-queue): ready counts the latest run per check name and hints the dispatch route on a conflicting PR; ship-policy follows #3439

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 06:44:01 +00:00
1 parent f5061445b1
commit 66674ca97b
4 files changed
+133 -14413

No files matched your search

+10 -14403
View File
File diff suppressed because it is too large. Load diff
+21 -2
View File
@@ -181,6 +181,25 @@ carry base + bump. The tool runs as head code in a throwaway worktree with a
bare environment and no tokens; one that fails at the head is an error, never
a pass.
**A PR that conflicts with the base on CHANGELOG.md alone.** GitHub shows every
READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no `pull_request` CI runs on the head and
`ready` prints `ci: none at <head12>` with a `ci-hint:` line. The lane then
dispatches the free workflows on the branch (`gh workflow run free-tests.yml
--ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>`),
clears the requires-remote pregate lanes with those runs
(`gstack-pregate clear --remote <lane> --run-url <run>`) and reruns `ready`,
which passes because the dispatched check runs attach to the commit; the
`ci-hint:` line then says the dispatched runs count. `ready --screen` on such a
PR is `QUEUE_SCREEN_STALE_REF` (there is no merge ref to screen), so it READYs
with `--no-screen` after the skill's own paid cases ran focused.
`ci:` counts the latest run per check name, as GitHub's rollup does: a check
that was re-run or superseded on the same commit (a cancelled run beside a later
pass of the same name) is judged by its newest run; commit-status contexts are
counted one each.
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass. Send the block as printed.
@@ -481,7 +500,7 @@ which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
| `ship: history merge-only: …` | never rebase, squash or force-push; `check-history --plan <merge\|squash\|rebase>` refuses a rewrite plan (`HISTORY_POLICY_VIOLATION`, exit 3) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows (NEW_VERSION, CHANGELOG entry text only, the merge's expected head) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows: under a train, `commit and push only the stamp diff above (FILES), with no edit of your own` and the merge's expected head (the stamp commit holds restamp's output alone); under the per-PR queue, NEW_VERSION and the CHANGELOG entry text |
| `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) |
| `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator's call |
| `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
@@ -530,7 +549,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. Duplicate headings (`CHANGELOG_DUPLICATE_HEADING`) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was <X>`, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+44 -8
View File
@@ -80,21 +80,55 @@ function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | '
return { error: `${key}: more than ${CI_PAGE * CI_MAX_PAGES} entries, not all read` };
}
/** CI at one exact SHA: every check run and every commit-status context; unreadable or incomplete is unknown, never green. */
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean } {
/**
* The latest run per check name, the way GitHub's rollup counts: a check that was
* re-run or superseded (a cancelled run beside a later pass of the same name on
* the same commit) is judged by its newest run alone. Runs without a name each
* stand on their own. Newest is the latest completed_at, then the highest id; a
* run still in progress has no completed_at and is newer than any finished one.
*/
export function latestRunPerName<T extends { name?: unknown; id?: unknown; status?: unknown; completed_at?: unknown }>(runs: T[]): T[] {
const latest = new Map<string, T>();
const unnamed: T[] = [];
const at = (r: T) => (r.status !== 'completed' ? Infinity : typeof r.completed_at === 'string' ? Date.parse(r.completed_at) || 0 : 0);
const id = (r: T) => (typeof r.id === 'number' ? r.id : Number(r.id) || 0);
for (const r of runs) {
if (typeof r.name !== 'string' || !r.name) { unnamed.push(r); continue; }
const have = latest.get(r.name);
if (!have || at(r) > at(have) || (at(r) === at(have) && id(r) > id(have))) latest.set(r.name, r);
}
return [...latest.values(), ...unnamed];
}
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean } {
const at = sha.slice(0, 12);
const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false };
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false };
const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses');
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false };
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false };
const verdicts = [
...runs.items.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
...latestRunPerName(runs.items).map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
...statuses.items.map(s => s.state === 'success' ? 'passed' : s.state === 'pending' ? 'pending' : 'failed'),
];
const count = (v: string) => verdicts.filter(x => x === v).length;
const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')];
const green = verdicts.length > 0 && pending === 0 && failed === 0;
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green };
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0 };
}
/**
* A PR that conflicts with the base (CHANGELOG-only conflicts included, which
* the train resolves) has no merge ref, so GitHub runs no pull_request CI on
* its head. The lane dispatches the free workflows on the branch by hand; their
* check runs attach to the commit, so ready reads them like any other.
*/
function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, branch: string | undefined, head: string): string | null {
if (mergeable !== 'CONFLICTING') return null;
const ref = branch ? `--ref ${branch}` : '--ref <branch>';
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready`;
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were dispatched on the branch, which counts`;
return null;
}
/** Under full_e2e: a passing gate bound to this head (the stamp journal, a gate-ahead attempt on it) or the lane's own run on it. */
@@ -120,8 +154,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out;
if (dirty) return fail({ code: 'QUEUE_NOT_READY', message: `uncommitted tracked changes: ${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}` }, [], run);
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body']);
let info: { headRefOid?: string; url?: string; body?: string } = {};
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body,mergeable,headRefName']);
let info: { headRefOid?: string; url?: string; body?: string; mergeable?: string; headRefName?: string } = {};
try { info = view.ok ? JSON.parse(view.out) : {}; } catch {}
if (!info.headRefOid) return fail({ code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120)})` }, [], run);
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
@@ -143,6 +177,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
}
const receipt = extractReceipt(info.body ?? '');
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, head);
const lines = [
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
`pr: ${info.url ?? `#${o.pr}`}`,
@@ -150,6 +185,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
`version: ${version}`,
`base: origin/${ctx.base}@${baseSha}`,
`ci: ${ci.line}`,
...(hint ? [hint] : []),
pregateLine,
gateLine(policy, attempt),
receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`,
+58
View File
@@ -370,6 +370,31 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
expect(line(pending.r.out, 'ci: ')).toBe(`ci: pending at ${pending.at} (passed=1 failed=0 pending=1 skipped=0)`);
}, 120_000);
test('a superseded run of a check is judged by its latest run, as GitHub\'s rollup counts (gbrain: a cancelled contributor-gate beside later passes)', () => {
const older = { id: 1, name: 'contributor-gate', status: 'completed', conclusion: 'cancelled', completed_at: '2026-10-26T01:00:00Z' };
const newer = { id: 2, name: 'contributor-gate', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:10:00Z' };
const other = { id: 3, name: 'free-tests', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:05:00Z' };
const ok = readyWith({ check_runs: [older, newer, other] });
expect(ok.r.status).toBe(EXIT.ok);
expect(line(ok.r.out, 'ci: ')).toBe(`ci: green at ${ok.at} (passed=2 failed=0 pending=0 skipped=0)`);
const reversed = readyWith({ check_runs: [newer, older, other] });
expect(line(reversed.r.out, 'ci: ')).toBe(`ci: green at ${reversed.at} (passed=2 failed=0 pending=0 skipped=0)`);
const latestFailed = readyWith({ check_runs: [{ ...older, conclusion: 'success' }, { ...newer, conclusion: 'failure' }, other] });
expect(latestFailed.r.status).toBe(EXIT.refused);
expect(line(latestFailed.r.out, 'ci: ')).toBe(`ci: red at ${latestFailed.at} (passed=1 failed=1 pending=0 skipped=0)`);
const rerunning = readyWith({ check_runs: [{ ...older, conclusion: 'failure' }, { id: 4, name: 'contributor-gate', status: 'in_progress' }, other] });
expect(line(rerunning.r.out, 'ci: ')).toBe(`ci: pending at ${rerunning.at} (passed=1 failed=0 pending=1 skipped=0)`);
const sameTime = readyWith({ check_runs: [{ ...older, id: 9, completed_at: newer.completed_at }, newer, other] });
expect(line(sameTime.r.out, 'ci: ')).toBe(`ci: red at ${sameTime.at} (passed=1 failed=1 pending=0 skipped=0)`);
const unnamed = readyWith({ check_runs: [{ status: 'completed', conclusion: 'cancelled' }, { status: 'completed', conclusion: 'success' }] });
expect(line(unnamed.r.out, 'ci: ')).toBe(`ci: red at ${unnamed.at} (passed=1 failed=1 pending=0 skipped=0)`);
}, 180_000);
test('a commit-status context is never collapsed with a check run of the same name', () => {
const r = readyWith({ check_runs: [{ id: 1, name: 'deploy', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' }] }, { state: 'failure', statuses: [{ context: 'deploy', state: 'failure' }] });
expect(line(r.r.out, 'ci: ')).toBe(`ci: red at ${r.at} (passed=1 failed=1 pending=0 skipped=0)`);
}, 120_000);
test('incomplete pagination is unknown, never green', () => {
const { r, at } = readyWith({ total_count: 101, check_runs: hundred });
expect(r.status).toBe(EXIT.refused);
@@ -377,6 +402,39 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
}, 120_000);
});
describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts, rule 14)', () => {
const pass = { id: 1, name: 'Free Tests', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' };
function readyWith(mergeable: string, checks: unknown) {
const repo = fixture();
const head = makePr(repo, 'pr-a');
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: '', mergeable, headRefName: 'pr-a' } }, checks: { [head]: checks } });
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
}
test('no CI at the head and a conflict: the refusal names the workflow_dispatch procedure', () => {
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
expect(r.status).toBe(EXIT.refused);
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready`);
}, 120_000);
test('dispatched runs green at the head and a conflict: READY, with the hint saying the dispatched runs count', () => {
const { r, at } = readyWith('CONFLICTING', { check_runs: [pass] });
expect(r.status).toBe(EXIT.ok);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref); the runs at ${at} were dispatched on the branch, which counts`);
}, 120_000);
test('no hint without a conflict, and none while the dispatched runs are red or pending', () => {
expect(readyWith('MERGEABLE', { check_runs: [] }).r.out).not.toContain('ci-hint:');
expect(readyWith('MERGEABLE', { check_runs: [pass] }).r.out).not.toContain('ci-hint:');
expect(readyWith('CONFLICTING', { check_runs: [{ ...pass, conclusion: 'failure' }] }).r.out).not.toContain('ci-hint:');
expect(readyWith('CONFLICTING', { check_runs: [{ ...pass, status: 'in_progress' }] }).r.out).not.toContain('ci-hint:');
}, 240_000);
});
describe('printed gate-ahead command (review #3235 P2-1)', () => {
test("next: runs as printed from a checkout whose path holds a space and an apostrophe", () => {
const { repo } = twoLanes({ ...BASE_POLICY, gate_command: 'node -e "process.exit(0)"', min_gstack_version: SHIP_QUEUE_KEYS_VERSION }, "gstack sq o'fx-");