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

Jesús Pérez 8 min read
Release engineering is the most-copied, least-governed surface in any multi-repo org: CI YAML pasted between services until no two pipelines agree. ontoref models the entire release surface as one typed NCL workflow layer and generates the real artifacts from it — so the pipeline is queryable like any other architectural fact, and drift fails before it ships. ADR-038 added multi-arch OCI distribution as a catalog entry plus a generator, not a fork.
Release engineering from one model

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 hooks
  • OnPR — checks per pull request
  • OnMainMerge — integration on merge to main
  • OnTag — 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 diff reports divergence between the model and the generated artifacts before it ships. A hand-edited release.yml is 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:

  1. A runnable multi-arch image — docker run …/ontoref/ontoref-daemon:VERSION
  2. 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 with cross and 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 diff show 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.

Was this useful? Rate it
Got something to add? Tell me what you think, what you'd suggest, or whether we should keep exploring this topic.
· reads

We use cookies to help this site function, understand service usage, and support marketing efforts. Cookie Policy for more info.