mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
190 lines
5.9 KiB
JavaScript
190 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { isDirectRunUrl } from "../lib/direct-run.mjs";
|
|
import { execPlainGh } from "../lib/plain-gh.mjs";
|
|
|
|
const SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
|
|
function requirePrRecord({ pr, headRefName, headRefOid, isCrossRepository }) {
|
|
if (!Number.isSafeInteger(pr) || pr <= 0) {
|
|
throw new Error("Expected a positive PR number.");
|
|
}
|
|
if (typeof headRefName !== "string" || headRefName.length === 0 || headRefName.startsWith("-")) {
|
|
throw new Error("Expected a non-empty PR headRefName.");
|
|
}
|
|
if (!SHA_PATTERN.test(headRefOid)) {
|
|
throw new Error("Expected a full PR headRefOid.");
|
|
}
|
|
if (isCrossRepository === true) {
|
|
throw new Error(
|
|
`PR #${pr} comes from a fork; release-gate workflow dispatch requires a branch in the base repository at ${headRefOid}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function buildCiDispatchArgs(record) {
|
|
requirePrRecord(record);
|
|
return [
|
|
"workflow",
|
|
"run",
|
|
"ci.yml",
|
|
"--ref",
|
|
record.headRefName,
|
|
"-f",
|
|
`target_ref=${record.headRefOid}`,
|
|
"-f",
|
|
"release_gate=true",
|
|
"-f",
|
|
`pull_request_number=${record.pr}`,
|
|
];
|
|
}
|
|
|
|
function listCiRuns(headRefOid) {
|
|
return JSON.parse(
|
|
execPlainGh(
|
|
[
|
|
"run",
|
|
"list",
|
|
"--commit",
|
|
headRefOid,
|
|
"--workflow",
|
|
"ci.yml",
|
|
"--event",
|
|
"workflow_dispatch",
|
|
"--limit",
|
|
"20",
|
|
"--json",
|
|
"databaseId,url,headSha,createdAt,status",
|
|
],
|
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
),
|
|
);
|
|
}
|
|
|
|
function readCurrentPrHeadOid(pr) {
|
|
return execPlainGh(["pr", "view", String(pr), "--json", "headRefOid", "--jq", ".headRefOid"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}).trim();
|
|
}
|
|
|
|
function delay(milliseconds) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, milliseconds);
|
|
});
|
|
}
|
|
|
|
async function dispatchCiForPr(
|
|
record,
|
|
{
|
|
pollAttempts = 10,
|
|
pollIntervalMs = 1500,
|
|
listRuns = listCiRuns,
|
|
runDispatch = (args) =>
|
|
execPlainGh(args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
|
|
readHeadOid = readCurrentPrHeadOid,
|
|
wait = delay,
|
|
} = {},
|
|
) {
|
|
requirePrRecord(record);
|
|
const priorRunIds = new Set(listRuns(record.headRefOid).map((run) => run.databaseId));
|
|
const headBeforeDispatch = readHeadOid(record.pr);
|
|
if (headBeforeDispatch !== record.headRefOid) {
|
|
throw new Error(
|
|
`PR #${record.pr} head changed before CI dispatch (expected ${record.headRefOid}, got ${headBeforeDispatch}).`,
|
|
);
|
|
}
|
|
runDispatch(buildCiDispatchArgs(record));
|
|
|
|
for (let attempt = 1; attempt <= pollAttempts; attempt += 1) {
|
|
const run = listRuns(record.headRefOid).find(
|
|
(candidate) =>
|
|
candidate.headSha === record.headRefOid &&
|
|
!priorRunIds.has(candidate.databaseId) &&
|
|
typeof candidate.url === "string" &&
|
|
candidate.url.length > 0,
|
|
);
|
|
if (run) {
|
|
const headAtObservation = readHeadOid(record.pr);
|
|
if (headAtObservation !== record.headRefOid) {
|
|
throw new Error(
|
|
`PR #${record.pr} head changed before an exact-SHA CI run became visible (expected ${record.headRefOid}, got ${headAtObservation}); verify the run before retrying.`,
|
|
);
|
|
}
|
|
return run;
|
|
}
|
|
if (attempt < pollAttempts) {
|
|
await wait(pollIntervalMs);
|
|
}
|
|
}
|
|
const headAfterDispatch = readHeadOid(record.pr);
|
|
if (headAfterDispatch !== record.headRefOid) {
|
|
throw new Error(
|
|
`PR #${record.pr} head changed while CI dispatch was being indexed (expected ${record.headRefOid}, got ${headAfterDispatch}); verify the run before retrying.`,
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Dispatch always targets the REMOTE head; unpushed local work silently gets
|
|
// no CI. Warn (never block) when a same-named local branch points elsewhere,
|
|
// so an operator who meant to test local changes pushes first. Best-effort:
|
|
// any git failure (no repo, no branch) skips the check.
|
|
function warnOnLocalHeadDrift(record) {
|
|
const probe = spawnSync(
|
|
"git",
|
|
["rev-parse", "--verify", "--quiet", `refs/heads/${record.headRefName}`],
|
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
);
|
|
if (probe.status !== 0) {
|
|
return;
|
|
}
|
|
const localOid = probe.stdout.trim();
|
|
if (SHA_PATTERN.test(localOid) && localOid !== record.headRefOid) {
|
|
console.error(
|
|
`warning: local branch ${record.headRefName} is at ${localOid}, but CI is being dispatched for the remote head ${record.headRefOid}; push first if you meant to test local changes.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main(argv = process.argv.slice(2)) {
|
|
if (argv.length !== 4 || !["true", "false"].includes(argv[3])) {
|
|
console.error("Usage: ci-dispatch.mjs <PR> <headRefName> <headRefOid> <isCrossRepository>");
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
const record = {
|
|
pr: Number(argv[0]),
|
|
headRefName: argv[1],
|
|
headRefOid: argv[2],
|
|
isCrossRepository: argv[3] === "true",
|
|
};
|
|
requirePrRecord(record);
|
|
warnOnLocalHeadDrift(record);
|
|
const run = await dispatchCiForPr(record);
|
|
if (run) {
|
|
console.log(
|
|
`GitHub accepted CI dispatch for PR #${record.pr} at unchanged remote head ${record.headRefOid} (${record.headRefName}).`,
|
|
);
|
|
console.log(
|
|
"Observed a new exact-SHA manual run after dispatch; GitHub does not expose a dispatch correlation ID, so concurrent requests cannot be distinguished.",
|
|
);
|
|
console.log(`observed_run_url=${run.url}`);
|
|
} else {
|
|
console.log(
|
|
`Requested CI for PR #${record.pr} at unchanged remote head ${record.headRefOid} (${record.headRefName}).`,
|
|
);
|
|
console.log(
|
|
"run_url=pending (GitHub accepted the dispatch, but Actions has not indexed it yet)",
|
|
);
|
|
console.log(
|
|
`inspect_with=gh run list --commit ${record.headRefOid} --workflow ci.yml --event workflow_dispatch`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|
await main();
|
|
}
|