GITHUB_TOKEN read-only on fork pull requests in GitHub Actions
GITHUB_TOKEN read-only on fork pull requests is a rule GitHub applies after it reads your permissions block, not a mistake inside it. The same job passes on a branch in the base repository and fails on a pull request opened from a fork, because the token it is handed is a different token.

What this error means
One step fails and everything after it is skipped. The step is almost always a write: a git push, a comment posted through the API, a label added, a release created. The job is green on every pull request opened from a branch in the base repository and red on every pull request opened from a fork, with the same file and the same permissions block in both. A git push ends in the log below. Through the REST API the same refusal reads Resource not accessible by integration. Neither message names the fork, which is why the workflow file gets edited first and edited in vain.
##[group]Push the commit or tag
[command]/usr/bin/git push origin gh-pages
remote: Permission to hachyderm/ansible.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/hachyderm/ansible.git/': The requested URL returned error: 403
##[error]Action failed with "The process '/usr/bin/git' failed with exit code 128"A workflow that produces it
This file is written for this page and has never been run. It is the ordinary shape: a build step, a publish step, and a permissions block that asks for write. On a branch in the base repository it works. On a pull request opened from a fork the publish step fails, and the block above it changes nothing.
name: docs
on:
pull_request:
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm run docs:build
- run: |
git config user.name github-actions[bot]
git commit -am 'docs: rebuild'
git push origin gh-pagesCommon causes
The event is a fork pull request, so write was downgraded to read
The common one by a wide margin, and the only one that explains a job green from a branch and red from a fork with no change in between. It fires on pull_request, pull_request_review and pull_request_review_comment when the head repository is not the base repository. The job setup summary may still list a write permission, because the block was applied before the adjustment.
A write step is sitting in a job that runs on the untrusted event
The build half of the job is worth running on a fork pull request. The publish half is not, and nothing in the file says so. With no continue-on-error, the first write to run takes the whole job red and skips everything after it, including the steps that would have told the contributor what went wrong.
The repository setting that would allow write is not selected
The documented exception is a repository setting, Send write tokens to workflows from pull requests, and in our experience it is off almost everywhere, because turning it on hands a fork's pull request a write token. A guide claiming the permissions block should work was written for a repository with this selected.
The token is being asked to reach outside its own repository
Worth ruling out before you blame the fork, because the message is identical. GitHub documents that the token's "permissions are limited to the repository that contains your workflow". A push to a second repository fails the same way from a branch as from a fork, and the fork is then a coincidence.
How to fix it
Confirm the downgrade before you touch the workflow file
- Open the failing run and compare it against the last green run of the same workflow.
- If the green one was on a branch in this repository and the red one came from a fork, the trigger is the cause and the
permissionsblock is not. - If both came from forks, or both from branches, read the resource the step was reaching for instead: a token cannot write to a repository other than its own.
Guard the write steps so the build still reports
The cheapest change and usually the right one. Keep the build running on fork pull requests, since that is the signal a contributor came for, and run the write steps only when the head repository is this repository.
- run: npm run docs:build
- name: Publish
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
git config user.name github-actions[bot]
git commit -am 'docs: rebuild'
git push origin gh-pagesMove the write into a second workflow on workflow_run
When the write is the point, split it. The first workflow builds on pull_request with a read-only token and uploads an artifact. The second runs on workflow_run and does the write. GitHub documents why: the workflow started by workflow_run "is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow." Treat the artifact as untrusted data, because its contents came from the fork.
name: publish
on:
workflow_run:
workflows: [docs]
types: [completed]
permissions:
contents: write
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: ./scripts/publish-artifact.shUse an app installation token when the write must cross a boundary
A GitHub App installed on the repository issues its own installation token with its own permissions, and the fork rule does not apply to it because it is not GITHUB_TOKEN. It is also the answer when the write is to a different repository. It costs an app, a private key held as a secret, and the discipline to keep that secret out of any job that runs fork code.
The block is read, then overruled
GitHub's workflow syntax reference sets out the order in which a job's permissions are computed, and the fork rule is the last step in it. The permissions start at the enterprise, organization or repository default, are then "adjusted based on any configuration within the workflow file, first at the workflow level and then at the job level", and then: "Finally, if the workflow was triggered by a pull request event other than pull_request_target from a forked repository, and the Send write tokens to workflows from pull requests setting is not selected, the permissions are adjusted to change any write permissions to read only."
So the permissions block is not ignored. It is read, applied, and then walked back. The same reference says it plainly in its own section on forks: you "can use the permissions key to add and remove read permissions for forked repositories, but typically you can't grant write access." That is why editing the block is the one repair that never works, and why the job setup summary can print a write permission that the token no longer has.
What each trigger actually hands the job
Three triggers can run in response to a fork pull request and they are not interchangeable. The table is the whole decision. Everything in it comes from GitHub's own reference pages, read on 2026-09-20.
pull_request from a fork | pull_request_target | workflow_run | |
|---|---|---|---|
| Workflow file that runs | The pull request merge commit | The base repository's default branch | The base repository's default branch |
| GITHUB_TOKEN | Write scopes downgraded to read | The base repository's token | Able to access write tokens |
| Repository and organization secrets | Withheld | Available | Available |
| Actions cache | Merge-ref scope, not affected by the low-trust rule | Default-branch scope, read only | Default-branch scope, read only by default |
| Blocked by default on public repos | No | Yes, in evaluate mode | No |
pull_request_target is no longer the easy swap
The advice that circulated for years was to change the trigger to pull_request_target and carry on. GitHub now ships a policy against it. Its reference page says that "For public repositories that do not already have an applicable Actions event policy, GitHub adds a default policy that blocks workflows triggered by pull_request_target." The policy "Does not apply to private or internal repositories", "Does not replace an applicable event policy that you have already configured", and "Currently runs in evaluate mode. In this mode, workflow runs continue, but you can use policy insights to identify runs that would be blocked after enforcement."
The page also names the date: "On November 2, 2026, GitHub will enforce the default policy for affected repositories that were using the default pull_request_target policy before general availability." If you reach for this trigger on a public repository, you are choosing something that is scheduled to stop working unless you write an event policy that allows it. The reason is the older one: the workflow "is taken from the base repository's default branch, not from the pull request", so pointing actions/checkout at the pull request head and then running the result hands the fork your secrets. actions/checkout now gates that behind an input named allow-unsafe-pr-checkout, described in its own action.yml as "Required to check out fork pull request code from a workflow triggered by pull_request_target or workflow_run".
Why there is no recorded run on this page
Every page here that carries a log from a job we ran says so. This one does not, and cannot. The decision is made by the Actions service when it mints the token for the job, using the event, the repository settings and the fork relationship. A script on a runner cannot cause it, cannot observe it beyond the 403 it gets back, and cannot undo it: a runner that could hand itself a write token would be the security hole this rule exists to close. The log above is quoted from a public repository's issue, and the workflows on this page are illustrative.
How to prevent it
- Split build from publish at the workflow level, not with a condition you have to remember to add to each new step.
- Put the fork condition on the job rather than on five steps, so a new write step inherits it.
- Prefer
workflow_runoverpull_request_targeton public repositories, given the default policy and its enforcement date. - When you do use
pull_request_target, never check out the pull request head into the working directory.
Frequently asked questions
Why does the permissions block not give the fork PR write access?
pull_request_target from a forked repository, with the Send write tokens to workflows from pull requests setting not selected, "the permissions are adjusted to change any write permissions to read only". The block never had a chance.What does "denied to github-actions[bot]" mean on a git push?
GITHUB_TOKEN and it has no write to this repository. On a fork pull request that is the documented state. The push exits 128 and the action wrapping it reports The process '/usr/bin/git' failed with exit code 128, which is git's exit code, not a runner fault.Is pull_request_target still safe to use for fork pull requests?
Can a contributor from a fork see my repository secrets?
pull_request run. GitHub documents that "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository." That is the same protection as the read-only token, and it is why a job that needs a secret has to move to a trusted trigger rather than gain one.Related guides
References
- GitHub Actions: workflow syntax, permissions and how they are calculated for a job
- GitHub Actions: securely using pull_request_target, and the default blocking policy
- GitHub Actions: events that trigger workflows, workflow_run
- hachyderm/ansible#27: gh-pages push denied to github-actions[bot] on a fork pull request
- GitHub Actions documentation