# ncipollo/release-action 403 on release create

> ncipollo/release-action 403 on release create means the token arrived and was refused, not that it was missing. Read the status, then fix it.

Source: https://latchkey.dev/learn/github-actions/ncipollo-release-action-missing-token  
Updated: 2026-09-20

A ncipollo/release-action 403 on release create means the releases API received a token and declined to act on it, which is a different failure from having no token at all. The number in front of the colon is the whole diagnosis, and the action prints it for exactly that reason.

## What this error means

The build steps are green and the release step is red. The line is short: the word `Error`, a three digit status, a colon, and a sentence that came from GitHub rather than from the action. Nothing in it names an input, a secret or a permission, so the workflow file gets edited on a guess. A 403 and a 401 look almost identical in the log and mean opposite things about your token, and a 404 arrives with one extra sentence that the other two do not have.

```Actions log, quoted from a public pull request listed in the references
Run ncipollo/release-action@v1
Error 403: Resource not accessible by integration
##[error]Error 403: Resource not accessible by integration
```

## Common causes

### The job has a read-only token and the step is a write

The 403 case. The default permissions for `GITHUB_TOKEN` are read-only in most organizations created recently, and creating a release is a write to repository contents. Nothing in the step declares that need, so the first time the job asks, it is refused.

### The token input was overridden with something empty

The 401 case. A step that passes `token: ${{ secrets.RELEASE_TOKEN }}` with no such secret sends an empty string, which replaces the working default. The action does not check, so `github.getOctokit("")` builds an unauthenticated client and the API answers 401 rather than naming the input.

### The release is being created in a repository the token cannot see

The 404 case, which the extra remediation sentence identifies. It happens with the `owner` and `repo` inputs pointed at a second repository, and with a token whose reach stops at the repository running the workflow. A private repository the token cannot see returns 404 rather than 403 by design.

### An organization policy overrides a correct permissions block

Worth ruling out last, because the block looks right. An organization or enterprise can cap what a workflow may request, and a job asking for `contents: write` under such a policy still runs with less. The job setup summary lists what was granted, which is the only place the two differ visibly.

## How to fix it

### Read the status before changing anything

1. Find the line beginning `Error` in the release step and note the three digit number.
2. If a second sentence follows about the token having access to the repo, the status was 404 and the repository is the problem, not the permission.
3. If there is no second sentence, a 403 means the token was refused and a 401 means it was never sent.

### Grant contents: write for the 403

The permission belongs on the job that creates the release, not on the workflow, so the other jobs keep the read-only default. This is the whole repair for the common case.

```.github/workflows/release.yml (illustrative)
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
      - uses: ncipollo/release-action@v1
        with:
          tag: ${{ github.ref_name }}
```

### Stop overriding the token, or make the override real

For the 401, delete the `token` input and let the declared default apply. Keep it only when the release must be authored by something other than the workflow's own identity, and then confirm the secret exists at the scope the job runs in. An organization secret not shared with this repository resolves to an empty string in exactly the same way a misspelled name does.

### Point owner and repo at a repository the credential can reach

For the 404, either drop the `owner` and `repo` inputs so the action uses the repository running the workflow, or supply a credential that can see the target. A GitHub App installation token issued for both repositories is the durable version of this; a personal access token is the quick one and carries a person's access rather than a job's.

## How to prevent it

- Declare `permissions` on every job that writes, so a read-only default is never the surprise.
- Leave `token` unset unless a different identity is genuinely required.
- Treat a 404 from a release step as a visibility problem and a 403 as a permission one, rather than trying both.
- Pin the action to a tag you have read, since the failure text is assembled by the action and has changed shape between majors.

## Where the line is assembled

Every failure this action reports passes through one function. `Main.ts` catches whatever `perform()` threw, wraps it in `GithubError`, and hands the result to `core.setFailed`. `GithubError.toString()` builds the string as `Error ${status}: ${message}`, where `status` and `message` are the octokit error's own fields, which in turn are GitHub's. For the statuses on this page the action contributes the word `Error`, the colon, and on a 404 one remediation sentence. It has one further branch, taken only when the response carries an `errors` array, which appends those entries as a bulleted list; a release create refused for permission does not carry one.

That is worth knowing because it tells you where to stop looking. The sentence after the colon is not the action's opinion about your workflow. It is the releases API describing what it did with the credential it was handed, and no input on the step changes what it says.

## What each status proves about the token

Three statuses account for almost every report, and they are not variations of one problem. Read the number before you read the sentence.

| Status in the line | State of the token that produced it | What changes it |
| --- | --- | --- |
| 403 | Sent and accepted as a credential, then refused for this write | A `contents: write` permission on the job, or a token belonging to a principal allowed to author releases |
| 401 | Absent, so the call went out unauthenticated | A non-empty `token` input; the action does not fail early on an empty one |
| 404 | Sent, but the repository is not visible to it | A token scoped to this repository, or the right `owner` and `repo` inputs |

> The 404 row is the one you can identify without reading the number, because it is the only status for which the action adds a line of its own. `GithubError.remediation()` returns a second sentence when and only when the status is 404: "Make sure your github token has access to the repo and has permission to author releases". If you see that sentence, the status was 404 and the repository was invisible, not the permission.

## The phrase "missing token" is not in this action

A long-lived piece of advice for this error says to look for a "missing token" message and add the `token` input. The message does not exist. Reading `main` and the `v1` branch of the action, the string appears in neither, while control phrases from the same files, such as the thrown `No tag found in ref or input!`, are found immediately. The action has no branch that could print it.

What it does have is an input that almost never arrives empty. `action.yml` declares `token` with `required: false` and `default: ${{ github.token }}`, so unless you overrode it with something that resolved to an empty string, a token was sent. There is also an `Inputs.token` getter that calls `core.getInput("token", { required: true })`, which would throw `Input required and not supplied: token`, but nothing in `Action.ts`, `Releases.ts`, `ActionSkipper.ts` or the uploader reads that getter. `Main.ts` uses the plain `core.getInput("token")` instead. So that message cannot reach you from this action either.

## Why there is no recorded run on this page

The failing call creates a release, and a recorded run would have to create one, on a real repository, over and over, to produce each status on demand. What it would then prove is how our repository's token is configured, which is not the question you arrived with. The status you need is already in your own log, and it is the only input to the decision. The line above is quoted from a public pull request against a repository that hit the 403 on `ncipollo/release-action@v1`, linked in the references, and the mapping table is read from `GithubError.ts` rather than reproduced.

## FAQ

### Does ncipollo/release-action ever print "missing token"?

No. The string is in neither the `main` nor the `v1` source, while other thrown strings in the same files are found by the same search. The action reports failures through `GithubError.toString()`, which prints `Error`, the HTTP status and GitHub's own sentence. Advice built around a "missing token" message was written for something else.

### Why does an empty token input give 401 instead of an input error?

Because the code path that would complain is never taken. `Main.ts` reads the token with `core.getInput("token")` and no `required` option, so an empty value passes through to `github.getOctokit("")`, which builds an unauthenticated client. The releases API then answers 401. The getter that does pass `{ required: true }` is not read by any caller in the action.

### What is the extra sentence about the github token access?

It is the action speaking rather than GitHub. `GithubError.remediation()` appends "Make sure your github token has access to the repo and has permission to author releases" when the status is 404 and never otherwise. Its presence is a reliable signal that the repository was invisible to the credential, which is a different repair from granting a permission.

### Is contents: write enough to create a release?

For the workflow's own token in its own repository, yes: releases are repository contents as far as the permission model is concerned. It is not enough when the target is a second repository, because the token's reach does not extend there whatever you write in the block, and it is not enough when an organization policy caps what the job may request.

## References

- [ncipollo/release-action: GithubError.ts, which builds every failure line](https://github.com/ncipollo/release-action/blob/main/src/GithubError.ts)
- [ncipollo/release-action: action.yml, the token input and its default](https://github.com/ncipollo/release-action/blob/main/action.yml)
- [GitHub Actions: workflow syntax, permissions for the GITHUB_TOKEN](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions)
- [A release step refused with 403 on ncipollo/release-action@v1, in a public pull request](https://github.com/zxkmm/siyuan_database_landscape_scrollbar/pull/5)

---

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
