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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| cargo-release | 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 | Every port is an npm package in one pnpm, yarn, or npm workspace | A 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 tag | Most ports publish outside npm, where those scripts do more of the release than changesets does |
| GoReleaser | 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 | 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 | 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.
{
"$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.
Related how-tos
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
-
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
.rockspecfile 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 nogo.modbehind it. For each, the proxy serves ago.modone line long. ↩︎ Back to text -
cargo-release’s reference describes its
--executeflag 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 -
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, so1.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 whetherv1.2.3is a semantic version, answers no: it is a tag name for one. ↩︎ Back to text