Skip to content

ci: fix CI on fork PRs by moving off pull_request_target - #2754

Merged
DonKoko merged 6 commits into
mainfrom
fix-external-pr-actions
Jul 28, 2026
Merged

ci: fix CI on fork PRs by moving off pull_request_target#2754
DonKoko merged 6 commits into
mainfrom
fix-external-pr-actions

Conversation

@DonKoko

@DonKoko DonKoko commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #2385

Problem

Every PR opened from a fork currently fails all five CI checks. Jobs die at the checkout step in 4–9s, before installing anything:

##[error]Refusing to check out fork pull request code from a 'pull_request_target'
workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets,
default-branch cache scope, and runner access. Fetching and executing a fork's
code in that trusted context commonly leads to "pwn request" vulnerabilities.

Example: run 30000187031 (fork SRF-Consulting-Group-Inc/scim) — authorize passed after manual approval, then ESLint / TypeScript / Vitest each failed at ⬇️ Checkout repo. React Doctor's two matrix legs failed identically. Three fork PRs are blocked on this right now (#2744, #2737, #2717).

Root cause

actions/checkout added a "pwn request" guard — v7 GA in June 2026, backported to all supported majors (including the v4 tag we pin) on 2026-07-20. That date matches our first failures.

The guard is narrow. From src/input-helper.ts:

const isDefaultCheckout = isWorkflowRepository && !core.getInput('ref')
if (!isDefaultCheckout) {
  unsafePrCheckoutHelper.assertSafePrCheckout({ ... })
}

It throws only when the event is pull_request_target/workflow_run and the head repo is a fork and the resolved ref points at the fork PR head. A default checkout (no ref:) is exempt.

So our explicit ref: ${{ github.event.pull_request.head.sha }} is precisely what trips it, and removing it is what makes the config legal again.

What changed

File Change
test.yml pull_request_targetpull_request; deleted authorize job and 3 ref: overrides; replaced 3 broken cancel-workflow-action steps with native concurrency; added permissions: contents: read, persist-credentials: false, and a SECURITY INVARIANT header. Renamed to 🧪 Test.
react-doctor.yml Same trigger swap and authorize/ref:/cancel removal; sticky comment gated to writable-token PRs; new job-summary step for every PR.
docs-deploy.yml Separate pre-existing bug — fork PRs have no CLOUDFLARE_API_TOKEN so the deploy always failed. Guarded the deploy step (not the job, so forks still build the docs). Also fixed a github.head_ref shell interpolation.

workflow_call and its secrets: block stay in test.ymldeploy.yml passes all six secrets, so removing it would break deploy.yml immediately. E2E gets its own gated workflow when it returns.

Security reasoning — please read this part

This removes risk rather than adding it. The instinct is that dropping a gate must be less safe, so it's worth being explicit about why the opposite is true.

Under on: pull_request from a fork, secrets are not passed to the runner at all — absent, not redacted. Per GitHub's docs: "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository."

A contributor adding console.log(process.env.DATABASE_URL) prints undefined. And since pull_request reads the workflow YAML from the merge commit, a fork can edit the workflow to add env: LEAK: ${{ secrets.DATABASE_URL }} — it evaluates to the empty string. Secrets aren't withheld from the code; they're never handed to the run.

We already have proof of this in our own repo. docs-deploy.yml runs on plain pull_request and references CLOUDFLARE_API_TOKEN. On fork run 30000188946 the log reads CLOUDFLARE_API_TOKEN: — empty. That job's failure is the protection working.

What we have today is the risky configuration. Under pull_request_target, approved fork code runs in the trusted context, and actions/checkout defaults persist-credentials: true — so the base repo's GITHUB_TOKEN is sitting in .git/config while fork code executes in the same job. Any fork-controlled code running afterward (a pnpm install lifecycle script, a modified vitest config) can read it. In react-doctor.yml that token carries pull-requests: write.

The structural problem is worse than the current concrete exposure: we're one careless PR away from a real leak. Someone adds env: DATABASE_URL: ${{ secrets.DATABASE_URL }} to the vitest job to make an integration test pass, and every fork PR silently exfiltrates the production database URL — no error, no review signal. After this change that same mistake is inert.

This also drops fork PRs from the default-branch cache scope to a PR-scoped one. Nothing here uses actions/cache today, so there's no active poisoning vector — but the trusted-context exposure goes away regardless.

Hardening included

  1. persist-credentials: false on all four PR checkouts — the token stops being written to disk, closing that path independently of which trigger is in use.
  2. Explicit permissions: contents: read in test.yml — the repo default is already read, but pinning it means a future org-level default change can't silently escalate these jobs.
  3. A SECURITY INVARIANT header in test.yml stating the rule: any job consuming a secret must never check out fork code — it belongs in a separate workflow behind the external environment.

Residual risk, stated honestly

A fork PR can still execute arbitrary code on an ephemeral runner and consume Actions minutes — bounded by our first_time_contributors approval policy — and run lifecycle scripts via a modified lockfile, identical to the risk of any dependency and equally true today. It cannot read a secret, write to the repo, poison the default-branch cache, or publish anything.

Side effects worth knowing

  • The authorize check disappears. Verified safe: branch protection has no required status checks (contexts: [], checks: []) and the only ruleset is Copilot review.
  • Returning contributors get fully automatic CI. Our fork-PR policy is first_time_contributors, so only genuinely new contributors need an approval click — down from every fork run today. That's the original ask in [Feature request]: Run CI validations without authorization gate for external PRs #2385.
  • React Doctor's sticky comment is skipped on fork and Dependabot PRs, both of which get a read-only token. They get inline annotations (log commands — no token needed) plus a new job summary instead. Dependabot PRs do currently hit this path (example), so this is a deliberate behaviour change, not an oversight.
  • Tests now run against the merge ref rather than the raw fork head — standard CI semantics, and it catches semantic conflicts the current setup misses.

Expect duplicate checks on this PR only

main still has pull_request_target registered, so its old definition fires alongside this branch's new pull_request one — two ⬣ ESLint, two ʦ TypeScript, etc., with identical names. Both should pass (same-repo PRs are exempt from the guard). This disappears on merge and is not a defect.

Verification

  • actionlint clean on all three changed files. Repo-wide findings went 5 → 4; the 4 remaining are pre-existing outdated action versions in deploy.yml (push-only, not fork-reachable), left alone to keep this PR scoped.
  • deploy.yml still resolves against the rewritten reusable workflow (no workflow_call errors).
  • On this PR: confirm the React Doctor sticky comment still posts and the job summary renders — this is the internal-PR path forks won't take.
  • Only a fork PR can prove the actual fix, since the guard fires nowhere else. Post-merge: cancel the six stale waiting runs, then close/reopen fix: allow reducing an oversubscribed QUANTITY_TRACKED booking #2744 and confirm all five checks go green with zero approval clicks.

No contributor needs to rebase — pull_request reads its workflow from the merge commit, so this applies to already-open fork PRs on their next event.

On #2385

The direction there was right, but the snippet couldn't be merged as written:

  1. It wouldn't have fixed the current failure. It keeps pull_request_target registered with the ref: override, so the checkout guard still fires. The issue predates the guard (filed 2026-03-02; guard landed 2026-07-20), so it addressed the approval friction, not the break.
  2. needs: is not an expression context. needs: ${{ ... && 'authorize' || '' }} is resolved when the dependency graph is built, before expressions evaluate.
  3. Registering both triggers duplicates runs on same-repo PRs unless every job carries an if: guard.
  4. react-doctor.yml didn't exist yet (added 2026-04-28), so half the affected surface wasn't covered.

Thanks @iuryeng for filing it — your #2744 is one of the PRs this unblocks.

Summary by CodeRabbit

  • Bug Fixes
    • Improved PR scanning so results diff correctly against the target base instead of reviewing the full codebase.
    • Docs deployment now avoids running the deploy steps on unsupported/forked PRs (docs still build).
    • For forked/automated PRs, moved findings to the job summary when inline commenting may be blocked.
  • Security
    • Tightened workflow permissions and switched key workflows to safer pull_request triggers.
    • Prevented checkout from persisting authentication credentials during CI runs.
  • CI Improvements
    • Added/updated concurrency controls to automatically cancel outdated runs.
    • Updated tooling versions in the (commented) E2E workflow.

DonKoko added 3 commits July 28, 2026 13:32
actions/checkout now refuses an explicit fork-PR ref inside a
pull_request_target workflow, which broke every fork PR at the checkout
step in under 10s. None of the three active jobs consume a secret, so
the trusted context bought them nothing.

Drops the authorize gate and the ref: override, pins least-privilege
permissions, stops persisting git credentials, and replaces the broken
cancel-workflow-action with native concurrency.

Refs #2385
Same checkout-guard fix as test.yml. Fork and Dependabot PRs run with a
read-only GITHUB_TOKEN, so the sticky comment is skipped for them and
findings surface via inline annotations plus a new job summary step.

Annotations are emitted as workflow log commands rather than API calls,
so they keep working without a writable token.

Refs #2385
…ead_ref

Fork PRs get no repository secrets, so CLOUDFLARE_API_TOKEN was empty and
the deploy always failed. The docs build still runs for them, which is the
part that can actually regress.

Also routes github.head_ref through an env var instead of interpolating it
into the shell command, matching the convention react-doctor.yml already
uses for BASE_REF.
Copilot AI review requested due to automatic review settings July 28, 2026 10:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@DonKoko
DonKoko requested a review from Copilot July 28, 2026 10:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@DonKoko

DonKoko commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

CI verification status on this PR

All four runs green — the duplicate pull_request / pull_request_target pairs behaved exactly as described above.

Proven by this PR (from the pull_request run, 30351392020 / 30351392719):

  • The trigger swap fires correctly.
  • Checkout of the merge ref with no ref: and persist-credentials: false succeeds — no guard error.
  • The git switch -C re-attach still works against the merge ref.
  • The changed-files gate resolves origin/<base> correctly, confirming fetch-depth: 0 still provides it under a merge-ref checkout.
  • ESLint / TypeScript / Vitest all pass under pull_request.

NOT proven by this PR: the React Doctor comment-gating and job-summary steps. This PR only touches .github/workflows/, so the app-dir gate correctly reported App apps/webapp changed in this PR: false and steps 5–12 skipped — including the scan, the sticky comment, and the new summary step.

Those if: expressions passed actionlint's syntax and context validation, but their runtime evaluation is unverified. Exercising them requires a PR that touches app source, so the first internal app-code PR after merge is the real check.

Risk if the gating expression is wrong: bounded. continue-on-error: true on the comment step means a bad predicate can't fail the job — worst case is a missing comment, not red CI.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7cba7384-dd62-4d47-9300-c19e55895029

📥 Commits

Reviewing files that changed from the base of the PR and between 5d4adb5 and 81b592e.

📒 Files selected for processing (1)
  • .github/workflows/docs-deploy.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/docs-deploy.yml

Walkthrough

The pull request hardens three GitHub Actions workflows by switching PR-triggered jobs to safer execution, disabling persisted checkout credentials, adding concurrency controls, gating fork deployments and comments, and updating related action versions.

Changes

Workflow hardening

Layer / File(s) Summary
Conditional documentation deployment
.github/workflows/docs-deploy.yml
Cloudflare Pages deployment and pinned Wrangler installation now run only for pushes or same-repository pull requests, with the branch passed through DEPLOY_BRANCH and checkout credentials disabled.
React Doctor PR execution and reporting
.github/workflows/react-doctor.yml
The workflow uses pull_request, cancellable concurrency, merge-ref checkout with named-branch reattachment, and conditional PR comment or job-summary reporting.
Test workflow execution and action updates
.github/workflows/test.yml
Testing uses read-only permissions, cancellable concurrency, credential-free checkouts, and updated action versions in the commented Playwright configuration.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PullRequest
  participant ActionsCheckout
  participant ReactDoctor
  participant GitHubReporting
  PullRequest->>ActionsCheckout: checkout merge ref without persisted credentials
  ActionsCheckout->>ReactDoctor: provide reattached named branch
  ReactDoctor->>GitHubReporting: post same-repository comment or append findings to job summary
Loading

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: CI workflows moved off pull_request_target to support fork PRs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-external-pr-actions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/docs-deploy.yml:
- Around line 42-45: Update the “Install Wrangler” step in the docs deployment
workflow to use a pinned Wrangler version or install it through the repository’s
lockfile, rather than resolving the latest global release. Preserve the existing
conditional execution and ensure the deploy step invokes the pinned installation
while using the Cloudflare credentials.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f971ee66-220f-4a21-8567-191c40e64c97

📥 Commits

Reviewing files that changed from the base of the PR and between 3e4c068 and 0ccb64b.

📒 Files selected for processing (3)
  • .github/workflows/docs-deploy.yml
  • .github/workflows/react-doctor.yml
  • .github/workflows/test.yml

Comment thread .github/workflows/docs-deploy.yml Outdated
The install step hands CLOUDFLARE_API_TOKEN to whatever it pulls, so
resolving "latest" at runtime would let a compromised release reach the
credential.

Pinned to the major rather than an exact version so patch and minor fixes
still land — an exact pin goes stale and breaks the deploy when Cloudflare
moves their API forward. Kept as a global install on purpose (acb25d0).
Copilot AI review requested due to automatic review settings July 28, 2026 11:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/docs-deploy.yml:
- Around line 45-51: Update the Wrangler installation in the deploy step to use
an immutable exact version instead of the floating wrangler@4 range. Preserve
the global npm installation and add the repository’s established automated
dependency-update mechanism or committed lockfile so the pinned version remains
maintainable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f848ed9a-a10e-416f-8564-13679838bb6e

📥 Commits

Reviewing files that changed from the base of the PR and between 0ccb64b and 0fe790d.

📒 Files selected for processing (1)
  • .github/workflows/docs-deploy.yml

Comment thread .github/workflows/docs-deploy.yml Outdated
wrangler@4 still resolved the newest 4.x at runtime, and that package
receives CLOUDFLARE_API_TOKEN. Wrangler pins all its own non-optional deps
exactly, so an exact pin here yields a deterministic tree.

Bumping is manual: Dependabot parses `uses:` refs, not npm names inside
`run:` scripts. Accepted trade — a stale wrangler fails loudly at deploy
time, a compromised one fails silently.

Not made a devDependency of apps/docs: wrangler pulls workerd (~127MB) and
CI has no pnpm caching, so all 5 jobs would download it on every PR.
Copilot AI review requested due to automatic review settings July 28, 2026 11:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/docs-deploy.yml (1)

38-44: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable checkout credentials before building fork PRs.

In fork-controlled pull_request runs, pnpm install and pnpm docs:build execute before the deploy guard, while the unconfigured actions/checkout@v4 persists GITHUB_TOKEN in local Git config by default. Set persist-credentials: false on checkout to remove token access from these steps.

Proposed fix
       - name: ⬇️ Checkout repo
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/docs-deploy.yml around lines 38 - 44, Update the
workflow’s actions/checkout@v4 step to set persist-credentials to false,
ensuring fork pull-request build steps do not retain GITHUB_TOKEN in local Git
configuration while leaving the existing build and deployment conditions
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In @.github/workflows/docs-deploy.yml:
- Around line 38-44: Update the workflow’s actions/checkout@v4 step to set
persist-credentials to false, ensuring fork pull-request build steps do not
retain GITHUB_TOKEN in local Git configuration while leaving the existing build
and deployment conditions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f7037fe2-e2cc-4849-8d34-7dc9f3854206

📥 Commits

Reviewing files that changed from the base of the PR and between 0fe790d and 5d4adb5.

📒 Files selected for processing (1)
  • .github/workflows/docs-deploy.yml

pnpm install and pnpm docs:build run fork-authored code (lifecycle
scripts, vitepress config) before the deploy guard, and checkout writes
GITHUB_TOKEN into .git/config by default. Nothing here needs git auth
after checkout.

Completes the sweep — test.yml and react-doctor.yml already had this;
docs-deploy.yml was the third fork-reachable workflow and was missed.
@DonKoko

DonKoko commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed: persist-credentials on the docs-deploy checkout — 81b592e

Good catch, and correctly reasoned: pnpm install and pnpm docs:build run fork-authored code (lifecycle scripts, vitepress config) before the deploy guard, while checkout has already written GITHUB_TOKEN into .git/config.

This was a genuine miss. The PR added persist-credentials: false to test.yml (×3) and react-doctor.yml (×1) but skipped docs-deploy.yml — the third fork-reachable workflow, and the one where untrusted code runs earliest.

Rather than fix only the flagged line, I swept every checkout in the repo:

Workflow Checkouts persist-credentials: false Fork-reachable?
test.yml 3 active Yes
react-doctor.yml 1 Yes
docs-deploy.yml 1 ✅ (this commit) Yes
build.yml 1 No — push on main/dev only
deploy.yml 2 No — push on main/dev only

All three fork-reachable workflows are covered. build.yml and deploy.yml are deliberately left alone: they only run on push to main/dev, so no untrusted code ever executes alongside that token. (test.yml:137 also reads as uncovered — that's inside the commented-out Playwright block, inert.)

For calibration: on a fork pull_request the token is read-only and this is a public repo, so the exposure here was low-value. The reason to fix it isn't blast radius — it's that hardening two of three fork-reachable workflows and missing the third is exactly the inconsistency that becomes a real hole the next time someone raises those job permissions.

Copilot AI review requested due to automatic review settings July 28, 2026 11:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@DonKoko
DonKoko merged commit f17168e into main Jul 28, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request]: Run CI validations without authorization gate for external PRs

2 participants