· via dev.to (home feed)
Safe patterns for GitHub Actions when secrets are withheld on fork PRs
A dev.to post explains why fork pull requests see empty secrets in GitHub Actions and lays out the patterns that keep CI useful without exposing repository credentials.

The failure that looks like a misconfiguration
According to a post on dev.to, a familiar GitHub Actions puzzle has a deliberately mundane cause. A job that passes on your own branches fails the moment the same commit arrives in a pull request from a contributor's fork, complaining about a missing password input, unusable cloud credentials, or an HTTP 401. The word "secret" rarely appears anywhere, because the secret simply resolves to an empty string and whatever consumes it fails in its own vocabulary.
The giveaway, the author writes, is the comparison: identical workflow, identical commit, green in-repo and red from a fork. A short diagnostic step settles it without printing any value, by logging whether a given secret is non-empty, the event name, and the github.event.pull_request.head.repo.fork flag; on a fork pull request that prints false and true. Nothing is broken — GitHub Actions withholds secrets from fork-triggered pull_request runs and scopes GITHUB_TOKEN to read-only, and no setting reverses it.
Why the boundary exists
The rationale is about who controls the running code. A pull_request run executes whatever the PR author wrote, and anyone with a GitHub account can open a PR against a public repository. If those runs carried deploy keys or publish tokens, an innocuous-looking edit to a test, a prepare script in package., or a Makefile target could exfiltrate every secret in the repository in one commit, with no write access required. So untrusted code runs with no secrets and a read-only token, while the two triggers that do carry secrets — pull_request_target and workflow_run — run in the base repository's context and by default check out your code, not the PR's. The meaningful boundary is who wrote the code that is executing, not which repository hosts the branch.
First, remove the need for the secret
The step the author says most teams skip is eliminating the dependency rather than routing around it. Many credential-dependent CI steps are that way by accident: a test hitting a staging API that a local stub could satisfy, a registry login needed only for publishing, an integration suite a service container would cover. Service containers and ephemeral local dependencies work fine in fork PRs precisely because they need no credentials at all.
Where a step genuinely cannot be faked, the recommended default is to skip it on fork PRs and make the skip visible. One detail matters: map the secret to a job-level environment variable first, for example HAS_TOKEN: ${{ secrets.STAGING_API_TOKEN != '' }}, then branch on that variable at step level and emit a notice explaining the skip. A bare secrets.* reference in a job-level if does not evaluate the way people expect, while the step-level check on the mapped variable behaves consistently.
Split the pipeline with workflow_run
For work that truly needs credentials, the post's central pattern is two workflows. Workflow A runs on pull_request, holds no secrets, builds the PR and uploads an artifact that includes the PR number. Workflow B triggers on A's completion via workflow_run, runs in your repository's context with full secrets, downloads only the artifact, and performs the privileged step, such as publishing a preview.
Two details make or break this. workflow_run jobs do not appear as checks on the pull request, so workflow B must post a commit status through the GitHub API against the head SHA; without that step, maintainers see no signal at all. And the deploy logic must come from your own checked-out repository or be inlined, never from the downloaded artifact — anything an untrusted build produced is untrusted input. Do not execute it, and be careful unpacking it into paths you later run, because of path traversal in crafted archives.
The alternatives and the trap
The post also maps the remaining options. pull_request_target restricted to base-repository code keeps secrets available at low risk, but is easy to break later by adding a checkout. Combining pull_request_target with a checkout of the PR head is the severe case: secrets and untrusted code in the same run, the classic "pwn request". A deployment environment with required reviewers puts a human in the loop, pausing the job until a maintainer approves — reasonable for small repositories with occasional outside contributions, at the cost of approval fatigue. Managed preview-deployment products from Vercel, Netlify and Cloudflare, the author notes, already handle the fork case with their own credentials, which is the same problem solved for you.
Why it matters
This behaviour burns hours because the failure surfaces as a downstream tool's error message rather than anything mentioning secrets, and the instinct is to hunt for a configuration fix that does not exist. Knowing the real boundary — untrusted code gets no credentials — turns an inexplicable red build into a design decision with several well-understood shapes. It also matters defensively: pull_request_target plus a checkout of PR code is a known route to full secret compromise, and repositories that adopt it casually are the ones that pay for it.
- #github-actions
- #ci-cd
- #devops
- #security
- #open-source