relnote docs · Next version · CHANGELOG.md · CI · GitHub Action · release.sh

Release notes for a monorepo (per-package tags)

Several packages in one git repository, each with its own tags (api/v1.2.0, web/v0.3.1) and its own Release notes, next version, and CHANGELOG.md. relnote does this with two flags and no config file: --path picks the package's commits, --tag-prefix picks the package's last release.

Install

brew install loki-inu/tap/relnote
# or: pixi global install relnote   (Windows / Scoop: see windows.html)
# or the single file:
curl -fsSL -o ~/.local/bin/relnote https://github.com/loki-inu/relnote/releases/latest/download/relnote && chmod +x ~/.local/bin/relnote
relnote --version   # relnote 0.3.7 (--path and --tag-prefix need 0.3.0 or later)

Layout and tag conventions

The example on this page uses this repository:

packages/api/      # tagged api/v1.0.0, then released as api/v2.0.0
packages/web/      # tagged web/v0.1.0, web/v0.2.0
packages/shared/   # no tags of its own; part of web's notes
docs/              # belongs to no package
Tag style--tag-prefixFirst release
api/v1.2.0 (Go modules style)api/vapi/v0.1.0
api-v1.2.0api-vapi-v0.1.0
@acme/api@1.2.0 (npm workspaces / Changesets style)@acme/api@@acme/api@0.1.0

Two rules: the part after the prefix must be x.y.z, and the prefix should end with its separator. The prefix is a plain string match, so api would also match api-gateway/v3.0.0. Whatever you put in the prefix, including a v, is kept on the version relnote prints.

Notes for one package

$ relnote --path packages/api --tag-prefix api/v
## What's changed

### Breaking

- **api:** drop /v1 routes (`d4b28a7`) — Ada Lovelace

### Fixes

- **api:** handle empty body (`74e115f`) — Ada Lovelace

---

Range: `api/v1.0.0` → `HEAD`.

**Full changelog:** https://github.com/acme/shop/compare/api/v1.0.0...HEAD

Only commits that touch packages/api are listed: fix(shared): trim whitespace and docs: monorepo guide are left out. The compare link works on GitHub with slashes and @ in tag names.

Why both flags

Leave out --tag-prefix and the range starts at the newest tag of any package. Here that is web/v0.2.0, which came after the api fix, so the fix silently drops out:

$ relnote --path packages/api --format plain
What's changed

Breaking
  * api: drop /v1 routes (d4b28a7) — Ada Lovelace

Range: web/v0.2.0 -> HEAD

Shared code

Repeat --path to count commits in shared directories as part of a package:

$ relnote --path packages/web --path packages/shared --tag-prefix web/v --format plain
What's changed

Fixes
  * shared: trim whitespace (63ee465) — Ada Lovelace

Range: web/v0.2.0 -> HEAD

A package with no matching commits since its last tag exits 1 with relnote: no commits in range web/v0.2.0..HEAD on stderr and nothing on stdout.

Next version per package

$ relnote --path packages/api --tag-prefix api/v --next-version
api/v2.0.0
$ relnote --path packages/web --path packages/shared --tag-prefix web/v --next-version
web/v0.2.1
$ relnote --path packages/cli --tag-prefix cli/v --next-version      # no cli/v* tag yet
cli/v0.1.0

Same rules as for a single repo: a breaking change bumps major (minor while the major is 0), any feat bumps minor, anything else bumps patch. See next version.

A CHANGELOG.md per package

$ relnote --path packages/api --tag-prefix api/v \
    --changelog packages/api/CHANGELOG.md --changelog-title api/v2.0.0 -q
$ head -7 packages/api/CHANGELOG.md
# Changelog

All notable changes to this project are documented in this file.

## [api/v2.0.0] - 2026-10-22

### Breaking

A missing file is created; an existing one gets the new section above the newest entry. To bump a version file, commit, and tag in one go, examples/release.sh takes the same flags:

examples/release.sh --tag-prefix web/v \
  --changelog packages/web/CHANGELOG.md --bump-file packages/web/package.json \
  -- --path packages/web
# next: web/v0.2.0 … committed: chore(release): web/v0.2.0 · tagged: web/v0.2.0

GitHub Actions: one matrix job per package

Run by hand (workflow_dispatch); every package with changes since its last tag gets a GitHub Release and tag, and unchanged packages are skipped.

name: Package releases
on:
  workflow_dispatch:
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        package: [api, web]
    env:
      PKG_PATH: packages/${{ matrix.package }}
      PREFIX: ${{ matrix.package }}/v
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install relnote
        run: |
          curl -fsSL -o "$RUNNER_TEMP/relnote" https://github.com/loki-inu/relnote/releases/download/v0.3.7/relnote
          chmod +x "$RUNNER_TEMP/relnote"
      - name: Next version
        id: next
        run: |
          if next="$("$RUNNER_TEMP/relnote" --path "$PKG_PATH" --tag-prefix "$PREFIX" --next-version)"; then
            echo "tag=$next" >> "$GITHUB_OUTPUT"
          else
            echo "No changes in $PKG_PATH since the last $PREFIX tag; skipping."
          fi
      - name: Create the release
        if: steps.next.outputs.tag != ''
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ steps.next.outputs.tag }}
        run: |
          "$RUNNER_TEMP/relnote" --path "$PKG_PATH" --tag-prefix "$PREFIX" --output "$RUNNER_TEMP/notes.md" -q
          gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md"

fetch-depth: 0 matters: relnote needs the full history and the tags. If a package's directory name differs from its tag prefix, use matrix.include with separate path and prefix keys.

Or: release when a package tag is pushed

on:
  push:
    tags: ['*/v*']        # api/v2.0.0, web/v0.2.1, …
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Notes for this package
        run: |
          pkg="${GITHUB_REF_NAME%%/v*}"
          curl -fsSL -o "$RUNNER_TEMP/relnote" https://github.com/loki-inu/relnote/releases/download/v0.3.7/relnote
          python3 "$RUNNER_TEMP/relnote" --path "packages/$pkg" --tag-prefix "$pkg/v" \
            --until "$GITHUB_REF_NAME" --output notes.md -q
      - run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md
        env:
          GH_TOKEN: ${{ github.token }}

With --until set to the pushed tag, the range starts at the package's previous tag (api/v1.0.0 → api/v2.0.0), and the compare link ends at the tag instead of HEAD.

The loki-inu/relnote action also takes path (one directory per line) and tag-prefix, and outputs notes and next-version. From v0.3.3 a package with no new commits is a clean no-op: the step succeeds with has-changes set to false and empty notes and next-version (set fail-on-empty: true to fail instead), so the action can replace the two relnote steps in the matrix above:

      - id: relnote
        uses: loki-inu/relnote@v0.3.7
        with:
          path: packages/${{ matrix.package }}
          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 }}

FAQ

Why do I need --tag-prefix if I already pass --path?

--path only filters commits. The start of the range is still the newest tag of any package, so without --tag-prefix the notes for packages/api start at the last web tag and miss api commits made before it. --tag-prefix api/v makes the range start at the last api/v* tag.

What tag format should I use?

Anything that is a fixed prefix followed by x.y.z: api/v1.2.0, api-v1.2.0, or @acme/web@1.2.0. End the prefix with its separator (api/v, not api), because the prefix is matched as a plain string prefix: api would also match api-gateway/v3.0.0. Put the v inside the prefix if you want it: --tag-prefix cli/v gives cli/v0.1.0 for a first release, --tag-prefix @acme/cli@ gives @acme/cli@0.1.0.

What happens for a package with no changes?

relnote exits 1 with relnote: no commits in range api/v2.0.0..HEAD and prints nothing on stdout, for notes and for --next-version. In a matrix job, treat that exit as 'skip this package' instead of a failure. The loki-inu/relnote action (v0.3.3+) does this for you and sets has-changes to false.

How do I include shared code in a package's notes?

Repeat --path: relnote --path packages/web --path packages/shared --tag-prefix web/v. A commit is listed if it touches any of the directories. --next-version uses the same commits.

Is the chore(release) commit listed in the notes?

Not from v0.3.3: commits with a chore(release) subject, such as chore(release): api/v2.0.0 from release.sh, are left out of notes, changelog sections, and --next-version by default, even when they touch packages/api/CHANGELOG.md. Pass --include-release-commits to keep them. In v0.3.2 and earlier they were listed under Other when you generated notes after the release commit, for example on the tag push.

Does relnote create the package tags or releases?

No. relnote prints notes, a changelog section, or the next tag. You run git tag, gh release create, or examples/release.sh.

How this page is tested

tests/test_monorepo_docs.py builds the example repository (two packages, shared code, api/v* and web/v* tags) on every CI run and checks the ranges, the skipped package, the next versions, the per-package changelog, and the tag-push notes shown here. The workflows pass actionlint, and their shell steps were run against the same repository with the v0.3.3 single file. Commit hashes in the sample output will differ in your repository.