Fix actions/checkout refusing to check out fork pull request code
A GitHub Actions workflow that passed for years starts failing on every pull request from a fork, in the first step, with no change to the workflow file:
##[error]Refusing to check out fork pull request code from a 'pull_request_target' workflow.
... set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.
actions/checkout, the standard action that downloads a repository’s code onto the runner, now throws this error when a privileged workflow asks for code from a fork. Setting allow-unsafe-pr-checkout: true clears the error. It also removes the protection the error was added to provide, so the choice between that flag and a restructured workflow depends on what the job does with the fork’s code.
Why a privileged workflow must not run fork code
GitHub Actions has two ways to run a workflow for a pull request. The pull_request trigger runs in a restricted context: for a PR from a fork, the token is read-only and repository secrets are withheld. The pull_request_target trigger runs the workflow file from the base branch with the base repository’s token and secrets, so it can comment on PRs or apply labels. The workflow_run trigger, which starts a second workflow after another one finishes, also runs with that access.
The dangerous combination is a privileged trigger plus a checkout of the fork’s code, followed by a build or test step. The fork’s author controls that code, so the workflow runs an attacker’s script with the base repository’s secrets. GitHub calls this a “pwn request.”
On 2026-06-18, GitHub released actions/checkout v7.0.0, which refuses this pattern by default. The changelog says enforcement was added to every supported older major version except v1. The date moved from July 16 to Monday, July 20, 2026, and floating tags such as @v4 pick it up automatically.
What trips the guard
The check lives in src/unsafe-pr-checkout-helper.ts, read at v7.0.1 (commit 3d3c42e). It throws only when all three conditions hold.
- The event is
pull_request_target, or it isworkflow_runwhose triggering event name starts withpull_request. - The pull request’s head repository id differs from the base repository id, so the PR comes from a fork.
- The step’s inputs point at the fork:
repositoryequals the fork’s full name (case-insensitive), orrefmatchesrefs/pull/<n>/headorrefs/pull/<n>/merge, or the resolved commit equals the PR’s head or merge commit SHA.
Applying those conditions to common step inputs gives this table. It comes from reading the source and the names of the unit tests, such as “refuses pull_request_target fork PR ref pattern (merge)” and “allows pull_request_target checkout of an unrelated third-party repo”. The action and its tests were not executed.
Step inputs in a fork’s pull_request_target run |
Result |
|---|---|
| none (default checkout) | passes, and checks out the base branch |
ref: $ |
refused |
ref: refs/pull/$/merge |
refused |
repository: $ |
refused |
repository: some-org/unrelated-repo |
passes |
| same-repository PR, any of the above | passes |
For workflow_run, the compared SHAs are workflow_run.head_commit.id, plus workflow_run.head_sha unless the underlying event was pull_request_target (in that case head_sha is the base branch). The common ref: $ in a “post results after CI” workflow is therefore refused for fork PRs.
Two details from the source explain odd reports. A default checkout skips the guard entirely (isDefaultCheckout in input-helper.ts), so only workflows that set ref or repository break. And the guard only sees inputs to actions/checkout. A run: git fetch origin pull/7/head step passes unchecked, which the GitHub changelog lists as out of scope. The guard is a speed bump and does not make such a workflow safe.
Why the workflow broke without an edit
In issue #2989 on ragas, a docs-check workflow on pull_request_target began failing for every fork PR about six seconds in. Commenter alexisromtz-lab counted its runs: the last success was 2026-07-20T11:26Z and 162 consecutive failures followed, the first at 16:54Z. actions/checkout v4.4.0 was published at 2026-07-20T15:36:10Z, 78 minutes before that first failure. The workflow pinned the floating @v4, so the tag moved under it. The commenter notes that logs from the first failure had expired, so the link is inferred from timing, and every failure that could still be read showed the guard.
The GitHub API confirms the tag side. v4 and v4.4.0 both resolve to commit 11d5960, and action.yml declares allow-unsafe-pr-checkout in v4.4.0, v6.1.0, and v3.7.0 but not in v4.3.1, v6.0.2, or v3.6.0.
That has a practical consequence. Per the same issue comment, earlier v4 releases ignore the unknown input, so adding allow-unsafe-pr-checkout: true to a workflow pinned at @v4.3.1 changes nothing. Pinning to a SHA or exact version keeps the old, unguarded behavior until Dependabot bumps it, and the guard then arrives at that bump.
Pick the fix by what the job needs
Decide what the job does with the fork’s code.
It builds, lints, or tests the code. Use pull_request. Fork PRs get a read-only token and no secrets, which is the right amount of trust for running a stranger’s tests. Move the job off pull_request_target, and the guard stops mattering.
It needs a secret or write access afterward, such as posting a comment or setting a label. Split it in two. An unprivileged pull_request job produces a result file and uploads it with actions/upload-artifact. A workflow_run job with a default checkout, which the guard allows, downloads the artifact and does the privileged part. The ragas issue recommends the same split, or reading the diff with gh pr diff and never checking the fork out.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
actions: read
steps:
- uses: actions/checkout@v7 # default inputs: base repository, default branch
- uses: actions/download-artifact@v8
with:
run-id: $
github-token: $
- run: ./scripts/post-comment.sh result.json # parse as data; never execute it
The workflow above was not run. It shows the shape: the checkout takes the defaults, and the only thing crossing from the fork’s run is a data file. Treat that file as attacker-controlled text. Validate the pull request number, sanitize the body before posting it, and never source or eval anything from it.
It must run fork code with privileges. Set allow-unsafe-pr-checkout: true only behind a gate such as a maintainer-applied label or a deployment environment with required reviewers. Cut permissions: to the minimum first. The ragas thread shows why: a second workflow there holds contents: write and also checks out fork code, and it stayed unreachable only because the first workflow failed before applying the label that triggers it. Fixing the first workflow alone would have exposed the second. Never set the flag on a job with contents: write or a long-lived token.
Check the result
Run a PR from a real fork through the workflow. Same-repository PRs bypass the guard and pass either way, so a branch in the base repository proves nothing. Because pull_request_target always runs the workflow file from the default branch, a fork PR cannot test its own fix: merge the change first, then re-run a failed fork run.
Find the remaining exposure with grep -rn "head.sha\|head.ref\|head.repo" .github/workflows. A hit under a pull_request_target or workflow_run trigger is either a step that now fails or a run: step the guard does not cover.