Running CI for fork pull requests safely Jump to heading
A pull request from a fork runs code written by someone outside your organisation — the tests, the build scripts, the package install hooks, all of it. If that code runs with your repository’s secrets or a write-capable token, the contributor can exfiltrate the secrets or push to your branches, and there is nothing masking can do about it. Forges know this, so the default pull_request trigger gives fork runs a read-only token and no secrets. Problems start when teams need something the default withholds — a comment posted back, a label applied, a preview deployed — and reach for pull_request_target, which runs with full privileges. Used naively, it hands the keys to whoever opened the pull request. This page shows the safe patterns, within CI/CD pipeline trigger mapping.
When to use this approach Jump to heading
- Your repository accepts pull requests from forks — typically an open-source project.
- Fork pull requests need CI results, and some automation needs write access or secrets.
- A workflow uses
pull_request_target, or someone proposes adding one. - You want to understand where fork code can and cannot run, alongside keeping secrets out of CI logs.
Step 1 — Know what each trigger runs and with what Jump to heading
The two triggers differ in two independent ways: which code they run by default, and which privileges that run receives.
The key fact: pull_request_target is safe as long as it never builds, installs or runs anything from the pull request. The moment a step checks out the pull request’s head and runs npm install or make, untrusted code executes with your secrets.
Step 2 — Run all build and test steps under pull_request Jump to heading
Keep the default trigger for anything that executes contributor code. Forks get CI results, and nothing they write can reach secrets.
# .github/workflows/ci.yml
on: pull_request
permissions: { contents: read }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test Setting permissions explicitly to read-only documents the intent and protects you if the repository’s default token permissions are ever widened.
# Verification: a fork PR run shows a read-only token and no secrets
# (in the job log, the "GITHUB_TOKEN Permissions" section lists only read scopes)
gh run view --log "$RUN_ID" | grep -A6 'GITHUB_TOKEN Permissions' Step 3 — Split privileged follow-up work into a second workflow Jump to heading
When fork pull requests need a privileged action — posting a coverage comment, uploading results to a service — let the unprivileged workflow produce an artefact, and have a second workflow, triggered by workflow_run, consume it with privileges. The privileged workflow never checks out or runs fork code; it only reads data.
# .github/workflows/ci.yml (unprivileged): save the report as an artefact
- run: npm test -- --coverage --json > coverage.json
- uses: actions/upload-artifact@v4
with: { name: coverage, path: coverage.json } # .github/workflows/comment.yml (privileged): runs after CI, treats the artefact as data
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions: { pull-requests: write, actions: read }
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: coverage, run-id: "${{ github.event.workflow_run.id }}", github-token: "${{ github.token }}" }
- run: |
pct=$(jq -r '.total.lines.pct' coverage.json | tr -cd '0-9.') # sanitise: data, not code
pr=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" --jq '.pull_requests[0].number')
gh pr comment "$pr" --body "Line coverage: ${pct}%"
env: { GH_TOKEN: "${{ github.token }}" } Treat the artefact as hostile input. Parse it with tools that cannot execute it, validate values, and never interpolate it into a shell command or script unescaped.
Step 4 — If you must use pull_request_target, never run fork code Jump to heading
Some automation genuinely suits pull_request_target: labelling by changed paths, checking the title format, welcoming first-time contributors. These read pull request metadata through the API and never touch the code.
on:
pull_request_target:
types: [opened, synchronize]
permissions: { pull-requests: write }
jobs:
label:
runs-on: ubuntu-latest
steps:
# No checkout of the PR head. Read changed paths from the API only.
- run: |
gh pr view "${{ github.event.number }}" --json files --jq '.files[].path' |
grep -q '^docs/' && gh pr edit "${{ github.event.number }}" --add-label docs || true
env: { GH_TOKEN: "${{ github.token }}", GH_REPO: "${{ github.repository }}" } ⚠️ SAFETY WARNING: A
pull_request_targetworkflow that checks outgithub.event.pull_request.head.shaand then runs any build, install or test command gives the fork author your secrets and a write token. This is a well-known path to repository takeover. If you find such a workflow, disable it immediately, rotate every secret it could reach, and audit recent runs:gh run list --workflow <file> --event pull_request_target.
Step 5 — Gate first-time contributors Jump to heading
Forges can require a maintainer’s approval before running workflows for first-time contributors. Even with the unprivileged trigger, this stops drive-by pull requests from consuming runner minutes on cryptocurrency miners or spam.
On GitHub this is a repository or organisation setting under Actions → General (“Approval for running fork pull request workflows from contributors”); choose at least “first-time contributors”. GitLab offers a similar control for pipelines in merge requests from forks, which run in the fork’s project by default unless a maintainer starts them in the parent project.
# Verification: a first-time contributor's run waits for approval instead of starting
gh run list --event pull_request --limit 10 --json status,conclusion,headBranch \
--jq '.[] | select(.status=="waiting" or .conclusion=="action_required")' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can fork pull requests use a cache? Jump to heading
They can restore caches from the base branch but, for safety, caches written by fork runs are scoped so they cannot poison the base branch’s caches. Never configure a cache key that lets fork runs write entries the default branch will read; see fixing cache poisoning between branches.
How do we deploy preview environments for fork pull requests? Jump to heading
Not automatically. A preview deploy needs credentials and runs the contributor’s build. Require a maintainer to apply a label after reviewing the code, and have the privileged workflow deploy the reviewed commit hash only.
Do self-hosted runners change any of this? Jump to heading
They make it more dangerous. Fork code on a self-hosted runner can persist on the machine or reach your network. Never run fork pull requests on self-hosted runners that are not ephemeral and isolated.
Related Jump to heading
- CI/CD Pipeline Trigger Mapping — the parent topic.
- Chaining Workflows with workflow_run — the mechanism behind step 3.
- Limiting Workflow Permissions per Job — keeping tokens minimal everywhere.
- Linting Commit Messages for Forked Pull Requests — a safe check that needs no secrets.