relnote docs · GitHub Action · standard-version alternative · semantic-release alternative · conventional-changelog alternative · CHANGELOG.md · Next version

A release-please alternative without a release PR

release-please keeps a release pull request open, updates it on every merge, and cuts the release when you merge it. If you would rather press one button when you want a release, this workflow does the same work in one run: next version from conventional commits, version bump in pyproject.toml or package.json, a new CHANGELOG.md section, a chore(release) commit, a tag, and a GitHub Release. No bot PR, no config files, no API calls besides the final gh release create.

The workflow

Save as .github/workflows/release.yml, then use Actions → Release → Run workflow. Change --bump-file pyproject.toml to your version file, or repeat it for several.

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). release.sh is the same script as the standard-version replacement; it is fetched from the pinned tag, so the job does not change under you. The Release body is generated after tagging, so its range and compare link end at the new tag.

What one run does

Start: v1.3.2 is the last tag, pyproject.toml says version = "1.3.2", and two PRs were squash-merged since. The 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 PR:

## 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_release_please_docs.py, with gh stubbed); your hashes and dates differ.

release-please or this?

release-pleaseThis workflow
When a release happensWhen you merge the release PRWhen you press Run workflow (or on a schedule:)
Review before releaseThe release PR diffNone; run release.sh --dry-run locally to preview
Configrelease-please-config.json, .release-please-manifest.json (manifest mode)None
Version filesLanguage-specific updatersFirst version line in each --bump-file
Protected mainWorks through the PRNeeds a token allowed to push
Runs locally tooCLI with a GitHub tokenexamples/release.sh, offline

Monorepo packages

Pass the package's tag prefix and path to release.sh and to the notes step: bash "$RUNNER_TEMP/release.sh" --tag-prefix api/v --bump-file packages/api/package.json -- --path packages/api, then relnote --tag-prefix api/v --path packages/api --output …. Change the guard's 'v*' to 'api/v*'. See the monorepo guide for a matrix over packages.

Looking for a semantic-release replacement without Node in the release pipeline? Same workflow under a different angle: semantic-release alternative without npm/Node.

Replacing conventional-changelog and commitizen instead? conventional-changelog / commitizen alternative (local CHANGELOG.md and version bump, no Node plugins).

Publishing to PyPI? Release a Python package from conventional commits adds the tag check, build, and trusted-publishing upload.

FAQ

Why not just keep release-please?

Keep it if you like reviewing each release as a pull request, or need its per-language version updaters and manifest monorepo mode. This page is for repos where the open release PR is noise: it rebases on every merge, sits in the PR list, and needs release-please-config.json plus .release-please-manifest.json. Here the release happens when you press Run workflow, and nothing waits in between.

Which files get the new version?

release.sh --bump-file sets the first version value in each file you name: package.json, pyproject.toml, Cargo.toml, or any file with a version = "x" or "version": "x" line. Repeat the flag for several files. Anything else (a __version__ in code, a Helm chart) needs your own sed line before the commit.

How is the version chosen?

From the commits since the last tag: a breaking change bumps major, any feat bumps minor, anything else bumps patch. In 0.x a breaking change bumps minor. The same rule as relnote --next-version. To force a version, add --release-as v2.0.0 to the release.sh line.

What if main is protected?

The push uses the workflow's GITHUB_TOKEN. If branch protection blocks direct pushes to main, allow GitHub Actions to bypass the rule, or push with a GitHub App token or deploy key that may. release-please avoids this by going through a pull request; that is the main thing you trade away.

Will the new tag trigger my other workflows?

Not with GITHUB_TOKEN: GitHub does not start new workflow runs for pushes made with it. Put publishing steps (PyPI, npm, container builds) in this same job after gh release create, or push with a GitHub App token if you want an on: push: tags workflow to fire.

What happens if I press Run workflow twice?

The second run has nothing to do. If no commits landed since the last tag, the relnote action reports has-changes=false and the release steps are skipped; if HEAD is already the release commit and tagged, the guard prints a message and exits 0.

Does the CHANGELOG.md compare link point at HEAD?

No. From v0.3.6, when --changelog-title is a version tag such as v1.4.0 and HEAD is not tagged yet, the section's compare link ends at that tag (compare/v1.3.2...v1.4.0). release.sh writes the changelog before it creates the tag, so older versions wrote ...HEAD there.