A contributor renames an option in your SDK and commits it as feat, without the !. The release
bot reads that commit, computes a minor version, tags it, and publishes it. Every caller who sets
the option by its previous name breaks on an upgrade their version range allowed, because a commit
message chose the version and no person was asked.
What you get
You will end up with a script that dry-runs four release tools against one throwaway repository and compares each computed version with yours, plus a check for publish jobs that never start. This is for you if you maintain an SDK and are choosing its release automation.
Short answer
Choose an SDK’s release tool by where the person who decides the version sits. changesets reads the bump from a file each contributor writes. release-please waits for someone to merge a release pull request. semantic-release ships every qualifying merge, and git-cliff writes the changelog and leaves the tag to you. Dry-run each against a throwaway copy and compare its version with the one you expected.
You will need
Node 22.14 or later on the 22 line, or 24.10 or later, which semantic-release 25.0.9 requires, and
git. Commits that follow
Conventional Commits, for the three tools that
read them; the conventions themselves are the subject of a separate page on conventional commits in
an SDK repository. Generating the notes from those commits is covered in
generate a changelog from conventional commits. Every
tool here is pinned in the sample’s package.json and runs from node_modules.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| changesets | Contributors know how their change should release, and you want that decision reviewed in the pull request | A file in every pull request, because a change without one is not released | Contributors will not write the file, or the SDK has no package.json |
| git-cliff | You want the changelog and a computed version, and a person cuts the tag | The bump, the tag, and the publish stay manual steps you script yourself | You want a pull request or a bot to own the release |
| release-please | Conventional commits, and each production release approved by merging a pull request | Needs the GitHub API and a token, which ties the release flow to GitHub | The repository is not on GitHub, or nobody will review release pull requests |
| semantic-release | Every merge to the release branch should ship, with no person between merge and publish | No gate at all, so a mislabeled commit ships at the version its message implied | Policy says a person approves each production release |
The choice is where the human decision sits. semantic-release removes it, which is fast and is exactly what a human-gated release policy forbids. release-please keeps the inference from commits and adds a gate, the merge of its release pull request, at the price of depending on GitHub. changesets records the decision instead of inferring it, which needs contributors who write the file. git-cliff infers the version from commits, as the other two readers do, and leaves every step after the changelog to you.
Dry-run all four against a throwaway repository
Run the candidates against the same history before choosing, because the version each computes is
the thing you are choosing. The sample builds a fresh repository for each scenario inside a temp
directory, with a bare origin beside it, and never touches yours. release-please needs the GitHub
API, so the sample answers it with a stand-in on 127.0.0.1 rather than GitHub itself. Each scenario
states the version a maintainer expected.
{
name: 'a breaking rename committed without the !',
commits: [
'fix(auth): refresh the token once under concurrent calls',
'feat(client): rename the timeout option to timeoutMs',
],
changeset: { bump: 'major', summary: 'Rename `timeout` to `timeoutMs`. Update every call that sets it.' },
expected: '2.0.0',
},
Run the comparison. It took about ten seconds here, most of it semantic-release and release-please
starting up. A tool that fails prints ERROR and its message in place of a version, so a crash
never reads as “no release.”
node dry-run.mjs
scenario 1/3: a fix and a feature, with a changeset saying minor
expected 1.5.0
changesets 1.5.0 match ran on: local files
git-cliff 1.5.0 match ran on: local git
release-please 1.5.0 match ran on: stand-in GitHub API, 3 requests
semantic-release 1.5.0 match ran on: local git, file:// remote
scenario 2/3: a breaking rename committed without the !
expected 2.0.0
changesets 2.0.0 match ran on: local files
git-cliff 1.5.0 MISMATCH ran on: local git
release-please 1.5.0 MISMATCH ran on: stand-in GitHub API, 3 requests
semantic-release 1.5.0 MISMATCH ran on: local git, file:// remote
scenario 3/3: the same fix and feature, with no changeset file
expected 1.5.0
changesets no release MISMATCH ran on: local files
git-cliff 1.5.0 match ran on: local git
release-please 1.5.0 match ran on: stand-in GitHub API, 3 requests
semantic-release 1.5.0 match ran on: local git, file:// remote
The first scenario is the baseline, and all four agree. The second is the problem statement: the
three tools that read commits compute 1.5.0 for a breaking change, because the message lacks the
!. The contributor’s changeset said major, and changesets released 2.0.0. The third is the
opposite failure. No changeset file, so changesets releases nothing, while the commit readers
release the feature. Each tool is wrong in the case the other kind handles, so pick the failure your
team is likelier to commit.
Give each tool only what it needs
All four ran here with no network and no real token, and two of them needed persuading. semantic-release’s dry run
verifies push permission
even though it pushes nothing, so the sample hands it a file:// bare repository. It also drops
the GitHub and npm plugins from the dry run. With them, it fails with ENOGHTOKEN and, for a
package npm would publish, ENONPMTOKEN, before a version is computed. The test suite below proves
that.
release-please is the one that cannot run without GitHub. Version 17.11.2 has a --local flag that
reads commits from a clone, and it still asks the API for the repository and its releases.
export async function releasePlease(repo, startApi = startFakeGitHub) {
const api = await startApi()
try {
const { out } = await run('release-please', [
'release-pr', '--dry-run', '--release-type', 'node',
'--repo-url', 'acme/sdk', '--token', 'not-a-real-token',
'--local', '--local-path', repo.work,
'--api-url', api.url, '--graphql-url', api.url,
], repo.work)
The stand-in on 127.0.0.1 saw three requests per run: GET /repos/acme/sdk and two GraphQL
releases queries. Against github.com, those calls carry the token you pass, and the
release-please CLI reference
marks --token as required. changesets and git-cliff read local files and local git, and need
nothing else.
Check the token that cuts the tag
The quietest failure in this choice happens after a release succeeds. release-please and
semantic-release push the tag and create the GitHub release, usually with the workflow’s default
GITHUB_TOKEN. GitHub’s documentation on
triggering a workflow from a workflow
says events made with that token do not create workflow runs, except workflow_dispatch,
repository_dispatch, and some pull request events. A publish job listening for the tag never
starts, so no failed run appears to warn you.1
tokens.mjs reads a directory of workflow files and pairs every release step that authenticates
with GITHUB_TOKEN with every workflow that starts on a tag push or a release event. That includes
the release workflow itself when its own on: names the tag or the release. For semantic-release it reads
GH_TOKEN before GITHUB_TOKEN, the order @semantic-release/github 12.0.10 uses.
node tokens.mjs workflows/default-token workflows/app-token workflows/same-workflow workflows/semantic-release
workflows/default-token
release.yml release-please-action authenticates with GITHUB_TOKEN
publish.yml starts on push tags v*
FAIL publish.yml never runs: it waits for push tags v*, and release.yml creates that with GITHUB_TOKEN
workflows/app-token
release.yml release-please-action authenticates with another token
publish.yml starts on push tags v*
ok
workflows/same-workflow
release.yml release-please-action authenticates with GITHUB_TOKEN
ok
workflows/semantic-release
release.yml semantic-release authenticates with GITHUB_TOKEN
docs.yml starts on release published
FAIL docs.yml never runs: it waits for release published, and release.yml creates that with GITHUB_TOKEN
Two fixes pass. One mints a token from a GitHub App with
create-github-app-token and passes it to the
release step. The other publishes in the same workflow, gated on the
release-please-action output
release_created, so no second workflow has to wake up.
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: googleapis/release-please-action@v4
with:
release-type: node
token: ${{ steps.app-token.outputs.token }}
Check it worked
Run the suite. Nineteen tests pin the three scenarios, the API requests release-please makes, the
token errors that stop semantic-release, the changeset guard, and the token check’s four fixtures.
They also cover the cases a reader would otherwise misread: a refused API call reported as an error,
the precedence of GH_TOKEN, and a release workflow waiting for its own tag.
node --test
1..19
# tests 19
# suites 0
# pass 19
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 11220.438698
The line to read in your own dry run is the version beside each tool. If it differs from the one you wrote down before running, the tool and you disagree about what a change means. Settle that before a release depends on it.
When it goes wrong
changesets reports no release although a changeset exists. The package is private, and changesets 3
versions private packages only when
privatePackages.version is true.2 The same default
lets changeset status --since=main, the pull request guard, pass a branch with no changeset. The
last two tests in release.test.mjs show both.
release-please --local throws away your uncommitted work. It runs git fetch origin,
git checkout main, and git reset --hard origin/main in the path it is given. Give it a fresh
clone, as the sample does, never your working copy.
semantic-release stops at ENOGHTOKEN before printing a version, with ENONPMTOKEN beside it for a
public package. The default plugin list includes @semantic-release/github and
@semantic-release/npm, and both check for a token before any version is computed. Pass --plugins
with the commit analyzer and the notes generator for a dry run, and keep the full list for the real
release.
git-cliff prints the next version but creates no tag. Per its
bump documentation, --bumped-version calculates
the next version and prints it to stdout. The tag and the publish are steps you script yourself.
When not to do this
Do not adopt semantic-release where a person must approve each production release. It is built to release without one, and a gate added around it in CI is a second release process to maintain.3
Do not run two of these tools on one repository. They compute versions from different evidence, as scenario 2 shows, so two of them can tag two different versions for the same commits.
Do not fix the token pitfall with a personal access token scoped to every repository you own. The semantic-release GitHub Actions recipe calls overriding the token a security risk, because a workflow on an unprotected branch can read the secret. A GitHub App token scoped to one repository, or a publish step in the release workflow, avoids it.
Do not let a commit-reading tool version an SDK whose history is not conventional yet. Fix the messages first, with a check on the pull request, or start with changesets.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, @changesets/cli 3.0.3, git-cliff 2.14.2,
release-please 17.11.2, semantic-release 25.0.9 with @semantic-release/github 12.0.10, and yaml
2.9.1. Every output block is what the
command preceding it printed. release-please ran against a stand-in for the GitHub API on
127.0.0.1, not against GitHub, and the workflow files were checked as files, not run on GitHub
Actions.
Footnotes
-
The two documents disagree about the pull request. The release-please-action README says CI checks will not run on a release pull request opened with the default token, and recommends a personal access token for that reason. GitHub’s own page lists that event among the exceptions: the run is created in an approval-required state, and someone with write access starts it by pressing a button. For the tag push, which is the event a publish job waits for, the two pages agree. ↩︎ Back to text
-
The default changed between major versions. The migration guide says changesets 2 versioned private packages without tagging them, and changesets 3 does neither, on the grounds that most projects do not version private packages, such as test fixtures. A repository upgraded across that line keeps its configuration file and loses the behavior the file never had to mention. ↩︎ Back to text
-
The project’s overview says that automating the release removes the immediate connection between human emotions and version numbers. In the second scenario on this page, that connection is what produced the right version: a contributor judged that renaming an option breaks its callers, wrote major, and changesets released 2.0.0. ↩︎ Back to text