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.
The distribution channel
Section titled “The distribution channel”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:
openregs pull eu@2026.01 --from https://github.com/openregs/regime-eu --into ./vendor- 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.
- It needs no credential that does not already exist.
release.yml’sattachjob holdscontents: 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. - 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. - 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.
Why not releases.openregs.io
Section titled “Why not releases.openregs.io”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.
The layout
Section titled “The layout”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 tree | As a release asset |
|---|---|
corpus.sqlite | corpus.sqlite |
attestation/signature-manifest.yaml | attestation__signature-manifest.yaml |
attestation/sigs/corpus.sqlite.sig | attestation__sigs__corpus.sqlite.sig |
constants-src/python/fixreg_constants.py | constants-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.
Laying a release out
Section titled “Laying a release out”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.
Attaching the assets
Section titled “Attaching the assets”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:
openregs release assets --release "$TAG" --path dist/release --out dist/mirrorgh release create "$TAG" --draft --verify-tag --title "$TAG" --notes-file …gh release upload "$TAG" dist/mirror/"$TAG"/* --clobbergh release edit "$TAG" --draft=falseattach 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 is | What attach does |
|---|---|
| absent | creates the draft, uploads, publishes |
| a draft an earlier run left | resumes it — the assets were never public, so the upload clobbers — then publishes |
| published with exactly these assets | nothing, and exits 0. gh run rerun re-runs the whole pipeline, and an ordinary operation must not look like a problem |
| published with anything else | refuses. 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.
Mirrors
Section titled “Mirrors”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 andfixtures/registryall are. - A static host —
--from https://mirror.example/openregs, serving whatopenregs 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.
Which release you get
Section titled “Which release you get”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.yamlnames 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 ofrelease-meta.yamlis the release. Every pull prints it —$ openregs pull fixreg@2025.04 --from https://github.com/openregs/openregs --into ./vendoridentity 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.
What pull refuses over the wire
Section titled “What pull refuses over the wire”Everything a local pull refuses, plus the four a filesystem never has to think about. None has a flag.
| Refused | Why |
|---|---|
cleartext http to anything but loopback | which 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 remote | the 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 password | release 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 release | a 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 names | the 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 reject | the 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 ./vendorpull: no trust anchor: no trust roster at /srv/app/config/trust.yamlThat 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-coreopenregs pull <regime>@<tag> --from <registry> --into ./vendor \ --trust /opt/openregs-core/config/trust.yamlTwo 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.
The two packages
Section titled “The two packages”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.
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.
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@f3a2d10:docs/runbooks/release-publish.md