mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
715ed04f5d
commit
6592d638ce
6 changed files with 1236 additions and 146 deletions
56
.github/workflows/fork-pr-workflow-approval.yml
vendored
Normal file
56
.github/workflows/fork-pr-workflow-approval.yml
vendored
Normal 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
|
||||
130
.github/workflows/visual-pr-capture.yml
vendored
130
.github/workflows/visual-pr-capture.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
78
.github/workflows/visual-pr-comment.yml
vendored
78
.github/workflows/visual-pr-comment.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
725
scripts/approve-fork-pr-workflows.test.ts
Normal file
725
scripts/approve-fork-pr-workflows.test.ts
Normal 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);
|
||||
});
|
||||
391
scripts/approve-fork-pr-workflows.ts
Normal file
391
scripts/approve-fork-pr-workflows.ts
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue