relnote docs · Monorepo · Next version · CHANGELOG.md · semantic-release alternative · Other CI
GitHub Action: release notes from conventional commits
loki-inu/relnote is a composite action. It reads your git history (no GitHub API calls, no token, no config file) and outputs a Release body grouped as Features, Fixes, Breaking, and Other, plus the next semver tag. You create the Release with whatever you already use.
Draft a Release on every tag push
Copy into .github/workflows/draft-release.yml. Push a tag such as v1.4.0 and a draft Release appears with the notes since the previous tag; review it and press Publish.
name: Draft release
on:
push:
tags:
- "v*"
jobs:
draft:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: relnote
uses: loki-inu/relnote@v0.3.7
- uses: softprops/action-gh-release@v2
with:
draft: true
name: ${{ github.ref_name }}
body: ${{ steps.relnote.outputs.notes }}
Three things matter:
fetch-depth: 0. The default checkout is one commit with no tags, so relnote cannot find the previous release.permissions: contents: writeis for the release step. relnote itself only reads the checkout.- On a tag push,
HEADis the new tag. relnote notices that and starts the range at the tag before it, so the notes coverv1.3.0→v1.4.0, not an empty range.
The Release body for that push:
## What's changed
### Features
- **cli:** add --json output (`9120f1b`) — Ada Lovelace
### Fixes
- handle empty config file (`900d4e8`) — Ada Lovelace
### Other
- **deps:** bump actions/checkout (`5a0577e`) — Ada Lovelace
- **docs:** usage examples (`056a02c`) — Ada Lovelace
---
Range: `v1.3.0` → `v1.4.0`.
**Full changelog:** https://github.com/acme/app/compare/v1.3.0...v1.4.0
From v0.3.4 the range line and the compare link name the pushed tag. v0.3.3 wrote …compare/v1.3.0...HEAD, which is right on the day and points at newer commits once main moves on. Dependabot and Renovate commits are dropped by default (no-bots: true); the chore(deps) line above is a person's commit.
No third-party release action? Use the GitHub CLI that is already on the runner:
- run: gh release create "$GITHUB_REF_NAME" --draft --title "$GITHUB_REF_NAME" --notes "$NOTES"
env:
GH_TOKEN: ${{ github.token }}
NOTES: ${{ steps.relnote.outputs.notes }}
Release from a button, only when there is something to release
Here the workflow picks the tag. next-version is the next semver after the last tag (breaking → major, feat → minor, anything else → patch; while the major is 0 a breaking change bumps the minor). has-changes is false when there are no new commits, and the step still succeeds, so you gate on it instead of handling a failure:
name: Release
on:
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: relnote
uses: loki-inu/relnote@v0.3.7
- if: steps.relnote.outputs.has-changes == 'true'
run: gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --notes "$NOTES"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.relnote.outputs.next-version }}
NOTES: ${{ steps.relnote.outputs.notes }}
- if: steps.relnote.outputs.has-changes != 'true'
run: echo "Nothing to release since the last tag."
gh release create makes the tag at --target. The same pattern works on a schedule: for weekly releases. Want the old behaviour (fail when empty)? Set fail-on-empty: true. Real errors, such as an unknown since ref, still fail the step.
Monorepo: one matrix job per package
path limits the notes to commits touching the package (one directory per line); tag-prefix makes the range start at that package's last tag (api/v1.0.0), not the newest tag of any package. Untouched packages come out as has-changes = false and are skipped:
strategy:
fail-fast: false
matrix:
package: [api, web]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: relnote
uses: loki-inu/relnote@v0.3.7
with:
path: |
packages/${{ matrix.package }}
packages/shared
tag-prefix: ${{ matrix.package }}/v
- if: steps.relnote.outputs.has-changes == 'true'
run: gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --notes "$NOTES"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.relnote.outputs.next-version }}
NOTES: ${{ steps.relnote.outputs.notes }}
For a package tag push (on: push: tags: ['*/v*']), derive the package from github.ref_name and pass it as path and tag-prefix; the range then runs from that package's previous tag to the pushed tag. Tag conventions, per-package changelogs, and a tested example repository: release notes for a monorepo.
Also prepend CHANGELOG.md
changelog writes a Keep a Changelog section (## [v1.4.0] - 2026-10-26) above the newest entry on the runner's disk; older entries are not touched and a duplicate title is refused. The action does not commit. Commit it yourself, or open a PR with the file:
- id: relnote
uses: loki-inu/relnote@v0.3.7
with:
changelog: CHANGELOG.md
changelog-title: ${{ github.ref_name }}
On a tag push the default title is already the pushed tag (v0.3.4+); outside a tagged HEAD it is Unreleased. More: CHANGELOG.md from conventional commits.
Inputs and outputs
| Input | Default | What it does |
|---|---|---|
since | previous tag | Start ref. With a tagged HEAD, the tag before it. |
until | HEAD | End ref. |
path | Only commits touching these directories, one per line. | |
tag-prefix | Only tags starting with this count as the previous release (api/v). | |
format | github | github (Markdown) or plain. |
no-bots | true | Drop dependabot, renovate, and [bot] authors. |
max | At most N commits after filters. | |
changelog | Also prepend a section to this file. | |
changelog-title | tag on HEAD, else Unreleased | Section title. |
include-release-commits | false | Keep chore(release): … commits. |
fail-on-empty | false | Fail instead of has-changes = false. |
repo | workspace | Path to the git working tree. |
| Output | Value |
|---|---|
notes | The Release body (empty when there is nothing to release). |
next-version | Next semver tag, keeping the prefix and v (v1.4.0, api/v2.0.0); empty if the previous tag is not x.y.z or there are no new commits. |
has-changes | "true" or "false". |
FAQ
Which version should I pin?
@v0.3.7. Tags v0.1.1–v0.3.2 have an action.yml the runner cannot parse ("Mapping values are not allowed in this context"); the CLI in them is fine, but uses: fails before the step starts. Any tag from v0.3.3 loads.
Why is the notes output empty?
has-changes is false: no commits since the previous tag after the filters (path, no-bots, release commits). The log shows a notice saying so. If you expected commits, check that tag-prefix, path, and since are what you meant, and for a monorepo that tag-prefix ends with its separator (api/v, not api).
The notes list only one commit and say (start)
The checkout is shallow. actions/checkout fetches one commit and no tags by default, so there is no previous tag and the "full history" is that one commit. Add fetch-depth: 0.
Can I use the outputs in a run: script?
Yes, through env: as in the examples on this page. Do not paste ${{ steps.relnote.outputs.notes }} into the script: the notes contain backticks and commit subjects, and the shell would run them.
Does it run on Windows and macOS runners?
Yes. The step uses bash (Git Bash on windows-latest) and the runner's Python 3.11+; it falls back to python when python3 is missing. CI runs the action on ubuntu-latest and windows-latest.
Does it need a token?
No. relnote reads only the local clone. The token is for whatever creates the Release.
Squash merges or merge commits?
Either. Merge commits are left out by default and the squashed commit's conventional-commit subject is what gets grouped, so PR titles like feat(cli): add --json output (#42) work as they are (the (#42) stays in the line, and GitHub links it).
Want a full bump → CHANGELOG → tag → GitHub Release job without Node in the release tooling? semantic-release alternative without npm/Node (same shape as the release-please alternative).
How this page is tested
CI runs the action with uses: ./ on Linux and Windows for a normal range, the no-op case, and a tagged HEAD (the tag-push case), and tests/test_action.py runs the action's shell step against temporary repositories, including the tag-push range above. The tag-push draft (with the tag made inside the job), the matrix, and the no-op case were also run with the published uses: loki-inu/relnote@v0.3.3 and @v0.3.4 on GitHub-hosted runners, and the workflows pass actionlint. Commit hashes in the sample output will differ in your repository.