GitHub Actions ghcr.io push denied and packages: write
A GitHub Actions ghcr.io push denied failure is the registry telling you that the app installation behind your job is not allowed to write this package. The login step succeeded, which is why the credential looks fine and the push still is not permitted.

What this error means
The image builds, layers upload, and the final push is refused. The login step several minutes earlier reported success, so the credentials are not in doubt. The failure arrives four times over in one block, each layer wrapping the last, and only the innermost sentence carries any information. The word in it that matters is installation, which is not a word anybody writes in a workflow file.
#18 exporting to image
#18 pushing layers 0.7s done
#18 ERROR: failed to push ghcr.io/ngeet/containers/spack-env:latest: denied: installation not allowed to Write organization package
------
> exporting to image:
------
[a Dockerfile lint warning, elided]
ERROR: failed to build: failed to solve: failed to push ghcr.io/ngeet/containers/spack-env:latest: denied: installation not allowed to Write organization package
[two build summary labels, elided]
Error: buildx failed with: ERROR: failed to build: failed to solve: failed to push ghcr.io/ngeet/containers/spack-env:latest: denied: installation not allowed to Write organization packageFour programs describing one refusal
The block is long and repetitive because each participant appends its own framing without discarding the previous one. Reading it outside in wastes the information; reading it inside out gives you the answer in one line.
| Layer | What it contributes to the block |
|---|---|
| The registry | denied: installation not allowed to Write organization package, the only sentence with a cause in it |
| BuildKit, pushing | failed to push <image reference>, which names the image but not the reason |
| BuildKit, solving | failed to solve, meaning the build graph could not be completed |
| The action wrapper | Error: buildx failed with:, which repeats everything below it |
Common causes
The job never asked for packages: write
The common case and the easy one. Default permissions for the automatic token are read-only in most organizations, and pushing an image is a write to a package. Nothing in the build step declares that, so the first push is refused while everything before it works.
The run is a fork pull request or a Dependabot run
The block is correct and ignored. Write scopes are downgraded to read for these events after the workflow file has been read, so the job pushes with a read-only token. The tell is that the same workflow succeeds on a push to a branch in the repository and fails only on pull requests from elsewhere.
The package exists and this repository is not allowed to write it
An organization-level package carries its own access list. A package created by another repository, or created manually, does not automatically grant this repository write access, and no permissions block can add it. In our experience this is the cause when the failure survives a correct block and a non-fork event.
The organization restricts which repositories may publish packages
Worth ruling out last because it is invisible from the repository. An organization policy can prevent package creation or writing regardless of the token, and the registry reports it in the same words. Someone with organization settings access has to look.
How to fix it
Work out which case you are in first
- Check the event: a fork pull request or a Dependabot run explains the failure on its own.
- Check whether the job declares
packages: write, and whether the run summary shows it was granted. - If both look right, the package's own settings are the remaining variable and they are not in the repository.
Declare the permission on the publishing job
Ask for exactly what the push needs and keep contents: read so checkout still works. Put it on the job rather than on the workflow, so jobs that only build keep the read-only default.
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:latestDo not push on events that cannot push
Build on fork pull requests, because that is the signal a contributor came for, and push only when the run can. Guarding the push keeps the job green and stops the contributor seeing a failure they cannot fix.
- uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:latestGive the repository write access to the package
In the package's own settings, add this repository with the write role. This is the repair for an existing organization package, and it is the one that cannot be done from the workflow file. If the organization forbids it outright, the policy has to change before anything else will.
Why the login step passing proves nothing
Logging in to a registry establishes who you are; pushing asks whether that identity may write a particular package. ghcr.io accepts the automatic token happily and will let you pull public images with it, so a green login step is consistent with every cause on this page. The two questions are answered by different systems at different times, and only the second one consults the package.
This is also why re-running changes nothing and why rotating a secret changes nothing. There is usually no secret involved: the common configuration authenticates with ${{ github.token }}, so the credential is minted fresh for every run and is never stale.
The permissions block is not always the deciding input
Adding packages: write is the right first move and it resolves the most common case. It is not always sufficient, because two situations override whatever the job asks for. A pull request from a fork gets a token whose write scopes were downgraded after the block was read, so a job that correctly declares packages: write still pushes with read. A run triggered by Dependabot is treated the same way for the same reason, which surprises people because such a run is not a fork.
The third case is outside the repository altogether. An organization decides which repositories may write which packages, and a package that is not linked to this repository, or whose access list does not include it, refuses a correctly scoped token. The message is identical in all three, which is why it is worth establishing which one you are in before editing YAML.
Why there is no recorded run on this page
The answer comes from a registry consulting one organization's package settings, and those settings are the whole variable. A recorded run would push to a package we own, under our own organization's policy, and would succeed or fail for reasons that say nothing about yours. There is also a practical objection: reproducing the interesting causes means publishing container images repeatedly to prove a negative. The block above is a real failure with all four layers intact, quoted from a public issue.
How to prevent it
- Declare
packages: writeon the job that publishes, at the moment you write the push, rather than after the first refusal. - Gate
pushon the event, so the workflow is honest about which runs can publish. - When a package is shared across repositories, record which repositories may write it somewhere a reader of the workflow will find.
- Prefer the automatic token over a stored personal token for ghcr.io, so there is no credential to expire.
Frequently asked questions
What does "installation" mean in the message?
GITHUB_TOKEN is an installation access token for that app, so the registry describes the caller as an installation rather than as a user or a workflow. It is a hint that the identity is the automatic token rather than a personal credential you supplied.