Release engineering from one model
Pre-commit, CI, justfiles, multi-arch OCI images and a curl|sh installer — generated from one typed model, not hand-maintained
Release engineering from one model
Release engineering is the most-copied, least-governed surface in any multi-repo organisation. CI YAML gets pasted between services and patched per-repo until no two pipelines agree. The build-and-ship procedure lives in the head of whoever set it up. And nothing ties how we release back to the architectural decisions that constrain it. When a registry moves, a signing policy changes, or an architecture is added, every repo is edited by hand and the drift is invisible — until a release breaks.
ontoref takes the opposite stance: the entire release surface is one typed
model, and the real artifacts are generated from it. This post walks through
what that means concretely, using the decision that pushed it furthest —
shipping ontoref as a multi-arch OCI image and a curl | sh installer
(ADR-038).
One workflow layer, four triggers
The release surface lives in a single NCL workflow model (ontology/workflow.ncl)
with four trigger-scoped layers:
OnCommit— pre-commit hooksOnPR— checks per pull requestOnMainMerge— integration on merge to mainOnTag— the release layer
Inside those layers, work is typed. Build kinds carry 'Bundle and 'Container;
distribution kinds carry 'ContainerRegistry, 'Artifact, 'Package. None of
these are strings in a YAML file — they are constructors in a typed model that a
generator reads.
The generators turn the model into the artifacts you actually run:
generate-precommit → .pre-commit-config.yaml
generate-woodpecker → .woodpecker/*.yml
generate-justfile → justfiles/*.just
generate-distribution → .woodpecker/release.yml
render-installer → install/install.sh
generate-release-justfile → justfiles/release.just
The same NCL declares CI and the build and distribution. There is one source of truth, and every emitted file is regenerable from it.
Why this beats programmable CI
The obvious objection: programmable CI already exists — Dagger, Earthly, monorepo
task runners. They make pipelines into code. But code is not a queryable model
governed by architectural decisions. With those tools the pipeline is a program;
with ontoref the pipeline is a fact you can interrogate through the same
describe / HTTP / MCP surface as the ontology and the ADRs.
Concretely, that buys two things hand-maintained YAML and programmable CI both lack:
- Drift detection.
onre workflow diffreports divergence between the model and the generated artifacts before it ships. A hand-editedrelease.ymlis caught, not discovered in production. - ADR anchoring. The release surface is tied to the decisions that constrain it. The distribution catalog isn’t free-floating config; it’s evidence for a value proposition and guarded by typed constraints in an ADR.
ADR-038: adding OCI distribution without a fork
Here’s the part that proves the pattern. Until recently ontoref could only be installed from a source checkout. Two install paths were wanted, sharing one artifact origin in the OCI registry:
- A runnable multi-arch image —
docker run …/ontoref/ontoref-daemon:VERSION curl … | sh— an installer that detects OS/arch, pulls the matching bundle (binary + data layer + wrappers), and installs into a prefix.
The mechanism was not a pile of standalone scripts. The workflow layer
already had the exact seam: build_kind already carried 'Container,
distribution_kind already carried 'ContainerRegistry | 'Artifact | 'Package,
and the distributions catalog was empty with no generator. The layer had been
built anticipating exactly this.
So the coherent move was to populate the catalog and add the missing
generator — not to invent a parallel packaging path that onre describe could
never see. A new provider is a catalog entry plus a generator, not a fork. That’s
the whole shape of the change, and onre workflow list shows the release layer
afterward like any other build artifact.
Build-once: the image and the bundle are the same binary
A typed model lets you state a hard property and enforce it. ADR-038’s is
build-once: the musl binary is cross-compiled once per arch (cross), and both
the install bundle and the runnable image consume that same binary. The image
is assembled with buildah from the cross output plus the data layer — no
docker buildx, no QEMU, no docker-in-docker on the runner.
This is cheaper and deterministic: a multi-arch index built from prebuilt
per-arch binaries, not by running an amd64 builder under arm64 emulation. And it
is a constraint, not a convention — the model forbids recompiling per arch to
build the image, so a future contributor reaching for the familiar
docker buildx --platform path is stopped by the model rather than silently
shipping a divergent binary.
Aside — the cache problem you don’t have to solve. Teams compiling a large Rust workspace inside Docker spend real effort taming
cargo-chef: every change to internal code busts the dependency-cache layer, and the fixes (stubbing workspace crates, lockfile-hash-tagged base images) are fiddly. ontoref sidesteps the whole category by building the binary once withcrossand assembling the image from it. There is no in-Docker workspace compile to cache, so there is no cache to invalidate.
The axiom the decision had to protect
Shipping a runnable daemon image risks reading ontoref as a runtime dependency
— and “Protocol, Not Runtime” is an invariant axiom. The model makes the
resolution explicit instead of hoping for it. The curl|sh installer installs a
CLI + data layer that work without the daemon (ADR-029: the daemon is a cache,
not a hard dependency); the runnable image is an optional accelerator for
container deployments. A typed constraint —
daemon-image-optional-not-runtime — guards that a future change cannot quietly
make the daemon required.
This is the rare case where formalisation and adoption move together. The fear
is always that more formalisation raises adoption friction. Here the
formalisation — an NCL distribution catalog plus a generator — produced the
artifact that most lowers friction: a one-line curl | sh install on a bare
host, no source, no cargo, no nickel (nickel is bundled).
What you get from modeling it
- ontoref installs on a bare Linux host with
curl … | sh— no checkout, no cargo, no nickel. - The daemon runs as a container from the same registry origin, multi-arch, cosign-signed with an attested SBOM.
- Distribution is queryable NCL:
onre workflow list/onre workflow diffshow the release layer and catch drift; regeneration is deterministic. - A publish gate (
smoke-bundle) extracts the freshly-assembled bundle and runs the binary before anything publishes — a broken artifact fails the pipeline rather than reaching users.
The point isn’t that ontoref has a nice CI setup. It’s that how we release is no longer tribal knowledge or copy-pasted YAML. It’s a typed fact, anchored to the decisions that constrain it, regenerable on demand, and unable to drift without the model saying so first.