
PR Review and CI Are Two Different Systems

Automate version bumps, changelogs, and publishing with semantic-release, Changesets, or release-please on GitHub Actions.
Most CI/CD guides stop at "merge to main and tests pass." What happens next is left as an exercise for the reader. Somebody bumps a version in package.json. Somebody else writes changelog entries by scrolling through git log. A third person tags the commit, pushes the tag, runs npm publish, and creates a GitHub Release by hand. If you're lucky, it's the same person doing all of that. If you're not, half the steps get skipped.
Manual releases are slow, error-prone, and weirdly stressful for something that should be mechanical. The version number is wrong. The changelog has a gap. The tag doesn't match what's on npm. These aren't hard problems individually, but they compound when a human is responsible for getting five things right in sequence every time.
Three tools dominate the release automation space on GitHub: semantic-release, Changesets, and release-please. They solve the same core problem but make fundamentally different tradeoffs around human involvement, commit conventions, and monorepo support. Picking the wrong one will either frustrate your contributors or leave you debugging broken changelogs at 11 PM.
Before diving into specific tools, it helps to see where they sit on the automation spectrum. At one end, you have fully manual releases: someone decides the version, writes the changelog, and publishes. At the other end, the machine handles everything based on commit history alone.
There's no objectively correct level. An open-source library with a single maintainer might prefer level 4. A company monorepo with coordinated releases across ten packages might need level 3 for the review step. What matters is that you pick one and stop doing releases by hand.
semantic-release is the most opinionated of the three. With 23,000+ GitHub stars and over 130,000 dependents, it's also the most widely adopted. The premise is simple: if your commit messages follow a convention, the machine can determine whether the next release is a patch, minor, or major. No human decision required.
By default, it uses the Angular commit convention. A fix: commit triggers a patch release. A feat: commit triggers a minor release. A commit with BREAKING CHANGE: in its footer triggers a major release. Anything else (docs, chore, style, refactor) produces no release at all.
Here's a minimal GitHub Actions workflow that runs semantic-release on every push to main:
name: Release
on:
push:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}A few things worth noting in this workflow. The fetch-depth: 0 is critical. semantic-release needs the full git history to analyze commits since the last release tag. A shallow clone will cause it to either skip the release or produce an incorrect version. The permissions block grants the GITHUB_TOKEN enough access to create tags and GitHub Releases. Without explicit contents: write, the release step fails silently or throws a 403.
semantic-release runs through a plugin pipeline: verify conditions, analyze commits, generate notes, create a git tag, prepare, publish, and notify. Each step can be handled by a different plugin. The defaults cover most Node.js projects, but the ecosystem extends to Docker, Helm, Python, and more.
A typical .releaserc.json looks like this:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github",
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version}"
}]
]
}This configuration analyzes commits, generates release notes, writes a CHANGELOG.md, publishes to npm, creates a GitHub Release, and commits the updated changelog and package.json back to the repo. The plugin order matters because it defines the execution sequence.
The strength is total automation. Once configured, you never think about releases again. Push a fix, and a patch version appears on npm minutes later. This is ideal for libraries with frequent small updates where speed matters more than ceremony.
The weakness is that it requires strict commit discipline. If developers write "fixed stuff" or "WIP" as commit messages, semantic-release either produces no release or produces the wrong one. You'll want commitlint or commitizen enforcing the convention, which adds setup overhead and can frustrate contributors who aren't used to structured commits. It also has limited monorepo support out of the box. Plugins like semantic-release-monorepo exist, but they're community-maintained and can be brittle with complex dependency graphs.
Changesets takes the opposite approach from semantic-release. Instead of deriving version information from commit messages, it asks developers to explicitly declare what changed and how it should be released. You run npx changeset and it prompts you: which packages are affected, is this a patch/minor/major, and write a human-readable summary. That produces a markdown file in the .changeset/ directory that gets committed alongside your code changes.
This is a deliberate design choice. The Changesets maintainers believe that the person making the change is the best judge of how to describe it to users. A commit message like "refactor: extract helper function" tells you nothing about the user-facing impact. A changeset entry like "Fixed timezone handling in date picker when DST transitions occur" tells users exactly what they need to know.
Projects like Astro, SvelteKit, Remix, pnpm, and Chakra UI use Changesets. Its 11,600 GitHub stars are lower than semantic-release's, but adoption among JavaScript monorepo projects is arguably higher.
The official changesets/action does two things. When changeset files exist on main, it opens a "Version Packages" PR that consumes those files, bumps versions, and updates changelogs. When that PR gets merged, it can optionally publish the updated packages.
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: changesets/action@v1
with:
publish: npm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}The publish input points to a script in your package.json (typically changeset publish) that handles the actual npm publish. The action itself orchestrates the versioning PR and calls your publish script when appropriate.
You can also add a changeset bot to your repo that comments on PRs missing a changeset file, which is a helpful guardrail for open-source projects where contributors might not know the workflow.
This is where Changesets really differentiates itself. It was designed for monorepos from the start. When you run npx changeset, it shows you all packages in the workspace and lets you select which ones are affected. It understands internal dependencies too. If package A depends on package B and package B gets a minor bump, Changesets will automatically patch-bump package A to reflect the updated dependency.
The config file supports two multi-package strategies. Linked packages share the same version number but can be released independently. Fixed packages are always released together at the same version (lockstep versioning). Both are configured in .changeset/config.json:
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@myorg/core", "@myorg/cli"]],
"linked": [["@myorg/react", "@myorg/vue"]],
"access": "public",
"baseBranch": "main"
}release-please sits between semantic-release and Changesets. Like semantic-release, it reads Conventional Commits to determine version bumps. Like Changesets, it gates the release behind a PR that you can review before merging. Google built it for their own open-source projects, and it shows in the design: it supports over 20 language ecosystems (Node, Python, Java, Go, Rust, Ruby, PHP, Helm, Terraform, and more) and has first-class monorepo support via a manifest configuration.
The workflow is straightforward. On every push to main, release-please scans commits since the last release. If it finds releasable changes (feat:, fix:, deps:), it opens or updates a release PR. That PR contains the version bump, updated CHANGELOG.md, and any language-specific file changes (package.json, pyproject.toml, pom.xml, etc.). When you merge the PR, release-please creates a git tag and a GitHub Release.
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
publish:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}The two-job pattern here is important. The first job runs release-please, which either creates/updates a release PR or (if a release PR was just merged) outputs that a release was created. The second job only runs when a release was actually created, handling the publish step. This keeps release PR management and publishing cleanly separated.
release-please doesn't handle publishing itself. That's a conscious decision. It focuses on versioning, changelogs, tags, and GitHub Releases. You wire up npm publish, Docker push, or whatever else you need as a downstream job.
Here's how the three tools stack up across the dimensions that actually matter when choosing:
Version determination. semantic-release and release-please both derive the version from commit messages. Changesets requires a human to explicitly choose the bump type. If your team already uses Conventional Commits consistently, the automated approach works. If your commit messages are messy or you want changelog entries written by humans who understand the user impact, Changesets wins.
Release gating. semantic-release publishes immediately on every qualifying merge. There's no review step. Changesets and release-please both create a PR you can inspect before merging. If you need sign-off before a release goes out (compliance, coordination with docs or marketing, QA holds), you want the PR-based approach.
Monorepo support. Changesets was purpose-built for monorepos and handles it natively, including internal dependency tracking. release-please supports monorepos through its manifest configuration, with independent versioning per component. semantic-release's monorepo support is community-plugin territory and can require significant configuration.
Language support. release-please is the clear winner here with 20+ language-specific strategies. It knows how to update pom.xml for Java, Cargo.toml for Rust, setup.py for Python, and Chart.yaml for Helm. semantic-release handles this through plugins. Changesets is JavaScript/TypeScript-focused, though you can use it for non-npm projects with some extra configuration.
The monorepo versioning question comes down to one choice: do all packages share a version number, or does each package version independently?
Independent versioning means each package has its own version number and its own changelog. A change to @myorg/utils bumps only that package (and its dependents' dependency ranges). This is what Changesets does by default and what release-please's manifest mode supports. It's the right choice when packages have different consumers and different release cadences.
Lockstep versioning means all packages share the same version. When any package changes, all packages get the same new version number. This simplifies compatibility reasoning ("I'm using v3.2.0 of everything") but creates noise in changelogs for packages that didn't actually change. Changesets supports this via the fixed config option.
For release-please in a monorepo, you define a manifest file (release-please-config.json) that maps paths to release strategies:
{
"packages": {
"packages/core": {
"release-type": "node"
},
"packages/cli": {
"release-type": "node"
},
"services/api": {
"release-type": "python"
}
}
}Each package gets its own release PR, its own version, and its own CHANGELOG.md. Commits are attributed to packages based on file paths.
All three tools work well once configured, but the setup phase and ongoing maintenance surface predictable problems. Here's what breaks most often.
Token permission errors. The default GITHUB_TOKEN has read-only permissions by default in newer repositories. If your workflow tries to push tags or create releases without the right permissions block, you'll get a 403 error that's easy to misdiagnose. Always set contents: write and pull-requests: write explicitly. Another gotcha: the GITHUB_TOKEN can't trigger other workflows. If your release creates a tag and you have a separate workflow that runs on tag pushes, it won't fire. You'll need a personal access token or a GitHub App token for that.
Shallow clones breaking version detection. GitHub Actions checks out code with fetch-depth: 1 by default, meaning you only get the latest commit. semantic-release needs the full history to find the last release tag and analyze commits since then. Without fetch-depth: 0, it either produces no release or starts over from version 1.0.0. release-please is less affected because it uses the GitHub API to read commit history rather than local git, but it can still miscount if commit history is incomplete.
Skipped versions. This happens when someone manually creates a tag or edits the version in package.json without going through the release tool. The tool sees the manual tag as the "last release" and may calculate the next version incorrectly, leading to gaps in your version history. The fix is simple: don't touch version numbers or tags manually once you've adopted automated releases.
Broken changelogs in monorepos. Commits that affect multiple packages can show up in every package's changelog if the scoping isn't configured correctly. With release-please, this is controlled by commit scopes and path-based attribution. With Changesets, you explicitly select which packages a change applies to, so this is less of an issue. With semantic-release in a monorepo, it's the most common problem.
Stale release PRs. Both Changesets and release-please create PRs that accumulate changes. If nobody merges the release PR for weeks, it can grow large and difficult to review. Worse, with release-please, stale autorelease: pending labels on old PRs can block new release PRs from being created. If you notice release-please stopped creating PRs, check for orphaned labels on closed PRs.
Use semantic-release if you want fully autonomous releases on a single-package repo, your team already follows Conventional Commits, and you don't need a human approval step before publishing. It's the fastest path from merge to published package.
Use Changesets if you're running a JavaScript/TypeScript monorepo, you want human-written changelog entries, and you value explicit control over what gets released and how it's described. The PR-based workflow gives you a natural review point.
Use release-please if you need multi-language support, you like the commit-convention-driven approach but still want a review step via release PRs, or you're in a Google-flavored ecosystem. It's the most versatile of the three, even if it's the least widely adopted (6,600 stars).
All three are free, well-maintained, and used in production by thousands of projects. The worst choice is continuing to release by hand.

PR Review and CI Are Two Different Systems

From Provision to Shutdown: The Lifecycle of a Tenki Runner

What Are GitHub Actions Runners? A Complete Beginner’s Guide to CI/CD Workflows
Get Tenki
Change 1 line of YAML for faster runners. Install a GitHub App for AI code reviews. No credit card, no contract. Takes about 2 minutes.