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:
| Ecosystem | Name | Version | Installed with |
|---|---|---|---|
| PyPI | openregs-data-<regime> | 2025.4 | pip install openregs-data-fixreg |
| npm | @openregs/data-<regime> | 2025.4.0 | npm 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.
What is inside, and what is not
Section titled “What is inside, and what is not”<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 proofsgraph.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 Pathcorpus.LICENSES # {'code': 'Apache-2.0', 'data': 'CC-BY-4.0'}corpus.signature_manifest_path() # what was signed, with whatconst corpus = require('@openregs/data-fixreg');
corpus.releaseTag; // '2025.04'corpus.corpusPath; // absolute path to corpus.sqlitecorpus.licenses; // { code: 'Apache-2.0', data: 'CC-BY-4.0' }corpus.signatureManifestPath; // what was signed, with whatThe two modes
Section titled “The two modes”--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 tarballNothing is uploaded, and the report says exactly what a live run would have run. The PyPI half installs with no server at all:
pip install --index-url file://$PWD/dist/packages/registries/pypi/simple \ openregs-data-fixregnpm 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.
What publishing refuses
Section titled “What publishing refuses”- 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
--outinside 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.
Determinism
Section titled “Determinism”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:
if [ "$OWNER" = "openregs" ]; then PUBLISH_MODE=live; else PUBLISH_MODE=dry-run; fiopenregs release publish --release "$TAG" --path dist/release \ --mode "$PUBLISH_MODE" --out dist/packagesThe 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.
Licence identifiers
Section titled “Licence identifiers”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.0so 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.
Cutting the documentation version
Section titled “Cutting the documentation version”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:
| Version | Pin lives in | Moves? | Served at |
|---|---|---|---|
latest | core-ref.json | follows this repository’s main, moved by a scheduled pull request | / |
| a frozen version | its entry in versions.json | never 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.
The procedure
Section titled “The procedure”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.
-
Take the commit the release was built from. It is the one the tag points at, and it is also the
commitfield of the release’s ownrelease-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 -
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
--commitfreezes whateverlatestis 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. -
Build every version and check them.
Terminal window $ pnpm checkThat builds
latestintodist/and each frozen version intodist/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 fromversions.json; neither is a file anybody edits. -
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.
What this procedure does not do yet
Section titled “What this procedure does not do yet”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.ymlbuilds, 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