Multi-Platform Packages
Most binary tools ship per-platform builds — different bytes for Linux/amd64, Linux/arm64, Darwin/arm64, Windows/amd64. The naive distribution approach is one tag per platform: acme/mytool:1.0.0-linux-amd64, acme/mytool:1.0.0-darwin-arm64. That works, but it pushes the platform-resolution problem onto every consumer's install script. OCX uses OCI Image Indexes instead — one tag, multiple manifests, OCX picks the right one at install time based on the consumer's platform.
This page covers the publisher view: how to assemble a multi-platform package, how ocx package push builds the index, and the digest-stability properties you can rely on.
One Tag, Many Manifests
An OCI Image Index is a manifest of manifests — a single descriptor that points at one image manifest per platform. When a consumer runs ocx package install acme/mytool:1.0.0, OCX fetches the index, finds the manifest matching the consumer's platform, and pulls only that manifest's layers. No conditional logic in install scripts, no platform-suffixed tags to keep in sync.
The publisher equivalent of "build for amd64, then arm64, then push the index" collapses to: push each platform separately under the same tag, and OCX assembles the index for you. When ocx package push sees a tag that already has a manifest, it merges the new platform into the existing index rather than replacing it.
The Per-Platform Push Pattern
The hand-publishing flow (the ocx_mirror tool runs the same pattern from a YAML spec):
- Bundle each platform's content with
ocx package create. Pass-i mytool:1.0.0 -p <platform>and let-o .infer the output name. Each create call drops a<name>-<tag>-<os>-<arch>.tar.xzarchive (<name>is the OCI repository's last segment) plus a sibling<…>-metadata.jsonsidecar — the pairocx package pushpicks up automatically. The full sidecar/inferred-name convention lives in Bundle Anatomy → sidecars. - Push the first platform with
--cascade.--cascadekeeps your rolling-tag aliases (1.0,1,latest) in sync. Omit-m: push reads the sidecar next to the layer. - Push subsequent platforms with
--cascadetoo. OCX detects the existing manifest, merges the new platform into the image index, re-points the tag at the index digest, and updates each rolling alias.
ocx package create build -i mytool:1.0.0 -p linux/amd64 -m metadata.json -o .
ocx package create build -i mytool:1.0.0 -p linux/arm64 -m metadata.json -o .
ocx package push -c -p linux/amd64 -i acme/mytool:1.0.0 mytool-1.0.0-linux-amd64.tar.xz
ocx package push -c -p linux/arm64 -i acme/mytool:1.0.0 mytool-1.0.0-linux-arm64.tar.xzAfter both pushes, acme/mytool:1.0.0 resolves to an image index with two platform descriptors, and any rolling aliases (1.0, 1, latest) point at the same index digest. A consumer on Linux/arm64 fetches only the Linux/arm64 manifest's layers; an amd64 Linux runner fetches only the amd64 manifest's layers. Add -p darwin/arm64, -p darwin/amd64, or -p windows/amd64 the same way for the rest of the matrix.
The recording also runs ocx index update and ocx package install after the second push so you can see the consumer side: a single tag, the right platform's layers fetched, the binary on the candidate symlink ready for ocx package exec.
Use the Same Metadata Across Platforms
The default and recommended pattern: ship one metadata.json that covers every platform. Env entries, dependencies, entrypoints — all of them apply uniformly. The platform-specific bits live in the archive, not in the metadata.
When platforms genuinely diverge — say, Windows needs different env keys, or a platform has different entry-point names — the ocx_mirror spec accepts a per-platform metadata override. Hand-driven publishers can pass --metadata <path> per push and use a different file each time. In a spec, the metadata block names the default plus per-platform overrides:
metadata:
default: metadata.json
platforms:
darwin/amd64: metadata-darwin.json
darwin/arm64: metadata-darwin.json
windows/amd64: metadata-windows.json
windows/arm64: metadata-windows.jsonThe Image Index Is Stable
Each per-platform manifest's digest depends only on its own bytes. Push the same archive bytes twice and the manifest digest is identical — no platform-side rebuild churn. (Re-running ocx package create produces a different archive on each invocation; see Bundle anatomy → stable archives for why.) The index manifest's digest is a function of its descriptors, so it changes when (and only when) you add a platform or push a new build for an existing platform. That stability is what lets --cascade work cleanly across multi-platform releases — the rolling tag points at the index, the index points at per-platform manifests, every layer caches independently.
libc Differentiation
Linux ships two major libc implementations: glibc (the GNU C Library, used by Ubuntu, Debian, Fedora, and most distributions) and musl (used by Alpine Linux and similar minimal environments). A binary compiled against glibc requires glibc at runtime, and the failure when it is absent is not a helpful one: if the host has some glibc but too old a one you get version 'GLIBC_X.Y' not found, but if the host has no glibc at all — the Alpine case — the kernel cannot find the ELF interpreter the binary names and reports No such file or directory for a file that is plainly there.
Declaring the requirement is what avoids this, and OCX will not let you skip it: an undeclared libc is a positive claim of universality, so ocx package create reads the packaged binaries and refuses a Linux platform whose os.features do not cover what they actually need.
--no-libc-lint is an escape hatch, not a routine flag
Pass --no-libc-lint only when the check itself refuses incorrectly — it exists so a bug in the check cannot block every create for a Linux target with no way through. It skips the check entirely, so the published tile's os.features claim goes unverified against the packaged binaries. See ocx package create.
If your tool ships both a glibc build and a musl build, you can publish both under the same tag and let OCX pick the right one automatically. The mechanism is the OCI os.features field: mark each manifest entry with libc.glibc or libc.musl and OCX's index resolution selects the one that matches the installing host.
Auto-resolution
When a package index carries both a libc.glibc entry and a libc.musl entry under the same tag, consumers need no special flags. ocx package install acme/mytool:1.0.0 detects the host's libc family at startup, then applies subset matching: the entry whose os.features are a subset of the host's detected features wins. A pure glibc host (Ubuntu, Fedora, Debian) picks the glibc build; a pure musl host (Alpine Linux) picks the musl build.
A host that provides both loaders — for example, Ubuntu with musl-tools installed, or a multi-target CI runner — advertises ["libc.glibc", "libc.musl"] as its feature set. Both the libc.glibc entry and the libc.musl entry match the host's features, and each declares exactly one os_features value, so their specificity is equal. Equal-specificity matches produce SelectResult::Ambiguous, so install fails (exit 65) with an ambiguous-selection error that lists the conflicting variants. Disambiguate by passing --platform linux/amd64+libc.glibc or --platform linux/amd64+libc.musl.
Comparison: how other tools handle this
cargo-binstall and uv detect the host libc family at install time, but encode it in download URLs or filenames — the publisher must maintain separate assets and the resolution is a name-lookup, not a metadata match. uv shipped a UV_LIBC manual override in July 2025 specifically because single-pick detection fails on dual-libc hosts. Alpine Linux, Wolfi, and Chainguard use separate repositories or tag suffixes (-musl, -static) to distinguish variants. Container tooling (ORAS, docker pull) does not use os.features for libc differentiation. OCX models the host as a set of libc families and performs set-subset matching inside one OCI index — no separate repos, no tag suffixes, no manual overrides needed.
To force a specific variant — for example to install the musl build on a glibc host for testing — pass --platform linux/amd64+libc.musl. The +libc.musl suffix is appended to the standard os/arch value and overrides the host's detected feature set for that invocation only.
Publishing glibc and musl variants
The mirror tool's YAML config supports an object form for asset entries that adds os_features and an optional platform override. Two entries can share the same (os, arch) pair when their os_features differ.
assets:
# legacy string form — no libc tag; matches every Linux host (static build)
"darwin/arm64": mytool-.*-aarch64-apple-darwin\.tar\.zst
# object form: declare libc.glibc on the glibc-linked build
"linux/amd64-glibc":
platform: linux/amd64
pattern: mytool-.*-x86_64-unknown-linux-gnu\.tar\.zst
os_features: [libc.glibc]
# object form: declare libc.musl on the musl-linked build
"linux/amd64-musl":
platform: linux/amd64
pattern: mytool-.*-x86_64-unknown-linux-musl\.tar\.zst
os_features: [libc.musl]The YAML key (linux/amd64-glibc) is a free-form identifier — when it differs from the actual platform, the platform field provides the canonical OCI platform string. Two entries with the same platform but different os_features produce two separate image index entries under the same tag.
After mirroring, the published image index carries the os.features array in each platform descriptor:
{
"platform": {
"architecture": "amd64",
"os": "linux",
"os.features": ["libc.glibc"]
}
}Hand-driven publishers (not using the mirror tool) declare the same os_features directly on ocx package push's --platform value — append +libc.glibc or +libc.musl to the standard os/arch value, following the Per-Platform Push Pattern:
ocx package push -c -p linux/amd64+libc.glibc -i acme/mytool:1.0.0 mytool-1.0.0-linux-amd64-glibc.tar.xz
ocx package push -c -p linux/amd64+libc.musl -i acme/mytool:1.0.0 mytool-1.0.0-linux-amd64-musl.tar.xzBoth pushes share the same tag and (os, arch), so they merge into the same image index as two entries differing only in os_features — the same result the mirror YAML's object form produces.
Generic OCI clients ignore os.features
Generic OCI clients — docker, podman, ORAS, and crane — resolve a multi-platform index through containerd-style platform matchers that key on os, architecture, and variant. They do not select on os.features, so a dual-libc index is ambiguous to them: which of the two entries they take is implementation-defined (typically the first match in index order), and it may be the wrong libc. When third-party tooling pulls these tags directly, pin the exact per-platform manifest by digest, or route the pull through ocx so the os.features subset match applies.
Static binaries as the universal fallback
A statically linked binary (e.g. a musl-static build that has no runtime libc dependency) should be published with no os_features declaration:
"linux/amd64":
pattern: mytool-{version}-x86_64-unknown-linux-musl-static.tar.zst
# no os_features — matches every Linux host regardless of detected libcAn empty os_features set is a subset of every host's features, so the entry matches a glibc host, a musl host, and a host where libc could not be detected. This is the "runs on every Linux host" idiom — prefer it when you control the build and can produce a fully static binary.
How OCX selects at install time
When a consumer runs ocx package install acme/mytool:1.0.0 on a Linux host, OCX detects the libc family in two stages: discover, then identify. It first discovers where the host's dynamic loaders live — it reads the PT_INTERP (the embedded interpreter path) from an ordered allowlist of system binaries (/usr/bin/env, /bin/sh, /bin/ls), probing each in turn and stopping at the first that yields an interpreter path; that path names the host's exact native loader wherever it sits. It then scans the canonical loader directories for any additional loaders. It then identifies each discovered loader by its --version banner. It enumerates all libc families the host provides — a host may advertise more than one (e.g. glibc plus musl on a machine that has musl-tools installed). Detection is a one-time probe cached for the process lifetime.
Reading the loader path off a present binary rather than guessing fixed paths is what lets detection work on hosts that put the loader somewhere non-standard. Gentoo Prefix, Homebrew-on-Linux, and custom sysroots all resolve libc-aware. NixOS resolves libc-aware when nix-ld is active (nix-ld installs an FHS shim at the canonical loader path, which the probe picks up normally); without nix-ld, the probe binaries on NixOS are statically linked and carry no PT_INTERP, so detection falls back to an empty set — install then matches only entries with no os_features, and you can override with --platform.
The selection rule is subset semantics: an index entry matches when every os_features value the candidate declares is present in the host's detected features. A glibc host therefore picks the libc.glibc entry; a musl host picks libc.musl; a dual-libc host (detected features ["libc.glibc", "libc.musl"]) matches both the libc.glibc and the libc.musl entry; a host where detection failed (a truly minimal container with no readable dynamic loader) matches only entries with no os_features.
Alpine Linux with gcompat
gcompat is a glibc compatibility layer for Alpine. When installed, it lets glibc-linked binaries run on a musl host. OCX does not treat a gcompat host as a glibc host — the probe reads what the dynamic linker identifies as, and on Alpine that is always the musl loader. A gcompat host installs the libc.musl variant, not libc.glibc. This is predictable and falsifiable: the loader tells the truth about what it is, not what it can emulate.
Diagnosing selection
ocx about reports the detected libc family and the host platform Index::select resolves against. Run it to confirm what OCX sees before publishing or troubleshooting a failed install.
...
Platform linux/amd64
Libc libc.glibc
...When the host provides more than one libc family, the Libc row lists each one (e.g. Libc libc.glibc, libc.musl). On a host where libc detection failed, the Libc row is absent — the host still resolves as the bare os/arch platform with no declared features, so install matches only manifests declaring no os_features of their own. For machine-readable output use ocx --format json about, which includes a libc array (empty when undetected).
WebAssembly
A .wasm file is not a native executable — it needs a runtime to load it, and the same bytes run unchanged on every host that has one. That makes it a poor fit for any, which claims no platform requirement at all: a WASI 0.1 module and a WASI 0.2 component target different ABIs, and a consumer resolving one needs to say which.
OCX gives each WASI ABI its own platform, so the choice is expressed the same way every other platform choice is:
| Platform | Artifact |
|---|---|
wasip1/wasm | A module targeting the WASI 0.1 (Preview 1) ABI |
wasip2/wasm | A component targeting the WASI 0.2 (Preview 2) ABI |
Publishing is the ordinary per-platform push — nothing about the flow is wasm-specific:
ocx package create -i my/tool:1.0.0 -p wasip1/wasm -m metadata.json -o tool.tar.xz ./dist
ocx package push -p wasip1/wasm -i my/tool:1.0.0 tool.tar.xzConsuming one always names the platform explicitly. No machine reports wasip1 or wasip2 as its host, so an omitted --platform resolves to the native host platform and will not find a wasm manifest:
ocx package install -p wasip1/wasm my/tool:1.0.0No binaries are scanned for a wasm target
For native targets, ocx package create scans the interface-visible PATH directories and fills the binaries claim from what it finds. A wasm target is skipped: OCX never exec's a .wasm file, so neither the executable bit nor a filename extension says anything about what a consumer should invoke. The claim comes back empty even when the content tree carries executable files, and a package that needs a named entry point declares one explicitly in its metadata.
Dependencies with Platform-Specific Builds
A package built once with --platform any — a shell script, a Python entry point, anything without native code of its own — can still depend on a package that ships different manifests per platform, such as the native binary the script wraps. A single dependency identifier can only carry one digest, so which platform's build does that digest name?
ocx package create answers this the same way it resolves any other dependency: against the single --platform you declared for the whole bundle, using the directed compatibility relation every platform decision in OCX routes through. A concrete --platform pins each dependency straight to the one compatible manifest digest. --platform any is the interesting case, covered below.
The any-Deps Rule
An any-targeted package performs no platform-specific resolution of its own — nothing about it varies by host — so it can only depend on dependencies that themselves offer an any manifest. This falls straight out of the compatibility relation: an any requirement is satisfied only by an any offer.
{ "dependencies": [{ "identifier": "ocx.sh/acme/mytool:1.0" }] }ocx package create --platform any resolves this the same way it resolves a concrete platform: a single manifest digest, bare on the identifier:
{
"dependencies": [
{ "identifier": "ocx.sh/acme/mytool:1.0@sha256:aaaa..." }
]
}If ocx.sh/acme/mytool:1.0 ships only platform-specific manifests (linux/amd64, darwin/arm64, and so on) with no any build, create fails with exit 65, naming the dependency and listing what it does offer. There is no partial coverage to derive — a platform-agnostic package either can lean on a platform-agnostic dependency, or it cannot depend on that package at all under --platform any.
Digest Pins Under --platform any
A leaf manifest carries no platform descriptor of its own — the platform lives in the image index entry that points at it, one level up. That means a dependency pinned by a bare @digest cannot be verified as genuinely any-offered from the digest alone; something else has to vouch for it.
ocx package create --platform any refuses that verification job outright: it resolves against an index, so a digest it did not resolve itself carries no evidence either way, and it rejects a direct digest pin anywhere in an any-targeted bundle's dependency list (exit 65) — including one already present before create ran. Pin the dependency through ocx package create --platform any (see The any-Deps Rule above) instead of hand-writing a digest.
ocx package push has the registry in hand, so it checks the stronger claim directly: it re-fetches the dependency's own image index by its advisory tag and accepts the pin iff that index advertises the pinned digest as any — either a flat (non-index) manifest whose own digest matches the pin, or an image-index entry declaring platform any at that digest. A digest the index does not advertise as any fails the push (exit 65), naming the dependency and the digest.
A tag-less pin loses its own evidence
The provenance check re-fetches the dependency by its advisory tag — the one ocx package create leaves on the identifier alongside the digest. A dependency pinned with no tag at all is fetched at latest instead, which usually fails closed: the tag is what names the index stream the digest was resolved against, and without it there is nothing to re-derive provenance from. Keep the advisory tag on every dependency identifier your sidecar pins.
Concrete-targeted bundles are unaffected by either check: a direct digest pin is fine there, since the platform being published is already known and an any claim was never in question.
Snapshot Semantics
A pin is a snapshot of the dependency's platform coverage at the moment create (or push, for the provenance check above) ran, not a live query. If ocx.sh/acme/mytool:1.0's publisher later adds an any manifest where none existed before, your already-published package does not retroactively pick it up — re-run ocx package create --platform any against a refreshed index, then ocx package push, to re-resolve.
See Also
ocx aboutreferenceocx package pushreference- Storage in depth — multi-layer packages
- Versioning in depth — platforms
- Building & pushing — cascade, layer reuse, BYO archives, dependency pin resolution
- Declaring dependencies — when to depend, visibility,
nameoverrides - Platforms reference — the canonical grammar and the compatibility relation this page builds on
- Dependencies reference — sidecar field shapes, manifest-pin rule
ocx package createbuild receipt — the fallbackpush/testread for a platform their own flags did not state- Migration patterns —
ocx_mirrorper-platform spec