# GitHub Actions ghcr.io push denied and packages: write

> A GitHub Actions ghcr.io push denied error names the app installation, not you. Four layers restate it, and only the innermost gives a cause.

Source: https://latchkey.dev/learn/github-actions/github-actions-ghcr-push-denied-packages-write  
Updated: 2026-09-20

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.

```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
```

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

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

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

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

> Note what the innermost sentence does not say. It does not name a permission, a workflow key or a secret. It names an installation, which is the GitHub App installed on your repository when Actions was enabled, and the identity behind `GITHUB_TOKEN`. The refusal is about what that installation may do with this package, and the workflow file is only one of several things that decide it.

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

## FAQ

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

## References

- [GitHub Packages: publishing and installing a package with GitHub Actions](https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions)
- [GitHub Actions: workflow syntax, the permissions key and its scopes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions)
- [NGEET/containers#7: the full four-layer refusal from a real push](https://github.com/NGEET/containers/issues/7)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
