A hexagonal solar-panel station orbits above Earth, catching streams of energy from a blazing sun while small ships pass nearby.
Guide

softprops/action-gh-release: How to Create GitHub Releases from GitHub Actions (v3 Guide)

Felix Macx · · Updated · 11 min read

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.tags limits the workflow to tag pushes. The action takes the release’s tag from github.ref_name by default, so on a tag push you don’t have to pass tag_name at all.
  • permissions: contents: write is 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: true turns 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_files defaults to true, 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: true creates the release unpublished, so a person can review the notes and assets and press Publish.
  • prerelease: true marks it as a pre-release, which keeps it from being treated as the newest stable version.
  • make_latest controls the Latest badge and accepts true, false, or legacy. Drafts and prereleases can’t be marked latest. Set it to false when 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. Set draft: true on 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: true for 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:

  1. Add the permissions block. permissions: contents: write on 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.
  2. 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.
  3. Check the trigger. GitHub’s docs are explicit that the GITHUB_TOKEN has read-only permissions in pull requests from forked repositories. Releases belong on tag pushes or workflow_dispatch, not on pull_request.
  4. 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 through token.
  5. Releasing to another repository? repository: other-org/other-repo always needs a token that can write there. github.token is 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: release workflows for the same reason. If something downstream — a deploy, a package publish, a notification — listens for release: published, give the action a personal access token or App token through the token input.

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 @v1 and @v2.
  • Add permissions: contents: write to the release job.
  • Trigger on tag pushes, or gate the step with if: github.ref_type == 'tag'.
  • Set fail_on_unmatched_files: true whenever you upload assets.
  • Choose a notes source: generate_release_notes, body_path, or body.
  • Derive prerelease from the tag if you ship release candidates.
  • If anything listens for release events, pass a PAT or App token through token.

Further reading

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.

github github-actions releases action-gh-release automation release-notes

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
Try me now!