mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
chore(pr): reuse green hosted CI from patch-identical pre-rebase heads (#111335)
* chore(pr): reuse green hosted CI from patch-identical pre-rebase heads (24h window) * fix(pr): type-safe fixtures and lint cleanup for hosted-CI reuse tests
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { execGhApiRead } from "./lib/plain-gh.mjs";
|
||||
import { execGhApiRead, plainGhEnv } from "./lib/plain-gh.mjs";
|
||||
|
||||
export const SCHEDULED_HOSTED_WORKFLOWS = [
|
||||
"Blacksmith Testbox",
|
||||
@@ -26,6 +27,9 @@ const COMPARE_COMMITS_PAGE_SIZE = 100;
|
||||
export const HOSTED_GATE_MAX_AGE_HOURS = 24;
|
||||
const HOSTED_GATE_MAX_AGE_MS = HOSTED_GATE_MAX_AGE_HOURS * 60 * 60 * 1_000;
|
||||
const HOSTED_GATE_CLOCK_SKEW_MS = 5 * 60 * 1_000;
|
||||
const MAX_CI_REUSE_CANDIDATES = 5;
|
||||
const CI_REUSE_RUN_LIST_LIMIT = 50;
|
||||
const GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function readOptionValue(argv, index, optionName) {
|
||||
const value = argv[index + 1];
|
||||
@@ -151,6 +155,128 @@ function isSuccessfulRecentRun(run, nowMs) {
|
||||
return run?.status === "completed" && run.conclusion === "success" && isRecentRun(run, nowMs);
|
||||
}
|
||||
|
||||
function runGit(args, { input } = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
||||
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args[0]} failed with exit code ${result.status ?? "unknown"}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function parseSingleObjectId(raw, label) {
|
||||
const values = String(raw).trim().split(/\s+/u).filter(Boolean);
|
||||
if (values.length !== 1 || !/^[0-9a-f]{40,64}$/u.test(values[0])) {
|
||||
throw new Error(`Expected one ${label} object id.`);
|
||||
}
|
||||
return values[0];
|
||||
}
|
||||
|
||||
function computePatchId(sha, mainRef, execGit) {
|
||||
const mergeBase = parseSingleObjectId(
|
||||
execGit(["merge-base", mainRef, sha]),
|
||||
`merge base for ${sha}`,
|
||||
);
|
||||
const diff = execGit(["diff", mergeBase, sha]);
|
||||
const patchIdLines = String(execGit(["patch-id", "--stable"], { input: diff }))
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean);
|
||||
if (patchIdLines.length !== 1) {
|
||||
throw new Error(`Expected one patch id for ${sha}.`);
|
||||
}
|
||||
const [patchId, commitId, ...rest] = patchIdLines[0].trim().split(/\s+/u);
|
||||
if (
|
||||
rest.length > 0 ||
|
||||
!/^[0-9a-f]{40,64}$/u.test(patchId ?? "") ||
|
||||
!/^[0-9a-f]{40,64}$/u.test(commitId ?? "")
|
||||
) {
|
||||
throw new Error(`Invalid patch-id output for ${sha}.`);
|
||||
}
|
||||
return patchId;
|
||||
}
|
||||
|
||||
function ensureCommitAvailable(sha, execGit) {
|
||||
try {
|
||||
execGit(["cat-file", "-e", `${sha}^{commit}`]);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
execGit(["fetch", "origin", sha]);
|
||||
execGit(["cat-file", "-e", `${sha}^{commit}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isQualifyingCiReuseRun(run) {
|
||||
if (run?.event === "pull_request") {
|
||||
return run?.name === "CI";
|
||||
}
|
||||
return isReleaseGateCiRun(run, run?.head_sha);
|
||||
}
|
||||
|
||||
function findPatchIdenticalCiReuse({
|
||||
sha,
|
||||
candidateRuns,
|
||||
nowMs,
|
||||
mainRef = "origin/main",
|
||||
execGit = runGit,
|
||||
}) {
|
||||
if (!Array.isArray(candidateRuns)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = candidateRuns
|
||||
.filter(
|
||||
(run) =>
|
||||
run?.head_sha !== sha &&
|
||||
/^[0-9a-f]{40,64}$/u.test(String(run?.head_sha ?? "")) &&
|
||||
isQualifyingCiReuseRun(run) &&
|
||||
isSuccessfulRecentRun(run, nowMs),
|
||||
)
|
||||
.toSorted((left, right) =>
|
||||
String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")),
|
||||
)
|
||||
.slice(0, MAX_CI_REUSE_CANDIDATES);
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let currentPatchId;
|
||||
try {
|
||||
currentPatchId = computePatchId(sha, mainRef, execGit);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const run of candidates) {
|
||||
if (!ensureCommitAvailable(run.head_sha, execGit)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (computePatchId(run.head_sha, mainRef, execGit) === currentPatchId) {
|
||||
return {
|
||||
run,
|
||||
reusedFromSha: run.head_sha,
|
||||
reusedRunId: run.id,
|
||||
patchIdMatched: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A candidate is reusable only when every local git proof step succeeds.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CI_GATE_CHECK_NAME = "openclaw/ci-gate";
|
||||
|
||||
/**
|
||||
@@ -349,6 +475,8 @@ export function collectHostedGateEvidence({
|
||||
pullRequestHeadRepository = "",
|
||||
workflowRuns,
|
||||
ciGateJobs = [],
|
||||
loadCiReuseCandidates = () => [],
|
||||
execGit = runGit,
|
||||
changelogOnly = false,
|
||||
nowMs = Date.now(),
|
||||
}) {
|
||||
@@ -357,17 +485,21 @@ export function collectHostedGateEvidence({
|
||||
}
|
||||
const pullRequestCommitShaSet = new Set(pullRequestCommitShas);
|
||||
|
||||
const collectForSha = (evidenceSha, { allowManual, requiredScheduledWorkflows = new Set() }) => {
|
||||
const collectForSha = (
|
||||
evidenceSha,
|
||||
{ allowManual, requiredScheduledWorkflows = new Set(), ciRun },
|
||||
) => {
|
||||
const workflows = [];
|
||||
const fallbackCoveredWorkflows = [];
|
||||
if (!changelogOnly) {
|
||||
workflows.push(
|
||||
successfulRunOrThrow(workflowRuns, "CI", evidenceSha, {
|
||||
allowManual,
|
||||
nowMs,
|
||||
// Gate proof only vouches for the exact head under verification.
|
||||
ciGateJobs: evidenceSha === sha ? ciGateJobs : [],
|
||||
}),
|
||||
ciRun ??
|
||||
successfulRunOrThrow(workflowRuns, "CI", evidenceSha, {
|
||||
allowManual,
|
||||
nowMs,
|
||||
// Gate proof only vouches for the exact head under verification.
|
||||
ciGateJobs: evidenceSha === sha ? ciGateJobs : [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const workflowName of SCHEDULED_HOSTED_WORKFLOWS) {
|
||||
@@ -402,14 +534,43 @@ export function collectHostedGateEvidence({
|
||||
return { workflows, fallbackCoveredWorkflows };
|
||||
};
|
||||
|
||||
let ciRun;
|
||||
let ciReuse;
|
||||
if (!changelogOnly) {
|
||||
try {
|
||||
ciRun = successfulRunOrThrow(workflowRuns, "CI", sha, {
|
||||
allowManual: true,
|
||||
nowMs,
|
||||
ciGateJobs,
|
||||
});
|
||||
} catch (exactCiError) {
|
||||
let candidateRuns;
|
||||
try {
|
||||
candidateRuns = loadCiReuseCandidates();
|
||||
} catch {
|
||||
candidateRuns = [];
|
||||
}
|
||||
ciReuse = findPatchIdenticalCiReuse({
|
||||
sha,
|
||||
candidateRuns,
|
||||
nowMs,
|
||||
execGit,
|
||||
});
|
||||
if (!ciReuse) {
|
||||
throw exactCiError;
|
||||
}
|
||||
ciRun = ciReuse.run;
|
||||
}
|
||||
}
|
||||
|
||||
let evidenceSha = sha;
|
||||
let selected;
|
||||
try {
|
||||
selected = collectForSha(sha, { allowManual: true });
|
||||
selected = collectForSha(sha, { allowManual: true, ciRun });
|
||||
} catch (exactError) {
|
||||
// Hosted CI proves the PR cohort, not ancestry freshness. A newer head's
|
||||
// failure must not discard a complete same-PR green cohort from the last
|
||||
// 24 hours; review and focused gates own the newer delta.
|
||||
// Scheduled hosted workflows retain their existing recent-cohort fallback.
|
||||
// CI itself is either exact-head proof or the patch-identical run selected
|
||||
// above; never silently replace it with an unverified prior-head run.
|
||||
const targetScheduledWorkflows = new Set(
|
||||
SCHEDULED_HOSTED_WORKFLOWS.filter(
|
||||
(workflowName) =>
|
||||
@@ -417,6 +578,7 @@ export function collectHostedGateEvidence({
|
||||
),
|
||||
);
|
||||
const fallbackShas = [
|
||||
ciReuse?.reusedFromSha,
|
||||
recentSha,
|
||||
...workflowRuns
|
||||
.filter(
|
||||
@@ -443,6 +605,7 @@ export function collectHostedGateEvidence({
|
||||
selected = collectForSha(fallbackSha, {
|
||||
allowManual: false,
|
||||
requiredScheduledWorkflows: targetScheduledWorkflows,
|
||||
ciRun,
|
||||
});
|
||||
evidenceSha = fallbackSha;
|
||||
break;
|
||||
@@ -473,6 +636,11 @@ export function collectHostedGateEvidence({
|
||||
if (evidenceSha !== sha) {
|
||||
evidence.evidenceHeadSha = evidenceSha;
|
||||
}
|
||||
if (ciReuse) {
|
||||
evidence.reusedFromSha = ciReuse.reusedFromSha;
|
||||
evidence.reusedRunId = ciReuse.reusedRunId;
|
||||
evidence.patchIdMatched = true;
|
||||
}
|
||||
if (selected.fallbackCoveredWorkflows.length > 0) {
|
||||
evidence.fallbackCoveredWorkflows = selected.fallbackCoveredWorkflows;
|
||||
}
|
||||
@@ -520,6 +688,51 @@ function loadWorkflowRuns(repo, sha, recentSha, headBranch) {
|
||||
return [...new Map(workflowRuns.map((run) => [run.id, run])).values()];
|
||||
}
|
||||
|
||||
function loadCiReuseCandidateRuns(repo, headBranch) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--workflow",
|
||||
"ci.yml",
|
||||
"--branch",
|
||||
headBranch,
|
||||
"--limit",
|
||||
String(CI_REUSE_RUN_LIST_LIMIT),
|
||||
"--json",
|
||||
"databaseId,workflowName,headSha,headBranch,event,status,conclusion,createdAt,updatedAt,url,displayTitle",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const runs = JSON.parse(stripAnsi(raw));
|
||||
if (!Array.isArray(runs)) {
|
||||
throw new Error("Expected gh run list to return an array.");
|
||||
}
|
||||
// The workflow selector above supplies the path identity that the REST
|
||||
// release-gate matcher normally reads from each full workflow-run object.
|
||||
return runs.map((run) => ({
|
||||
id: run.databaseId,
|
||||
name: run.workflowName,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
head_sha: run.headSha,
|
||||
head_branch: run.headBranch,
|
||||
path: CI_WORKFLOW_PATH,
|
||||
created_at: run.createdAt,
|
||||
updated_at: run.updatedAt,
|
||||
html_url: run.url,
|
||||
display_title: run.displayTitle,
|
||||
}));
|
||||
}
|
||||
|
||||
export function compareCommitPageCount(totalCommits) {
|
||||
if (!Number.isSafeInteger(totalCommits) || totalCommits < 0) {
|
||||
throw new Error("Expected comparison total_commits to be a non-negative integer.");
|
||||
@@ -643,6 +856,7 @@ function main(argv = process.argv.slice(2)) {
|
||||
pullRequestHeadRepository: headRepository,
|
||||
workflowRuns,
|
||||
ciGateJobs: loadCiGateJobs(args.repo, workflowRuns, args.sha),
|
||||
loadCiReuseCandidates: () => loadCiReuseCandidateRuns(args.repo, headBranch),
|
||||
changelogOnly: args.changelogOnly,
|
||||
});
|
||||
const evidenceHeadSha = evidence.evidenceHeadSha ?? args.sha;
|
||||
@@ -652,13 +866,28 @@ function main(argv = process.argv.slice(2)) {
|
||||
repo: args.repo,
|
||||
pullRequestNumber: args.pr,
|
||||
selection: {
|
||||
mode: evidenceHeadSha === args.sha ? "exact-head" : "recent-pr-head",
|
||||
mode: evidence.patchIdMatched
|
||||
? "patch-identical-pre-rebase"
|
||||
: evidenceHeadSha === args.sha
|
||||
? "exact-head"
|
||||
: "recent-pr-head",
|
||||
maxAgeHours: HOSTED_GATE_MAX_AGE_HOURS,
|
||||
},
|
||||
...evidence,
|
||||
};
|
||||
mkdirSync(path.dirname(args.output), { recursive: true });
|
||||
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
if (evidence.patchIdMatched) {
|
||||
const reusedRun = manifest.workflows.find((workflow) => workflow.id === evidence.reusedRunId);
|
||||
const updatedAtMs = runUpdatedAtMs({ updated_at: reusedRun?.updatedAt });
|
||||
const ageHours =
|
||||
updatedAtMs === null
|
||||
? "unknown"
|
||||
: `${(Math.max(0, Date.now() - updatedAtMs) / (60 * 60 * 1_000)).toFixed(1)}h`;
|
||||
console.log(
|
||||
`hosted CI reused from patch-identical pre-rebase head ${evidence.reusedFromSha} (run ${evidence.reusedRunId}, age ${ageHours})`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Hosted gates passed for PR #${args.pr} at ${args.sha} using ${evidenceHeadSha}: ${manifest.workflows
|
||||
.map((workflow) => `${workflow.name}#${workflow.id}`)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
const sha = "773ffd87a1e1e34451ad6e38fda37380c2569a50";
|
||||
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
|
||||
const scheduledFallbackSha = "ad620a11e5d9ed3888b6afb3c35c4c30e8054f4e";
|
||||
const pr = 100606;
|
||||
const nowMs = Date.parse("2026-06-17T10:55:00Z");
|
||||
const BUILD_ARTIFACTS_WORKFLOW = "Blacksmith Build Artifacts Testbox";
|
||||
@@ -27,7 +28,24 @@ const requiredCliArgs = [
|
||||
".local/gates-hosted-checks.json",
|
||||
];
|
||||
|
||||
function successfulRun(name: string, id: number, updatedAt: string) {
|
||||
type WorkflowRunFixture = {
|
||||
id: number;
|
||||
name: string;
|
||||
event: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
head_sha: string;
|
||||
head_branch: string;
|
||||
head_repository: { full_name: string };
|
||||
pull_requests: Array<{ number: number }>;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
display_title?: string;
|
||||
};
|
||||
|
||||
function successfulRun(name: string, id: number, updatedAt: string): WorkflowRunFixture {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -68,13 +86,234 @@ function queuedBuildArtifactFallbackRuns() {
|
||||
];
|
||||
}
|
||||
|
||||
function collectHostedGateEvidence(
|
||||
options: Omit<Parameters<typeof collectHostedGateEvidenceRaw>[0], "nowMs" | "pr">,
|
||||
function collectHostedGateEvidence(options: Omit<CollectHostedGateOptions, "nowMs" | "pr">) {
|
||||
return collectHostedGateEvidenceWithReuse({ nowMs, pr, ...options });
|
||||
}
|
||||
|
||||
type GitExec = (args: string[], options?: { input?: string }) => string;
|
||||
type CollectHostedGateOptions = Parameters<typeof collectHostedGateEvidenceRaw>[0] & {
|
||||
loadCiReuseCandidates?: () => Array<Record<string, unknown>>;
|
||||
execGit?: GitExec;
|
||||
};
|
||||
type HostedGateEvidence = ReturnType<typeof collectHostedGateEvidenceRaw> & {
|
||||
reusedFromSha?: string;
|
||||
reusedRunId?: unknown;
|
||||
patchIdMatched?: boolean;
|
||||
};
|
||||
const collectHostedGateEvidenceWithReuse = collectHostedGateEvidenceRaw as unknown as (
|
||||
options: CollectHostedGateOptions,
|
||||
) => HostedGateEvidence;
|
||||
|
||||
function priorSuccessfulCiRun(overrides: Partial<WorkflowRunFixture> = {}): WorkflowRunFixture {
|
||||
return {
|
||||
...successfulRun("CI", 101, "2026-06-17T09:55:00Z"),
|
||||
head_sha: previousSha,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type PatchIdExecOptions = {
|
||||
currentPatchId?: string;
|
||||
priorPatchId?: string;
|
||||
unfetchableShas?: Set<string>;
|
||||
failCommand?: string;
|
||||
};
|
||||
|
||||
function createPatchIdExec({
|
||||
currentPatchId: suppliedCurrentPatchId = "a".repeat(40),
|
||||
priorPatchId,
|
||||
unfetchableShas = new Set<string>(),
|
||||
failCommand = "",
|
||||
}: PatchIdExecOptions = {}) {
|
||||
const currentPatchId: string = suppliedCurrentPatchId;
|
||||
const resolvedPriorPatchId = priorPatchId ?? currentPatchId;
|
||||
const calls: string[] = [];
|
||||
const execGit: GitExec = (args, options = {}) => {
|
||||
const command = args.join(" ");
|
||||
calls.push(command);
|
||||
if (command === failCommand) {
|
||||
throw new Error(`mock failure: ${command}`);
|
||||
}
|
||||
switch (args[0]) {
|
||||
case "cat-file": {
|
||||
const candidateSha = args[2]?.replace(/\^\{commit\}$/u, "") ?? "";
|
||||
if (unfetchableShas.has(candidateSha)) {
|
||||
throw new Error("missing object");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "fetch":
|
||||
if (unfetchableShas.has(args[2] ?? "")) {
|
||||
throw new Error("unfetchable object");
|
||||
}
|
||||
return "";
|
||||
case "merge-base":
|
||||
return `${(args[2] === sha ? "b" : "c").repeat(40)}\n`;
|
||||
case "diff":
|
||||
return `diff:${args[2]}`;
|
||||
case "patch-id": {
|
||||
const patchId = options.input === `diff:${sha}` ? currentPatchId : resolvedPriorPatchId;
|
||||
return `${patchId} ${"0".repeat(40)}\n`;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unexpected git command: ${command}`);
|
||||
}
|
||||
};
|
||||
return { calls, execGit };
|
||||
}
|
||||
|
||||
function patchReuseOptions(
|
||||
candidate: WorkflowRunFixture = priorSuccessfulCiRun(),
|
||||
execGit = createPatchIdExec().execGit,
|
||||
) {
|
||||
return collectHostedGateEvidenceRaw({ nowMs, pr, ...options });
|
||||
return {
|
||||
loadCiReuseCandidates: () => [candidate],
|
||||
execGit,
|
||||
};
|
||||
}
|
||||
|
||||
describe("verify-pr-hosted-gates", () => {
|
||||
it("reuses successful recent CI from a patch-identical pre-rebase head", () => {
|
||||
const candidate = priorSuccessfulCiRun();
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
...patchReuseOptions(candidate),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
workflows: [expect.objectContaining({ id: 101, name: "CI", headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 101,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a successful prior-head CI run whose patch differs", () => {
|
||||
const { execGit } = createPatchIdExec({ priorPatchId: "d".repeat(40) });
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
...patchReuseOptions(priorSuccessfulCiRun(), execGit),
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
});
|
||||
|
||||
it("rejects patch-identical CI reuse after the 24-hour window", () => {
|
||||
let gitCalled = false;
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
loadCiReuseCandidates: () => [priorSuccessfulCiRun({ updated_at: "2026-06-16T10:54:59Z" })],
|
||||
execGit: () => {
|
||||
gitCalled = true;
|
||||
throw new Error("stale candidates must be filtered before git");
|
||||
},
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
expect(gitCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("skips an unfetchable prior head", () => {
|
||||
const { calls, execGit } = createPatchIdExec({
|
||||
unfetchableShas: new Set([previousSha]),
|
||||
});
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
...patchReuseOptions(priorSuccessfulCiRun(), execGit),
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
expect(calls).toContain(`fetch origin ${previousSha}`);
|
||||
});
|
||||
|
||||
it("fails closed when patch-id computation errors", () => {
|
||||
const { execGit } = createPatchIdExec({
|
||||
failCommand: `merge-base origin/main ${previousSha}`,
|
||||
});
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
...patchReuseOptions(priorSuccessfulCiRun(), execGit),
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
});
|
||||
|
||||
it("filters prior runs by successful qualifying CI shape", () => {
|
||||
const invalidCandidates = [
|
||||
priorSuccessfulCiRun({ id: 1, conclusion: "failure" }),
|
||||
priorSuccessfulCiRun({ id: 2, name: "Docs" }),
|
||||
priorSuccessfulCiRun({
|
||||
id: 3,
|
||||
event: "workflow_dispatch",
|
||||
display_title: `CI release gate ${previousSha}`,
|
||||
path: ".github/workflows/not-ci.yml",
|
||||
}),
|
||||
];
|
||||
let gitCalled = false;
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
loadCiReuseCandidates: () => invalidCandidates,
|
||||
execGit: () => {
|
||||
gitCalled = true;
|
||||
throw new Error("invalid candidates must be filtered before git");
|
||||
},
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
expect(gitCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a patch-identical prior release-gate run with the exact dispatch title", () => {
|
||||
const candidate = priorSuccessfulCiRun({
|
||||
id: 102,
|
||||
event: "workflow_dispatch",
|
||||
path: ".github/workflows/ci.yml",
|
||||
display_title: `CI release gate ${previousSha}`,
|
||||
});
|
||||
expect(
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [],
|
||||
...patchReuseOptions(candidate),
|
||||
}),
|
||||
).toEqual({
|
||||
headSha: sha,
|
||||
workflows: [expect.objectContaining({ id: 102, event: "workflow_dispatch" })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 102,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits reuse discovery when exact-head CI already succeeds", () => {
|
||||
let reuseCalled = false;
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [successfulRun("CI", 1, "2026-06-17T10:47:00Z")],
|
||||
loadCiReuseCandidates: () => {
|
||||
reuseCalled = true;
|
||||
throw new Error("exact-head success must not inspect reuse candidates");
|
||||
},
|
||||
execGit: () => {
|
||||
reuseCalled = true;
|
||||
throw new Error("exact-head success must not execute git reuse proof");
|
||||
},
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
workflows: [expect.objectContaining({ id: 1, headSha: sha })],
|
||||
});
|
||||
expect(reuseCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an in-progress CI run whose own attempt's ci-gate job succeeded", () => {
|
||||
const inProgressRun = {
|
||||
...successfulRun("CI", 42, "2026-06-17T10:52:00Z"),
|
||||
@@ -235,68 +474,77 @@ describe("verify-pr-hosted-gates", () => {
|
||||
});
|
||||
|
||||
it("accepts 13-hour green evidence from the recorded pre-rebase head", () => {
|
||||
const priorRun = {
|
||||
...successfulRun("CI", 1, "2026-06-16T21:55:00Z"),
|
||||
head_sha: previousSha,
|
||||
};
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
recentSha: previousSha,
|
||||
workflowRuns: [
|
||||
{
|
||||
...successfulRun("CI", 1, "2026-06-16T21:55:00Z"),
|
||||
head_sha: previousSha,
|
||||
},
|
||||
priorRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorRun),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: previousSha,
|
||||
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts recent green evidence from an earlier head of the same PR", () => {
|
||||
const priorRun = {
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
};
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [
|
||||
{
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
},
|
||||
priorRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorRun),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: previousSha,
|
||||
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a recent green fork head when GitHub omits pull request links", () => {
|
||||
const headBranch = "fix/token-listener";
|
||||
const headRepository = "contributor/openclaw";
|
||||
const priorRun = {
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
head_branch: headBranch,
|
||||
head_repository: { full_name: headRepository },
|
||||
pull_requests: [],
|
||||
};
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
pullRequestCommitShas: [previousSha, sha],
|
||||
pullRequestHeadBranch: headBranch,
|
||||
pullRequestHeadRepository: headRepository,
|
||||
workflowRuns: [
|
||||
{
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
head_branch: headBranch,
|
||||
head_repository: { full_name: headRepository },
|
||||
pull_requests: [],
|
||||
},
|
||||
priorRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
|
||||
head_branch: headBranch,
|
||||
@@ -305,12 +553,15 @@ describe("verify-pr-hosted-gates", () => {
|
||||
conclusion: "failure",
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorRun),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: previousSha,
|
||||
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,9 +637,14 @@ describe("verify-pr-hosted-gates", () => {
|
||||
targetArmRun,
|
||||
];
|
||||
|
||||
expect(() => collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns })).toThrow(
|
||||
`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`,
|
||||
);
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
recentSha: previousSha,
|
||||
workflowRuns,
|
||||
...patchReuseOptions(workflowRuns[0]),
|
||||
}),
|
||||
).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`);
|
||||
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
@@ -400,35 +656,85 @@ describe("verify-pr-hosted-gates", () => {
|
||||
head_sha: previousSha,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(workflowRuns[0]),
|
||||
});
|
||||
expect(evidence.workflows).toEqual([
|
||||
expect.objectContaining({ name: "CI", headSha: previousSha }),
|
||||
expect.objectContaining({ name: "Blacksmith ARM Testbox", headSha: previousSha }),
|
||||
]);
|
||||
expect(evidence).toMatchObject({
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing scheduled-workflow fallback after CI reuses another head", () => {
|
||||
const priorCiRun = priorSuccessfulCiRun({ id: 1 });
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
workflowRuns: [
|
||||
priorCiRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
},
|
||||
{
|
||||
...successfulRun("Blacksmith ARM Testbox", 3, "2026-06-17T10:54:00Z"),
|
||||
status: "queued",
|
||||
conclusion: null,
|
||||
},
|
||||
{
|
||||
...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:53:00Z"),
|
||||
head_sha: scheduledFallbackSha,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorCiRun),
|
||||
});
|
||||
|
||||
expect(evidence).toMatchObject({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: scheduledFallbackSha,
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
workflows: [
|
||||
expect.objectContaining({ name: "CI", headSha: previousSha }),
|
||||
expect.objectContaining({
|
||||
name: "Blacksmith ARM Testbox",
|
||||
headSha: scheduledFallbackSha,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["failure", "cancelled", "skipped"])(
|
||||
"reuses recent same-PR green evidence after a current-head %s run",
|
||||
(conclusion) => {
|
||||
const priorRun = {
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
};
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
recentSha: previousSha,
|
||||
workflowRuns: [
|
||||
{
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
},
|
||||
priorRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
|
||||
conclusion,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorRun),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: previousSha,
|
||||
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -473,7 +779,16 @@ describe("verify-pr-hosted-gates", () => {
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
},
|
||||
{
|
||||
...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:54:00Z"),
|
||||
status: "queued",
|
||||
conclusion: null,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions({
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
}),
|
||||
}),
|
||||
).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`);
|
||||
});
|
||||
@@ -497,20 +812,30 @@ describe("verify-pr-hosted-gates", () => {
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
},
|
||||
{
|
||||
...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:54:00Z"),
|
||||
status: "queued",
|
||||
conclusion: null,
|
||||
},
|
||||
],
|
||||
...patchReuseOptions({
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
}),
|
||||
}),
|
||||
).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`);
|
||||
});
|
||||
|
||||
it("reuses pre-rebase green evidence after a failed current-head manual gate", () => {
|
||||
const priorRun = {
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
};
|
||||
const evidence = collectHostedGateEvidence({
|
||||
sha,
|
||||
recentSha: previousSha,
|
||||
workflowRuns: [
|
||||
{
|
||||
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
|
||||
head_sha: previousSha,
|
||||
},
|
||||
priorRun,
|
||||
{
|
||||
...successfulRun("CI", 2, "2026-06-17T10:53:00Z"),
|
||||
status: "in_progress",
|
||||
@@ -523,12 +848,15 @@ describe("verify-pr-hosted-gates", () => {
|
||||
conclusion: "failure",
|
||||
},
|
||||
],
|
||||
...patchReuseOptions(priorRun),
|
||||
});
|
||||
|
||||
expect(evidence).toEqual({
|
||||
headSha: sha,
|
||||
evidenceHeadSha: previousSha,
|
||||
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
|
||||
reusedFromSha: previousSha,
|
||||
reusedRunId: 1,
|
||||
patchIdMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -549,7 +877,7 @@ describe("verify-pr-hosted-gates", () => {
|
||||
recentSha: previousSha,
|
||||
workflowRuns: [staleRun, currentPending],
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${previousSha}`);
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
|
||||
const recentUnrelatedRun = {
|
||||
...successfulRun("CI", 4, "2026-06-17T10:50:00Z"),
|
||||
@@ -562,7 +890,7 @@ describe("verify-pr-hosted-gates", () => {
|
||||
recentSha: previousSha,
|
||||
workflowRuns: [recentUnrelatedRun, currentPending],
|
||||
}),
|
||||
).toThrow(`Missing successful recent CI workflow for ${previousSha}`);
|
||||
).toThrow(`Missing successful recent CI workflow for ${sha}`);
|
||||
expect(() =>
|
||||
collectHostedGateEvidence({
|
||||
sha,
|
||||
|
||||
Reference in New Issue
Block a user