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.

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.
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.
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.
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, '-') }} SHA for v3.0.4 from
git ls-remote https://github.com/softprops/action-gh-release refs/tags/v3.0.4^{}, read 2026-11-12.
Nineteen inputs, all optional
All inputs are optional. Booleans arrive as strings, so true and false are the only values worth writing.
| Input | Default | What it does |
|---|---|---|
tag_name | github.ref_name | The tag to release; refs/tags/<name> is normalized to <name> |
name | the tag name | Release title |
body | none | Release notes text |
body_path | none | File to read the notes from; tried first, falls back to body |
append_body | false | Append to an existing release body instead of replacing it |
generate_release_notes | false | Ask GitHub to generate the notes; body is prepended to them |
previous_tag | none | Comparison base for generated notes |
draft | false | Keep the release a draft |
prerelease | false | Mark the release as a prerelease |
make_latest | GitHub API default | true, false or legacy; drafts and prereleases cannot be latest |
files | none | Newline-delimited globs of assets to upload |
working_directory | workspace root | Base directory the files globs resolve against |
fail_on_unmatched_files | false | Fail if any glob matches nothing |
overwrite_files | true | Replace an existing asset with the same name |
preserve_order | false | Upload sequentially in the order given |
target_commitish | default branch | Branch or SHA to create a new tag from |
repository | current repository | Release in another <owner>/<repo> |
token | github.token | Token for the API calls; a non-empty value overrides GITHUB_TOKEN |
discussion_category_name | none | Create a linked discussion in this existing category |
Outputs are
url,id,upload_urlandassets, a JSON array of the assets uploaded or overwritten in this run. Inputs and defaults from action.yml and README.md at tag v3.0.4, read 2026-11-12.
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.
- 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.
- 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.
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.
| Release | Change |
|---|---|
| v2.0.0 | Runtime moved to Node 20 |
| v2.5.0 | New releases stay a draft until every asset is uploaded |
| v2.6.0 | previous_tag for generated notes |
| v2.6.2 | Final v2 release, no longer maintained |
| v3.0.0 | Runtime moved to Node 24; no input changes |
| v3.0.2 | An existing draft is reused when publishing it as a prerelease |
From CHANGELOG.md at tag v3.0.4, read 2026-11-12.
FAQ
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.