Skip to content
Latchkey LogoLatchkey home

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.

Four nested wrappers around one registry refusal, with only the innermost naming a cause
One refusal, restated outward. The registry says "denied: installation not allowed to Write organization package" and every layer above it only adds its own name.

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.

Actions log, quoted from NGEET/containers#7
#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 package

Four 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.

LayerWhat it contributes to the block
The registrydenied: installation not allowed to Write organization package, the only sentence with a cause in it
BuildKit, pushingfailed to push <image reference>, which names the image but not the reason
BuildKit, solvingfailed to solve, meaning the build graph could not be completed
The action wrapperError: 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

  1. Check the event: a fork pull request or a Dependabot run explains the failure on its own.
  2. Check whether the job declares packages: write, and whether the run summary shows it was granted.
  3. 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.

.github/workflows/publish.yml (illustrative)
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 }}:latest

Do 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.

.github/workflows/publish.yml (illustrative)
      - uses: docker/build-push-action@v6
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ghcr.io/${{ github.repository }}:latest

Give 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: write on the job that publishes, at the moment you write the push, rather than after the first refusal.
  • Gate push on 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?
It refers to the GitHub App that Actions installs on your repository. 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.
Why does the push fail when the login succeeded?
Because they ask different questions. Logging in establishes an identity the registry accepts, which the automatic token always is. Pushing asks whether that identity may write this particular package, which is answered from the package's settings and the token's scopes. A green login is expected in every case on this page.
Does packages: write fix it for a pull request from a fork?
No. Write scopes are downgraded to read for fork pull requests after the workflow file has been read, so the block is applied and then walked back. The job pushes with a read-only token whatever it declared. Build on those runs and push on the ones that can.
Why is the same sentence repeated four times?
Each layer wraps the one below without replacing it. The registry produces the refusal, BuildKit reports that the push and then the solve failed, and the action prefixes the whole thing with its own failure. Only the innermost sentence contains a cause, so read the block from the inside out.

Related guides

References

Same GITHUB_TOKEN, same ghcr.io push: Latchkey runs the job at $0.0025/min at 2 vCPU against $0.006. Start free → 30-day trial · No credit card