How-to › Maintain libraries across languages

How to release every port of a library on one version number#

Link a canonical library and its npm, PyPI, Go, and Rust ports to one version, tag the Go module where Go looks, and check every artifact before anything publishes.

Audience
Library maintainer
Level
intermediate
Topic
Port a library across languages
Languages
TypeScript, Go, Python and Rust
Verified

You tag v1.4.0 at the repository root, and the npm, PyPI, and crates.io jobs publish it within the hour. go get still resolves the previous release, because the Go port lives in go/ and Go reads a module in a subdirectory only from tags that start with go/. Nothing in the release fails, so nobody notices until a Go user asks why the fix is missing.

What you get

You will end up with one release configuration for four registries, and a check that confirms every artifact’s version, smoke test, and Go tag before anything publishes. This is for you if your ports ship together.

Short answer

Link every port to one version with the release-please linked-versions plugin, and set tag-separator to "/" on the Go package, because Go reads a module in go/ only from tags such as go/v1.4.0. Before anything publishes, build each artifact the way its registry receives it, ask it for its version in that registry’s spelling, and run its smoke test. A release candidate that npm calls 1.4.0-rc.1 is 1.4.0rc1 on PyPI.

You will need

A canonical library with ports in subdirectories of one repository, the toolchain for each port, and a release tool. Verified 2026-10-20 against Node 22.22.0, npm 10.9.4, Python 3.13.16, flit_core 4.1.0, packaging 26.3, Go 1.24.7, and Rust 1.97.0. The sample builds and smoke-tests every port locally. It does not run release-please, publish to a registry, or create a git tag; it computes each tag from release-please’s documented template.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
cargo-releaseThe Rust port, or a workspace of crates that share one versionRust only, and below the repository root its tags start with the crate name, one more tag shape for the train to trackThe train spans more than Rust, where cargo-release becomes one step inside another tool
changesetsEvery port is an npm package in one pnpm, yarn, or npm workspaceA port outside npm gets a version bump only through a private package.json of its own, and a script must copy that version into pyproject.toml, Cargo.toml, or a Go tagMost ports publish outside npm, where those scripts do more of the release than changesets does
GoReleaserThe Go port ships binaries as well as a libraryMonorepo tag prefixes are a GoReleaser Pro feature, and a library gains nothing the tag alone does not give itThe Go port is a library, where the tag is the whole release
release-pleaseOne repository whose node, python, go, and rust packages release togetherIts default tag puts a hyphen after the component, which Go cannot read, until tag-separator says otherwisePorts live in separate repositories, or each publishes on its own schedule
struct, released per portMany ports at different levels of polish, where waiting for the slowest would stall every releaseEach registry shows its own number, so a version tells users nothing about which canonical behavior a port hasUsers expect one version to mean one behavior in every language

Voxgig maintains struct. It is one of five options here, not the recommendation.

Lockstep, which release-please and changesets both offer, gives users one number to reason about and makes the slowest port your release manager. Skew, the way struct publishes, ships each port when it is ready and moves the parity question onto the user, who has only the version numbers to go on.1 cargo-release covers one ecosystem, and changesets reaches past npm only through a package.json per port, so a lockstep train across four registries needs release-please or scripts around either.2

release-please keeps each package’s version in a manifest, and the linked-versions plugin moves every component in a group to the same version whenever any of them changes. The Go package needs two settings the other three do not.

{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "packages": {
    "ts": { "release-type": "node", "component": "ts" },
    "py": { "release-type": "python", "component": "py" },
    "go": {
      "release-type": "go",
      "component": "go",
      "tag-separator": "/",
      "extra-files": ["version.go"]
    },
    "rust": { "release-type": "rust", "component": "rust" }
  },
  "plugins": [
    { "type": "linked-versions", "groupName": "acme-merge", "components": ["ts", "py", "go", "rust"] }
  ]
}

tag-separator turns go-v1.4.0 into go/v1.4.0, the form the Go modules reference requires for a module defined in a subdirectory. The configuration schema is where release-please documents the option. extra-files is there because the go strategy updates only a changelog. A Go version lives in its tag, so a Version constant moves only when its file is listed and the line carries an x-release-please-version annotation, as the customizing guide describes.

Check every artifact before anything publishes

Build each port the way its registry receives it, ask the artifact for its version, and run its smoke test from there. The wheel is the case that teaches the most, because the version PyPI records is the one inside the wheel, not the one in pyproject.toml.

  py: {
    registry: 'PyPI',
    smoke(tmp) {
      const build = 'import sys, flit_core.buildapi as b; print(b.build_wheel(sys.argv[1]))'
      const wheel = run('python3', ['-c', build, tmp], {
        cwd: join(REPO, 'py'),
        env: { PYTHONPATH: PYDEPS },
      })
      const site = join(tmp, 'site')
      const unzip = 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])'
      run('python3', ['-c', unzip, join(tmp, wheel), site])
      return run('python3', ['-m', 'acme_merge'], { env: { PYTHONPATH: site } })
    },
  },

The npm port runs its smoke script from the unpacked npm pack tarball. The Go and Rust ports run theirs from source, which is what the Go proxy and crates.io serve.

node train.mjs
release train 1.4.0-rc.1, config repo/release-please-config.json
port  registry    reports      smoke  tag
ts    npm         1.4.0-rc.1   ok     ts-v1.4.0-rc.1
py    PyPI        1.4.0rc1     ok     py-v1.4.0-rc.1
go    Go modules  v1.4.0-rc.1  ok     go/v1.4.0-rc.1
rust  crates.io   1.4.0-rc.1   ok     rust-v1.4.0-rc.1
example.com/acme/merge/go@v1.4.0-rc.1 needs tag go/v1.4.0-rc.1
the config tags the go package go/v1.4.0-rc.1

All four artifacts report the same release in three spellings. PyPI’s 1.4.0rc1 is the PEP 440 normal form, which drops the hyphen and the dot.3 The check compares each artifact with its registry’s spelling of the train version, computed by packaging, rather than with one string.

Check it worked

Run the suite. It repeats the train check and pins two facts a train can get wrong while every version looks right: the PyPI spelling, and the Go tag with and without tag-separator.

test('release-please tags the Go port where Go looks for it', () => {
  const tag = tagFor(config('repo/release-please-config.json'), 'go', '1.4.0')
  assert.equal(tag, goTagFor('go', '1.4.0'))
})

test('without tag-separator, the Go tag is one Go never reads', () => {
  const tag = tagFor(config('pitfall/release-please-config.json'), 'go', '1.4.0')
  assert.equal(tag, 'go-v1.4.0')
  assert.notEqual(tag, goTagFor('go', '1.4.0'))
})
node --test train.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 802.218063

# pass 4 means every port reports the train version in its registry’s spelling and passes its smoke test, and the configuration tags the Go module where Go looks. After the release, confirm the tag exists with git tag --list 'go/v*'.

When it goes wrong

The release goes out everywhere but Go. The configuration has no tag-separator, so release-please tags the Go package with a hyphen, and Go never reads that tag. The same check, run against that configuration:

node train.mjs pitfall/release-please-config.json
release train 1.4.0-rc.1, config pitfall/release-please-config.json
port  registry    reports      smoke  tag
ts    npm         1.4.0-rc.1   ok     ts-v1.4.0-rc.1
py    PyPI        1.4.0rc1     ok     py-v1.4.0-rc.1
go    Go modules  v1.4.0-rc.1  ok     go-v1.4.0-rc.1
rust  crates.io   1.4.0-rc.1   ok     rust-v1.4.0-rc.1
example.com/acme/merge/go@v1.4.0-rc.1 needs tag go/v1.4.0-rc.1
the config tags the go package go-v1.4.0-rc.1, which Go never reads

Every version and every smoke test passes, which is why a train that checks only versions ships without Go. Add "tag-separator": "/" to the Go package. A plain v1.4.0 at the repository root fails the same way. Go reads it as a version of a module at the root path, and when the root has no go.mod, Go synthesizes one that holds only a module line.

A check that compares version strings fails on every release candidate. npm and crates.io keep 1.4.0-rc.1, while PyPI stores 1.4.0rc1, so a string comparison fails one registry in four. Compare each artifact with its registry’s spelling, as spellings in train.mjs does.

The Go smoke test reports the previous version. The go strategy updated the changelog and left version.go alone, because the file was missing from extra-files or its line had no x-release-please-version annotation. Add both, and rerun the check.

When not to do this

Do not hold every port in lockstep when the ports differ in maturity. A second-tier port whose smoke test fails stops the release for every user of the first-tier ports. Release with skew instead, and have each port state which canonical version it implements. struct releases with skew, but its README states no such mapping, so its registry numbers say nothing about which canonical behavior a port has.

Do not tag a Go module in a subdirectory from the repository root alone. A root tag versions a module at the root path, and the port in go/ stays on its previous release until a go/ tag exists.

Do not read a passing train check as parity between ports. It proves that every artifact carries the train version and passes a smoke test. Whether the ports behave the same is the job of a shared test corpus, run against every port before the train leaves.

Do not publish from the check itself. Keep it a gate in front of the release job, so a failure stops the train before any registry holds a version that cannot be replaced.

Last verified

Verified 2026-10-20 against Node 22.22.0, npm 10.9.4, Python 3.13.16, flit_core 4.1.0, packaging 26.3, Go 1.24.7, and Rust 1.97.0. Every output block is what the command preceding it printed. release-please, changesets, cargo-release, and GoReleaser were read from their documentation, not run: the tags come from release-please’s documented template, and nothing was published or tagged. The struct versions in the first note were read from each registry on the day.

Footnotes

  1. On the day this page was verified, struct’s registries held five versions. The npm registry’s latest was 0.3.7, and crates.io listed 0.1.7. The Go port’s module stood at v0.1.6, released from the tag go/v0.1.6 as the rule requires. PyPI held 0.1.0, and LuaRocks 0.1.0-1, the final 1 being the revision of the .rockspec file itself. The repository’s root tags reach the Go proxy as well, which lists eighteen versions of github.com/voxgig/struct, a module path with no go.mod behind it. For each, the proxy serves a go.mod one line long. ↩︎ Back to text

  2. cargo-release’s reference describes its --execute flag as the one that actually performs a release, and adds that dry-run mode is the default. A command named release therefore describes a release unless it is told, by a second word, to carry one out. ↩︎ Back to text

  3. Semantic Versioning marks a pre-release with a hyphen and dot-separated identifiers, so 1.4.0-rc.1. PEP 440 accepts both separators and makes its normal form drop them, so 1.4.0rc1. The Go modules reference adds a third spelling by requiring every version to start with the letter v. The FAQ at the end of the Semantic Versioning specification, asked whether v1.2.3 is a semantic version, answers no: it is a tag name for one. ↩︎ 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.