How-to › Release and secure packages

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.

Audience
Library maintainer
Level
intermediate
Topic
Version, changelog and automate releases
Verified

Read first: Generate a changelog from conventional commits

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
changesetsContributors know how their change should release, and you want that decision reviewed in the pull requestA file in every pull request, because a change without one is not releasedContributors will not write the file, or the SDK has no package.json
git-cliffYou want the changelog and a computed version, and a person cuts the tagThe bump, the tag, and the publish stay manual steps you script yourselfYou want a pull request or a bot to own the release
release-pleaseConventional commits, and each production release approved by merging a pull requestNeeds the GitHub API and a token, which ties the release flow to GitHubThe repository is not on GitHub, or nobody will review release pull requests
semantic-releaseEvery merge to the release branch should ship, with no person between merge and publishNo gate at all, so a mislabeled commit ships at the version its message impliedPolicy 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.

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

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

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

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.