Overview
A GitHub Release is a named snapshot at a tag, with notes and optional assets. Tag releases from main as vMAJOR.MINOR.PATCH, draft first and publish after review, and group generated notes by label. The PR titles and labels that feed the notes are set in github-pr-workflow.
Tag releases from main as vMAJOR.MINOR.PATCH
Every release tag points at a commit on main, in the form v1.4.0 with no release- or -stable variations (semver). Increment MAJOR for breaking changes, MINOR for backward-compatible features, PATCH for backward-compatible fixes. Use v1.5.0-beta.1 for pre-releases.
For a content site, treat changed URLs or removed pages as MAJOR, a new section as MINOR, and copy fixes as PATCH.
Draft first, publish after review
A draft release is not public and does not notify watchers. Review the generated notes there.
gh release create v1.4.0 --draft --generate-notes --title "v1.4.0"
gh release edit v1.4.0 --draft=false # publishUse the draft window to add migration notes, linked issues, and known limits that the generator cannot infer.
Group generated notes by label
--generate-notes builds the changelog from the PRs merged since the previous release. GitHub categorizes them by PR label, not by title prefix, using .github/release.yml.
changelog:
exclude:
labels: ["skip-changelog"]
categories:
- title: "Features"
labels: ["enhancement"]
- title: "Bug fixes"
labels: ["bug"]
- title: "Maintenance"
labels: ["chore", "dependencies"]
- title: "Other changes"
labels: ["*"]* catches PRs that match no earlier category. Apply labels when the PR opens, or automate them from title prefixes with a labeler workflow; the notes are only as good as the labels.
Automate the release with a manual-dispatch workflow
Dispatch keeps a human choosing the version. Pass the input through env, not through ${{ }} inside run:, so the value cannot inject shell.
on:
workflow_dispatch:
inputs:
version: { description: "Semver, for example 1.4.0", required: true }
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: gh release create "v$VERSION" --draft --generate-notes --title "v$VERSION" --target "$GITHUB_SHA"
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}gh release create creates the tag from --target when it does not exist. Automatic version bumps from commit types need a policy for MAJOR, which is rarely worth the complexity.
Attach assets while the release is a draft
gh release upload v1.4.0 dist/myapp-v1.4.0-linux-amd64 dist/myapp-v1.4.0-darwin-arm64Name assets with version and platform so automation can pick the right file. Upload before publishing: GitHub’s immutable releases, generally available since October 2025, lock assets and the tag once a release is published. With the repository or organization setting on, you cannot add, replace, or delete assets of a published release, nor move or delete its tag. Notes and the pre-release flag stay editable. Existing releases stay mutable unless republished. Source: GitHub changelog.
A static site’s public/ directory is not a release asset; the deploy workflow publishes it (deploy-quartz-site, github-pages).
Put the action users must take first
Auto-generated notes list what merged; the human-written part says what to do. Open with one sentence on the theme, then migration steps, then the generated list. A line like “rotate your API key” buried among thirty merged PRs ships breakage silently.