Skip to content

Runbook — packaging the standalone CLI

What this produces: four single-file executables of openregslinux-amd64, linux-arm64, macos-arm64, windows-amd64 — that run on a machine with no Python installed, plus one SHA256SUMS over the set, a cosign-format signature beside each file and beside the checksum file, and the signer’s public key.

The whole surface is python -m openregs.standalone, with three subcommands (build, sign, verify) and a make package shortcut for the first.


PyInstaller does not cross-compile. Freezing a program means shipping the interpreter and the compiled extension modules of the machine the build ran on, so a linux-amd64 host can produce a linux-amd64 executable and nothing else.

openregs.standalone.build therefore refuses any target that is not its host, and names the runner that does build it:

$ uv run --frozen python -m openregs.standalone build --target macos-arm64
package: cannot build macos-arm64 on this host (Linux x86_64): PyInstaller does
not cross-compile — it freezes the interpreter and extension modules of the
machine it runs on. macos-arm64 is built on macos-14; see
.github/workflows/package.yml

That refusal is the honest behaviour. A file named openregs-macos-arm64 that no macOS has ever executed is worse than no file at all, because somebody will download it.

The four builds happen in .github/workflows/package.yml, one matrix leg per target on the runner named in openregs.standalone.TARGETS. Each leg uploads standalone-<target>; the sign job downloads all four into one directory, which is the only place in the pipeline that holds the whole set at once.


On a machine of the platform you are targeting:

make package # the host's target, into dist/standalone/
# or, explicitly:
uv run --frozen python -m openregs.standalone build \
--target linux-amd64 --commit "$(git rev-parse HEAD)" --out dist/standalone

Two things travel inside the executable:

  • spec/ — the JSON Schemas, taxonomies and the AKN XSD. A binary on a machine with no checkout still validates documents, because openregs/standalone/hook.py points OPENREGS_SPEC_DIR at the bundled copy before anything imports. Export OPENREGS_SPEC_DIR yourself to override it.
  • build-info.json — the version, the schema majors and the commit. That is what openregs --version prints:
$ ./dist/standalone/openregs-linux-amd64 --version
openregs 0.1.0
schema majors: 1
commit: <the 40-hex sha the binary was built from>

The frozen record wins over OPENREGS_BUILD_COMMIT and over git rev-parse, so a binary carried onto another machine keeps reporting the commit it was built from rather than the commit of whatever checkout it is standing in.

Canon, releases and config/trust.yaml are not bundled. They belong to the working tree, and a binary carrying its own copy of the law would answer from it. Point the binary at a checkout with --root, the way every subcommand already allows.


Once all four binaries are in one directory:

uv run --frozen python -m openregs.standalone sign --dir dist/standalone --fixture-key

It refuses until every declared target is present. A checksum file written over three binaries would be a true statement about the wrong set.

It writes SHA256SUMS in sha256sum format (so sha256sum -c SHA256SUMS reads it), a .sig beside each binary, a .sig over SHA256SUMS itself, and cosign.pub. Signing the checksum file as well as the binaries is what stops a tamper that rewrites both: recomputed digests would agree with the rewritten file, and the signature over it would not.

--fixture-key signs with the published, non-secret fixture key — it is what CI uses, because this repository holds no release signing key, and the command says so on stderr. A deployment with a real key passes --key <pem> instead; --signer cosign forces the cosign binary rather than the in-process signer that produces the identical artifact.


This is the consumer’s command, and it is what CI runs immediately after signing:

uv run --frozen python -m openregs.standalone verify --dir dist/standalone
package: verify dist/standalone
binaries ok 4 declared target(s) present
checksums ok 4 digest(s) recomputed from the bytes on disk
trust ok signing key openregs-fixture (24f0b959505940ce) is in config/trust.yaml (fixture key)
signature ok 5 signature(s) valid under key 24f0b959505940ce
package: OK — dist/standalone verifies

Every digest is recomputed from the bytes on disk rather than read out of SHA256SUMS, and the signing key is anchored in config/trust.yaml — never in the cosign.pub shipped beside the download, which proves nothing on its own. Exit codes: 0 verified, 1 a check failed, 2 the directory could not be read.


5. Checking a binary on a machine with nothing on it

Section titled “5. Checking a binary on a machine with nothing on it”

The claim “no runtime dependency on a system interpreter” is checked by running the binary in a container that has no interpreter in it:

docker run --rm --network=none \
--entrypoint /usr/local/bin/openregs \
--volume "$PWD/dist/standalone/openregs-linux-amd64:/usr/local/bin/openregs:ro" \
--volume "$PWD:/repo:ro" \
gcr.io/distroless/base-debian13:nonroot --version

gcr.io/distroless/base-debian13 carries a C library and nothing else: no shell, no package manager, no Python, no Node. Swap --version for verify --release fixreg@2025.04 --path /repo/fixtures/registry/fixreg/2025.04 --root /repo, or for pull or diff, to exercise the real commands there.

The image must be at least as new as the glibc the binary was linked against — a binary built on Ubuntu 24.04 (glibc 2.39) will not start on a debian-12 base. That is a property of dynamic linking, not of the packaging: build on the oldest platform you intend to support.

tooling/tests/test_standalone.py runs all of this. The container checks skip when the runtime or the image is not already on the machine, and never pull, because a test that reaches a registry is not an offline test.

Rendered from openregs/openregs@cbe9615:docs/runbooks/package.md