# How to choose a release automation tool for an SDK repository

> Dry-run changesets, git-cliff, release-please, and semantic-release against a throwaway copy of the repository, then pick by where a person decides the release.

Source: https://voxgig.com/howto/choose-a-release-automation-tool-for-an-sdk

- Audience: library-maintainer
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://www.conventionalcommits.org/en/v1.0.0/), 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](/howto/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](https://changesets.dev/guide/getting-started) | 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](https://git-cliff.org/docs/) | 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](https://github.com/googleapis/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](https://semantic-release.gitbook.io/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.

```js title="dry-run.mjs"
  {
    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."

```bash
node dry-run.mjs
```

```text output
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](https://semantic-release.gitbook.io/semantic-release/usage/configuration)
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.

```js title="tools.mjs"
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](https://github.com/googleapis/release-please/blob/main/docs/cli.md)
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](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-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.

```bash
node tokens.mjs workflows/default-token workflows/app-token workflows/same-workflow workflows/semantic-release
```

```text output
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](https://github.com/actions/create-github-app-token) and passes it to the
release step. The other publishes in the same workflow, gated on the
[release-please-action](https://github.com/googleapis/release-please-action) output
`release_created`, so no second workflow has to wake up.

```yaml title="workflows/app-token/release.yml"
      - 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.

```bash
node --test
```

```text output
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`](https://changesets.dev/guide/config) 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](https://git-cliff.org/docs/usage/bump-version/), `--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](https://semantic-release.gitbook.io/semantic-release/recipes/ci-configurations/github-actions)
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

- [Generate a changelog from conventional commits](/howto/changelog-from-conventional-commits)

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

- [Ship a changelog inside your published package](/howto/ship-a-changelog-inside-the-package)

- [Write a CVE response runbook for an SDK](/howto/write-a-cve-response-runbook-for-an-sdk)

- [Run a changelog channel developers read in time](/howto/run-a-changelog-channel-developers-read)

## 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.

[^1]: The two documents disagree about the pull request. The
[release-please-action README](https://github.com/googleapis/release-please-action) 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.

[^2]: The default changed between major versions. The
[migration guide](https://changesets.dev/guide/migration) 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.

[^3]: The project's [overview](https://semantic-release.gitbook.io/semantic-release) 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.