Hardening GitHub Actions Checkouts for Pull Requests

John Burns

A pull request workflow often needs two things that should be kept separate: untrusted code to test and trusted repository credentials to comment, label, publish, or update a status. GitHub’s June 2026 actions/checkout changes made one dangerous combination harder to enable by accident, but they do not turn every pull request workflow into a safe deployment workflow.

This guide builds a review workflow that tests pull-request code with the least useful token possible, then shows the narrow case where pull_request_target is appropriate. The successful result is easy to inspect: a pull request runs the contributor’s code without repository write permissions, while a trusted follow-up job can add a label without checking out that code.

Start with the event, not the checkout step

The important decision is the workflow trigger. pull_request runs in the context of the pull request. For an open, mergeable pull request, GitHub documents that GITHUB_REF is the merge branch; without an explicit ref, actions/checkout therefore checks out that merge result. This is normally the right starting point for linting, tests, builds, and other work that must evaluate a contribution.

A pull request from a fork receives a read-only GITHUB_TOKEN by default. That limitation is a useful boundary, not an inconvenience to work around. Treat the checkout as code supplied by someone outside the repository: package installation scripts, test fixtures, build hooks, and generated configuration can all be part of the attack surface.

Create .github/workflows/pr-test.yml in a repository that accepts pull requests:

name: pull-request tests

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the merge result
        uses: actions/checkout@<FULL_COMMIT_SHA>
        with:
          persist-credentials: false
          fetch-depth: 1

      - name: Set up runtime
        uses: actions/setup-node@<FULL_COMMIT_SHA>
        with:
          node-version: 22
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Replace each <FULL_COMMIT_SHA> with the complete 40-character commit ID for a reviewed action release. GitHub’s secure-use guidance identifies a full-length commit SHA as the immutable form of action pinning. A moving tag such as @v4 is convenient, but it does not provide the same reviewable guarantee about what code runs later.

The top-level permissions block makes the job’s intent visible. contents: read is sufficient for checkout and prevents an accidental workflow edit from inheriting broader repository permissions. The token is still available to actions that need it, but persist-credentials: false tells checkout not to leave the token in the local Git configuration for later commands. This matters when build or test steps execute code from the pull request.

fetch-depth: 1 is not a security control by itself. It reduces the checkout to the commit needed for the job and is a sensible default when the build does not need history. Set fetch-depth: 0 only for a specific reason, such as a versioning tool that needs tags or a complete commit graph.

Do not promote a test job just to write a comment

A common mistake is changing the trigger to pull_request_target because a job needs to apply a label or comment on a pull request. That event runs with the workflow from the base repository’s default branch and can receive a write-capable token even when the pull request is from a fork. It is useful for repository-maintenance work, but it crosses a trust boundary.

The safe pattern is to keep untrusted code out of the privileged job. This workflow labels an incoming pull request and does not check out any pull-request files:

name: label incoming pull requests

on:
  pull_request_target:
    types: [opened, reopened]

permissions:
  pull-requests: write
  contents: read

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Apply the needs-review label
        env:
          GH_TOKEN: ${{ github.token }}
          NUMBER: ${{ github.event.pull_request.number }}
        run: gh pr edit "$NUMBER" --add-label "needs-review"

Keep the permissions narrow even in this trusted workflow. It does not need contents: write, package access, an environment deployment token, or a cloud credential. More importantly, it does not run npm ci, make, a repository script, or an action stored in the pull request. The code is intentionally absent from the job.

Validate the YAML before relying on it, and do not copy an example containing placeholder pins into production unchanged.

Avoid unsafe pull-request refs in privileged jobs

GitHub’s current documentation is explicit: a pull_request_target workflow, and a later actions/checkout call without a ref, uses the base repository’s default branch rather than the pull request. That default protects a maintainer who only needs trusted repository automation.

The danger returns when a workflow explicitly requests the contributor’s head SHA, head ref, or repository. The following pattern must not appear in a privileged pull_request_target job that runs commands:

# Do not use this in a privileged pull_request_target job.
- uses: actions/checkout@<FULL_COMMIT_SHA>
  with:
    ref: ${{ github.event.pull_request.head.sha }}

- run: npm test

Checking out a fork’s code and then executing its package scripts can expose GITHUB_TOKEN, cloud credentials, deployment keys, or any other secret made available to the job. GitHub added protections in actions/checkout v7 for common “pwn request” patterns. GitHub also documents an opt-out input, allow-unsafe-pr-checkout; it should be treated as a review-stopping exception, not a routine workaround. The documented condition is strict: only opt out after confirming that checked-out pull-request code is never executed.

If a maintainer genuinely needs an artifact produced by an untrusted pull request, split the work into two workflows. The pull_request workflow builds or tests with read-only permissions and uploads a deliberately constrained artifact. A second, privileged workflow consumes only a validated data format and never executes content from that artifact. For example, a report containing test counts is safer to parse as JSON than to source as a shell script. Validate filenames, sizes, paths, and schema before use. Do not use an artifact transfer as an indirect way to run a contributor’s code with secrets.

Verify the boundary with a harmless pull request

Use a test repository or a temporary branch rather than changing a production deployment workflow first. Open a pull request that changes a normal source file and inspect the Actions run.

For the pull_request test workflow, verify these points:

  1. The event shown in the run is pull_request.
  2. The job checks out the merge result or the expected explicitly selected ref.
  3. The workflow permissions display only contents: read.
  4. The checkout log shows no need for persistent credentials, and the test completes normally.
  5. A deliberately added command that attempts a repository write fails or is not permitted in a fork-based test.

For the pull_request_target labeling workflow, use a pull request from a fork and confirm that the label is applied. Then inspect the job definition and logs: it should contain no checkout of the fork, no package installation, and no command that reads executable content from the pull request. The correct result is a narrow metadata update, not a successful build.

Repository settings can further enforce this design. Require approval for workflows from first-time contributors where that fits the project, restrict which actions may run, and enable immutable action pinning if the organization supports it. These controls complement workflow design; they cannot make untrusted code safe after a privileged job has checked it out and executed it.

Keep the workflow reviewable

The June 2026 checkout protections are a useful safety net, but the durable control is a clear separation between untrusted execution and privileged repository automation. Use pull_request with a minimal token for builds and tests. Use pull_request_target only for narrow metadata operations that do not check out or execute pull-request content. Pin actions, set explicit permissions, and make any exception easy for the next reviewer to see.

That design gives contributors fast feedback while keeping repository write access and deployment credentials out of the code path they control.

Sources