Files
gstack/lib/ship-queue-dispatch-ci.ts
T
garrytanandcapy-ai[bot] c75ec3ad5a feat(ship-queue): screens read from the run, train watch/forget/prune --stale, verify --post-merge, ready --dispatch-ci, --stack-tests
Operating merge trains on 2026-10-26 showed the gaps this closes. A dispatched
screen's verdict is read from the run's gate-binding and report-verdict artifacts
(lib/ship-queue-screen.ts) and recorded in the READY receipt: a RED screen refuses
ready under either choice and is never re-dispatched at the same merge SHA
(QUEUE_SCREEN_RED); a run that concluded success with no slice is NOT-RUN
(QUEUE_SCREEN_NOT_RUN), never followed, and counted as unscreened by train build.
When refs/pull/<n>/merge lags or is missing the screen is planned on a scratch merge
pushed as screen/<pr>-<head12> (#3465); QUEUE_SCREEN_STALE_REF means a real
conflict only. train watch --train is the resumable follow command; train prune
--stale settles a merging/green train whose PRs all merged or closed elsewhere;
train forget removes one record with an exact lease on refs/notes/gstack-trains;
verify --post-merge <sha> watches main's push workflows until they settle;
ready --dispatch-ci <wf,…> dispatches the free workflows on a conflicting PR,
waits, and clears the matching requires-remote pregate lanes with the run URLs;
train build --dry-run --stack-tests runs each PR's new test files on its integrated
prefix before anything is dispatched (QUEUE_TRAIN_STACK_RED).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 15:30:32 +00:00

82 lines
5.5 KiB
TypeScript

/**
* `ready --dispatch-ci <workflow.yml[,…]> [--timeout <s>]`: a PR that
* conflicts with the base (a CHANGELOG-only conflict after every stamp
* counts) has no merge ref, so GitHub runs no pull_request CI on its head and
* `ready` prints `ci: none`. The lane used to dispatch the free workflows by
* hand, wait, clear the requires-remote pregate lanes with the run URLs and
* rerun ready. This does those four steps before `ready` reads the head: one
* `gh workflow run <wf> --ref <branch>` per named workflow (skipped when a
* run at this head already concluded), a wait for each run, and one
* `PREGATE_CLEARED:` per requires-remote lane whose workflow is one of them
* and whose run concluded success. A red run clears no lane and refuses. A
* fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { PregateFile } from './headless-artifacts';
import { PREGATE_OUT_REL, clearObligation, readPregateFile, writePregateFile } from './pregate/index';
import type { QueueContext } from './ship-queue';
import type { QueueFailure } from './ship-queue-gate';
import { execFor, repoSlug, runView } from './ship-queue-train-github';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export interface DispatchCiOptions { pr: string; workflows: string[]; pregate?: string; timeoutSec: number; pollMs: number }
export type DispatchCiResult = { lines: string[] } | { lines: string[]; failure: QueueFailure };
interface RunRow { databaseId: number; url: string; status: string; conclusion: string | null; headSha: string }
function runsAt(ctx: QueueContext, workflow: string, branch: string, head: string): RunRow[] {
const r = ctx.gh(['run', 'list', '--workflow', workflow, '--branch', branch, '--event', 'workflow_dispatch', '--limit', '20', '--json', 'databaseId,url,status,conclusion,headSha']);
try { return r.ok ? (JSON.parse(r.out) as RunRow[]).filter(x => x.headSha === head) : []; } catch { return []; }
}
export async function dispatchCi(ctx: QueueContext, o: DispatchCiOptions): Promise<DispatchCiResult> {
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,headRefName,isCrossRepository']);
let info: { headRefOid?: string; headRefName?: string; isCrossRepository?: boolean } = {};
try { info = view.ok ? JSON.parse(view.out) : {}; } catch {}
if (!info.headRefOid || !info.headRefName) return { lines: [], failure: { code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head and branch through gh (${(view.err ?? view.out).trim().slice(0, 120)})` } };
if (info.isCrossRepository) return { lines: [], failure: { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr}'s head is on a fork; its workflows are not dispatched here (the coordinator decides)` } };
const head = info.headRefOid;
const branch = info.headRefName;
const slug = repoSlug(ctx);
const exec = execFor(ctx);
const deadline = Date.now() + o.timeoutSec * 1000;
const lines: string[] = [];
const concluded = new Map<string, RunRow>();
for (const wf of o.workflows) {
const prior = runsAt(ctx, wf, branch, head).find(r => r.status === 'completed');
if (prior) { lines.push(`CI_DISPATCH: ${wf} not dispatched (run ${prior.databaseId} already concluded ${prior.conclusion ?? 'unknown'} at this head)`); concluded.set(wf, prior); continue; }
const live = runsAt(ctx, wf, branch, head)[0];
if (!live) {
const d = exec('gh', ['workflow', 'run', wf, '--ref', branch]);
if (d.status !== 0) return { lines, failure: { code: 'QUEUE_NOT_READY', message: `gh workflow run ${wf} --ref ${branch} failed (${d.stdout.trim().slice(0, 120) || 'no output'})` } };
}
let run: RunRow | undefined = live;
for (;;) {
run = run && slug ? runView(ctx, slug, run.databaseId) ?? run : runsAt(ctx, wf, branch, head)[0];
if (run?.status === 'completed') break;
if (Date.now() >= deadline) return { lines: [...lines, `CI_DISPATCH: ${wf} --ref ${branch} ${run ? `run ${run.databaseId} ${run.url} ${run.status}` : 'no run appeared'}`], failure: { code: 'QUEUE_TRAIN_PENDING', message: `${wf} ${run ? `run ${run.databaseId} is still ${run.status}` : 'has no workflow_dispatch run at this head yet'} at the --dispatch-ci timeout; rerun ready --dispatch-ci to continue waiting` } };
await sleep(o.pollMs);
}
lines.push(`CI_DISPATCH: ${wf} --ref ${branch} run ${run.databaseId} ${run.url} ${run.conclusion ?? 'unknown'}`);
concluded.set(wf, run);
}
const red = [...concluded.entries()].filter(([, r]) => r.conclusion !== 'success');
if (red.length) return { lines, failure: { code: 'QUEUE_NOT_READY', message: red.map(([wf, r]) => `${wf} run ${r.databaseId} concluded ${r.conclusion ?? 'unknown'} at ${head.slice(0, 12)}`).join('; ') + '; a red run clears no lane' } };
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
if (!fs.existsSync(pregateFile)) return { lines };
const read = readPregateFile(pregateFile);
if (!read.file) return { lines };
let file: PregateFile = read.file;
let changed = false;
for (const lane of (file.requires_remote ?? []).filter(l => !l.receipt && concluded.has(l.workflow))) {
const run = concluded.get(lane.workflow)!;
file = clearObligation(file, lane.lane, run.url, `gstack-ship-queue ready --dispatch-ci ${new Date().toISOString()}`);
lines.push(`PREGATE_CLEARED: ${lane.lane} ${run.url}`);
changed = true;
}
if (changed) writePregateFile(ctx.repoRoot, pregateFile, file);
return { lines };
}