Skip to content
Latchkey LogoLatchkey home

ncipollo/release-action 403 on release create

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.

Three release-create statuses beside the token state each one proves and its repair
The action prints GitHub's status and GitHub's sentence. Only the 404 branch appends a sentence of its own: "Make sure your github token has access to the repo and has permission to author releases".

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

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.

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.

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 lineState of the token that produced itWhat changes it
403Sent and accepted as a credential, then refused for this writeA contents: write permission on the job, or a token belonging to a principal allowed to author releases
401Absent, so the call went out unauthenticatedA non-empty token input; the action does not fail early on an empty one
404Sent, but the repository is not visible to itA token scoped to this repository, or the right owner and repo inputs

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.

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.

Frequently asked questions

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.

Related guides

References

Fix the permission once. The release build runs on every tag, at $0.0025/min at 2 vCPU on Latchkey. Start free → 30-day trial · No credit card