Skip to content

Runbook: publishing a release — where it is served from, and as what

openregs pull is how somebody who knows about OpenRegs gets a release. Package publishing is how somebody who does not gets one: a consumer’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.

Two channels, then, and this runbook covers both: the distribution channel pull fetches from, and the two packages.


Decided: GitHub Releases, in the repository that owns the regime. A release of eu@2026.01 is served from openregs/regime-eu; fixreg@2025.04 from this repository. openregs pull reads it directly:

Terminal window
openregs pull eu@2026.01 --from https://github.com/openregs/regime-eu --into ./vendor
  1. The tag is already the identity. A GitHub release is keyed by its tag, and the tag is what the pipeline is triggered by, so the artifacts and the commit they were built from cannot end up under different coordinates. (The release object itself is created by the pipeline — pushing a tag creates a tag, not a release — but it is created for that tag, and the tag ruleset forbids moving it afterwards.) An object store has its own namespace, which is one more thing that can be right about a release the tag is wrong about, or the reverse.
  2. It needs no credential that does not already exist. release.yml’s attach job holds contents: write — the only write permission in any of these pipelines — and its token is scoped to the run. An object store means a long-lived access key in repository secrets — precisely the shape of secret the signing path removed when it went keyless (release-sign.md). Putting one back on the distribution path would give back half of what that bought.
  3. A stranger can fetch it with no account. Release assets are anonymous, TLS, CDN-backed and unmetered. “Verify and run a release from public artifacts alone” then needs a URL and nothing else — no sign-up, no key, no client beyond openregs.
  4. It has the failure modes a single maintainer can carry. An object store is a bill, a lifecycle policy, an availability promise and a bucket that can be misconfigured into public-write. A GitHub release is none of those, and its immutability is already covered: the tag ruleset forbids moving or deleting a release tag, so the name an asset hangs off cannot be repointed.

It was the better option on one axis and the loser on the rest. An object store serves a tree, so the remote layout would have been the on-disk layout with a hostname in front and the flattening below would not exist. That is a real simplification and it was not enough to outweigh (2) and (4): a long-lived credential in CI, plus a bill, a DNS record and an availability surface, bought for a tidiness no consumer can observe. What a consumer observes is whether the URL answers.

Revisit it if any of these becomes true, and not before: an artifact outgrows GitHub’s per-asset limit; downloads are throttled in practice; or a regime’s releases need to outlive that regime’s GitHub repository. The switch is cheap by construction — nothing in the transport is GitHub-specific except one shorthand in normalize_base, and every other host is already served by pointing --from at it.

A release is a file tree. GitHub release assets are a flat namespace whose names go straight into the download URL. So the layout is a flattening, and it is the whole of what had to be defined:

<base>/<regime>@<tag>/<asset-name>

where <base> is https://github.com/<owner>/<repo>/releases/download — passing the plain repository URL is enough, pull expands it — and <asset-name> is the release-relative path with its segments joined by __:

In the release treeAs a release asset
corpus.sqlitecorpus.sqlite
attestation/signature-manifest.yamlattestation__signature-manifest.yaml
attestation/sigs/corpus.sqlite.sigattestation__sigs__corpus.sqlite.sig
constants-src/python/fixreg_constants.pyconstants-src__python__fixreg_constants.py

tooling/openregs/release/distribution.py is the single definition — the publisher and pull both compute names from it, so they cannot drift into disagreeing. Two rules make the mapping reversible rather than merely plausible, and both are enforced by refusing, never by repairing:

  • every path segment must match [A-Za-z0-9][A-Za-z0-9._-]*, so no name needs percent-encoding to survive a URL;
  • no segment may itself contain __, because then two different paths would answer to one asset name.

A release holding such a path cannot be published at all — the layout refuses the whole release before a byte is written, since a half-published release is worse than an unpublished one. Rename the file.

Terminal window
openregs release assets --release <regime>@<tag> --out <dir>

writes <dir>/<regime>@<tag>/ with every file flattened into one asset name. That one directory is both halves of the channel: upload its contents to a GitHub release, or serve <dir> over https and it is a registry — --from names <dir>, and the release resolves to the <regime>@<tag>/ inside it.

The release’s five checks run before a byte is copied, for the reason publish verifies too: a mirror is as wide a distribution as a package, and bytes that do not verify here fail for every consumer who later fetches them with nothing pointing back at the machine that served them. Nothing is uploaded, no credential is read and no network is touched — moving a directory is something whoever is standing up a mirror already knows how to do.

This half is the release pipeline’s, not pull’s: release.yml’s attach stage, the only step in any pipeline here that writes to a forge.

It must not be gh release upload $TAG dist/release/*, and the trap is worth stating before the implementation is. An asset’s name is the uploaded file’s basename, so that command would publish the top-level files under their bare names and every nested file not at all — pull would then 404 on the whole attestation/ tree, which is to say on every file that proves the release is the release. The flat names have to exist as filenames before gh sees them, which is what the previous section’s command is for. So the stage is:

Terminal window
openregs release assets --release "$TAG" --path dist/release --out dist/mirror
gh release create "$TAG" --draft --verify-tag --title "$TAG" --notes-file …
gh release upload "$TAG" dist/mirror/"$TAG"/* --clobber
gh release edit "$TAG" --draft=false

attach owns the upload and nothing else. The layout is computed by the same module pull computes its URLs from, so what is uploaded and what is asked for cannot disagree; and assets runs the release’s five checks before it copies a byte, so a release that does not verify never reaches the forge at all.

It hangs off eval, not off publish. The eval gate governs both stages, so a regressed release reaches neither the registries nor this channel. But the two are otherwise independent: attach reads the same two artifacts publish reads — the release from build, the attestation from sign — and consumes nothing publish produces. Wiring it behind publish would have made a package index’s availability decide whether regulatory data could be distributed at all, and it would have put the irreversible stage (a spent version number) in front of the recoverable one (a draft that resumes). So a failed publish reddens the run and leaves the channel served; ci.md has the whole argument.

Draft first, published in one edit. A release is downloadable the instant it exists, so creating it and then uploading would leave a window in which pull fetches a release whose signed manifest names files that 404. A draft’s assets are not served at releases/download, so that window does not exist here: what a stranger can see goes from nothing to the whole verified set. The set is compared against what assets wrote — every name and every size — before the draft is published, and a mismatch leaves it a draft.

--verify-tag so a release is never created for a tag that does not exist. A release invented for a ref nobody pushed is one nothing can be rebuilt from: the tag is the identity and the commit comes from the release the repository holds under it.

Re-running is safe, and the three cases are decided rather than left to the API.

On a re-run the tag’s release isWhat attach does
absentcreates the draft, uploads, publishes
a draft an earlier run leftresumes it — the assets were never public, so the upload clobbers — then publishes
published with exactly these assetsnothing, and exits 0. gh run rerun re-runs the whole pipeline, and an ordinary operation must not look like a problem
published with anything elserefuses. Those bytes are public, may already have been fetched, and the tag cannot be re-cut. Publish the correction as a new release version — release-tag.md

Two more refusals, both because the alternative is a quiet wrong answer: an empty GH_TOKEN fails before the first call rather than skipping the upload, and a gh release view that failed for any reason other than “release not found” is not read as “there is no release” — a forge that is down would otherwise have the stage create a second release for a tag that already has one.

It is not gated on the repository owner, and publish is. The asymmetry is deliberate. PyPI and npm are one global namespace shared with this project, reached with a long-lived credential, so a fork that uploaded would be writing under the canonical project’s name. A GitHub release is written to the repository the run is already in, with a token scoped to that run: a fork’s assets live at github.com/<that fork>/…/releases/download and are reached by --from naming that fork. There is nothing to squat and nothing to leak, and mirroring your own signed releases is a thing this channel exists to let anybody do.

None of this is exercised against GitHub. tooling/tests/test_release_attach.py runs the stage for real — the workflow harness executes its run: steps as shells — against a gh on PATH that keeps its releases in a directory, and ends by serving what was attached over loopback and pulling it back with openregs pull. A tag is never pushed: refs/tags/*@* carries deletion with no bypass actors, so a probe tag could not be taken back.

A mirror is anything that answers those URLs. Two forms, both first-class:

  • A tree on a filesystem — --from /srv/registry, laid out <root>/<regime>/<tag>/<release-relative-path>. This is what a shared volume, an air-gapped copy and fixtures/registry all are.
  • A static host — --from https://mirror.example/openregs, serving what openregs release assets --out <dir> wrote. Standing one up is that command and a web server; nothing else is involved, and no part of it is reserved to whoever operates the canonical channel.

Both are verified identically, and identically to GitHub. A mirror earns no trust by being a mirror — see Trust roots below.

The five checks answer “are these bytes a genuine release?”. They do not answer “are these bytes that release”, and cannot: a release built, signed and logged under one tag satisfies every one of them when it is served under another, because it really is a release. And a mirror is a directory tree its operator lays out, so which release sits at <regime>@<tag> is entirely theirs to decide.

Concretely: serving FIXREG’s pre-amendment 2025.02 corpus at the 2025.04 URLs hands a consumer the EUR 5000 threshold when the law says 10000 — signed, logged, provenance intact, five of five. The wrong law with a clean bill of health.

So pull binds the download to the request, on the one file it reads first:

  • the pin is checked against the release’s own statement of it. release-meta.yaml names the regime and tag, the signature covers that statement, and a release that will not say which release it is counts as a mismatch rather than a pass. Nothing is staged;

  • --expect <sha256> names the bytes. A tag is a name, and on a mirror it means whatever that mirror’s operator decides; the sha256 of release-meta.yaml is the release. Every pull prints it —

    $ openregs pull fixreg@2025.04 --from https://github.com/openregs/openregs --into ./vendor
    identity release-meta.yaml sha256 85f82d57…

    so pulling once gives you the value to pin on every pull after it. It is the same value an answer carries as corpus.release_meta_sha256, which is what lets somebody holding an answer fetch the exact release it was read out of rather than whatever the tag points at today.

Both are settled before a second file is requested, so a mirror holding something else is refused at the cost of one small file and never gets to spend your disk.

Everything a local pull refuses, plus the four a filesystem never has to think about. None has a flag.

RefusedWhy
cleartext http to anything but loopbackwhich regulations you pull is metadata about your compliance posture, and anyone on the path can fail a pull at will. Loopback is exempt: a sidecar mirror has no hop to wiretap
a redirect that changes scheme, or leaves http(s)every hop is re-checked, not just the first. GitHub’s own redirect to its object store is followed, which is why redirects are not refused outright
a redirect to loopback, when --from was remotethe cleartext exemption belongs to the registry you named. Pointing --from at a sidecar is a deployment; a registry on the internet answering http://127.0.0.1:… is a stranger steering the pull at a service only your machine can reach
a --from URL carrying a username or passwordrelease assets are public and this client authenticates to nothing, so userinfo is either a mistake or a secret about to be printed in a log line
more than 2 GiB in one file, or 8 GiB in one releasea registry is untrusted until the digests say otherwise, including about how much disk it may cost. Raising these is a code change, deliberately
a 404 on any file the signed manifest namesthe file list comes from attestation/signature-manifest.yaml, never from a listing the registry composes, so a registry that omits a file fails rather than yielding a smaller release
anything the five checks rejectthe staging directory is deleted and the target is left exactly as it was

A redirect may change host, and that is not the same rule ingest uses. A GitHub asset URL answers 302 with a Location on objects.githubusercontent.com, so a client that pinned a redirect to the host it started on could not use this channel at all. It does not need to: a release is checked against its own signed manifest, the trust roster and the transparency log before it is used, so which host handed the bytes over is not part of the claim that they are the release. The ingest fetcher is pinned to one host precisely because the opposite holds there — the bytes it fetches are the evidence, their sha256 becomes the provenance root, and no later check would notice a different publisher.

So TLS here is not what makes a pulled release authentic; the five checks are. What it is load-bearing for is the other two properties — that a pull does not announce which regulations you read, and that nobody on the path can fail one at will — together with the refusals above, which are what stop a registry that has not been verified yet from spending your disk or steering you at your own loopback before the first digest is computed.

The order is the same over the wire as on disk — stage, verify, then one atomic rename — because the transport is a read(regime, tag, relative) -> bytes seam underneath the staging step. There is no remote code path that could treat a release more kindly than the local one, and there is still no --skip-verify.

Under a keyless posture the pull also fetches attestation/keyless.sigstore.json, which the signature manifest cannot name because it is the thing that signs the manifest. Whether to fetch it is read from config/trust.yaml, never from the release: a release that could declare its own posture could declare itself keyed and be checked against a key it supplied.

Trust roots are unaffected by this decision

Section titled “Trust roots are unaffected by this decision”

config/trust.yaml needs no change, and that is a property of the design rather than an omission. The roster names an identity — https://token.actions.githubusercontent.com and …/openregs/openregs/.github/workflows/release.yml@refs/tags/* — not a location. Who signed is a fact about the run that produced the bytes; where those bytes were later copied to is not a fact about anything. So moving the channel, adding a mirror, or serving the same release from three hosts changes nothing a verifier checks, and no host can become trusted by hosting. The roster changes when the signer changes: release-sign.md and key-rotation.md, not this file.

Getting an anchor onto a machine that is not a checkout

Section titled “Getting an anchor onto a machine that is not a checkout”

The channel serves releases to consumers who have installed the CLI and nothing else, and an installed CLI carries no roster. That is deliberate at both ends: the wheel ships tooling/openregs and no configuration ([tool.hatch.build.targets.wheel]), and the standalone binaries bundle spec/ and build-info.json while leaving canon, releases and config/trust.yaml out (openregs.standalone, What travels inside the binary). Outside a checkout there is therefore nothing to anchor against, and the pull says so rather than passing:

$ cd /srv/app && openregs pull fixreg@2025.04 --from <registry> --into ./vendor
pull: no trust anchor: no trust roster at /srv/app/config/trust.yaml

That is the right refusal — a verifier with no anchor can check that a release is internally consistent and nothing more — but it leaves the operator a step to take, and the message names only --key, which is advice for a keyed release and useless for a keyless one. There is no key under a keyless posture: the anchor is an identity, a CA and a log key together, and no flag accepts those. The roster is the only way to state one.

So: obtain the roster, then point at it.

git clone https://github.com/openregs/openregs /opt/openregs-core
openregs pull <regime>@<tag> --from <registry> --into ./vendor \
--trust /opt/openregs-core/config/trust.yaml

Two things about that first line, both load-bearing:

  • Cloning is how the roster is obtained, not how it is trusted. What makes it an anchor is that somebody read it. config/trust.yaml’s own header carries the sha256 fingerprints of the Fulcio certificates and the Rekor log key it pins, and the log key’s fingerprint is also the log id Rekor publishes and every bundle carries — so the two derivations can be compared without going anywhere. key-rotation.md §5 is the procedure. A roster taken on the word of whoever served it is not a root, which is the same reason a release’s own copy of its public key is not one.
  • The roster you want belongs to the repository that owns the regime. Core’s pins …/openregs/openregs/.github/workflows/release.yml@refs/tags/* and applies to *@*, which is the signer for releases built here. A corpus in its own repository is signed by its workflow, so core’s roster does not cover it and cloning core is not how you anchor it. Clone the corpus.

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.

Nothing needs publish. When it fails, the run is red and the packages are the only thing missing: attach has still put the release on the channel above, and gh run rerun <run-id> --failed re-runs this stage alone once the credential exists.

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@f3a2d10:docs/runbooks/release-publish.md