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

Source: https://voxgig.com/howto/release-every-port-on-one-version

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, go, python, rust
- Verified: 2026-10-20
- Published: 2026-10-20

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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [cargo-release](https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md) | The Rust port, or a workspace of crates that share one version | Rust only, and below the repository root its tags start with the crate name, one more tag shape for the train to track | The train spans more than Rust, where cargo-release becomes one step inside another tool |
| [changesets](https://changesets.dev/guide/fixed-packages) | Every port is an npm package in one pnpm, yarn, or npm workspace | A port outside npm gets a [version bump](https://changesets.dev/guide/beyond-npm) only through a private `package.json` of its own, and a script must copy that version into `pyproject.toml`, `Cargo.toml`, or a Go tag | Most ports publish outside npm, where those scripts do more of the release than changesets does |
| [GoReleaser](https://goreleaser.com/customization/monorepo/) | The Go port ships binaries as well as a library | Monorepo tag prefixes are a GoReleaser Pro feature, and a library gains nothing the tag alone does not give it | The Go port is a library, where the tag is the whole release |
| [release-please](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md) | One repository whose node, python, go, and rust packages release together | Its default tag puts a hyphen after the component, which Go cannot read, until `tag-separator` says otherwise | Ports live in separate repositories, or each publishes on its own schedule |
| [struct, released per port](https://github.com/voxgig/struct) | Many ports at different levels of polish, where waiting for the slowest would stall every release | Each registry shows its own number, so a version tells users nothing about which canonical behavior a port has | Users 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]

## Link the ports and spell out the Go tag

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.

```json title="repo/release-please-config.json"
{
  "$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](https://go.dev/ref/mod#vcs-version) requires for a module defined in a
subdirectory. The [configuration schema](https://github.com/googleapis/release-please/blob/main/schemas/config.json)
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](https://github.com/googleapis/release-please/blob/main/docs/customizing.md)
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`.

```js title="train.mjs"
  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.

```bash
node train.mjs
```

```text output
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](https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-release-separators)
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`.

```js title="train.test.mjs"
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'))
})
```

```bash
node --test train.test.mjs
```

```text output
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:

```bash
node train.mjs pitfall/release-please-config.json
```

```text output
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](https://go.dev/ref/mod#non-module-compat) 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.

## Related how-tos

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

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

- [Design the case shape of a language-neutral test corpus](/howto/design-the-case-shape-of-a-test-corpus)

- [Choose between porting a library and reimplementing it](/howto/choose-between-a-port-and-a-reimplementation)

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

[^1]: On the day this page was verified, struct's registries held five versions. The npm
registry's [latest](https://registry.npmjs.org/@voxgig/struct/latest) was 0.3.7, and
[crates.io](https://crates.io/crates/voxgig-struct) listed 0.1.7. The Go port's
[module](https://pkg.go.dev/github.com/voxgig/struct/go) stood at v0.1.6, released from the tag
go/v0.1.6 as the rule requires. [PyPI](https://pypi.org/project/voxgig-struct/) held 0.1.0, and
[LuaRocks](https://luarocks.org/modules/rjrodger/voxgig-struct) 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](https://proxy.golang.org/github.com/voxgig/struct/@v/list) 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.

[^2]: cargo-release's [reference](https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md)
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.

[^3]: [Semantic Versioning](https://semver.org/#semantic-versioning-specification-semver) 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](https://go.dev/ref/mod#versions) 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.