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.

pull_request against pull_request_target for forkspull_request runs the workflow and code from the pull request's merge commit, with a read-only token and no secrets for forks. pull_request_target runs the workflow from the base branch with a write token and secrets, and checks out the base branch by default — the danger arises when it is told to check out the fork's code.pull_requestpull_request_targetworkflow file fromthe PRthe base branchcode checked outPR merge commitbase branch (default)token for forksread-onlyread/writesecrets for forksnoneallpull_request_target is safe only while it never executes the fork's code

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 }}" }
Splitting untrusted and privileged workThe pull_request workflow runs the fork's code with no secrets and uploads results as an artefact. A workflow_run workflow starts afterwards from the base branch with write permissions, downloads the artefact, treats its contents strictly as data, and posts the comment.pull_requestfork code runsNo secretsread-only tokenArtefactcoverage.jsonworkflow_runbase-branch codePrivileged actioncomment, labelthe artefact is the only thing that crosses — and it must be parsed, never executed

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_target workflow that checks out github.event.pull_request.head.sha and 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")'
Defences for fork pull request CIApproval for first-time contributors stops drive-by abuse. The pull_request trigger runs fork code without secrets or write access. Privileged follow-ups run separately via workflow_run and treat results as data. pull_request_target is reserved for metadata-only tasks that never touch fork code.layered from entry to privileged actionsFirst-run approvalmaintainer gate for newcomerspull_request for codeno secrets, read-only tokenworkflow_run for privilegeartefacts parsed as datapull_request_targetmetadata only, never checkouteach layer assumes the one above might fail

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.