Skip to content

Runbook: publishing a release as a Python and an npm package

openregs pull is how somebody who knows about OpenRegs gets a release. Package publishing is how somebody who does not gets one: a bank’s build already resolves dependencies from PyPI and npm, and a corpus that arrives that way needs no new tool, no new credential and no new step in a pipeline nobody wants to change.

openregs release publish --release <regime>@<tag> [--path <release-dir>] \
--mode <dry-run|live> --out <dir>

Two packages come out of one release:

EcosystemNameVersionInstalled with
PyPIopenregs-data-<regime>2025.4pip install openregs-data-fixreg
npm@openregs/data-<regime>2025.4.0npm install @openregs/data-fixreg

Neither version is the release tag, and that is deliberate. 2025.04 is a date. PEP 440 normalises it to 2025.4; semver refuses a leading zero in a component, so npm gets 2025.4.0. Both packages therefore carry the tag verbatim as release_tag (Python) and releaseTag (JavaScript), and that is the string to print, pin and cite. A consumer who needs the tag reads it from the package; one who needs a range writes ^2025.4.0.

<package>/
__init__.py | index.js + index.d.ts the exports below
manifest.json the same facts, machine-readable, in both
data/corpus.sqlite the bundle every question is answered from
data/release-meta.yaml verbatim — commit, schema version, digests
data/attestation/… the signature manifest, the signer's public
key, the provenance statement, the log proofs

graph.idx, embeddings.parquet and constants-src/ are not in the package. The constants have their own channel — openregs compile writes them into the consumer’s build — and the index and the vectors are a serving concern an order of magnitude larger than the bundle and reconstructible from it. A package is the smallest thing a consumer can answer questions from and check the signature of.

import openregs_data_fixreg as corpus
corpus.release_tag # '2025.04'
corpus.corpus_path() # the SQLite bundle, as a Path
corpus.LICENSES # {'code': 'Apache-2.0', 'data': 'CC-BY-4.0'}
corpus.signature_manifest_path() # what was signed, with what
const corpus = require('@openregs/data-fixreg');
corpus.releaseTag; // '2025.04'
corpus.corpusPath; // absolute path to corpus.sqlite
corpus.licenses; // { code: 'Apache-2.0', data: 'CC-BY-4.0' }
corpus.signatureManifestPath; // what was signed, with what

--mode dry-run builds both packages and writes them into a registry laid out on the filesystem under --out:

<out>/openregs_data_fixreg-2025.4-py3-none-any.whl
<out>/openregs-data-fixreg-2025.4.0.tgz
<out>/publish-report.json
<out>/registries/pypi/{packages/…, simple/…} a PEP 503 index
<out>/registries/npm/@openregs/data-fixreg/… a packument and a tarball

Nothing is uploaded, and the report says exactly what a live run would have run. The PyPI half installs with no server at all:

Terminal window
pip install --index-url file://$PWD/dist/packages/registries/pypi/simple \
openregs-data-fixreg

npm has no filesystem equivalent — its client speaks HTTP to a registry — so the npm registry directory is served by anything that answers a packument request from it. tooling/tests/local_registry.py is that server, and it is what the test suite points npm install at. Both installs run offline; the suite proves it by pointing every proxy variable at a dead port first.

--mode live does all of the above and then uploads: uv publish for the wheel, npm publish for the tarball. Credentials come from the environment, by name — OPENREGS_PYPI_TOKEN and OPENREGS_NPM_TOKEN — and never appear on a command line: uv reads UV_PUBLISH_TOKEN, and the generated .npmrc writes ${OPENREGS_NPM_TOKEN}, which npm interpolates when it reads the file. A missing token fails the command, before either upload starts. It does not skip, warn, or fall back to a dry run: in a pipeline log, “the credential was absent” and “the upload succeeded” must never look the same.

  • An unsigned release. The package embeds the signature so a consumer who installed it from a registry can check the bytes that registry served. There is nothing to embed for a release nobody signed, so sign it first.
  • A release that does not verify. All five checks run — signature, trust, digests, provenance, log inclusion — before a byte of the release is read. A package is the widest distribution these bytes get.
  • An --out inside the release directory. Every file in a release is covered by a signature, and the check that says so also says the converse: a file not covered should not be there. Packages written into a release would leave it failing verification for every later reader. Write them beside it.

Publishing one release twice produces byte-identical archives. Archive formats record timestamps, ownership and member order; all three are pinned — every member’s mtime is the release’s own built_at (which is the build commit’s committer date), ownership is root:root with no names, members are written sorted, and the gzip header carries the same epoch rather than the wall clock. So a package can be rebuilt from a tag and compared with the one the registry served.

release.yml’s fourth stage, after build, sign and eval. It downloads the release and the attestation the sign stage uploaded, then:

Terminal window
if [ "$OWNER" = "openregs" ]; then PUBLISH_MODE=live; else PUBLISH_MODE=dry-run; fi
openregs release publish --release "$TAG" --path dist/release \
--mode "$PUBLISH_MODE" --out dist/packages

The mode is derived from the repository owner rather than from whether a secret happens to be set, so a fork runs the same command and produces the same packages without ever attempting an upload — and a canonical run whose secret went missing goes red instead of quietly publishing nothing. Because publish needs eval, a release that regressed the retrieval gate has no path to a package index.

Both packages declare both licences: the code under Apache-2.0, the corpus under CC-BY-4.0. The wheel carries the SPDX expression in its License: metadata field, package.json carries it in license, and both carry the split ({"code": …, "data": …}) in manifest.json and in their exports, because the expression cannot say which licence covers which half.

The identifiers are read from release-meta.yaml’s licenses block, which every release built since spec 1.2.0 declares:

licenses:
code: Apache-2.0
data: CC-BY-4.0

so what reaches the registries is the release’s own statement about itself rather than the publishing machine’s opinion. A release cut before that block existed has none, and packages.DEFAULT_LICENSES — the same split, read from openregs.governance.licensing.RELEASE_LICENSES — is what it publishes instead: a package with no licence is one a consumer’s legal review rejects, and silence is not a safer answer than the licence the repository is under. See README.md’s Licensing section and NOTICE.

Publishing a release puts its bytes where consumers can install them. Cutting a documentation version puts the documentation for those bytes where they can read it, permanently — “the docs for what I am running” has to go on existing after latest has moved on.

The documentation site is a separate repository, openregs/docs. It holds no copy of this content: it reads docs/ out of this repository at a pinned commit and renders it. There are two kinds of pin, and freezing is the whole difference between them:

VersionPin lives inMoves?Served at
latestcore-ref.jsonfollows this repository’s main, moved by a scheduled pull request/
a frozen versionits entry in versions.jsonnever again/v/<id>/

A documentation version is therefore a commit of this repository that somebody promised to go on rendering. Cutting one is recording that commit.

Do this once the release tag is pushed and its commit is on main. The site fetches the pinned commit from GitHub by sha, so a commit that is not there yet is a build that cannot run.

  1. Take the commit the release was built from. It is the one the tag points at, and it is also the commit field of the release’s own release-meta.yaml — two independent readings of one fact, which is worth using as a check rather than picking either alone.

    Terminal window
    $ git rev-parse 'fixreg@2025.04^{commit}'
    $ grep '^commit:' regimes/fixreg/releases/2025.04/release-meta.yaml
  2. In a checkout of openregs/docs, on a branch, cut it. The id becomes a URL path segment, so it is the date part of the tag and never the tag itself: @ is not usable there and the command refuses it.

    Terminal window
    $ pnpm cut-version --id 2025.04 --commit <sha> --label "2025.04"

    Omitting --commit freezes whatever latest is pinned to at that moment, which is correct only if the pin has already caught up to the release commit. Passing it explicitly is the version that cannot be quietly wrong.

  3. Build every version and check them.

    Terminal window
    $ pnpm check

    That builds latest into dist/ and each frozen version into dist/v/<id>/, runs the orphan check against each one as it is built, and checks routes and internal links over the finished tree. The banner marking the version frozen and its entry in the version switcher are both generated from versions.json; neither is a file anybody edits.

  4. Open the pull request. The cut is a one-file diff, and that is the point: a frozen version is a promise that a URL keeps answering, so it should be reviewable as the one decision it is.

Stated plainly, because a runbook that oversells its own machinery is worse than one that says where the seams are.

  • Nothing ties a documentation version to a release tag. A frozen entry records the core commit, a label and the date it was cut. It does not record fixreg@2025.04, and nothing checks that the commit it froze is the commit that tag points at. Steps 1 and 2 are joined by the operator, not by a program.
  • Nothing fires when a release tag is pushed. release.yml builds, signs, evaluates, publishes and attaches; it asks the documentation repository for nothing. Cutting is a human step taken in another repository afterwards.
  • There is nowhere to publish it to yet. That repository’s CI builds every version and uploads the tree as an artifact. The hosting provider has not been chosen, so “deploy” is not a step this procedure can honestly name.
  • It has not been run against a real release. This repository has pushed no release tag at all, so the only frozen version that exists is a snapshot cut to exercise the machinery end to end. It is meant to be deleted by the same change that cuts the first real one.

Rendered from openregs/openregs@cbe9615:docs/runbooks/release-publish.md