# softprops/action-gh-release already_exists on release create

> A softprops/action-gh-release already_exists failure is a race between two jobs on one tag, not a refusal to reuse. The version decides what follows.

Source: https://latchkey.dev/learn/github-actions/softprops-action-gh-release-validation-failed-already-exists  
Updated: 2026-09-20

A softprops/action-gh-release already_exists failure means the action looked for a release on your tag, did not find one, and lost a race to create it. It is not the action refusing to update an existing release, because updating is the path it takes whenever the lookup succeeds.

## What this error means

One job in a matrix goes red while its siblings go green, and rerunning it usually succeeds. The log shows the action deciding to create a release, then reporting status 422 with a JSON body naming `already_exists` on `tag_name`. The release exists by the time you look, which is what makes the message read like a refusal to reuse it. On newer versions the same race produces a different final line, and on older versions it stops immediately.

```Actions log on v2.2.2, quoted from softprops/action-gh-release#616
👩‍🏭 Creating new GitHub release for tag graph@v20250429.0 using commit "c75edc7a731e637485d4a6bb7f707e7ba95f38e3"...
⚠️ GitHub release failed with status: 422
{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}],"documentation_url":"https://docs.github.com/rest/releases/releases#create-a-release","status":"422"}
Skip retry - validation failed
⚠️ Unexpected error fetching GitHub release for tag refs/heads/main: HttpError: Validation Failed: {"resource":"Release","code":"already_exists","field":"tag_name"} - https://docs.github.com/rest/releases/releases#create-a-release
Error: Validation Failed: {"resource":"Release","code":"already_exists","field":"tag_name"} - https://docs.github.com/rest/releases/releases#create-a-release
```

## Common causes

### Two matrix jobs publish to the same tag

The dominant shape. A matrix over architectures or operating systems runs the same release step in each leg, every leg looks up the tag at roughly the same moment, none finds it, and all of them try to create. One wins. The others get the 422, and which leg loses changes between runs, which is why the failure looks intermittent.

### Two workflows release the same tag

Less obvious because nothing in either file mentions the other. A tag push that triggers two workflows, or a release workflow that also runs on a schedule, produces the same overlap without any matrix. Pinning different action versions in the two files makes it stranger still, since only one of them retries.

### A rerun overlaps the run it is replacing

Rerunning a failed job while the original is still finishing gives you two creates on one tag. In our experience this is the version people hit while investigating one of the other two, which makes the investigation harder rather than easier.

### The lookup cannot see a release that exists

Worth ruling out when the failure is not intermittent. If every run fails identically and no second job exists, the create is being reached because the lookup is failing rather than because it is racing, and the tag itself is the thing to examine.

## How to fix it

### Stop the race rather than retrying it

The durable repair is to have exactly one job own the release. Build artifacts in the matrix, upload them, and publish from a single job that runs after the matrix is complete. Nothing then competes for the tag, and the version of the action stops mattering.

```.github/workflows/release.yml (illustrative)
jobs:
  build:
    strategy:
      matrix:
        target: [linux, darwin, windows]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.target }}

  publish:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v5
      - uses: softprops/action-gh-release@v2
        with:
          files: dist-*/*
```

### Create the release once, then let the matrix attach to it

When the matrix legs must upload their own assets, publish a draft release from a single job first. Every leg then finds the release on lookup and takes the update path, so no leg ever calls create. This is the workaround the canonical issue converged on, and it works on every version because it removes the create entirely.

### Move to v2.3.4 or later if you cannot restructure

From that version the 422 is retried rather than thrown, and the retry finds the release the winner made. It is a mitigation rather than a fix: the race still happens, the run is slower, and a job that loses repeatedly still ends on `Too many retries.`

### Add a concurrency group as a backstop

A concurrency group keyed on the tag serializes runs of the same workflow, which removes the rerun overlap and the double-trigger case. It does not help with a matrix, because the legs of one run share a group rather than queueing behind each other.

```.github/workflows/release.yml (illustrative)
concurrency:
  group: release-${{ github.ref_name }}
  cancel-in-progress: false
```

## How to prevent it

- Let one job own the release and let the matrix own the artifacts.
- Pin the action to a tag you have read, since the 422 branch changed behavior inside the v2 line.
- Key a concurrency group on the tag so a rerun cannot overlap the run it replaces.
- Treat an intermittent release failure as a concurrency question before treating it as a permission one.

## The action prefers updating, which is why this is a race

Reading `release()` in `src/github.ts` settles the premise. The first thing it does is look the tag up through `findTagFromReleases`. If a release comes back, the action takes the update path and calls `updateRelease`, and no create is attempted at all. Only when the lookup returns nothing does it call `createRelease`.

The lookup itself changed shape in v2.2.2, which is worth knowing when you meet a report naming a last working version. Up to v2.2.1 the non-draft path called `getReleaseByTag` for the one tag, a single point lookup, and only a draft release walked `allReleases`. From v2.2.2 both paths go through `findTagFromReleases`, which pages through every release in the repository looking for a matching `tag_name`. The create it guards is unchanged and so is the 422 branch, but the interval between reading "no release" and asking to create one is now as long as a paginated list walk rather than as long as one request. The race is the same race; the window it has to lose in is wider.

So a 422 saying the tag already has a release is a contradiction in time rather than in logic: the lookup was truthful when it ran, and something created the release between that moment and the create call. In a matrix, that something is one of the other jobs. Every report we could find behind this message involves more than one job, more than one workflow, or a rerun overlapping its predecessor, and the maintainer thread on the canonical issue converges on the same reading.

## What the version does with the 422

The branch that handles a 422 was rewritten, so the same race produces a different log and a different outcome depending on what you pinned. This is the table to check before changing anything else.

| Version range | What the 422 already_exists branch does | How the job ends |
| --- | --- | --- |
| v2.2.1 through v2.3.3 | Logs `Skip retry - validation failed` and rethrows immediately | Red, ending with octokit's `Validation Failed:` line |
| v2.3.4 and later, including v3 | Logs a race line and calls `release()` again with one less retry | Green, once the retry finds the release the other job created |
| Any version, once the budget is spent | The recursion arrives at `maxRetries <= 0` | Red, ending with `Error: Too many retries.` |

> The consequence is that upgrading changes which message you are searching for. On v2.3.4 and later the phrase `already_exists` is still printed, by the line that reports the status before the retry, but it is no longer the failure. A job that genuinely cannot resolve the race ends on `Too many retries.` instead, and searching the older string will not find anything useful about it.

## Where each line in the log comes from

The excerpt above is several programs talking in turn, which is worth separating before you decide who is at fault. The first line is `createRelease` announcing its intent. The second and third are its `catch`, printing the status it received and then GitHub's response body verbatim. The fourth is the `case 422` arm of that same `catch`, which logs and rethrows. The fifth is the outer `catch` in `release()`, which logs anything that is not a 404 and rethrows again, and it names `refs/heads/main` because it prints the workflow ref rather than the tag being released. The last line is octokit, which builds its message as the response `message`, then the `errors` array serialized one entry at a time, then the `documentation_url` after a hyphen. None of it is the action interpreting anything, which is why none of it mentions the other job.

## Why there is no recorded run on this page

This failure only exists when two jobs overlap inside a window measured in hundreds of milliseconds, so a recorded run would have to manufacture a race and then hope it landed the same way. A run that went green would prove nothing, and a run that went red would prove our timing rather than the mechanism. The log above is better evidence than anything we could stage: it is a real race, on a real matrix, quoted from the issue where it was reported, and the version boundaries in the table are read from the action's source at each tag.

## FAQ

### Does the action refuse to update an existing release?

No. `release()` looks the tag up first and calls `updateRelease` whenever it finds one, so updating is the normal path. The create is only reached when the lookup returns nothing, which is why a 422 saying the release exists points at something that appeared after the lookup ran rather than at a missing feature.

### Why does the same workflow pass on a rerun?

Because the second time, the lookup succeeds. The release the winning job created is there now, so the action takes the update path and never calls create. That is also why the failure moves between matrix legs: it belongs to whichever leg lost, not to any particular target.

### What is "Too many retries." and how is it related?

It is the terminal error from `release()` when the retry budget reaches zero. On v2.3.4 and later a 422 already_exists no longer fails the job directly; it consumes a retry instead. If every retry also loses, the run ends on `Too many retries.` rather than on the validation message, which is the same race wearing a different name.

### Where does the trailing docs link in the error come from?

From octokit, not from GitHub and not from the action. Its request wrapper builds the message by joining the response `message`, then each entry of the `errors` array as JSON, then the `documentation_url` prefixed by a hyphen. Any GitHub validation error surfaced through octokit has that shape, so the link is generic rather than specific to releases.

## References

- [softprops/action-gh-release: src/github.ts, release(), findTagFromReleases and the 422 branch in createRelease](https://github.com/softprops/action-gh-release/blob/master/src/github.ts)
- [softprops/action-gh-release#616: 422 already_exists reported on v2.2.2](https://github.com/softprops/action-gh-release/issues/616)
- [octokit/request.js: fetch-wrapper.ts, how a validation error message is assembled](https://github.com/octokit/request.js/blob/main/src/fetch-wrapper.ts)
- [GitHub REST API: create a release](https://docs.github.com/en/rest/releases/releases#create-a-release)

---

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
