From ca4af7818488d9822091dd49b6736cdc5aee3cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 13:15:38 -0400 Subject: [PATCH] ci: dispatch required checks for catalog publication (#4345) --- .github/workflows/catalog-publish.yml | 1 + .github/workflows/ci.yml | 29 +++++++++++---- .github/workflows/codeql.yml | 1 + .github/workflows/pr-captures.yml | 17 +++++---- .github/workflows/regression.yml | 3 +- .github/workflows/windows-render.yml | 8 ++--- package.json | 2 +- scripts/catalog-publication.test.mjs | 41 +++++++++++++++++++-- scripts/ci/resolve-workflow-pr.mjs | 48 +++++++++++++++++++++++++ scripts/ci/resolve-workflow-pr.test.mjs | 27 ++++++++++++++ scripts/publish-catalog.mjs | 29 ++++++++++++++- 11 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 scripts/ci/resolve-workflow-pr.mjs create mode 100644 scripts/ci/resolve-workflow-pr.test.mjs diff --git a/.github/workflows/catalog-publish.yml b/.github/workflows/catalog-publish.yml index 074f9b9f2f..bbe9b33acc 100644 --- a/.github/workflows/catalog-publish.yml +++ b/.github/workflows/catalog-publish.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: permissions: + actions: write contents: write pull-requests: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 504d2fe3cf..ec2304bcfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b8460a1305..75691762eb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,6 +11,7 @@ name: CodeQL on: + workflow_dispatch: push: branches: [main] pull_request: diff --git a/.github/workflows/pr-captures.yml b/.github/workflows/pr-captures.yml index a4c4943edf..708af14c6e 100644 --- a/.github/workflows/pr-captures.yml +++ b/.github/workflows/pr-captures.yml @@ -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" diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 7877fc8f24..bda7ece6b7 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -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. diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index be93fef110..339c2da21e 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -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 # ----------------------------------------------------------------- diff --git a/package.json b/package.json index cb9ef40c72..e7022853e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/catalog-publication.test.mjs b/scripts/catalog-publication.test.mjs index 22d49938d6..78b1fc6d04 100644 --- a/scripts/catalog-publication.test.mjs +++ b/scripts/catalog-publication.test.mjs @@ -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"); +}); diff --git a/scripts/ci/resolve-workflow-pr.mjs b/scripts/ci/resolve-workflow-pr.mjs new file mode 100644 index 0000000000..59e237c738 --- /dev/null +++ b/scripts/ci/resolve-workflow-pr.mjs @@ -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(); diff --git a/scripts/ci/resolve-workflow-pr.test.mjs b/scripts/ci/resolve-workflow-pr.test.mjs new file mode 100644 index 0000000000..953289493a --- /dev/null +++ b/scripts/ci/resolve-workflow-pr.test.mjs @@ -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/); +}); diff --git a/scripts/publish-catalog.mjs b/scripts/publish-catalog.mjs index 3b770f8d31..c76259fbb8 100644 --- a/scripts/publish-catalog.mjs +++ b/scripts/publish-catalog.mjs @@ -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; }