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
+1
View File
@@ -6,6 +6,7 @@ on:
workflow_dispatch:
permissions:
actions: write
contents: write
pull-requests: write
+23 -6
View File
@@ -10,6 +10,7 @@ env:
HYPERFRAMES_NO_TELEMETRY: "1"
on:
workflow_dispatch:
pull_request:
# `edited` is required so the workflow re-fires when a PR's base ref is
# set back to `main` after a Graphite stack restack momentarily flips
@@ -56,7 +57,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
code: ${{ steps.filter.outputs.code }}
code: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.code == 'true' }}
docs_catalog: ${{ steps.filter.outputs.docs_catalog }}
cli: ${{ steps.filter.outputs.cli }}
skills: ${{ steps.filter.outputs.skills }}
@@ -232,7 +233,7 @@ jobs:
run: node scripts/check-catalog-source-pr.mjs origin/main
- name: Generate and validate catalog
env:
PUBLISH_PR: ${{ github.head_ref == 'bot/catalog-publish' }}
PUBLISH_PR: ${{ (github.head_ref || github.ref_name) == 'bot/catalog-publish' }}
run: |
if [ "$PUBLISH_PR" = "true" ]; then
bun run check:catalog-drift --committed
@@ -1062,15 +1063,31 @@ jobs:
semantic-pr-title:
name: Semantic PR title
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- id: pr
run: node scripts/ci/resolve-workflow-pr.mjs
env:
GH_TOKEN: ${{ github.token }}
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: amannn/action-semantic-pull-request
ref: e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
path: .semantic-pr-action
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Validate PR title
run: GITHUB_EVENT_PATH="$PR_EVENT_PATH" node .semantic-pr-action/dist/index.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
PR_EVENT_PATH: ${{ steps.pr.outputs.event_path }}
INPUT_GITHUBBASEURL: ${{ github.api_url }}
INPUT_TYPES: |
feat
fix
docs
+1
View File
@@ -11,6 +11,7 @@
name: CodeQL
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
+11 -6
View File
@@ -6,12 +6,13 @@ permissions:
# Runs on every PR (no path filter) so it can be a required check; the script passes when neither package changed.
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, edited]
branches: [main]
concurrency:
group: pr-captures-${{ github.event.pull_request.number }}
group: pr-captures-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -23,12 +24,16 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- id: pr
run: node scripts/ci/resolve-workflow-pr.mjs
env:
GH_TOKEN: ${{ github.token }}
- name: Require Before and After captures
env:
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_BODY: ${{ fromJSON(steps.pr.outputs.pr).body }}
PR_NUMBER: ${{ fromJSON(steps.pr.outputs.pr).number }}
BASE_REF: ${{ fromJSON(steps.pr.outputs.pr).base.ref }}
HEAD_REPO: ${{ fromJSON(steps.pr.outputs.pr).head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_SHA: ${{ fromJSON(steps.pr.outputs.pr).head.sha }}
run: node scripts/check-pr-captures.mjs --base "origin/$BASE_REF" --head "$HEAD_SHA"
+2 -1
View File
@@ -15,6 +15,7 @@ concurrency:
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
# Re-run when Graphite changes only the PR base for the same head SHA.
types: [opened, synchronize, reopened, edited]
@@ -28,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
code: ${{ steps.filter.outputs.code }}
code: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.code == 'true' }}
matrix: ${{ steps.shards.outputs.matrix }}
steps:
# Force git-based change detection instead of the pull_request REST API.
+4 -4
View File
@@ -30,7 +30,7 @@ on:
ref:
description: "Git ref to render (branch / tag / SHA)."
required: false
default: "main"
default: ""
concurrency:
group: windows-render-${{ github.ref }}
@@ -71,7 +71,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.inputs.ref }}
ref: ${{ inputs.ref || github.sha }}
- uses: ./.github/actions/preflight
render-windows:
@@ -90,7 +90,7 @@ jobs:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.inputs.ref }}
ref: ${{ inputs.ref || github.sha }}
lfs: true
- name: Show platform info
@@ -416,7 +416,7 @@ jobs:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.inputs.ref }}
ref: ${{ inputs.ref || github.sha }}
lfs: true
# -----------------------------------------------------------------
+1 -1
View File
@@ -52,7 +52,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+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;
}