ci: dispatch required checks for catalog publication (#4345)

This commit is contained in:
Miguel Ángel
2026-09-23 13:15:38 -04:00
committed by GitHub
parent 0618fc0dc3
commit ca4af78184
11 changed files with 184 additions and 22 deletions
+38 -3
View File
@@ -82,7 +82,7 @@ test("oversized API files fail before any publication", () => {
);
});
function publicationFixture(t, changed) {
function publicationFixture(t, changed, apiEnv = {}) {
const root = mkdtempSync(join(tmpdir(), "catalog-publication-api-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
@@ -116,11 +116,14 @@ const endpoint = args[1];
const method = args[args.indexOf("--method") + 1];
const body = args.includes("--input") ? JSON.parse(fs.readFileSync(0, "utf8")) : undefined;
fs.appendFileSync(process.env.API_CALLS, JSON.stringify({ endpoint, method, body }) + "\\n");
if (endpoint === "graphql" || method === "DELETE") {
if (!process.env.PUBLISH_SUCCESS && (endpoint === "graphql" || method === "DELETE")) {
console.error(endpoint === "graphql" ? "commit rejected" : "cleanup rejected");
process.exit(1);
}
if (endpoint.endsWith("/ref/heads/main")) console.log(process.env.BASE);
if (endpoint === "graphql") console.log("c".repeat(40));
else if (endpoint.includes("/runs?")) console.log(JSON.stringify({ workflow_runs: JSON.parse(process.env.WORKFLOW_RUNS || "[]") }));
else if (endpoint.endsWith("/dispatches") && process.env.DISPATCH_FAIL) process.exit(1);
else if (endpoint.endsWith("/ref/heads/main")) console.log(process.env.BASE);
else if (endpoint.includes("/matching-refs/")) console.log("refs/heads/bot/catalog-publish");
else if (endpoint.includes("/ref/heads/bot/catalog-publish")) console.log("a".repeat(40));
else if (endpoint.includes("/commits/")) console.log("b".repeat(40));
@@ -155,6 +158,7 @@ catch (error) {
GITHUB_REPOSITORY: "test/catalog",
GITHUB_RUN_ID: "123",
GITHUB_RUN_ATTEMPT: "1",
...apiEnv,
},
stdio: ["pipe", "pipe", "pipe"],
},
@@ -213,3 +217,34 @@ test("publication refuses symlinks instead of reading outside the generated arti
symlinkSync(unrelated, artifact);
assert.throws(() => catalogChanges(fixture.root, "HEAD"), /ELOOP|symbolic link/);
});
test("publication dispatches every required workflow after updating its branch and PR", (t) => {
const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" });
fixture.run();
const calls = fixture.calls();
const dispatches = calls.filter((call) => call.endpoint.endsWith("/dispatches"));
assert.deepEqual(
dispatches.map((call) => call.endpoint.split("/").at(-2)),
["ci.yml", "regression.yml", "windows-render.yml", "pr-captures.yml", "codeql.yml"],
);
for (const call of dispatches) assert.deepEqual(call.body, { ref: "bot/catalog-publish" });
assert.ok(
calls.indexOf(dispatches[0]) >
calls.findIndex((call) => call.endpoint.endsWith("/pulls/42") && call.method === "PATCH"),
);
});
test("publication leaves existing successful workflows at the same head alone", (t) => {
const fixture = publicationFixture(t, true, {
PUBLISH_SUCCESS: "1",
WORKFLOW_RUNS: JSON.stringify([{ status: "completed", conclusion: "success" }]),
});
fixture.run();
assert.equal(fixture.calls().filter((call) => call.endpoint.endsWith("/dispatches")).length, 0);
});
test("dispatch rejection fails publication and still cleans up the staging branch", (t) => {
const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1", DISPATCH_FAIL: "1" });
assert.throws(fixture.run);
assert.equal(fixture.calls().at(-1).method, "DELETE");
});
+48
View File
@@ -0,0 +1,48 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
export function dispatchedPullRequest(pulls, repository, branch, sha) {
const matches = pulls.filter(
(pr) => pr.state === "open" && pr.head.repo?.full_name === repository && pr.head.ref === branch,
);
if (matches.length !== 1)
throw new Error("Dispatch requires exactly one open PR for this branch.");
const pr = matches[0];
if (pr.head.sha !== sha)
throw new Error("PR head changed after dispatch; run checks on the new head.");
return pr;
}
function main() {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
if (process.env.GITHUB_EVENT_NAME === "workflow_dispatch") {
const repository = process.env.GITHUB_REPOSITORY;
const branch = process.env.GITHUB_REF_NAME;
const owner = repository.split("/")[0];
const raw = execFileSync(
"gh",
[
"api",
`repos/${repository}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=100`,
],
{ encoding: "utf8", timeout: 60_000 },
);
event.pull_request = dispatchedPullRequest(
JSON.parse(raw),
repository,
branch,
process.env.GITHUB_SHA,
);
}
if (!event.pull_request) throw new Error("This check requires an open pull request.");
const path = join(process.env.RUNNER_TEMP, "workflow-pr-event.json");
writeFileSync(path, JSON.stringify(event));
appendFileSync(
process.env.GITHUB_OUTPUT,
`event_path=${path}\npr=${JSON.stringify(event.pull_request)}\n`,
);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { dispatchedPullRequest } from "./resolve-workflow-pr.mjs";
const pr = {
state: "open",
number: 7,
head: { repo: { full_name: "owner/repo" }, ref: "bot/catalog-publish", sha: "a".repeat(40) },
};
const resolve = (pulls, sha = pr.head.sha) =>
dispatchedPullRequest(pulls, "owner/repo", pr.head.ref, sha);
test("uses the PR for the dispatched repository, branch and exact head", () => {
assert.equal(
resolve([{ ...pr, head: { ...pr.head, repo: { full_name: "fork/repo" } } }, pr]),
pr,
);
});
test("refuses a dispatch without an open PR", () => {
assert.throws(() => resolve([{ ...pr, state: "closed" }]), /exactly one/);
});
test("refuses ambiguous PRs", () => {
assert.throws(() => resolve([pr, { ...pr, number: 8 }]), /exactly one/);
});
test("refuses a PR which advanced after dispatch", () => {
assert.throws(() => resolve([pr], "b".repeat(40)), /head changed/);
});
+28 -1
View File
@@ -177,7 +177,7 @@ function openPublishPr(repository, base) {
const body =
`Generated catalog snapshot from ${base}.\n\n` +
"Item sources are reviewed in their own PRs. This PR publishes the registry index, search vectors, docs pages, payloads and navigation together.\n\n" +
"Approve any GitHub Actions runs awaiting approval, then review and merge this PR after checks pass. Publication uses GITHUB_TOKEN and GitHub-signed API commits; branch protection remains in effect.";
"Required workflows are dispatched on this snapshot. Review and merge after checks pass. Publication uses GITHUB_TOKEN and GitHub-signed API commits; branch protection remains in effect.";
if (number !== undefined) {
if (api(`${endpoint}/${number}`, "GET", undefined, ".body") !== body)
api(`${endpoint}/${number}`, "PATCH", { title: TITLE, body });
@@ -186,6 +186,31 @@ function openPublishPr(repository, base) {
}
}
function checkAlreadyScheduled(run) {
if (!run) return false;
return run.status !== "completed" || run.conclusion === "success";
}
function dispatchPublishChecks(repository) {
const head = commitOid(
api(`repos/${repository}/git/ref/heads/${BRANCH}`, "GET", undefined, ".object.sha"),
);
for (const workflow of [
"ci.yml",
"regression.yml",
"windows-render.yml",
"pr-captures.yml",
"codeql.yml",
]) {
const endpoint = `repos/${repository}/actions/workflows/${workflow}`;
const runs = JSON.parse(
api(`${endpoint}/runs?event=workflow_dispatch&head_sha=${head}&per_page=1`),
);
if (checkAlreadyScheduled(runs.workflow_runs[0])) continue;
api(`${endpoint}/dispatches`, "POST", { ref: BRANCH });
}
}
function actionEnvironment() {
const { GITHUB_REPOSITORY: repository = "", GITHUB_RUN_ID: run = "" } = process.env;
if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !/^\d+$/.test(run))
@@ -245,6 +270,7 @@ function publishSnapshot(context, base, exists, batches) {
assertCurrentMain(endpoint, base);
updateStandingBranch(endpoint, exists, head);
openPublishPr(repository, base);
dispatchPublishChecks(repository);
} catch (error) {
errors.push(error);
}
@@ -269,6 +295,7 @@ export function publish(root) {
if (batches.length === 0) return clearObsoletePublication(repository, base, exists);
if (snapshotMatches(root, endpoint, exists)) {
openPublishPr(repository, base);
dispatchPublishChecks(repository);
console.log("Standing catalog PR already contains this snapshot.");
return;
}