cover

TL;DR

Release Please automates the boring half of shipping: version bumps, changelogs, git tags, GitHub releases. It reads your commit messages, keeps a pull request open that always shows what the next release would look like, and cuts the release the moment you merge that PR. Setup is about fifteen lines of YAML. I'm setting it up on my own project after twenty-two hand-cut releases, so in this post I'll walk through how the model works, the gotchas waiting behind the happy path (token permissions, squash merges, a release PR that refuses to appear), and a cheat sheet you can keep next to your terminal.


The release ritual

Here's what shipping a version of notifycat looks like today. Notifycat is my self-hosted service that turns GitHub pull request webhooks into living Slack threads, written in Go, twenty-two releases in. The commits already follow Conventional Commits. The releases are still handmade: I scroll the history since the last tag to remember what shipped, write the CHANGELOG.md entry, decide whether this is v0.15.0 or v0.14.1, tag, push, create the GitHub release, and paste the same notes a second time into the release description.

Twenty minutes when everything goes right. Multiply by twenty-two releases and that's most of a working day spent on transcription.

The failure modes are predictable. Merged fixes sit on main for two weeks because cutting a release is a chore and chores get postponed. The changelog entry, written from memory at the end of the day, misses half of what landed. The "minor or patch?" debate, held with myself, settles differently depending on my mood. And every step is trivial on its own. The sequence is what's fragile.

Here's the part that finally annoyed me into automating it. The commit messages already carry all the information: what changed, whether it's a fix or a feature, whether it breaks anything. I'm the clerk who copies that information into three other places. Nobody needs that clerk.

The replacement is automation attached to something I already do anyway: merging pull requests. That's the whole idea behind release-please, the tool Google uses to manage releases across its own client libraries.

One PR that is always about to be a release

The mechanism rests on a commit message convention you may already be using.

Conventional Commits is a specification where the commit prefix carries meaning: fix: marks a bug fix, feat: marks a new feature, and a ! after the prefix (or a BREAKING CHANGE: footer) marks a breaking change. (conventionalcommits.org)

Release Please parses every commit on your main branch since the last release and maps prefixes to semver bumps:

CommitBumpChangelog section
fix: handle empty payloadpatchBug Fixes
feat: add CSV exportminorFeatures
feat!: drop the v1 config formatmajorFeatures, plus a breaking change notice
deps: bump guzzle to 7.9patchDependencies
chore:, docs:, refactor:, test:nonehidden by default

Then comes the part I find genuinely clever. It doesn't release anything. Instead, it opens a pull request:

feat: add CSV export      ──┐
fix: handle empty payload ──┤  merged to main
chore: bump CI runners    ──┘
┌──────────────────────────────────┐
│  Release PR (updates itself)     │
│  "chore(main): release 1.4.0"    │
│                                  │
│  • CHANGELOG.md entry            │
│  • version bump in package files │
└──────────────────────────────────┘
             │  you merge it
   tag v1.4.0 + GitHub release
   your publish step (npm, Docker…)

That release PR is a living preview. It contains the changelog entry and the version bump, and it rewrites itself every time another commit lands on main. Merge five more fixes and the PR updates from 1.4.0 to reflect them. Ship a breaking change and it becomes 2.0.0.

Nothing releases until a human merges that PR. That's the design decision that sold me. Tools like semantic-release cut a release on every push to main, fully automated. Release Please keeps you in the loop: you read the changelog, you decide the timing, you click merge. The release becomes a one-click decision instead of a twenty-minute ritual, but it's still a decision.

After the merge, the tool tags the commit, creates the GitHub release, and flips the PR label from autorelease: pending to autorelease: tagged. Those labels are how it tracks state, so leave them alone.

Fifteen lines of YAML

The recommended setup is the official GitHub Action. Create .github/workflows/release-please.yml:

name: release-please

on:
  push:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
          release-type: go

Two things to get right here:

  • Permissions. The action needs contents: write to push tags and pull-requests: write to manage the release PR. If your org restricts Actions from creating PRs, there's a toggle under Settings → Actions → General called "Allow GitHub Actions to create and approve pull requests". It's easy to miss, and the error message won't point you there.
  • Release type. This tells the tool which files hold your version. go is the light one (Go versions live in tags, so it mostly maintains the changelog), php bumps composer.json, node bumps package.json, and there are types for python, rust, java, helm, terraform, and a dozen more. For anything exotic, simple maintains a version.txt and a changelog, nothing else.

Push that workflow, merge one commit with a feat: or fix: prefix, and the release PR shows up within a minute. Merge the PR and you have a tag, a release, and a changelog entry you didn't write by hand.

Publishing stays your job

Release Please stops at the GitHub release. It won't run npm publish or push a Docker image, and that's deliberate: deciding to release and shipping artifacts are separate concerns. The action exposes outputs so you can chain whatever comes next:

steps:
  - uses: googleapis/release-please-action@v4
    id: release
    with:
      release-type: node
  - uses: actions/checkout@v4
    if: ${{ steps.release.outputs.release_created }}
  - run: npm ci && npm publish
    if: ${{ steps.release.outputs.release_created }}
    env:
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The release_created output is only true on the run where the release PR got merged, so the publish step stays dormant the rest of the time. You also get tag_name, version, major/minor/patch, and upload_url for attaching binaries. For notifycat, the step hanging off release_created is the Docker image build, tagged with whatever tag_name says.

If you live in PHP land (my other home), you may not need a publish step at all. Packagist watches your repository and picks up new tags on its own. The tag is the release.

The gotchas

Everything above is the happy path. Here's where the time actually goes.

The release PR has no CI checks

You set everything up, the release PR appears, and your test suite never runs on it. The branch protection rule blocks the merge. What's going on?

GitHub has anti-recursion protection: anything created with the default GITHUB_TOKEN will not trigger other workflows. The release PR is created by a workflow, so your on: pull_request checks stay silent.

The fix is to give the action a token that isn't GITHUB_TOKEN: either a personal access token from a machine account or, cleaner for teams, a GitHub App token generated in the workflow. That's why the example above uses secrets.RELEASE_PLEASE_TOKEN instead of the default.

The release PR never appears

You merge three commits, check the PR tab, nothing. The usual cause: none of the commits were releasable. Only feat, fix, and deps count. A stretch of chore: and refactor: commits produces no release PR because, from the changelog's point of view, nothing happened that a user would care about.

This is by design, not a bug. If you need a release anyway, the Release-As footer below is the escape hatch.

The changelog is full of "address review comments"

Release Please reads commits on main, not PR descriptions. If your team merges with merge commits, every "wip", "fix typo", and "address review comments" lands in the history and gets parsed. The changelog becomes an archaeology site.

Switch the repository to squash merging only. The PR title becomes the single commit on main, so one PR equals one changelog entry. Then lint the titles so a sloppy one can't slip through. The amannn/action-semantic-pull-request action fails the check when a PR title doesn't follow Conventional Commits.

Squash plus title lint is most of the quality difference. Without it you get a changelog; with it you get a changelog someone can read.

You need a specific version

Maybe you're bootstrapping the first stable release, or marketing wants 3.0.0 to land with the conference keynote. Put a Release-As footer in any commit body and Release Please obeys:

git commit --allow-empty -m "chore: release 2.0.0" -m "Release-As: 2.0.0"

The empty commit trick is handy on day one, when a repo has years of non-conventional history and you want to draw a line and start from a known version.

The typo already shipped

A merged commit message says feat: add suport for webhooks and it's now sitting in your pending changelog. You can't rewrite history on main, but you don't need to. Edit the body of the merged PR and add an override block:

BEGIN_COMMIT_OVERRIDE
feat: add support for webhooks
END_COMMIT_OVERRIDE

On the next run, Release Please uses the override instead of the original message. There's a sibling trick, BEGIN_NESTED_COMMIT, that splits one squashed PR into several changelog entries when a PR genuinely contained two features.

The version lives in more than one file

A README badge, a Version constant the binary prints at startup, an image tag in docker-compose.yml. Release Please can bump those too, via extra-files in release-please-config.json:

{
  "packages": {
    ".": {
      "release-type": "go",
      "extra-files": [
        "internal/version/version.go",
        { "type": "yaml", "path": "docker-compose.yml", "jsonpath": "$.services.app.image" }
      ]
    }
  }
}

For plain text files, mark the line to bump with an inline annotation and the tool finds it:

const Version = "0.14.0" // x-release-please-version

Monorepos, briefly

Everything so far assumed one repo, one package. For a monorepo, Release Please runs in manifest mode: a release-please-config.json describes each package path and a .release-please-manifest.json tracks the current version per path. Each package gets its own release PR (or one combined PR, your choice), its own tag like pkg-a-v1.2.0, and its own path-prefixed action outputs. The bootstrap command in the cheat sheet below generates both files for an existing repo, which beats writing them by hand.

The cheat sheet

The part to bookmark.

Commit prefixes

PrefixEffect
fix:patch bump, "Bug Fixes" section
feat:minor bump, "Features" section
feat!: / fix!:major bump, breaking change notice
deps:patch bump, "Dependencies" section
chore: docs: refactor: test: ci:no bump, hidden from changelog

Magic footers and blocks

SyntaxWhat it does
Release-As: 2.0.0 in a commit bodyForces the next release to that exact version
BEGIN_COMMIT_OVERRIDEEND_COMMIT_OVERRIDE in a merged PR bodyRewrites how that commit appears in the changelog
BEGIN_NESTED_COMMITEND_NESTED_COMMIT in a PR bodySplits one squashed PR into several changelog entries

CLI commands (the Action covers daily use; the CLI is for setup and debugging)

# one-time: generate config + manifest for an existing repo
npx release-please bootstrap \
  --token=$GITHUB_TOKEN \
  --repo-url=owner/repo \
  --release-type=go

# preview what the release PR would contain, without touching anything
npx release-please release-pr \
  --token=$GITHUB_TOKEN \
  --repo-url=owner/repo \
  --dry-run

# force-create the GitHub release for an already-merged release PR
npx release-please github-release \
  --token=$GITHUB_TOKEN \
  --repo-url=owner/repo

--dry-run is the debugging tool. When the release PR looks wrong (or doesn't appear), run it locally and read what the tool thinks your pending release is.

Files

FileRole
release-please-config.jsonBehavior: release types, paths, changelog sections, extra files
.release-please-manifest.jsonState: current version per package, maintained by the tool

Config keys worth knowing

  • "extra-files": bump version strings in arbitrary files
  • "changelog-sections": surface hidden types (show chore or docs in the changelog)
  • "versioning": "always-bump-patch": ignore semver mapping, every release is a patch
  • "bump-minor-pre-major": true: while on 0.x, breaking changes bump minor instead of jumping to 1.0.0
  • "draft-pull-request": true: open release PRs as drafts
  • "pull-request-title-pattern": rename the release PR if chore(main): release 1.4.0 bothers you

Tips

  1. Squash merge only, and lint PR titles in CI. This is non-negotiable if you want a readable changelog.
  2. Use a PAT or GitHub App token, not GITHUB_TOKEN, so CI runs on the release PR.
  3. Starting on an old repo? Draw the line with git commit --allow-empty -m "chore: release 1.0.0" -m "Release-As: 1.0.0".
  4. Don't delete the autorelease: pending and autorelease: tagged labels. The tool uses them to track state.
  5. Releases feel stuck? --dry-run first, force-push panic second.

Where it falls short

A few honest caveats before you roll this out.

It's a team contract, not a tool install. Release Please is only as good as the commit messages feeding it. One weekend of non-conventional merges and your changelog has holes. The linting step isn't optional polish. It's load-bearing.

Garbage in, garbage out. fix: fix bug produces a changelog entry that says "fix bug". The tool removes the mechanical work of releasing. The editorial work of writing a useful PR title is still yours.

It assumes trunk-based development. Merge to main, release from main. If your team runs gitflow with long-lived release branches, you can make it work (the action accepts a target-branch), but you're swimming against the current.

It's not the only option. If you want zero human involvement, semantic-release ships on every push and skips the PR entirely. I prefer the review gate, but that's a preference, not a law.

Wrapping up

Twenty-two releases of notifycat, every one of them cut by hand, and the funny part is that the hard work was already done: the commits have followed Conventional Commits from the start. The only thing missing was the tool that reads them. So that's the change going into notifycat now: a release PR that maintains itself, a changelog I review instead of write, and a merge button where the ritual used to be. I'm done being the clerk.

If you want to try it, pick a side project, add the fifteen-line workflow, and merge one feat: commit. The release PR that shows up a minute later explains the model better than any post can.