ci: gate fork PR workflow auto-approval (#2683)

* ci: gate fork PR workflow auto-approval

* ci: rename fork PR approval workflow

* ci: normalize fork workflow paths

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): match action_required workflow runs

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): denylist tool config paths

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): retry action_required workflow lookup

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): restrict fork workflow approvals to target PR

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): keep polling fork workflow approvals

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): revalidate fork workflow approvals before approving

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): poll longer for first fork approval run

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): make fork approval poll budget configurable

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): drop stale fork approval runs

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): deny dotted tsconfig variants in fork approvals

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): run fork approval regression in guard

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): refresh Nix pnpm deps hash

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* test(web): mock useI18n in reattach restore test

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): accept status-only fork approvals

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): rerun fork approval on retarget

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): ignore base tip churn in PR association

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): broaden pending approval run fetch

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): skip non-retarget fork approval edits

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): checkout visual comment workflow head

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): paginate workflow approval run lookup

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): harden fork workflow follow-ups

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): honor full post-appearance settling window

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix(ci): validate manual visual comment checkout

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
This commit is contained in:
Marc Chan 2026-05-23 11:48:36 +08:00 committed by GitHub
parent 715ed04f5d
commit 6592d638ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1236 additions and 146 deletions

View file

@ -0,0 +1,56 @@
name: fork-pr-workflow-approval
# This workflow runs in the trusted base-repository context. It never checks out
# or executes the fork head; the TypeScript policy script only reads PR metadata
# through the GitHub API and approves pending pull_request runs when the touched
# paths are inside the low-risk source allowlist. It only approves low-privilege
# pull_request workflows (`ci`, visual capture, visual verify); privileged
# workflow_run / release / deploy workflows stay on manual gates.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to evaluate.
required: true
type: string
dry_run:
description: Log decisions without approving workflow runs.
required: false
default: false
type: boolean
permissions:
actions: write
contents: read
pull-requests: read
concurrency:
group: fork-pr-workflow-approval-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false
jobs:
approve:
if: github.repository == 'nexu-io/open-design' && (github.event_name != 'pull_request_target' || github.event.action != 'edited' || github.event.changes.base != null)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout trusted base code
uses: actions/checkout@v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
- name: Evaluate and approve low-risk fork PR workflows
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: node --experimental-strip-types scripts/approve-fork-pr-workflows.ts

View file

@ -18,7 +18,6 @@ on:
permissions:
actions: read
contents: read
pull-requests: write
concurrency:
group: visual-pr-capture-${{ github.event.pull_request.number }}
@ -73,132 +72,3 @@ jobs:
e2e/ui/reports/visual-report/manifest.json
if-no-files-found: ignore
retention-days: 7
comment_same_repo:
name: Comment PR visual screenshots
needs: [capture]
if: ${{ always() && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact
uses: actions/download-artifact@v8
with:
run-id: ${{ github.run_id }}
path: visual-artifact
merge-multiple: true
github-token: ${{ github.token }}
- name: Validate capture manifest
id: manifest
shell: bash
env:
EXPECTED_PR_NUMBER: ${{ github.event.pull_request.number }}
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
EXPECTED_RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
manifest="visual-artifact/visual-report/manifest.json"
if [ ! -f "$manifest" ]; then
manifest="visual-artifact/manifest.json"
fi
if [ ! -f "$manifest" ]; then
echo "Capture manifest not found" >&2
find visual-artifact -maxdepth 4 -type f >&2
exit 1
fi
pr_number="$(jq -r '.pr_number' "$manifest")"
head_sha="$(jq -r '.head_sha' "$manifest")"
base_sha="$(jq -r '.base_sha' "$manifest")"
run_id="$(jq -r '.run_id' "$manifest")"
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
if [ "$pr_number" != "$EXPECTED_PR_NUMBER" ] || [ "$head_sha" != "$EXPECTED_HEAD_SHA" ] || [ "$base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
echo "Capture manifest does not match the current pull_request event." >&2
jq . "$manifest" >&2
exit 1
fi
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
- name: Build visual diff report
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
run: |
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
--run-id "${{ steps.manifest.outputs.run_id }}" \
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
--screenshots ../visual-artifact/visual-screenshots \
--comment-out ui/reports/visual-report/comment.md \
--manifest-out ui/reports/visual-report/report-manifest.json
- name: Upsert PR comment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
run: |
set -euo pipefail
body_path="e2e/ui/reports/visual-report/comment.md"
comments_json="$RUNNER_TEMP/comments.json"
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
else
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
fi
- name: Upload visual report artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-report-${{ github.event.pull_request.number }}-${{ github.run_id }}
path: e2e/ui/reports/visual-report
if-no-files-found: ignore
retention-days: 7

View file

@ -27,7 +27,10 @@ concurrency:
jobs:
comment:
name: Publish PR visual diff comment
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
# Fork PR capture artifacts are untrusted. Publish comments/R2 reports for
# same-repository PRs automatically; fork captures require maintainer
# workflow_dispatch with a specific capture run id and PR number.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository) }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@ -36,6 +39,8 @@ jobs:
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
repository: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name || github.repository }}
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
@ -48,20 +53,6 @@ jobs:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact
uses: actions/download-artifact@v8
with:
@ -141,6 +132,63 @@ jobs:
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
- name: Validate live PR state for trusted checkout
if: ${{ github.event_name == 'workflow_dispatch' }}
id: trusted-pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
run: |
set -euo pipefail
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
pr_state="$(jq -r '.state' <<< "$pr_json")"
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
if [ "$pr_state" != "OPEN" ]; then
echo "Refusing trusted checkout for PR $PR_NUMBER because state is $pr_state." >&2
exit 1
fi
if [ "$pr_is_draft" != "false" ]; then
echo "Refusing trusted checkout for PR $PR_NUMBER because it is draft." >&2
exit 1
fi
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
echo "Artifact head_sha ($ARTIFACT_HEAD_SHA) does not match live PR head ($current_head)." >&2
exit 1
fi
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
echo "Artifact base_sha ($ARTIFACT_BASE_SHA) does not match live PR base ($current_base)." >&2
exit 1
fi
echo "base_sha=$current_base" >> "$GITHUB_OUTPUT"
- name: Checkout trusted base revision
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/checkout@v6.0.2
with:
clean: false
fetch-depth: 0
repository: ${{ github.repository }}
ref: ${{ steps.trusted-pr.outputs.base_sha }}
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Stop stale visual runs
id: stale
env:

View file

@ -16,7 +16,7 @@
"tools-pr": "pnpm exec tools-pr",
"tools-serve": "pnpm exec tools-serve",
"nix:update-hash": "tsx ./scripts/update-nix-pnpm-deps-hash.ts",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts",
"i18n:check": "tsx ./scripts/i18n-check.ts",
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",

View file

@ -0,0 +1,725 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
hasPullApprovalStateDrift,
isDeniedChangedPath,
isPendingApprovalRun,
listPendingApprovalRuns,
runTargetsPullRequest,
waitForPendingApprovalRuns,
} from "./approve-fork-pr-workflows.ts";
test("isPendingApprovalRun matches approval-gated fork PR runs from GitHub's captured payload shape", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(isPendingApprovalRun(run, pull), true);
});
test("isPendingApprovalRun also accepts action_required runs reported only in status", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463770,
name: "CI",
event: "pull_request",
status: "action_required",
conclusion: null,
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(isPendingApprovalRun(run, pull), true);
});
test("isPendingApprovalRun rejects runs outside the allowlist or without action_required state", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
assert.equal(
isPendingApprovalRun(
{
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "success",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
},
pull,
),
false,
);
assert.equal(
isPendingApprovalRun(
{
id: 26273463770,
name: "Visual PR Comment",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-comment.yml@main",
pull_requests: [],
},
pull,
),
false,
);
});
test("runTargetsPullRequest accepts empty run.pull_requests only when the head SHA maps to this one open PR", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), true);
});
test("runTargetsPullRequest rejects ambiguous empty run.pull_requests associations", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const otherPull = {
...pull,
number: 3001,
base: {
ref: "release",
sha: "8db117d728f967d108f6fdd64cb8d921d057f7f6",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [pull, otherPull]), false);
});
test("runTargetsPullRequest rejects runs that GitHub already associates to a different PR", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [
{
number: 3001,
head: pull.head,
base: {
ref: "release",
sha: "8db117d728f967d108f6fdd64cb8d921d057f7f6",
repo: { full_name: "nexu-io/open-design" },
},
},
],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), false);
});
test("runTargetsPullRequest approves only the run that GitHub associates to the current PR when two PRs share a head SHA", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const otherPull = {
...pull,
number: 3001,
base: {
ref: "release",
sha: "8db117d728f967d108f6fdd64cb8d921d057f7f6",
repo: { full_name: "nexu-io/open-design" },
},
};
const currentPrRun = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [pull],
};
const otherPrRun = {
...currentPrRun,
id: 26273463770,
pull_requests: [otherPull],
};
assert.equal(runTargetsPullRequest(currentPrRun, pull, [pull, otherPull]), true);
assert.equal(runTargetsPullRequest(otherPrRun, pull, [pull, otherPull]), false);
});
test("runTargetsPullRequest ignores base tip churn for the same PR association", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [
{
number: pull.number,
head: pull.head,
base: {
...pull.base,
sha: "08a88a65482123629ebda5a090c71533bd6b8a88",
},
},
],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), true);
});
test("listPendingApprovalRuns paginates all pull_request runs for the head SHA and filters action_required client-side", async () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const requestedPaths: string[] = [];
const pendingRuns = await listPendingApprovalRuns("nexu-io/open-design", pull, {
loadWorkflowRunsResponsePage: async (path) => {
requestedPaths.push(path);
if (path.endsWith("page=1")) {
return {
workflow_runs: Array.from({ length: 100 }, (_, index) => ({
id: 26273463600 + index,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "success",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
})),
};
}
return {
workflow_runs: [
{
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
},
{
id: 26273463770,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "success",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
},
],
};
},
loadPullRequestsForHeadSha: async () => [pull],
});
assert.deepEqual(requestedPaths, [
"/repos/nexu-io/open-design/actions/runs?event=pull_request&head_sha=734076155c44e569304856590019cea54506fdab&per_page=100&page=1",
"/repos/nexu-io/open-design/actions/runs?event=pull_request&head_sha=734076155c44e569304856590019cea54506fdab&per_page=100&page=2",
]);
assert.equal(requestedPaths.some((path) => path.includes("status=action_required")), false);
assert.deepEqual(
pendingRuns.map((run) => run.id),
[26273463769],
);
});
test("hasPullApprovalStateDrift ignores base tip churn but still rejects base retargeting and head drift", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
assert.equal(hasPullApprovalStateDrift(pull, pull), false);
assert.equal(
hasPullApprovalStateDrift(pull, {
...pull,
base: { ...pull.base, sha: "08a88a65482123629ebda5a090c71533bd6b8a88" },
}),
false,
);
assert.equal(hasPullApprovalStateDrift(pull, { ...pull, draft: true }), true);
assert.equal(hasPullApprovalStateDrift(pull, { ...pull, state: "closed" }), true);
assert.equal(
hasPullApprovalStateDrift(pull, {
...pull,
head: { ...pull.head, sha: "08a88a65482123629ebda5a090c71533bd6b8a88" },
}),
true,
);
assert.equal(
hasPullApprovalStateDrift(pull, {
...pull,
base: { ...pull.base, ref: "release" },
}),
true,
);
});
test("isDeniedChangedPath blocks common tool config files under allowlisted source trees", () => {
assert.equal(isDeniedChangedPath("apps/web/vitest.config.ts"), true);
assert.equal(isDeniedChangedPath("apps/web/vite.config.ts"), true);
assert.equal(isDeniedChangedPath("apps/web/playwright.config.ts"), true);
assert.equal(isDeniedChangedPath("apps/web/tsconfig.sidecar.json"), true);
assert.equal(isDeniedChangedPath("apps/daemon/tsconfig.tests.json"), true);
assert.equal(isDeniedChangedPath("packages/contracts/tsconfig.tests.json"), true);
assert.equal(isDeniedChangedPath("apps/web/src/app/page.tsx"), false);
});
test("waitForPendingApprovalRuns retries until action_required runs appear and keeps polling through the retry window", async () => {
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[], [], [run]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [run],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{ settlingWindowMs: 9_000 },
);
assert.deepEqual(pendingRuns, [run]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000]);
});
test("waitForPendingApprovalRuns keeps polling and returns the latest eligible run snapshot across the retry window", async () => {
const ciRun = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
id: 26273463770,
name: "Visual PR Verify",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
pull_requests: [],
};
const batches = [[ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{ settlingWindowMs: 9_000 },
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000]);
});
test("waitForPendingApprovalRuns drops runs that disappear in later polls", async () => {
const staleRun = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const survivingRun = {
...staleRun,
id: 26273463770,
name: "Visual PR Verify",
path: ".github/workflows/visual-pr-verify.yml@main",
};
const batches = [[staleRun], [staleRun, survivingRun], [survivingRun], [survivingRun]];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [survivingRun],
async (ms) => {
now += ms;
},
() => now,
{ settlingWindowMs: 6_000 },
);
assert.deepEqual(pendingRuns, [survivingRun]);
});
test("waitForPendingApprovalRuns keeps polling until the run set is stable, even when another eligible run appears after the old three-poll budget", async () => {
const ciRun = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
id: 26273463770,
name: "Visual PR Verify",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
pull_requests: [],
};
const batches = [[ciRun], [ciRun], [ciRun], [ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{
firstAppearanceTimeoutMs: 30_000,
settlingWindowMs: 15_000,
},
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.equal(sleeps.length, 10);
});
test("waitForPendingApprovalRuns gives late first appearances their own full settling window", async () => {
const ciRun = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
id: 26273463770,
name: "Visual PR Verify",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
pull_requests: [],
};
const batches = [[], [], [], [ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun], [ciRun, visualRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{
firstAppearanceTimeoutMs: 12_000,
settlingWindowMs: 9_000,
},
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000, 3000, 3000, 3000]);
});
test("waitForPendingApprovalRuns keeps polling until the first run appears, even after the old short retry budget", async () => {
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[], [], [], [], [run]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [run],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{
firstAppearanceTimeoutMs: 15_000,
settlingWindowMs: 6_000,
},
);
assert.deepEqual(pendingRuns, [run]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000, 3000]);
});
test("waitForPendingApprovalRuns accepts a configurable longer polling budget before the first run appears", async () => {
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[], [], [], [], [], [run]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [run],
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{
firstAppearanceTimeoutMs: 18_000,
settlingWindowMs: 3_000,
},
);
assert.deepEqual(pendingRuns, [run]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000, 3000]);
});
test("waitForPendingApprovalRuns stops after the first-appearance timeout when no runs arrive", async () => {
let calls = 0;
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => {
calls += 1;
return [];
},
async (ms) => {
sleeps.push(ms);
now += ms;
},
() => now,
{
firstAppearanceTimeoutMs: 9_000,
},
);
assert.deepEqual(pendingRuns, []);
assert.equal(calls, 4);
assert.equal(sleeps.length, 3);
assert.equal(now, 9_000);
});

View file

@ -0,0 +1,391 @@
import { fileURLToPath } from "node:url";
type PullRequest = {
number: number;
state: string;
draft?: boolean;
changed_files: number;
head: {
sha: string;
repo: { full_name: string } | null;
};
base: {
ref: string;
sha: string;
repo: { full_name: string };
};
};
type PullRequestFile = {
filename: string;
previous_filename?: string;
status: string;
};
type WorkflowRun = {
id: number;
name: string | null;
event: string;
status: string | null;
conclusion: string | null;
head_sha: string;
path: string;
pull_requests: Array<{
number: number;
head: { sha: string; repo: { full_name: string } | null };
base: { ref: string; sha: string; repo: { full_name: string } };
}>;
};
type WorkflowRunsResponse = {
workflow_runs: WorkflowRun[];
};
type ListPendingApprovalRunsDeps = {
loadWorkflowRunsResponsePage?: (path: string) => Promise<WorkflowRunsResponse>;
loadPullRequestsForHeadSha?: (repo: string, headSha: string) => Promise<PullRequest[]>;
};
const dryRun = process.env.DRY_RUN === "true";
const defaultPendingRunPollIntervalMs = 3_000;
const defaultPendingRunFirstAppearanceTimeoutMs = 4 * 60_000;
const defaultPendingRunSettlingWindowMs = 30_000;
type PendingRunPollingConfig = {
pollIntervalMs?: number;
firstAppearanceTimeoutMs?: number;
settlingWindowMs?: number;
};
function pendingRunSetSignature(runs: WorkflowRun[]): string {
return runs
.map((run) => `${run.id}:${run.head_sha}:${normalizeWorkflowPath(run.path)}`)
.sort()
.join(",");
}
// Workflow allowlisting is the security boundary: fork PRs may touch broader
// source paths, but this script only approves low-privilege pull_request
// workflows. Keep privileged workflow_run / release / deploy workflows out of
// this set.
const allowedWorkflowPaths = new Set([
".github/workflows/ci.yml",
".github/workflows/visual-pr-capture.yml",
".github/workflows/visual-pr-verify.yml",
]);
export function normalizeWorkflowPath(path: string): string {
const suffixIndex = path.indexOf("@");
return suffixIndex >= 0 ? path.slice(0, suffixIndex) : path;
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function getRepo(): string {
return requireEnv("GITHUB_REPOSITORY");
}
function getToken(): string {
return requireEnv("GITHUB_TOKEN");
}
function getPrNumber(): number {
return Number(requireEnv("PR_NUMBER"));
}
function isAllowedChangedPath(path: string): boolean {
if (isDeniedChangedPath(path)) return false;
return (
isDocsPath(path) ||
path.startsWith("apps/web/") ||
path.startsWith("apps/daemon/src/") ||
path.startsWith("apps/daemon/tests/") ||
path.startsWith("packages/contracts/src/") ||
path.startsWith("packages/contracts/tests/")
);
}
export function isDeniedChangedPath(path: string): boolean {
return (
path.startsWith(".github/") ||
path.startsWith("scripts/") ||
path.startsWith("e2e/scripts/") ||
path.startsWith("nix/") ||
path.startsWith("tools/pack/") ||
path.startsWith("apps/packaged/") ||
path === "package.json" ||
path.endsWith("/package.json") ||
path === "pnpm-lock.yaml" ||
path === "pnpm-workspace.yaml" ||
path === "flake.nix" ||
path === "flake.lock" ||
/(^|\/)tsconfig(\.[^.]+)*\.json$/.test(path) ||
/(^|\/)(next|vite|vitest|playwright|astro|postcss|tailwind|eslint|prettier|wrangler|electron-builder)(\.config)?\.[^.]+$/.test(
path,
) ||
path.endsWith("esbuild.config.mjs") ||
path.endsWith("esbuild.config.ts")
);
}
function isDocsPath(path: string): boolean {
return (
path === "README.md" ||
path === "README.zh-CN.md" ||
path === "CONTRIBUTING.md" ||
path === "CONTRIBUTING.zh-CN.md" ||
path === "QUICKSTART.md" ||
path.startsWith("docs/")
);
}
function changedPathSet(file: PullRequestFile): string[] {
return [file.filename, file.previous_filename].filter((path): path is string => Boolean(path));
}
export function isPendingApprovalRun(run: WorkflowRun, pull: PullRequest): boolean {
return (
run.head_sha === pull.head.sha &&
run.event === "pull_request" &&
(run.status === "action_required" || run.conclusion === "action_required") &&
allowedWorkflowPaths.has(normalizeWorkflowPath(run.path))
);
}
export function hasPullApprovalStateDrift(initialPull: PullRequest, latestPull: PullRequest): boolean {
return (
latestPull.state !== "open" ||
Boolean(latestPull.draft) ||
latestPull.head.sha !== initialPull.head.sha ||
latestPull.base.ref !== initialPull.base.ref ||
latestPull.head.repo?.full_name !== initialPull.head.repo?.full_name
);
}
function isSamePullRequest(candidate: WorkflowRun["pull_requests"][number] | PullRequest, pull: PullRequest): boolean {
return (
candidate.number === pull.number &&
candidate.head.sha === pull.head.sha &&
candidate.head.repo?.full_name === pull.head.repo?.full_name &&
candidate.base.ref === pull.base.ref &&
candidate.base.repo.full_name === pull.base.repo.full_name
);
}
export function runTargetsPullRequest(
run: WorkflowRun,
pull: PullRequest,
associatedPullsForHeadSha: PullRequest[],
): boolean {
if (run.pull_requests.length > 0) {
if (run.pull_requests.length !== 1) return false;
const [associatedPull] = run.pull_requests;
if (!associatedPull) return false;
return isSamePullRequest(associatedPull, pull);
}
if (associatedPullsForHeadSha.length !== 1) return false;
const [associatedPull] = associatedPullsForHeadSha;
if (!associatedPull) return false;
return isSamePullRequest(associatedPull, pull);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForPendingApprovalRuns(
loadRuns: () => Promise<WorkflowRun[]>,
sleep: (ms: number) => Promise<void> = delay,
now: () => number = Date.now,
config: PendingRunPollingConfig = {},
): Promise<WorkflowRun[]> {
const pollIntervalMs = config.pollIntervalMs ?? defaultPendingRunPollIntervalMs;
const firstAppearanceTimeoutMs = config.firstAppearanceTimeoutMs ?? defaultPendingRunFirstAppearanceTimeoutMs;
const settlingWindowMs = config.settlingWindowMs ?? defaultPendingRunSettlingWindowMs;
const firstAppearanceDeadline = now() + firstAppearanceTimeoutMs;
let pendingRuns: WorkflowRun[] = [];
const collectRuns = async (): Promise<void> => {
pendingRuns = await loadRuns();
};
await collectRuns();
while (pendingRuns.length === 0 && now() < firstAppearanceDeadline) {
await sleep(pollIntervalMs);
await collectRuns();
}
let settlingDeadline = pendingRuns.length > 0 ? now() + settlingWindowMs : null;
let lastPendingRunSetSignature = pendingRunSetSignature(pendingRuns);
while (pendingRuns.length > 0 && settlingDeadline !== null && now() < settlingDeadline) {
await sleep(pollIntervalMs);
const previousPendingRunSetSignature = lastPendingRunSetSignature;
await collectRuns();
lastPendingRunSetSignature = pendingRunSetSignature(pendingRuns);
if (lastPendingRunSetSignature !== previousPendingRunSetSignature) {
settlingDeadline = now() + settlingWindowMs;
}
}
return pendingRuns;
}
async function github<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${getToken()}`,
"User-Agent": "open-design-fork-pr-workflow-approver",
"X-GitHub-Api-Version": "2022-11-28",
...init.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(`GitHub API ${init.method ?? "GET"} ${path} failed with ${response.status}: ${body}`);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
async function githubPaginated<T>(path: string): Promise<T[]> {
const results: T[] = [];
for (let page = 1; ; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const items = await github<T[]>(`${path}${separator}per_page=100&page=${page}`);
results.push(...items);
if (items.length < 100) return results;
}
}
async function approveRun(run: WorkflowRun): Promise<void> {
const repo = getRepo();
if (dryRun) {
console.log(`[dry-run] would approve workflow run ${run.id} (${run.name ?? run.path})`);
return;
}
await github<void>(`/repos/${repo}/actions/runs/${run.id}/approve`, { method: "POST" });
console.log(`Approved workflow run ${run.id} (${run.name ?? run.path})`);
}
async function listPullRequestsForHeadSha(repo: string, headSha: string): Promise<PullRequest[]> {
return githubPaginated<PullRequest>(`/repos/${repo}/commits/${headSha}/pulls`);
}
async function listWorkflowRunsForHeadSha(
repo: string,
headSha: string,
loadWorkflowRunsResponsePage: (path: string) => Promise<WorkflowRunsResponse>,
): Promise<WorkflowRun[]> {
const workflowRuns: WorkflowRun[] = [];
for (let page = 1; ; page += 1) {
const response = await loadWorkflowRunsResponsePage(
`/repos/${repo}/actions/runs?event=pull_request&head_sha=${headSha}&per_page=100&page=${page}`,
);
workflowRuns.push(...response.workflow_runs);
if (response.workflow_runs.length < 100) return workflowRuns;
}
}
export async function listPendingApprovalRuns(
repo: string,
pull: PullRequest,
deps: ListPendingApprovalRunsDeps = {},
): Promise<WorkflowRun[]> {
const loadWorkflowRunsResponsePage = deps.loadWorkflowRunsResponsePage ?? ((path: string) => github<WorkflowRunsResponse>(path));
const loadPullRequestsForHeadSha =
deps.loadPullRequestsForHeadSha ?? ((currentRepo: string, headSha: string) => listPullRequestsForHeadSha(currentRepo, headSha));
const workflowRuns = await listWorkflowRunsForHeadSha(repo, pull.head.sha, loadWorkflowRunsResponsePage);
const associatedPullsForHeadSha = (await loadPullRequestsForHeadSha(repo, pull.head.sha)).filter(
(candidate) => candidate.state === "open",
);
return workflowRuns.filter(
(run) => isPendingApprovalRun(run, pull) && runTargetsPullRequest(run, pull, associatedPullsForHeadSha),
);
}
async function main(): Promise<void> {
const repo = getRepo();
const prNumber = getPrNumber();
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER ?? ""}`);
}
const pull = await github<PullRequest>(`/repos/${repo}/pulls/${prNumber}`);
if (pull.state !== "open") {
console.log(`Skipping PR #${prNumber}: state is ${pull.state}.`);
return;
}
if (pull.draft) {
console.log(`Skipping PR #${prNumber}: draft PR.`);
return;
}
if (!pull.head.repo) {
console.log(`Skipping PR #${prNumber}: head repository is unavailable.`);
return;
}
if (pull.head.repo.full_name === pull.base.repo.full_name) {
console.log(`Skipping PR #${prNumber}: not a fork PR.`);
return;
}
const files = await githubPaginated<PullRequestFile>(`/repos/${repo}/pulls/${prNumber}/files`);
if (files.length !== pull.changed_files) {
console.log(
`Skipping PR #${prNumber}: GitHub returned ${files.length} changed files, but PR reports ${pull.changed_files}.`,
);
return;
}
const latestPull = await github<PullRequest>(`/repos/${repo}/pulls/${prNumber}`);
if (hasPullApprovalStateDrift(pull, latestPull)) {
console.log(`Skipping PR #${prNumber}: PR head/base changed while evaluating workflow approval.`);
return;
}
const blockedPaths = files.flatMap((file) => changedPathSet(file).filter((path) => !isAllowedChangedPath(path)));
if (blockedPaths.length > 0) {
console.log(`Skipping PR #${prNumber}: changed paths are outside the auto-approval allowlist.`);
for (const path of blockedPaths) console.log(`- ${path}`);
return;
}
const pendingRuns = await waitForPendingApprovalRuns(() => listPendingApprovalRuns(repo, pull));
if (pendingRuns.length === 0) {
console.log(`No action_required pull_request workflow runs found for PR #${prNumber} at ${pull.head.sha}.`);
return;
}
const approvalPull = await github<PullRequest>(`/repos/${repo}/pulls/${prNumber}`);
if (hasPullApprovalStateDrift(pull, approvalPull)) {
console.log(`Skipping PR #${prNumber}: PR changed while waiting for approvable workflow runs.`);
return;
}
for (const run of pendingRuns) await approveRun(run);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}