# Refusing to allow a GitHub App to create or update workflow files

> Refusing to allow a GitHub App to update a workflow is a push rejection, not a workflow error. See which grant the message names and where it lives.

Source: https://latchkey.dev/learn/github-actions/github-actions-app-refuses-workflow-update  
Updated: 2026-09-20

The message refusing to allow a GitHub App to create or update workflow files comes from GitHub's git server during a push, not from Actions, and it names a grant that is deliberately separate from repository write access. Which grant depends on what pushed: an App installation needs a repository permission, and an OAuth token needs a scope with a different name.

## What this error means

A push is rejected and nothing else about the branch is unusual. The same token pushed every other file in the same commit range without complaint, and the only difference in the failing change is that it touches something under .github/workflows. The rejection is printed by git, so it appears in a bot's log in the middle of a checkout or push step rather than in any Actions annotation, and the branch simply does not appear on GitHub.

```Quoted from Fragment256/githatch#60, a push rejection reported verbatim
! [remote rejected] ci/fix -> ci/fix
  (refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission)
```

## Common causes

### The App installation does not hold the Workflows permission

The commonest cause for bot pushes. An app configured for contents and pull requests can do everything else it needs and is refused on any push that touches .github/workflows, because the workflow file privilege is a separate repository permission.

### A personal access token is missing the workflow scope

Classic tokens list workflow as its own checkbox, separate from repo. A token created for general automation usually does not have it, and the failure only appears the first time the automation edits a workflow rather than when the token was created.

### The push is using the token the workflow run was given

That token cannot be granted this privilege at all. There is no key for it in the `permissions` block, so no amount of editing the workflow will help, and `contents: write` is not a substitute.

### The grant was added but the installation was never re-approved

A new repository permission on an existing GitHub App installation has to be accepted before it takes effect. In our experience this is the version that produces the longest debugging session, because the app's settings page shows the permission the whole time.

## How to fix it

### Read the rejection and identify which grant it named

1. Look for the words GitHub App or OAuth App in the message.
2. A GitHub App means the `workflows` repository permission on the app.
3. An OAuth App means the `workflow` scope on the token.
4. Then confirm the grant on the credential in hand rather than on the settings page you edited.

### Push with an App installation token that holds Workflows

Mint a short-lived installation token in the job and hand it to checkout, so the push is authenticated as the app rather than as the run. Keep the app's permission set minimal and add Workflows only to the installation that needs it.

```.github/workflows/sync.yml (illustrative)
- uses: actions/checkout@v7
  with:
    token: ${{ steps.app-token.outputs.token }}
    persist-credentials: true
```

### Re-approve the installation after changing permissions

Adding a permission to an app does not update installations that already exist. Accept the new permission for the installation on the organization or account, then mint a fresh token, because a token issued before the acceptance carries the old set.

### Split the change when only part of it touches a workflow

If a bot generates both code and workflow files, let it push the code with the credential it already has and open a pull request for the workflow change, or keep the workflow file identical to one already on another branch. The scopes reference records that an identical file at the same path on another branch may be committed without the scope, which is a narrow exemption but occasionally the cheapest route.

## How to prevent it

- Give bots that edit workflows their own App installation, separate from general automation.
- Record which credential each automated push uses, so the rejection names something you can find.
- Never plan on the run's own token being able to write workflow files.
- Re-approve installations in the same change that adds a permission.

## Three identities, three names, one privilege

The wording of the rejection tells you which identity pushed, and that is the only thing you need in order to know where to look. An App installation token produces a message naming a GitHub App and a permission called workflows, in the plural. An OAuth token produces a message naming an OAuth App and a scope called workflow, in the singular. They are different grants in different settings screens, and quoting one when you mean the other is why searches return advice that does not apply.

GitHub's own documentation matches the split. The page on choosing permissions for a GitHub App says that an app which needs to access or edit Actions files in the .github/workflows directory should request the Workflows repository permission. The OAuth scopes reference describes the workflow scope as granting the ability to add and update GitHub Actions workflow files.

The third identity is the one that surprises people, because it cannot be fixed at all in the obvious place. The token a workflow run is given has its permissions declared in the workflow's own `permissions` block, and that block has no key for workflow files. There is an `actions` key, which covers workflow runs and artifacts, and it is not the same thing.

| What pushed | What the rejection calls the grant | Where it is granted |
| --- | --- | --- |
| A GitHub App installation token | `workflows` permission | the app's repository permissions, then re-approved |
| An OAuth or personal access token | `workflow` scope | the token's own scopes |
| The token a workflow run is given | not grantable | nowhere, use a different credential |
| A person over SSH or HTTPS | not applicable | ordinary write access is enough |

> The OAuth scopes reference records one exemption that explains a lot of confusing behavior: a workflow file can be committed without the scope if the same file, with both the same path and the same contents, already exists on another branch in the same repository. A branch push that only moves an identical file therefore succeeds where an edit would fail.

## Why no permissions block can fix this inside a workflow

The `permissions` key in a workflow accepts a fixed set of names, and that set is published in the schema the parser reads. It contains actions, attestations, checks, contents, deployments, discussions, id-token, issues, models, packages, pages, pull-requests, repository-projects, security-events, statuses and a couple of newer entries. There is no workflows key in it. Writing one produces a rejected workflow file rather than a working push.

The `contents: write` most people try next is genuinely the permission that lets the run's token push commits, and it is not sufficient here, because the workflow file protection is a separate check on the contents of the push rather than a check on write access. Escalating contents does nothing, which is why this loop is so frustrating: the setting that looks relevant is already at its maximum.

So a job that must edit a workflow file has to authenticate as something else. A GitHub App installation with the Workflows permission, or a personal access token carrying the workflow scope, both work, and both should be scoped as tightly as the rest of your automation.

```.github/workflows/sync.yml (illustrative)
- uses: actions/create-github-app-token@v3
  id: app-token
  with:
    app-id: ${{ vars.APP_ID }}
    private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: actions/checkout@v7
  with:
    token: ${{ steps.app-token.outputs.token }}
```

## Why granting the permission is not the end of it

Adding a repository permission to a GitHub App changes what the app may request, and an installation that already exists keeps the permission set it was installed with until somebody accepts the new one. Until that acceptance happens the installation token still lacks the grant and the push still fails, with a message that now contradicts the app's configuration page.

The same shape appears with personal access tokens under single sign-on, where a token can carry the right scope and still be refused for an organization it has not been authorized against. And for a fine-grained token the name changes again: the permission is selected per repository rather than as a global scope, so a token that works on one repository can fail on the next.

The practical consequence is that you should verify the grant on the token you are actually using rather than on the configuration you edited. The rejection is produced at push time against the credential in hand, and that credential may predate your change.

## Why there is no recorded run on this page

The rejection is produced by GitHub's git server when it receives a push, and the surface it appears on is git's output on whichever machine did the pushing. There is no Actions run involved in the rejection itself, so a Latchkey runner has nothing to record even when the push happens inside a job.

Producing one would mean installing an App on a repository with a deliberately incomplete permission set and publishing the transcript, which documents the configuration of one installation on one day rather than the rule. The rule is in the documentation for the two grants, and the message text is quoted above from a public report where a real push was refused.

## FAQ

### Why is my push rejected only when it touches .github/workflows?

Because the ability to create or update workflow files is a separate grant from repository write access. GitHub's git server checks the contents of the push and refuses one that adds or changes a workflow file unless the pushing identity holds that specific permission or scope.

### Can I grant workflow write to GITHUB_TOKEN in the permissions block?

No. The `permissions` block accepts a fixed set of names published in the workflow schema, and there is no key for workflow files among them. The `actions` key covers workflow runs and artifacts, and `contents: write` is about writing repository contents generally, which this check sits on top of.

### What is the difference between the workflows permission and the workflow scope?

They are the same privilege for two kinds of identity. A GitHub App requests a repository permission called Workflows, and a classic OAuth or personal access token carries a scope called workflow. The rejection message names whichever one applies to the credential that pushed.

### Why did the push work once and fail the next time?

Most often because the earlier push added a workflow file that already existed unchanged on another branch. The OAuth scopes documentation records that a workflow file can be committed without the scope when the same path and the same contents exist on another branch in the repository, so the exemption disappears as soon as you edit the file.

## References

- [GitHub Apps: choosing permissions, the Workflows repository permission](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app)
- [OAuth apps: scopes reference, the workflow scope](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)
- [actions/runner: the published workflow schema, permissions keys](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/workflow-v1.0.json)
- [GitHub Actions: workflow syntax, the permissions key](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [Fragment256/githatch#60, a verbatim push rejection](https://github.com/Fragment256/githatch/issues/60)

---

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
