mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* ci: windows-smoke becomes the required Windows check; the six-shard Windows suite runs nightly on main (GSTA-203 PR 1, R50) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (dispatch-ci skips a workflow the branch predates; harness decision; changelog fragment) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CONTRIBUTING names windows-smoke; TODOS for the round-2 review notes 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>
90 lines
6.0 KiB
TypeScript
90 lines
6.0 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). A workflow the
|
|
* base has but the head predates (windows-smoke.yml on a branch cut before it
|
|
* landed) is skipped with a note: GitHub reads the workflow file from the
|
|
* branch, so the dispatch could only fail.
|
|
*/
|
|
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 { git } from './ship-queue-gate';
|
|
import { prInfo, 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 info = prInfo(ctx, o.pr);
|
|
if ('error' in info) return { lines: [], failure: { code: 'QUEUE_NOT_READY', message: info.error } };
|
|
if (!info.headRefName) return { lines: [], failure: { code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s branch through gh (no headRefName)` } };
|
|
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 deadline = Date.now() + o.timeoutSec * 1000;
|
|
const lines: string[] = [];
|
|
const concluded = new Map<string, RunRow>();
|
|
for (const wf of o.workflows) {
|
|
const atHead = runsAt(ctx, wf, branch, head);
|
|
const prior = atHead.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 = atHead[0];
|
|
const wfPath = `.github/workflows/${wf}`;
|
|
if (!live && git(ctx.repoRoot, ['cat-file', '-e', `${head}^{commit}`]).ok && git(ctx.repoRoot, ['cat-file', '-e', `${ctx.remote ?? 'origin'}/${ctx.base}:${wfPath}`]).ok && !git(ctx.repoRoot, ['cat-file', '-e', `${head}:${wfPath}`]).ok) {
|
|
lines.push(`CI_DISPATCH: ${wf} not dispatched (${wfPath} is on ${ctx.base} but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`);
|
|
continue;
|
|
}
|
|
if (!live) {
|
|
const d = ctx.gh(['workflow', 'run', wf, '--ref', branch]);
|
|
if (!d.ok) return { lines, failure: { code: 'QUEUE_NOT_READY', message: `gh workflow run ${wf} --ref ${branch} failed (${(d.err ?? d.out).trim().replace(/\s+/g, ' ').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 };
|
|
}
|