OCI Distribution and Installer — Multi-Arch Runnable Image plus curl|sh Bundles, Modeled in the Workflow Layer
Accepted
Context
Until now ontoref could only be installed from a source checkout: `just install-daemon` runs `cargo build --release` then `install/install.nu`, which lays out the binary, the bootstrapper, the CLI wrapper (with ONTOREF_ROOT baked), and the data layer (reflection/, ontology/, domains/, templates/) across the platform bin/data/config dirs. There was no way to install on a machine that does not have the source, and no way to run the daemon as a container.
Two install paths are wanted, sharing ONE artifact origin in the OCI registry (`reg.librecloud.online`, already declared in manifest.ncl registry_provides):
1. A runnable multi-arch image — `docker run …/ontoref/ontoref-daemon:VERSION` — for deploying the daemon as a service/cache. 2. `curl … | sh` — an installer that detects OS/arch, pulls the matching OCI bundle (binary + data layer + wrappers), extracts it, and installs into a chosen prefix, reproducing install.nu's layout.
The mechanism is NOT a pile of standalone scripts. The workflow layer (the NCL-first CI/build/distribution model) already had the exact seam: `build_kind` carried 'Container, `distribution_kind` carried 'ContainerRegistry | 'Package | 'Artifact, and the `distributions` catalog was empty with no generator. The infrastructure was also already declared: `oras`, `cosign`, `sops`, `age` are Hard dependencies in manifest.ncl, and ADR-017 models the registry credential vault. So the coherent move was to populate the workflow layer's distribution catalog and add the missing distribution generator, not to invent a parallel packaging path.
The load-bearing risk is the "Protocol, Not Runtime" axiom: shipping a runnable daemon image could be read as making ontoref a runtime dependency. The framing that resolves it is ADR-029 (tier coexistence): the daemon is a cache, not a hard dependency. The installer therefore installs a CLI + data layer that work WITHOUT the daemon; the image is an optional accelerator for those who want the service.
Decision
Distribute ontoref through the OCI registry along two paths from one build, modeled entirely in the workflow layer (catalog in reflection/defaults/workflow.ncl, a `release` layer triggered OnTag in ontology/workflow.ncl, and a distribution generator in reflection/modules/workflow.nu). Migration 0029 propagates the schema/catalog additions to consumers.
DESIGN PROPERTIES (each is a constraint below):
1. Modeled in the workflow layer, not hand-maintained scripts. The 'Bundle build kind, the build/distribution catalog entries, and the generator that emits .woodpecker/release.yml + install/install.sh + justfiles/release.just all live in the NCL workflow model. Distribution is therefore self-describing and queryable via `ontoref workflow`, like every other build/CI artifact.
2. Build-once; the image is assembled FROM the binary, never recompiled. The musl binary is cross-compiled once per arch (`cross`). Both the install bundle AND the runnable image consume that same binary. The image is assembled with buildah from the cross output + the data layer — no `docker buildx`, no QEMU, no docker-in-docker on the runner.
3. The daemon image is an OPTIONAL accelerator (ADR-029). The curl|sh installer installs a CLI + data layer that operate without the daemon. The runnable image serves container deployments only. Adopting ontoref never requires running the daemon — the "Protocol, Not Runtime" axiom is preserved.
4. The bundle reproduces install.nu's layout. assemble-bundle.nu packages the binary, bootstrapper, CLI wrapper, a target-arch nickel, the data layer, and a config skeleton; install.sh places them exactly as install.nu does, including the baked ONTOREF_ROOT in the wrapper. The bundle's config skeleton is taken ONLY from install/resources/ — never from .ontoref/config.ncl.
5. curl|sh needs nothing but POSIX tools. Bundles are .tar.gz (gzip is universal; zstd is not assumed). install.sh prefers `oras` and falls back to plain `curl` against the OCI Distribution API; it verifies the .sha256 sidecar and, if present, a cosign signature. The bundle namespace (ontoref/dist) is published for anonymous pull, so no credentials are needed. The installer itself is served at a plain HTTPS URL — never as an oras artifact, which a bare `curl | sh` could not fetch.
6. A publish gate. The `smoke-bundle` validation extracts the freshly-assembled native bundle and runs the daemon binary before any distribution step publishes. A broken artifact fails the pipeline rather than reaching users.
7. Supply chain. The image is cosign-signed and the SBOM cosign-attested. Since the self-hosted Woodpecker has no Fulcio, signing is key-based; the cosign key and the registry RW credential come from the src-vault (ADR-017).
Platform scope is Linux amd64 + arm64 (static musl) and macOS amd64 + arm64. The two are NOT symmetric, and the asymmetry is a toolchain fact, not a preference: `cross` has no apple-darwin image because the Apple SDK cannot be redistributed in a builder image, so a Darwin target only builds on a macOS host. The Linux arches therefore stay in the `release` layer on the Woodpecker runner, while the Darwin arches live in a separate `release-darwin` layer with a Justfile-only provider — emitting them into .woodpecker/release.yml would put steps into the Linux pipeline that cannot pass. A Woodpecker provider joins that layer the day a macOS runner exists; nothing else moves. Bundles fetched by curl+tar carry no quarantine attribute, so signing/notarisation becomes required only if a .dmg or a browser-downloaded artifact is ever published.
Constraints
- Hard OCI distribution MUST be declared in the workflow layer — build entries in the shared catalog (reflection/defaults/workflow.ncl), the project's own distribution entries in its workflow declaration (<spine>/ontology/workflow.ncl), and the generator in reflection/modules/workflow.nu — and the published artifacts (.woodpecker/release.yml, install/install.sh, justfiles/release.just) MUST be generated from it, never hand-maintained. The `distributions` catalog MUST be non-empty.
- Hard The installer MUST install a CLI + data layer that operate WITHOUT the daemon. The runnable daemon image MUST remain an optional accelerator. Making the daemon a required runtime for adoption is forbidden — it would violate the Protocol-Not-Runtime axiom (ADR-029: the daemon is a cache, not a hard dependency).
- Hard The bundle and installer MUST reproduce install.nu's layout: the binary, bootstrapper, CLI wrapper with a baked ONTOREF_ROOT, a bundled nickel, the data layer, and a config skeleton, placed in the same bin/data/config locations. Divergence silently breaks the installed CLI.
- Hard The published runnable image MUST be assembled from the already cross-compiled musl binary (buildah copy), NOT recompiled in CI and NOT built with docker buildx/QEMU. The bundle and the image MUST consume the same per-arch binary.
- Hard The bundle's config skeleton MUST be taken only from install/resources/ — never from the live .ontoref/config.ncl. A corrupt or environment-specific project config must never leak into a published bundle.
- Soft The bundle namespace (ontoref/dist) SHOULD be published for anonymous pull so the curl|sh installer needs no registry credentials. The installer script itself MUST be served at a plain HTTPS URL, not as an oras artifact.
Alternatives considered
- Plain GitHub/Gitea-release tarballs, no OCI — rejected: ontoref already standardizes on OCI: oras/cosign/sops are Hard deps and the registry topology is declared in manifest.ncl (ADR-017). Plain tarballs would need a separate hosting and integrity story and would not reuse the existing registry credentials or signing. The installer keeps a curl fallback for hosts without oras, but the artifact origin stays OCI.
- Extract the binary + data from the runnable image's layers — rejected: Couples the installer to the image's internal layer layout — a fragile contract that breaks whenever the image build changes. Dedicated per-arch bundle artifacts are arch-addressable (:VERSION-linux-amd64), robust to image changes, and let the bundle carry exactly the install layout without the image's runtime concerns.
- docker buildx multi-arch (recompile or QEMU emulation) — rejected: Duplicates the cross build and requires QEMU/binfmt and docker-in-docker on the runner. Since the musl binaries are already cross-compiled, assembling the image from them with buildah is single-origin, faster, and daemonless on the runner. buildx would rebuild what cross already produced.
- Standalone install.sh + Dockerfile, not modeled in the workflow layer — rejected: Distribution would not be self-describing or queryable and would diverge from the PAP — every other build/CI artifact is generated from the NCL workflow model. The DistributionSpec/'Container kinds exist precisely so distribution is declared, not scripted. Hand-maintained scripts drift from the model.
- Bundle nushell into the artifact too — rejected: Nushell is large and is the CLI's runtime; bundling it bloats every download. nickel (small, static musl, needed by the bootstrapper) IS bundled; nushell is detected at install time and the user is given install guidance. The daemon image needs neither — only the daemon binary + nickel + data.
Anti-patterns
- Treating the Daemon Image as a Required Runtime — A change makes the CLI or adoption depend on a running daemon — e.g. the installer starts/expects the daemon, or commands fail without it. This turns the optional accelerator into a runtime dependency and breaks Protocol-Not-Runtime.
- Editing the Generated Distribution Artifacts Directly — A contributor edits .woodpecker/release.yml, install/install.sh, or justfiles/release.just by hand instead of changing the workflow catalog and regenerating. The artifacts drift from the NCL model that is supposed to be their source of truth.
- Recompiling to Build the Multi-Arch Image — The image is built by recompiling per arch (docker buildx + QEMU, or cargo inside the Dockerfile) instead of assembling from the already cross-compiled binary. This duplicates the build, reintroduces emulation/docker-in-docker, and can make the image binary differ from the bundle binary.
- Shipping the Live Project Config in the Bundle — The assembler copies .ontoref/config.ncl (environment-specific, possibly corrupt) into the bundle instead of the install/resources skeleton, leaking local state into a published artifact.