feat(release): publish to crates.io, npm and PyPI from the tag #328

Merged
jqueuniet merged 2 commits from feat/publish-registries into main 2026-09-05 10:23:38 +02:00
Owner

Cutting a release meant three manual command sets after the tag, each with its
own trap: cargo publish --workspace, a wasm-pack build then npm publish, and
a twine upload that fails outright unless twine is newer than the one in
nixpkgs. Three jobs now do it.

Shape

build ─┐
       ├→ publish (Forgejo release) ─┬→ publish-crates
wheel-linux ─┘                       ├→ publish-npm
                                     └→ publish-pypi

Each registry job is gated on the tag and on publish, so nothing is published
unless every build succeeded and the reversible step went out first. Each probes
its registry and skips a version already present, so a half-failed run can be
re-run rather than wedging.

PyPI receives the same wheels the release carries — what lands on the index is
byte-identical to the release asset. npm is built by the same build-npm.sh
used locally, which is also the first time CI compiles the crate for wasm32
rather than type-checking it.

Secrets required before the next tag

secret registry scope
CARGO_REGISTRY_TOKEN crates.io the 11 crates, publish-update only
NPM_TOKEN npm granular, automation type, dm2-tools only
PYPI_TOKEN PyPI project-scoped to dm2-tools

Long-lived tokens are the only option. All three registries support OIDC trusted
publishing and none accepts a self-hosted Forgejo — crates.io and npm allow
GitHub and GitLab, PyPI adds Google and ActiveState. So a tag push now hands
three publish-capable credentials to job containers and publishes to three
registries that never allow a version to be replaced. Tag protection on v* is
the natural counterpart.

Found by testing rather than assuming

  • crates.io answers 403 without a User-Agent. My first guard read that as
    "not published" and would have triggered a doomed re-publish on every run. With
    a UA it behaves: dm2-ffi/0.2.1 → 200, dm2-ffi/9.9.9 → 404.
  • uv publish, not twine. twine 6.x rejects the Metadata-Version: 2.5
    hatchling emits, because the packaging it vendors predates that spec version.
    PyPI accepts 2.5 — it hosts hatchling's own wheel at that version. uv is
    already pinned and hash-checked in this workflow.
  • The npm guard was verified in rust:1-trixie (dm2-tools@0.2.1 → found,
    @9.9.9 → absent, unknown package → absent), since npm cannot reach its cache
    in my sandbox.
  • Every run: block in the workflow was syntax-checked with bash -n.

Known limits

  • A partial crates.io failure still needs a hand: the probe checks dm2-ffi,
    the last crate in dependency order. Cargo verifies every crate before uploading
    any, so this should be rare.
  • wasm-pack downloads wasm-opt itself at build time — the one unpinned fetch in
    the release path.
  • I could not verify that this instance exposes custom secrets to job
    containers; the jobs will fail clearly if not.
Cutting a release meant three manual command sets after the tag, each with its own trap: `cargo publish --workspace`, a wasm-pack build then `npm publish`, and a twine upload that fails outright unless twine is newer than the one in nixpkgs. Three jobs now do it. ## Shape ``` build ─┐ ├→ publish (Forgejo release) ─┬→ publish-crates wheel-linux ─┘ ├→ publish-npm └→ publish-pypi ``` Each registry job is gated on the tag and on `publish`, so nothing is published unless every build succeeded and the reversible step went out first. Each probes its registry and skips a version already present, so a half-failed run can be re-run rather than wedging. PyPI receives the same wheels the release carries — what lands on the index is byte-identical to the release asset. npm is built by the same `build-npm.sh` used locally, which is also the first time CI compiles the crate for wasm32 rather than type-checking it. ## Secrets required before the next tag | secret | registry | scope | | --- | --- | --- | | `CARGO_REGISTRY_TOKEN` | crates.io | the 11 crates, `publish-update` only | | `NPM_TOKEN` | npm | granular, automation type, `dm2-tools` only | | `PYPI_TOKEN` | PyPI | project-scoped to `dm2-tools` | Long-lived tokens are the only option. All three registries support OIDC trusted publishing and **none accepts a self-hosted Forgejo** — crates.io and npm allow GitHub and GitLab, PyPI adds Google and ActiveState. So a tag push now hands three publish-capable credentials to job containers and publishes to three registries that never allow a version to be replaced. Tag protection on `v*` is the natural counterpart. ## Found by testing rather than assuming - **crates.io answers 403 without a `User-Agent`.** My first guard read that as "not published" and would have triggered a doomed re-publish on every run. With a UA it behaves: `dm2-ffi/0.2.1 → 200`, `dm2-ffi/9.9.9 → 404`. - **`uv publish`, not twine.** twine 6.x rejects the `Metadata-Version: 2.5` hatchling emits, because the `packaging` it vendors predates that spec version. PyPI accepts 2.5 — it hosts hatchling's own wheel at that version. uv is already pinned and hash-checked in this workflow. - The npm guard was verified in `rust:1-trixie` (`dm2-tools@0.2.1 → found`, `@9.9.9 → absent`, unknown package → absent), since npm cannot reach its cache in my sandbox. - Every `run:` block in the workflow was syntax-checked with `bash -n`. ## Known limits - A *partial* crates.io failure still needs a hand: the probe checks `dm2-ffi`, the last crate in dependency order. Cargo verifies every crate before uploading any, so this should be rare. - wasm-pack downloads `wasm-opt` itself at build time — the one unpinned fetch in the release path. - I could not verify that this instance exposes custom secrets to job containers; the jobs will fail clearly if not.
feat(release): publish to crates.io, npm and PyPI from the tag
All checks were successful
CI / msrv (1.90) (pull_request) Successful in 41s
CI / audit (pull_request) Successful in 18s
CI / stable (pull_request) Successful in 1m45s
CI / python bindings (3.11) (pull_request) Successful in 46s
5f0eb74418
Cutting a release was three manual command sets after the tag, each with its
own trap: `cargo publish --workspace`, a wasm-pack build followed by
`npm publish`, and a twine upload that fails outright unless twine is newer
than the one in nixpkgs. Three jobs now do it.

Each is gated on the tag and on `publish`, so nothing reaches a registry
unless every build succeeded and the Forgejo release — the only reversible
step — went out first. Each probes its registry and skips a version already
there, making a half-failed run re-runnable. PyPI gets the same wheels the
release carries, so what is on the index is byte-identical to the asset; npm
is built by the same `build-npm.sh` used locally, and that build is also the
first time CI compiles the crate for wasm32 rather than type-checking it.

Long-lived tokens are the only option. All three registries support OIDC
trusted publishing and none accepts a self-hosted Forgejo: crates.io and npm
allow GitHub and GitLab, PyPI adds Google and ActiveState. So a tag push now
hands three publish-capable credentials to job containers and publishes to
three registries that never allow a version to be replaced. Tag protection on
`v*` is the natural counterpart.

Two details found by testing the guards rather than assuming them: crates.io
answers 403 without a `User-Agent`, which would have read as "not published"
and triggered a doomed re-publish, and the upload uses `uv publish` because
twine 6.x rejects the `Metadata-Version: 2.5` hatchling emits while PyPI
accepts it.
docs(release): flag the 90-day npm token expiry
All checks were successful
CI / msrv (1.90) (pull_request) Successful in 40s
CI / audit (pull_request) Successful in 20s
CI / stable (pull_request) Successful in 1m42s
CI / python bindings (3.11) (pull_request) Successful in 45s
279523ea29
npm caps granular token lifetime at 90 days, which is shorter than the gap
between releases here, so NPM_TOKEN will usually be stale when it is needed.
Note it in the release steps as a pre-tag check, and record the failure mode:
publish-npm fails alone, the release and the other two registries are
unaffected, and re-running after rotating the secret is enough.
jqueuniet deleted branch feat/publish-registries 2026-09-05 10:23:38 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jqueuniet/dm2-tools!328
No description provided.