Skip to content
Latchkey LogoLatchkey home

softprops/action-gh-release: tags, asset globs that match nothing, and reruns

softprops/action-gh-release creates a GitHub release for a tag, or updates the one that already exists, and uploads every file your files globs match; it needs permissions: contents: write and a tag, either from a tag push or from the tag_name input. v3 moved it to the Node 24 runtime with no input changes, and v2.6.2, the last v2 release, is no longer maintained.

You push v1.4.0, a workflow builds the binaries, and you want a release page with notes and the archives attached, without clicking through the GitHub UI. softprops/action-gh-release is the action most workflows use for that last step, and most of what goes wrong with it comes from defaults: a glob that matched nothing and did not fail, a release body that was overwritten on rerun, a release created by GITHUB_TOKEN that never triggered the workflow listening for it.

A rerun is the case to design for. When the release for a tag already exists, the action updates it instead of creating a second one, which makes a retry safe, but a rerun with body set rewrites notes someone edited by hand. The one error with its own page is the matrix race, softprops/action-gh-release already_exists.

Decision flow: tag check, release lookup by tag, update or create as draft, upload assets, publish
What one run of v3.0.4 does, read from src/run.ts and src/github.ts. A new full release stays a draft until every asset is uploaded.

A workflow that runs on version tags

The usual shape is a workflow that only runs on version tags, builds, and hands the output to the action. With no tag_name input the action uses github.ref_name, so the tag filter on the trigger is what supplies the tag. If the run is not on a tag and you set neither tag_name nor draft, the action stops with ⚠️ GitHub Releases requires a tag, the string thrown in src/run.ts.

Set fail_on_unmatched_files: true on any workflow that ships artifacts. Without it a pattern that matches nothing only prints a warning, and the release publishes without the files your users came for.

.github/workflows/release.yml
name: release
on:
  push:
    tags: ['v*.*.*']
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - run: ./scripts/build-release.sh   # writes dist/*.tar.gz and dist/SHA256SUMS
      - uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
        with:
          files: |
            dist/*.tar.gz
            dist/SHA256SUMS
          fail_on_unmatched_files: true
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}

Nineteen inputs, all optional

All inputs are optional. Booleans arrive as strings, so true and false are the only values worth writing.

InputDefaultWhat it does
tag_namegithub.ref_nameThe tag to release; refs/tags/<name> is normalized to <name>
namethe tag nameRelease title
bodynoneRelease notes text
body_pathnoneFile to read the notes from; tried first, falls back to body
append_bodyfalseAppend to an existing release body instead of replacing it
generate_release_notesfalseAsk GitHub to generate the notes; body is prepended to them
previous_tagnoneComparison base for generated notes
draftfalseKeep the release a draft
prereleasefalseMark the release as a prerelease
make_latestGitHub API defaulttrue, false or legacy; drafts and prereleases cannot be latest
filesnoneNewline-delimited globs of assets to upload
working_directoryworkspace rootBase directory the files globs resolve against
fail_on_unmatched_filesfalseFail if any glob matches nothing
overwrite_filestrueReplace an existing asset with the same name
preserve_orderfalseUpload sequentially in the order given
target_commitishdefault branchBranch or SHA to create a new tag from
repositorycurrent repositoryRelease in another <owner>/<repo>
tokengithub.tokenToken for the API calls; a non-empty value overrides GITHUB_TOKEN
discussion_category_namenoneCreate a linked discussion in this existing category

files: globs that match nothing do not fail by default

files is one string with a pattern per line, which is why it is almost always written as a YAML block with |. Patterns resolve from the workspace root unless you set working_directory, ~/ expands to the runner home directory, and literal [ or ] in a filename must be escaped because the input is a glob.

A pattern that matches nothing logs 🤔 Pattern 'dist/*.zip' does not match any files. and the run continues. With fail_on_unmatched_files: true the same condition throws instead. When an asset with the same name is already on the release, the default overwrite_files: true deletes it and uploads the new file, logging ♻️ Deleting previously uploaded asset; with false the existing file is kept and the new one skipped.

.github/workflows/release.yml
- uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
  with:
    working_directory: dist
    files: |
      *.tar.gz
      checksums/*.txt
    fail_on_unmatched_files: true
    overwrite_files: false

Drafts, prereleases and make_latest

Since v2.5.0 a new full release is created as a draft, the assets are uploaded, and only then is the draft published. A reader never sees a published release with half its files, and on a repository with immutable releases the order matters, because GitHub locks assets once a release is published.

Prereleases are the exception. They are created already published, so workflows listening for release: types: [prereleased] still fire. On an immutable-release repository that means uploads to a prerelease fail, and the action's own error tells you the fix: set draft: true, publish the draft later, and have downstream workflows listen for release.published. Setting draft: true on any release keeps it a draft; leaving it unset on an existing draft publishes that draft after the upload.

make_latest passes straight to the GitHub API, which defaults it to true for a newly published release. Set it to false when you publish a patch for an older line, such as v1.9.3 after v2.0.0, so the repository's Latest badge stays on the newest major.

Release notes: generated, from a file, or both

generate_release_notes: true passes the REST API flag of the same name, so GitHub writes the release name and body from the changes since the previous release. If you also set body, it is prepended. When GitHub picks the wrong comparison base, for example because you tag staging and production releases in one repository, previous_tag pins it.

body_path reads the notes from a file your workflow wrote, which is how you plug in any changelog generator. If the file cannot be read the action logs a warning and falls back to body rather than failing.

.github/workflows/deploy.yml
- uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
  with:
    tag_name: prod-2026-11-12
    target_commitish: ${{ github.sha }}
    previous_tag: prod-2026-10-29
    generate_release_notes: true
    body: 'Deployed to production from ${{ github.sha }}.'

Updating a release that already exists

The action looks the release up by tag first. If it finds one, it updates it instead of creating a second: the name stays unless you set name, the body is replaced by whatever your workflow supplies unless append_body: true, prerelease keeps its old value unless you set it, and the draft state is kept. Assets are then uploaded with the overwrite rule above. That makes a rerun of a release job safe, with one trap: a rerun with body set rewrites notes someone edited by hand on the release page.

Two jobs that publish the same tag at the same moment are a different story. Both find no release, both try to create one, and one loses with a 422. That race, and the fix of creating the release once and letting a matrix attach to it, is on the already_exists page.

Permissions and the token

The action needs contents: write, plus discussions: write if you set discussion_category_name. A 403 Resource not accessible by integration usually means the job's token is read-only. The README also names one case where contents: write is not enough: creating a new tag on an older commit through target_commitish can be refused for github.token, and a PAT or another token with enough contents permission is the fix.

The other token question is downstream. A release created with GITHUB_TOKEN does not start workflows that listen for release events, because GitHub does not create new workflow runs from events that token causes, except workflow_dispatch and repository_dispatch. If a separate workflow publishes on release: types: [published], create the release with a GitHub App installation token or a PAT, or do the publishing in the same workflow.

.github/workflows/release.yml
permissions:
  contents: write
  discussions: write   # only with discussion_category_name
steps:
  - uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
    with:
      token: ${{ secrets.RELEASE_TOKEN }}   # so release: published workflows fire
      discussion_category_name: Announcements

v2.5 drafts until uploaded, v3 moves to Node 24

Pin to a full SHA and let Dependabot move it; the floating v3 tag moves with every patch.

ReleaseChange
v2.0.0Runtime moved to Node 20
v2.5.0New releases stay a draft until every asset is uploaded
v2.6.0previous_tag for generated notes
v2.6.2Final v2 release, no longer maintained
v3.0.0Runtime moved to Node 24; no input changes
v3.0.2An existing draft is reused when publishing it as a prerelease

Key takeaways

  • The tag comes from tag_name or from a tag push; without either the action stops.
  • A glob that matches nothing only warns unless fail_on_unmatched_files: true.
  • New full releases stay drafts until uploads finish; prereleases publish immediately.
  • A release made with GITHUB_TOKEN does not trigger release workflows; use an App token or PAT.

Frequently asked questions

Why does the action say GitHub Releases requires a tag?
Because the run was not triggered by a tag and no tag_name was given, so there is nothing to attach a release to. Add a tag filter to the push trigger, gate the step with if: github.ref_type == 'tag', or pass tag_name explicitly. Setting draft: true also lets the action proceed without one.
Why does it say Pattern does not match any files?
The glob resolved to nothing from the directory the action used, which is the workspace root unless working_directory is set. Check the path the build actually wrote, remember that files needs one pattern per line in a | block, and turn on fail_on_unmatched_files so the next miss fails the job instead of publishing an empty release.
Why is the release asset upload failing with Resource not accessible by integration?
The token the step used cannot write to the repository. Add permissions: contents: write to the job or workflow, and check that an organization or repository setting has not made the default token read-only. Releasing into another repository always needs a PAT or App token, because GITHUB_TOKEN is scoped to the repository the workflow runs in.
Does softprops/action-gh-release run on Node 24?
v3 does: v3.0.0 moved the runtime from Node 20 to Node 24 with no input changes. v2.6.2 was the last Node 20 release and is no longer maintained, so moving a pinned v2 SHA to v3.0.4 is a drop-in change.

References

Tag builds fail on full disks too. Latchkey repairs machine faults during the job. Start free → 30-day trial · No credit card