relnote docs · Compare · CHANGELOG.md · conventional-changelog alternative · semantic-release alternative · GitHub Action · Single-file zipapp

A git-cliff alternative without cliff.toml

git-cliff (oratorio-rs/git-cliff) is a Rust changelog generator: install the binary, keep a cliff.toml, and write Tera templates that rebuild CHANGELOG.md from history. If what you want is a GitHub Release body from conventional commits — plus one Keep a Changelog section prepended per release, and an optional bump/tag via examples/release.sh — the offline stdlib Python relnote zipapp does that with no config file, no cargo install, and no template language.

git-cliff, or this?

git-cliffrelnote + release.sh
Language / runtimeRust binary (cargo install git-cliff or a package)Python 3.11+ stdlib zipapp; release.sh is bash + git
Config filecliff.toml plus Tera templatesNone
CHANGELOG.mdRebuilds (or appends) from history via templatesPrepends one Keep a Changelog section for the new release; older entries untouched
Release bodyPossible with a custom template / stdoutDefault: prints a GitHub Release body grouped by conventional-commit type
Next-version bumpBuilt-in bump / --bumped-version (config-driven)relnote --next-version; release.sh --bump-file sets package.json / pyproject.toml / Cargo.toml
GitHub ActionCommunity / self-hosted wrappers around the binaryloki-inu/relnote@v0.3.7 (notes, next-version, has-changes)
MonorepoPath filters / include-path in config--path DIR / --tag-prefix PREFIX (CLI and action)
NetworkOffline for local history; optional remote contribOffline until you push or gh release create
Install sizeCompiled Rust binary + config you maintain~single-file zipapp from the GitHub Release; no third-party packages

Keep git-cliff when you need a full-history rebuild, custom Tera sections, or remote contributor metadata. This page replaces the zero-config Release-body path — and the one-section CHANGELOG plus bump/tag via release.sh.

Pin the zipapp

Python 3.11+ and git on PATH. The download is the v0.3.7 zipapp from the GitHub Release (same pin as the single-file install), plus examples/release.sh from that tag. No cargo install git-cliff and no cliff.toml.

mkdir -p ~/.local/bin
curl -fsSL -o ~/.local/bin/relnote https://github.com/loki-inu/relnote/releases/download/v0.3.7/relnote
chmod +x ~/.local/bin/relnote
curl -fsSL -o release.sh https://raw.githubusercontent.com/loki-inu/relnote/v0.3.7/examples/release.sh
chmod +x release.sh

Put ~/.local/bin on PATH. Commit release.sh into the repo (for example scripts/release.sh) so the release command does not depend on the raw URL next time. It is plain bash; read it before you run it.

Release body, one CHANGELOG section, or the full local release

Three shapes. First, a GitHub Release body only (stdout; nothing written):

relnote

Second, prepend one Keep a Changelog section — older entries stay; duplicate titles are refused. You choose the title (usually the next tag):

relnote --next-version
relnote --changelog CHANGELOG.md --changelog-title v1.4.0

Third, the local release command. The working tree must be clean. ./release.sh reads conventional commits since the last tag, prepends CHANGELOG.md, commits chore(release): vX.Y.Z, and creates an annotated tag. Nothing is pushed. Pass --bump-file (repeatable) to set the first "version" / version = line in package.json, pyproject.toml, or Cargo.toml. Omit it when you only want the changelog, commit, and tag. --dry-run prints the plan and writes nothing. These are three separate invocations, not one script to run top to bottom:

./release.sh --dry-run
./release.sh
./release.sh --bump-file package.json

Afterwards: git push --follow-tags. If HEAD is already tagged, or the next tag already exists, the script exits 1 instead of guessing. Section shape: the CHANGELOG.md guide. Version rule: next version.

Optional GitHub Actions release

When you also want a GitHub Release, save this as .github/workflows/release.yml and use Actions → Release → Run workflow. Same shape as the semantic-release alternative and the conventional-changelog alternative: a workflow_dispatch button, a has-changes gate, then release.sh. Drop --bump-file pyproject.toml or point it at another file. No Rust toolchain for the tooling. Action inputs: GitHub Action guide.

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
      - name: Install relnote and release.sh
        if: steps.relnote.outputs.has-changes == 'true'
        env:
          V: v0.3.7
        run: |
          mkdir -p "$HOME/.local/bin"
          curl -fsSL -o "$HOME/.local/bin/relnote" "https://github.com/loki-inu/relnote/releases/download/$V/relnote"
          chmod +x "$HOME/.local/bin/relnote"
          curl -fsSL -o "$RUNNER_TEMP/release.sh" "https://raw.githubusercontent.com/loki-inu/relnote/$V/examples/release.sh"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - name: Bump, changelog, tag, publish
        if: steps.relnote.outputs.has-changes == 'true'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          if [ -n "$(git tag --points-at HEAD --list 'v*')" ]; then
            echo "HEAD is already tagged; nothing to release."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          bash "$RUNNER_TEMP/release.sh" --bump-file pyproject.toml
          tag=$(git describe --tags --exact-match)
          git push origin HEAD "refs/tags/$tag"
          relnote --output "$RUNNER_TEMP/notes.md" --quiet
          gh release create "$tag" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md"

The first relnote step only checks whether anything is unreleased (has-changes). Notes go through a file and --notes-file. Never interpolate the body inside run: (backticks in commit subjects break the YAML). Pin the action and the curl URLs to the same tag.

A tag-push workflow can publish the same notes after you tag locally: checkout with fetch-depth: 0, run the action, write notes.md, then gh release create with --notes-file. Same rule: do not put the notes in run:. See the tag-push example on the Action guide.

What one run does

Start: v1.3.2 is the last tag, pyproject.toml says version = "1.3.2", and two conventional commits landed since. The release step log:

next: v1.4.0
changelog: CHANGELOG.md
bump: pyproject.toml -> 1.4.0
committed: chore(release): v1.4.0
tagged: v1.4.0
next step: git push --follow-tags
To https://github.com/you/project.git
 * [new branch]      HEAD -> main
 * [new tag]         v1.4.0 -> v1.4.0

pyproject.toml now says version = "1.4.0", and CHANGELOG.md gains this section above the previous one:

## [v1.4.0] - 2026-10-28

### Features

- add --json output ([#12](https://github.com/you/project/pull/12)) (`e4e9bff`) — Ada Lovelace

### Fixes

- handle empty config ([#13](https://github.com/you/project/pull/13)) (`8c0e206`) — Ada Lovelace

**Full changelog:** https://github.com/you/project/compare/v1.3.2...v1.4.0

The GitHub Release v1.4.0 gets this body; on GitHub, #12 links to the pull request:

## What's changed

### Features

- add --json output (#12) (`e4e9bff`) — Ada Lovelace

### Fixes

- handle empty config (#13) (`8c0e206`) — Ada Lovelace

---

Range: `v1.3.2` → `v1.4.0`.

**Full changelog:** https://github.com/you/project/compare/v1.3.2...v1.4.0

The CHANGELOG.md section and Release body are produced by running the workflow's last step against a scratch repository in relnote's test suite (tests/test_git_cliff_docs.py, with gh stubbed); your hashes and dates differ. The chore(release) commit is not listed. Unlike git-cliff, the rest of CHANGELOG.md is not regenerated.

FAQ

When should I keep git-cliff?

Keep git-cliff when you need a CHANGELOG.md rebuilt from full history, custom Tera templates in cliff.toml, commit parsers, remote contributor metadata, or git-cliff's own version bump. relnote does not rebuild the whole file and has no template language. The short table on the compare page covers gh --generate-notes and swapnanil/relnotes too.

How does this differ from the conventional-changelog alternative and changelog.html?

The conventional-changelog / commitizen alternative is for people leaving Node changelog plugins and commit prompts. The CHANGELOG.md guide covers --changelog flags in detail. This page is for people leaving oratorio-rs/git-cliff: Rust binary, cliff.toml, and Tera templates that regenerate CHANGELOG.md from history.

How do I dry-run?

Locally, release.sh --dry-run prints the next version, the changelog path, any version file it would bump, and the notes, and writes nothing. Add --bump-file only when a version file should change. Omit --bump-file when you only want a preview of the changelog, commit, and tag. In the Actions job, has-changes false skips the release steps when nothing is unreleased, and a second run whose HEAD is already tagged exits 0.

Are chore(release) commits included in the notes?

No by default. relnote skips chore(release) subjects so the release commit does not appear in its own CHANGELOG.md section or Release body. Pass --include-release-commits on the CLI, or include-release-commits: true on the action, to keep them.

Does relnote support monorepos?

Yes. Pass --path DIR and/or --tag-prefix PREFIX so notes and --next-version only see one package. release.sh accepts the same flags. Details: the monorepo guide.

What about Windows?

Each release ships relnote.pyz (same bytes as the zipapp). Run it with py -3 relnote.pyz. Scoop: scoop bucket add loki-inu https://github.com/loki-inu/scoop-bucket then scoop install relnote. See the Windows guide for PowerShell redirection and UTF-8.

Should I pin versions?

Yes. Pin the curl URL and the loki-inu/relnote action to the same tag (this page uses v0.3.7). Floating latest can change behaviour under you.