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.
Learning objectives
- Choose the event architecture from the required code-execution and authority boundaries.
-
Compare
pull_request,pull_request_targetandworkflow_runwithout 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.
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.
Knowledge check
A test job needs no mutation. Which trigger is the default for a fork PR?
Use pull_request with minimal/read-only authority.
Do not switch to a privileged event merely for convenience.
When is workflow_run safer than doing everything
in pull_request_target?
When untrusted code can run in an unprivileged upstream workflow and the privileged follow-up can operate only on independently validated metadata/data without executing the upstream artifact/code.
Does workflow_run start only when the upstream
workflow succeeds?
No. A completed trigger can fire regardless of conclusion; the
follow-up must check
github.event.workflow_run.conclusion if success is
required.
Why is a PR number artifact preferable to a tarball of the whole workspace for a metadata comment job?
It minimizes attacker-controlled data crossing the trust boundary and can be strictly validated without executing code.
What does fork-run approval primarily govern?
Whether external-contributor workflow code may consume Actions runner compute under the configured approval policy; it is not a trust certification of the code.
Official references and version notes
- Secure use reference — GitHub guidance for untrusted input, least privilege, action pinning and risky privileged triggers.
- Securely using pull_request_target — Current guidance on privileged PR workflows and checkout protections.
- Events that trigger workflows — Authoritative event, ref/SHA, fork, Dependabot and workflow_run semantics.
- Script injections — Why event-controlled text must not become shell source.
- Approving workflow runs from forks — Current external-contributor approval behavior.
- Managing Actions settings for a repository — Repository controls for fork workflows and token/secrets behavior.
- actions/checkout v7.0.1 action metadata — Node 24 checkout metadata including current unsafe-PR-checkout guard.
- workflow_run event reference — Current follow-up trigger behavior, limits and security warning.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.