What I Was Trying to Do
I’d wired up a workflow that runs prettier --write on every push to main and commits the formatted diff straight back into the repo. Nothing fancy — I’d run the exact same command sequence locally a dozen times without issue, so I expected the workflow to just format, commit, and push.
The formatting step ran fine, but the job failed right at the final git push, with this in the Actions log:
remote: Permission to my-org/my-repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/my-org/my-repo/': The requested URL returned error: 403
git push worked fine from my own machine against the same repo, so this was clearly something specific to how Actions authenticates — but whether it was a git config issue or a token scope issue wasn’t obvious at first.
Environment
- GitHub Actions,
ubuntu-latestrunner actions/checkout@v4- Trigger:
pushonmain - Auth: the default auto-generated
${{ secrets.GITHUB_TOKEN }}(no PAT configured) - Repository created after GitHub’s 2023 default-permissions change (workflow permissions default to read-only)
What I Tried
First I dropped git remote -v in right before the push step to sanity-check the remote URL. It printed https://github.com/my-org/my-repo.git — correct, so the URL wasn’t the problem. That pointed at credentials rather than configuration.
Next I assumed the bot’s identity wasn’t set correctly and added:
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
Same 403 on the next run. At that point it clicked that user.name/user.email only control commit authorship — they have nothing to do with the credentials used to authenticate the push. So I moved on to the token itself.
I generated a personal access token, saved it as secrets.PAT, and figured that alone would fix it. It didn’t — I’d forgotten to actually pass it to actions/checkout’s token: input. Without that, the checkout step still caches the default GITHUB_TOKEN’s (read-only) credentials for every git command that follows, so the push kept failing with the identical error even though a working token existed in secrets.
Why This Happens
The GITHUB_TOKEN that Actions generates for a run has its write access controlled in two places at once, and both have to agree.
The first is the repository-wide default under Settings → Actions → General → Workflow permissions. Any repository created after GitHub’s February 2023 change defaults to “Read repository contents permission” — read-only — unless someone flips it manually.
The second is the permissions: key inside the workflow YAML itself, settable per job or per workflow. Critically, this key can only narrow or restore permissions within whatever the repository-wide default allows — if the repo-wide setting is read-only, or an organization policy blocks YAML from overriding it, then permissions: contents: write in your YAML is silently ineffective.
On top of that, actions/checkout caches the token’s credentials into local git config (http.https://github.com/.extraheader) at checkout time. Whatever permission that token had at that moment is what every later git command uses — so raising permissions after checkout already ran doesn’t retroactively fix anything.
The Fix
Start by declaring write access explicitly at the job level:
jobs:
format:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- run: npx prettier --write .
- run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "chore: auto format"
git push
Scoping permissions: to the job (rather than the whole workflow) means only this job gets write access; every other job in the file stays read-only by default, which is the safer default when only one job actually needs to push.
Then check the repository-side setting. In the GitHub web UI, go to the repo’s Settings → Actions → General, and look at the “Workflow permissions” section:
Workflow permissions
( ) Read repository contents permission
(•) Read and write permissions
If it’s set to read-only, switch it to “Read and write permissions.” That’s what actually lets the permissions: contents: write line in your YAML take effect at all.
With both pieces in place — the YAML permission and the repo-level setting — a successful run’s log looks like this:
To https://github.com/my-org/my-repo.git
a1b2c3d..e4f5g6h main -> main
Once you see the commit-range line instead of a 403, the push went through. The reason both fixes were necessary is that the effective permission is an AND of the two settings — fixing only one still leaves you with a read-only token.
Where I Got Stuck
- Fixing
git config user.name/user.emaildidn’t help at all — that’s commit authorship, a completely separate concern from push authentication. - I created
secrets.PATbut never wired it intoactions/checkout’stoken:input, so every subsequent git command kept using the cached, read-only default token instead. - I added
permissions: contents: writeto the YAML and assumed that alone would fix it, but the repository’s “Workflow permissions” setting was still read-only, so the 403 kept showing up despite the YAML change — I burned a few pointless commits before realizing both settings need to agree. - Testing the same workflow under a
pull_requesttrigger from a fork still failed with 403 even withpermissions: contents: writeset. That’s a separate, non-overridable restriction — forked-PR runs always get a read-onlyGITHUB_TOKENregardless of what the YAML says.
FAQ
Why doesn’t permissions: contents: write fix this on a pull_request workflow triggered from a fork?
For security reasons, workflows triggered by pull requests from forks always receive a read-only GITHUB_TOKEN, and the permissions: key cannot override that. If the workflow genuinely needs to push, split the write step into a separate workflow triggered by workflow_run (or use pull_request_target carefully, understanding its security implications), rather than trying to grant write access directly on the fork-triggered workflow.
When should I use a personal access token instead of the default GITHUB_TOKEN?
For a simple push-back into the same repo, the default GITHUB_TOKEN is enough once permissions are configured correctly. You need a PAT (or a GitHub App token) when pushing to a different repository, when an organization policy blocks bots from pushing directly, or when you need the push to re-trigger other workflows — pushes made with the default GITHUB_TOKEN intentionally don’t trigger downstream workflow runs, which trips people up more than the permission issue itself.
Should permissions: go at the workflow level or the job level?
Scope it to the job whenever only some jobs need write access — that keeps the rest of the workflow at the safer read-only default. Only set it at the workflow’s top level if every job genuinely needs the same elevated permission.