mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
ci: dispatch required checks for catalog publication (#4345)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user