Chapter 22Lesson 03~170 minutes

Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Configuration, Design Patterns, and Trade-Offs

Choose between pull_request, pull_request_target and workflow_run by trust boundary, runner isolation, side effects and evidence requirements.

Design patternsPrivilege separationworkflow_runRunner trustTrade-offs

Learning objectives

  • Choose the event architecture from the required code-execution and authority boundaries.
  • Compare pull_request, pull_request_target and workflow_run without conflating them.
  • Design artifact/data handoffs as validated protocols rather than implicit trust transfers.
  • Explain why hosted-versus-self-hosted is a security decision for contributor code.
  • Document external-contributor approval settings as governance state, not as a code-safety control.

1. Architecture starts with two questions

Before choosing an event, ask: must this job execute contributor-controlled code? and must this job hold privileged authority? If the answer to both is yes, the design deserves suspicion. Most secure architectures split the responsibilities so no single job simultaneously owns arbitrary contributor code and valuable authority.

2. Event comparison: different revisions, authority and intended jobs

Mechanism Workflow/ref identity Typical authority Safe primary use
pull_request PR merge-oriented context; proposed code path Fork runs restricted; normal secrets withheld Build, lint, test and inspect untrusted PR code.
pull_request_target Trusted base default-branch workflow; default-branch ref/SHA Privileged base context except documented protective cases Metadata-only PR operations that do not execute fork code.
workflow_run Follow-up workflow from default branch; trigger data under github.event.workflow_run Can access secrets/write token even when upstream could not Privilege-separated follow-up after validating upstream identity/data.

3. Single workflow versus privilege-separated follow-up

A single pull_request workflow is simplest when the job only needs to test and report a check result. Do not add write authority merely to simplify a comment or deployment. If a post-test operation truly requires authority, separate it into a trusted workflow and define the handoff as a small protocol.

workflow_run is often preferable to pull_request_target for a post-CI action because the privileged workflow can react to a completed named workflow. But it does not inherit trust from the upstream job. Its trigger occurs regardless of upstream conclusion unless you check github.event.workflow_run.conclusion, and artifacts remain attacker-influenced if the upstream run executed fork code.

Privilege-separated workflow
flowchart TD
  A[Fork PR] --> B[pull_request: Test PR
contents read]
  B --> C[Run result + small data artifact
untrusted until validated]
  C --> D[workflow_run on default branch
privileged follow-up]
  D --> E{Validate upstream repo / event / conclusion / run id / data schema}
  E -->|invalid| F[Stop; preserve evidence]
  E -->|valid| G[Metadata-only API mutation]
  G --> H[Audit response + cleanup]

4. Handoff protocol: minimize what crosses the trust boundary

Do not transfer an entire workspace when the privileged workflow needs only a PR number and tested commit SHA. Prefer a tiny non-executable data record with strict schema. Bind it to the triggering workflow's run ID and repository, validate numeric/range constraints, compare expected head SHA to the API/event state, and extract into a temporary directory—not into a location from which scripts will be automatically executed.

Never use “the upstream workflow was green” as the only validation. A malicious PR can intentionally generate a green result and a crafted artifact. The privileged workflow needs independent checks tied to the intended security property.

5. Hosted versus self-hosted for untrusted PR code

Runner choice Benefit Risk Recommendation
GitHub-hosted ephemeral Fresh job environment and no inherited internal host state Untrusted code still controls the job process Default for public/fork contributor tests.
Persistent self-hosted Custom hardware/network/tooling Compromise may persist; internal network/credentials may be reachable Do not expose to arbitrary public-fork code.
Ephemeral isolated self-hosted Custom environment with stronger lifecycle controls Still your responsibility to enforce teardown, network and secret isolation Use only with mature isolation and explicit threat model.

6. Metadata mutation versus code execution

A privileged workflow can safely read a PR number, author login or label request only when those values are treated as data and validated for their use. It becomes a code-execution workflow if it checks out the fork and runs a build, evaluates a contributor-provided script, sources an environment file from the PR, lets a package manager execute lifecycle hooks, or runs an artifact generated by the fork.

7. Permission design: authority belongs to the job that uses it

For tests, start with permissions: {} and add contents: read only when checkout needs it. For a metadata follow-up, grant only the API scope required by that operation. Do not give tests write access “because the next step needs it.” Split the next step into its own trust boundary.

Private-fork settings can optionally send write tokens or secrets to fork workflows. Their existence does not make that choice safe by default. Treat those settings as high-impact governance changes and avoid them for general untrusted code.

8. External-contributor approval is compute governance

GitHub lets repositories/organizations require approval for workflow runs from public forks, with policy choices ranging from new contributors to all external contributors. This reduces surprise compute consumption and gives maintainers a review point. It does not transform the contributor into a trusted committer. A malicious actor can also gain “known contributor” status over time, so approval history cannot replace least privilege.

9. Dependabot-specific design

Dependabot PRs have fork-like restrictions for ordinary workflow events. If package tests require credentials, resist the urge to switch the whole test job to pull_request_target. Prefer public test dependencies, Dependabot-specific secrets where appropriate, or a narrowly designed follow-up that never executes dependency-update code with base secrets.

10. workflow_run operational details

The follow-up workflow file must exist on the default branch. The workflow_run event exposes requested, in_progress and completed activity types; requested does not fire on reruns. GitHub currently limits chaining to three levels. The follow-up run's own ref/SHA are default-branch values, so use github.event.workflow_run.head_sha and related trigger fields when binding an operation to the upstream source revision.

11. Decision table: choose the smallest authority envelope

Need Recommended pattern Prerequisite Evidence
Test arbitrary fork code pull_request + hosted runner + read-only token None beyond Actions Run/attempt, base/head SHA, checkout SHA, permissions.
Apply a deterministic label from trusted rules Metadata-only pull_request_target Narrow PR/issues write permission Base workflow SHA, validated PR number, API response.
Comment after tests Low-privilege test + validated workflow_run follow-up Default-branch follow-up workflow Upstream run ID/conclusion/head SHA + validated handoff + comment response.
Deploy PR code with production secret Do not combine directly Redesign trust/review/environment boundary Artifact provenance, approved trusted revision, deployment authorization.

12. Lesson summary

Event choice is architecture. pull_request exists for ordinary PR CI; pull_request_target exists for trusted-base PR automation; workflow_run can create a privilege-separated follow-up but requires explicit validation. Runner trust, approval settings and data handoffs complete the design.

Next lesson

Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Diagnostics, Failure Modes, and Production Practices

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

A test job needs no mutation. Which trigger is the default for a fork PR?

When is workflow_run safer than doing everything in pull_request_target?

Does workflow_run start only when the upstream workflow succeeds?

Why is a PR number artifact preferable to a tarball of the whole workspace for a metadata comment job?

What does fork-run approval primarily govern?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.