---
title: "GitHub Releases"
slug: "github-releases"
category: "tooling"
tags: ["tooling", "github", "releases", "semver", "changelog", "versioning", "git"]
status: "stable"
last_updated: 2026-05-14
summary: "Tag conventions, semver rules, auto-generated changelogs from PR titles, draft-then-publish release workflow, and release notes patterns."
related: ["[[tooling/github]]", "[[tooling/github-actions]]", "[[tooling/github-pr-workflow]]", "[[tooling/github-cli]]", "[[tooling/github-branch-protection]]", "[[coding/git]]", "[[ops/github-pages]]"]
---

## Overview

A GitHub Release is a named snapshot of the repository at a tag, with a changelog and optional binary assets. Done well, releases give users and automation a stable reference point and a human-readable record of what changed. Done poorly, releases are a sporadic collection of "v2 final FINAL" tags with empty bodies. This page covers the tag convention, semver rules, auto-generated changelogs, and the draft-then-publish flow. Read [[tooling/github-actions]] for the `release.yml` workflow that automates this.

## Tag releases from `main` using semver

Every release tag points to a commit on `main`. The tag format is `vMAJOR.MINOR.PATCH` ([[glossary/semantic-versioning|semver]]) with no variation: no `ver`, no `release-`, no trailing `-stable`.

- `MAJOR` increments on breaking changes: removed APIs, changed behavior that requires user action.
- `MINOR` increments on new backward-compatible features.
- `PATCH` increments on backward-compatible bug fixes.

For content sites and documentation, treat a major restructure (changed URLs, removed pages) as a `MAJOR` bump, a new section as `MINOR`, and copy fixes as `PATCH`. The version is a contract with any consumer that links to or depends on your output.

Pre-release versions use the format `v1.2.0-beta.1`. Use pre-releases to publish a release for testing before it graduates to the stable channel.

## Draft the release before publishing

Create a draft release so you can review the generated notes before the release is visible to users. A draft is not visible on the releases page and does not notify watchers.

```bash
# Create a draft release with auto-generated notes
gh release create v1.4.0 --draft \
  --title "v1.4.0" \
  --generate-notes

# Edit, then publish
gh release edit v1.4.0 --draft=false
```

The draft window is the time to add context the auto-generator misses: migration notes, linked issues, and known limitations. Publish only when the notes are accurate and complete.

## Use auto-generated changelogs from PR titles

GitHub can generate a changelog from the PR titles merged since the previous release. The output is grouped by Conventional Commits type if the PR titles follow the convention from [[tooling/github-pr-workflow]].

Configure the grouping in `.github/release.yml` (separate from the Actions workflow file):

```yaml
changelog:
  categories:
    - title: "Features"
      labels:
        - "enhancement"
    - title: "Bug Fixes"
      labels:
        - "bug"
    - title: "Maintenance"
      labels:
        - "chore"
        - "dependencies"
    - title: "Other Changes"
      labels: ["*"]
```

Apply labels to PRs at open time, or let the auto-labeler apply them based on PR title prefixes. The generated changelog is as good as the PR titles and labels; this is why [[tooling/github-pr-workflow]] enforces Conventional Commits.

## Automate tagging and release creation in `release.yml`

A `release.yml` workflow triggered on push to `main` or on manual dispatch can create the tag and draft release without human intervention.

```yaml
on:
  workflow_dispatch:
    inputs:
      version:
        description: "Semver version (e.g. 1.4.0)"
        required: true

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - name: Create tag and draft release
        run: |
          git tag "v${{ github.event.inputs.version }}"
          git push origin "v${{ github.event.inputs.version }}"
          gh release create "v${{ github.event.inputs.version }}" \
            --draft --generate-notes \
            --title "v${{ github.event.inputs.version }}"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Manual dispatch keeps the human in the loop for the version number decision. A fully automated release that bumps based on commit types is possible but requires a strategy for handling `MAJOR` bumps automatically, which is rarely worth the complexity.

## Attach build artifacts to the release

Binary releases (compiled executables, bundled packages, exported PDFs) attach as assets to the release object.

```bash
gh release upload v1.4.0 dist/myapp-linux-amd64 dist/myapp-darwin-arm64
```

Name artifacts with the version and platform: `myapp-v1.4.0-linux-amd64`. Consistent naming lets automation download the right asset without parsing the release notes.

For static site deployments, the `public/` directory is not a release artifact; the deploy workflow handles it. See [[howto/deploy-quartz-site]] and [[ops/github-pages]] for the deploy path.

## Write release notes that answer "what do I need to do?"

Auto-generated notes list what merged; the human-written introduction answers why it matters and what action users should take.

A useful release note structure:

1. One sentence on the theme of this release ("This release adds OIDC support and removes the legacy token flow.")
2. Migration notes if anything breaks or requires user action.
3. Auto-generated changelog below the fold.

Keep migration notes above the generated list so readers see them first. A changelog that buries "you must rotate your API key" in the middle of thirty merged PRs ships breakage silently.

## Related

- [[tooling/github]]
- [[tooling/github-actions]]
- [[tooling/github-pr-workflow]]
- [[tooling/github-cli]]
- [[tooling/github-branch-protection]]
- [[coding/git]]
- [[ops/github-pages]]
- [[howto/deploy-quartz-site]]
