softprops/action-gh-release is the GitHub Action most repositories use to turn a pushed tag into a GitHub Release — title, notes, and uploaded build files in a single step. It’s small, it works on every runner OS, and it has exactly three places where people get stuck: the token’s permissions, files that silently don’t upload, and workflows that never start.
This guide gives you a working workflow first, then walks through the inputs that matter, the v3 upgrade, and the fixes for those three problems. If you only need the notes themselves, GitHub’s built-in generator has its own guide: GitHub auto-generated release notes.
Jump to: Working workflow · v3 and the upgrade · Uploading assets · Release notes · Drafts and prereleases · All inputs · Troubleshooting · Action vs gh CLI
A working workflow in 25 lines
Save this as .github/workflows/release.yml. It runs when you push a tag that looks like a version, builds, and publishes a release with generated notes and everything in dist/ attached:
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build
run: make dist
- name: Release
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/*.tar.gz
dist/checksums.txt
Then cut a release from your machine:
git tag v1.4.0
git push origin v1.4.0
Three details in that file do most of the work:
on.push.tagslimits the workflow to tag pushes. The action takes the release’s tag fromgithub.ref_nameby default, so on a tag push you don’t have to passtag_nameat all.permissions: contents: writeis what lets the built-in token create a release. Leave it out and you’ll meet the 403 covered in troubleshooting.fail_on_unmatched_files: trueturns a silent problem into a loud one. Without it, a glob that matches nothing produces a release with no assets and a green check.
If the same workflow also runs on branch pushes, gate the step instead of the trigger — if: github.ref_type == 'tag' on the release step keeps everything else reusable.
v3, v2, and what to pin
The current major version is v3, and it’s the only one you should be pinning. The project’s README is blunt about it: v2.6.2 is the final v2 release and is no longer maintained or supported, because it runs on the Node 20 runtime that GitHub Actions has deprecated. v3 moved the action to Node 24.
For most workflows the upgrade is one line:
# before
uses: softprops/action-gh-release@v2
# after
uses: softprops/action-gh-release@v3
Two things worth checking while you’re there:
- Self-hosted runners need a runner version that supports Node 24. GitHub’s deprecation notice names runner v2.328.0 as the release that added it, and notes that Node 24 doesn’t run on macOS 13.4 and lower or on ARM32.
- Coming from
@v1? Later v2 releases changed how publishing is sequenced: the action keeps a new release as a draft until every asset has uploaded, then publishes it. That’s a fix for half-published releases, but it matters if anything downstream listens for release events — see drafts and prereleases.
On pinning style: @v3 follows the moving major tag and picks up fixes automatically. If your security policy requires immutable references, pin the full commit SHA of a v3 release instead and update it deliberately.
Uploading release assets
files takes a newline-delimited list of paths or globs. The | matters; it’s what makes the value a multi-line YAML string:
- name: Release
uses: softprops/action-gh-release@v3
with:
working_directory: dist
fail_on_unmatched_files: true
files: |
myapp-linux-amd64.tar.gz
myapp-darwin-arm64.tar.gz
checksums/*.txt
What the action does with that list:
- Paths resolve from the workspace root unless you set
working_directory, in which case the patterns are relative to that directory. A leading~/expands to the runner’s home directory. - Existing assets are overwritten.
overwrite_filesdefaults totrue, so re-running a release job replaces files with the same name rather than failing. - An existing release is updated, not duplicated. If the tag already has a GitHub Release, the action uploads the assets to it. Fields you don’t set — name, body, prerelease — keep their existing values.
- Globs are globs. A literal filename containing
[or]needs those characters escaped. On Windows runners both\and/work as separators. - GitHub controls the final asset name. Filenames with special or non-ASCII characters may be normalized by GitHub on upload. If a download name looks wrong, that’s where it happened.
For a cross-platform build, the usual shape is a matrix where each job builds for its OS and runs the release step with its own files. Because the action updates the release that exists for the tag, every job contributes its assets to the same release:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- run: make package
- uses: softprops/action-gh-release@v3
with:
files: dist/*
If you’d rather publish once, with all assets present from the first second, have the matrix jobs upload workflow artifacts and add a final job that downloads them and runs the release step a single time. It’s a few more lines and it’s the cleaner model when anything watches the release — and it’s the only model that works on a repository with immutable releases, where the first job to publish locks the release before the others can add their files.
Later steps can read what was uploaded. The action exposes url, id, upload_url, and assets (a JSON array) as outputs:
- name: Release
id: release
uses: softprops/action-gh-release@v3
with:
files: dist/myapp.tar.gz
- name: Print download URL
run: echo "${{ fromJSON(steps.release.outputs.assets)[0].browser_download_url }}"
Three ways to fill the release body
The action doesn’t write release notes. It publishes whatever you hand it, and there are three ways to hand it something.
1. Let GitHub generate them. generate_release_notes: true asks GitHub for the same “What’s Changed” list the Releases page button produces — merged pull requests, new contributors, and a compare link. If you also pass body, your text is prepended to the generated notes. When GitHub picks the wrong starting point, previous_tag pins the comparison base:
with:
generate_release_notes: true
previous_tag: v1.3.0
The categories and exclusions come from .github/release.yml, which is its own subject — the release.yml guide covers it line by line, including the wrong-previous-tag problem that previous_tag solves.
2. Load them from a file. body_path reads the body from a file produced earlier in the job, which is how you plug in any changelog generator:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate notes
run: npx conventional-changelog-cli -p angular -r 1 -o RELEASE_NOTES.md
- name: Release
uses: softprops/action-gh-release@v3
with:
body_path: RELEASE_NOTES.md
The fetch-depth: 0 matters: checkout fetches a single commit by default, and a generator that reads commit history needs all of it.
If you set both body_path and body, the action tries the file first and falls back to body when the file can’t be read.
3. Write them inline. body takes a string, which suits a fixed header or a link to your changelog. append_body: true adds to an existing release’s body instead of replacing it.
None of the three produces something a customer would want to read. They produce a developer-facing record, which is the right thing for the Releases tab — the difference is spelled out in changelog vs release notes.
Drafts, prereleases, and the Latest badge
Three inputs decide how a release is presented:
draft: truecreates the release unpublished, so a person can review the notes and assets and press Publish.prerelease: truemarks it as a pre-release, which keeps it from being treated as the newest stable version.make_latestcontrols the Latest badge and acceptstrue,false, orlegacy. Drafts and prereleases can’t be marked latest. Set it tofalsewhen you’re publishing a patch on an old maintenance line and don’t want it to displace the current release.
You can derive prerelease from the tag, so v2.0.0-rc.1 is flagged automatically and v2.0.0 isn’t:
with:
prerelease: ${{ contains(github.ref_name, '-') }}
That works because a hyphen is how semantic versioning marks a pre-release. What the stages in front of a stable release actually mean is covered in release candidate, alpha, beta, and GA.
Two behaviours around drafts catch people out:
- Reusing a draft publishes it unless you say otherwise. If the tag already has a draft release and the action runs without
draft: true, the action uploads the assets and then publishes that draft. Setdraft: trueon every run where the release should stay a draft. - Immutable releases lock assets at publication. On a repository with immutable releases enabled, a published release can’t take new assets. A standard release from a single job is fine, because the action uploads before it publishes. A matrix where each job publishes separately isn’t — use one release job there. Prereleases are published straight away by default, so on an immutable repository use
draft: truefor prereleases that carry assets and publish the draft afterwards.
Every input in one table
All inputs are optional.
| Input | What it does |
|---|---|
tag_name |
Tag for the release. Defaults to github.ref_name; a refs/tags/<name> value is normalized to <name>. |
name |
Release title. Defaults to the tag name. |
body |
Release notes as a string. |
body_path |
Path to a file containing the release notes. Tried before body. |
append_body |
Append to an existing release body instead of overwriting it. |
generate_release_notes |
Have GitHub generate the name and body. An explicit body is prepended. |
previous_tag |
Comparison base for generated notes. If omitted, GitHub chooses. |
files |
Newline-delimited paths or globs of assets to upload. |
working_directory |
Base directory for files. Defaults to the workspace. |
overwrite_files |
Replace assets that already exist. Defaults to true. |
fail_on_unmatched_files |
Fail the step if any files pattern matches nothing. |
preserve_order |
Upload assets one at a time in the listed order. Doesn’t control the order GitHub displays. |
draft |
Keep the release as a draft. Defaults to false. |
prerelease |
Mark the release as a pre-release. |
make_latest |
true, false, or legacy. Uses GitHub’s API default if omitted. |
target_commitish |
Branch or commit SHA the tag is created from when it doesn’t exist yet. Defaults to the default branch. |
discussion_category_name |
Create a linked discussion in an existing category. Needs discussions: write. |
repository |
Target repository as owner/repo. Defaults to the current one. |
token |
Token to authenticate with. Defaults to github.token. |
One note on token: passing an empty string counts as explicitly unset, so either omit the input or give it a real value. A non-empty token overrides the GITHUB_TOKEN environment variable, which still works as a fallback but is deprecated in favour of the input.
Troubleshooting
403: Resource not accessible by integration
The workflow token isn’t allowed to write. Work through these in order:
- Add the permissions block.
permissions: contents: writeon the job or the workflow. In a new personal repository the built-in token only has read access to contents by default, and an organization repository inherits whatever its organization has set, so this is the usual cause. - Check the repository setting. The default lives under Settings → Actions → General → Workflow permissions. If the organization has chosen the more restrictive default, the permissive option is disabled there.
- Check the trigger. GitHub’s docs are explicit that the
GITHUB_TOKENhas read-only permissions in pull requests from forked repositories. Releases belong on tag pushes orworkflow_dispatch, not onpull_request. - Check
target_commitish. If you’re asking the action to create a new tag on an older commit, the built-in token may not be permitted to create that ref. The README names this case directly; the fix is a personal access token or GitHub App token with contents access, passed throughtoken. - Releasing to another repository?
repository: other-org/other-repoalways needs a token that can write there.github.tokenis scoped to the repository the workflow runs in.
If you use discussion_category_name, the job also needs discussions: write, and the category has to exist already.
The release was created but the files are missing
The glob matched nothing. Set fail_on_unmatched_files: true so this fails the job, then check three things: the path is relative to the workspace root (or to working_directory if you set it), the build step actually ran in this job rather than in an earlier one whose files didn’t carry over, and the pattern isn’t being treated as a single line — a list of files needs the | block scalar.
The step fails because there’s no tag
On a branch push or a manual run, github.ref_name is a branch name, and a GitHub Release needs a tag. Either restrict the step with if: github.ref_type == 'tag', or pass tag_name explicitly. When the tag doesn’t exist yet, GitHub creates it from target_commitish, which defaults to the default branch — set it to ${{ github.sha }} if the release should point at the commit the workflow ran on.
The workflow never starts
This one isn’t the action’s fault, and it’s the most confusing of the lot. Events triggered with the built-in GITHUB_TOKEN don’t create new workflow runs — it’s how GitHub prevents recursive runs. The only exceptions are workflow_dispatch and repository_dispatch. That has two consequences:
- A tag pushed by another workflow won’t trigger your release workflow if that workflow pushed it with
GITHUB_TOKEN. Push the tag with a GitHub App token or a personal access token, or call the release job directly from the tagging workflow. - A release created by this action won’t trigger
on: releaseworkflows for the same reason. If something downstream — a deploy, a package publish, a notification — listens forrelease: published, give the action a personal access token or App token through thetokeninput.
Two releases appear for the same tag
This can happen when parallel jobs try to create the release at the same moment, or when a draft exists that a later job doesn’t pick up. Move to the single-release-job pattern described under uploading assets, and keep draft consistent across every job that touches the release.
action-gh-release vs gh release create
GitHub archived its own actions/create-release and actions/upload-release-asset in March 2021 — the archived README lists softprops/action-gh-release among the maintained alternatives. Today the two common ways to create a release from a workflow are this action and the gh CLI that ships on GitHub-hosted runners.
softprops/action-gh-release |
gh release create |
|
|---|---|---|
| Setup | One uses: step |
One run: step, GH_TOKEN in env |
| Asset uploads | Globs, working_directory, overwrite by default |
File arguments; gh release upload --clobber to overwrite |
| Existing release for the tag | Updated in place | Add files with gh release upload |
| Publishing with assets | Kept as a draft until uploads finish, then published | Creates a draft, uploads each asset, then publishes |
| Generated notes | generate_release_notes, previous_tag |
--generate-notes, --notes-start-tag |
| Outputs for later steps | url, id, upload_url, assets |
Parse with gh release view --json |
| Self-hosted runners | Needs Node 24 support | Needs gh installed |
| Version to maintain | The action pin | Whatever the runner ships |
Pick the CLI when the release is one command and you’d rather not track an action version. Pick the action when you want the update-in-place behaviour, glob uploads across three operating systems, or outputs to feed later steps.
And if what you want is for the tag and the version to be decided for you, neither is the right layer. Release Please, semantic-release, and Changesets each compute the version, write the changelog, and create the GitHub Release themselves — the comparison is in how to automate release notes from GitHub commits.
Where ReleasePad fits
Everything on this page ends at the Releases tab: a tag, a list of pull requests, and some binaries. That’s the right destination for people who install your software from GitHub. It isn’t where your customers look, and a line like “bump esbuild to 0.25” isn’t something they can use.
ReleasePad picks up from the same repository activity and drafts the customer-facing version — plain-language entries you review and publish to a public changelog page and an in-app widget. Keep the action for the release artifact, and let the GitHub integration draft the notes your users actually read.
Setup checklist
- Pin
softprops/action-gh-release@v3(or a v3 commit SHA) everywhere; search the repository for@v1and@v2. - Add
permissions: contents: writeto the release job. - Trigger on tag pushes, or gate the step with
if: github.ref_type == 'tag'. - Set
fail_on_unmatched_files: truewhenever you upload assets. - Choose a notes source:
generate_release_notes,body_path, orbody. - Derive
prereleasefrom the tag if you ship release candidates. - If anything listens for release events, pass a PAT or App token through
token.
Further reading
- GitHub Auto-Generated Release Notes:
.github/release.ymlline by line, and the fixes for empty notes. - Release Please: a release PR that computes the version and creates the GitHub Release for you.
- semantic-release: push-to-publish, with the GitHub Release created by a plugin.
- Semantic Versioning Explained: what the tag you’re pushing is supposed to mean.
- Release Candidate, Alpha, Beta, and GA: the stages behind the
prereleaseflag.
Frequently Asked Questions
What does softprops/action-gh-release do?
It's a GitHub Action that creates a GitHub Release from a workflow, or updates the release that already exists for a tag. In one step it can set the release name and body, upload build files as release assets, mark the release as a draft or prerelease, and ask GitHub to generate the release notes. It runs on Linux, Windows, and macOS runners and needs the contents: write permission on the workflow token.
What is the latest version of softprops/action-gh-release?
The current major version is v3, which runs on the Node 24 runtime. The project's README states that v2.6.2 is the final v2 release and is no longer maintained or supported, because v2 uses the Node 20 runtime that GitHub Actions has deprecated. If your workflows still reference softprops/action-gh-release@v1 or @v2, change the reference to @v3.
Why does action-gh-release fail with 403 Resource not accessible by integration?
Almost always because the workflow token can't write to the repository. Add a permissions block with contents: write to the job or workflow, and check the repository's Actions settings for workflow permissions. Two other causes: the workflow was triggered by a pull request from a fork, where GitHub gives the token read-only permissions, or you set target_commitish to create a new tag on an older commit, which the built-in token may not be allowed to do. In that last case, pass a personal access token or GitHub App token through the token input.
How do I upload files to a GitHub release with action-gh-release?
Use the files input with a newline-delimited list of paths or glob patterns, written as a multi-line YAML string. Set working_directory when the files live in a subdirectory such as dist, and set fail_on_unmatched_files to true so the job fails when a pattern matches nothing instead of publishing a release with missing assets. If the tag already has a release, the action uploads the files to it and overwrites assets with the same name by default.
Should I use softprops/action-gh-release or gh release create?
Both create the same release through the same API. The gh CLI is preinstalled on GitHub-hosted runners, so gh release create is the least code when you only need a release with notes and a couple of files. The action is the better fit when you want declarative YAML, glob-based asset uploads across Linux, Windows, and macOS, release outputs such as the URL and asset list for later steps, or the update-an-existing-release behaviour in a build matrix.
Is actions/create-release still maintained?
No. GitHub archived actions/create-release and actions/upload-release-asset in March 2021, and their READMEs say the repositories are unmaintained. The archived create-release README lists softprops/action-gh-release among the maintained alternatives, and it covers both jobs in one step. The other route is the gh CLI, with gh release create and gh release upload.
Ready to put this into practice?
Your changelog shouldn't be an afterthought.
ReleasePad makes it easy to publish great release notes — from a public changelog page to an in-app widget, GitHub integration, and analytics. Free to get started.
Get started — it's free