Skip to content

Building and Pushing

Every package starts the same way: a tar archive on disk, a metadata.json next to it, and a destination registry. This page covers the publisher workflow from local archive to published OCI image, including the cascade convention for rolling tags and the layer-reuse pattern that lets a single base archive back many releases.

The First Push

ocx package push uploads zero or more layers as OCI blobs and records them under one image manifest for the single platform this invocation publishes. ocx package create's --platform picks that platform and writes it to a build receipt beside the bundle — never into the metadata sidecar itself, which carries no platform field. push reads that receipt for anything its own flags did not state, so a push with no --platform publishes under exactly what the dependency pins were resolved against, and a push with no --identifier publishes under the identifier create was given. Flags you do pass are used as given; the receipt is not consulted for them. Publishing more than one platform under the same tag means running create/push once per platform — see the multi-platform guide for how OCX assembles the resulting OCI Image Index across those pushes.

sh
ocx package create build -m metadata.json -o mytool-1.0.0.tar.xz -p linux/amd64 -i mytool:1.0.0
ocx package push mytool-1.0.0.tar.xz
Publishing a package for the first time

Test before you push

Before pushing, verify the package works locally with ocx package test. It runs the same install pipeline — dep resolution, extraction, env composition — in a temp directory with no registry round-trip. Exit code is forwarded from the command you run, so CI can gate on it. See Testing locally for the full workflow.

Redirect Refusals

An upload session in the OCI Distribution Specification hands control to the registry mid-request: push opens a session, and the registry answers with a Location header naming where the next chunk or the commit request goes. That header is registry-supplied, not something OCX chose — a compromised or misconfigured registry, or an on-path party editing the header in flight, can point it anywhere.

OCX refuses to follow it blindly. Four specific handoffs are checked before the next request goes out, and a failed check aborts the push rather than completing it silently over a route you didn't intend. All four exit 65 (DataError) — the registry served something OCX will not act on, and a rerun reaches the same answer:

  • A different registry. The upload session's Location names a host other than the one you are pushing to. Following it would send that host your registry credentials and the blob body (CWE-918) — an internal address included, since nothing about the header requires it to be a public one.
  • A plaintext credential realm. OCX sends Basic or Bearer credentials to a WWW-Authenticate realm only when the realm is https://, its host matches the registry's own, or that realm host is itself declared plaintext-eligible. Anything else is refused, naming the realm URL (CWE-319) — most commonly an HTTPS registry naming a plaintext realm, but also a plain-HTTP registry whose token service sits on a different, undeclared host or port.
  • A downgraded redirect. Any request — not only the upload session — gets redirected from https:// to http:// mid-flight. OCX follows redirects (registries commonly hand blob downloads off to a CDN this way), but never one that drops TLS.
  • A redirected upload request. The requests that upload each chunk and commit the session never follow a redirect at all, not even a same-host one. (Opening the session is an ordinary request and still follows redirects; it carries no blob body, and the Location it comes back with is vetted before anything is sent to it.) If a registry answers one of them with a 3xx after the session was already vetted as same-host, that status comes back as a refusal instead of being followed — closing the gap a one-time host check alone leaves open. This applies to uploads only; blob pulls still follow redirects, since that's how registries hand blobs off to a CDN. No registry redirects an upload write — it computes the blob's digest as the bytes stream past, which it can't do for a write it handed off elsewhere — so this refusal is not expected to fire against a correctly behaving registry.

The first and last cases are specific to push's upload-session flow. The middle two can surface on any authenticated request — pull, install, and login included — since they are checked at the transport layer, not the push path specifically.

One more case lands in the same exit code without being a refusal at all: a redirect no client could act on — a missing or unparseable Location, or a body it can't replay — reaches any request, pull included. A registry emitting a malformed redirect looks identical to the fourth case from here; it isn't evidence of an attack, just a broken response.

The cross-host, redirected-upload, and downgraded-redirect refusals are not something a plain-HTTP allowance opens back up: insecure = true (or OCX_INSECURE_REGISTRIES) only changes which registries OCX is willing to dial over HTTP, not which host an upload session may redirect to, whether it may redirect at all, or which host a TLS connection may downgrade to. The realm check is the exception — it does consult the allowance, for the realm's own host. See insecure for what to declare when a plain-HTTP registry's token service lives on a separate host or port.

Reporting it

The cross-host session, the redirected upload request, and the downgraded redirect are registry-side problems, not something to route around client-side — a same-registry upload session should never redirect at all, and nothing should ever downgrade to plain HTTP; if you hit any of the three, the registry (or a mirror/proxy in front of it) is misconfigured or compromised. A refused realm on an already-plain-HTTP registry is different: it's your own config missing an entry for the realm's host, fixed the same way as any other undeclared host — see the insecure field.

Bring Your Own Archives

ocx package push does not bundle a directory for you. Every file layer must be a pre-built .tar.gz / .tar.xz / .tar.zst archive (the aliases .tgz, .txz, .tzst, and .tar.zstd are also accepted) — and that asymmetry is deliberate. Re-bundling the same content yields a non-deterministic digest (timestamps, compression entropy), and the registry treats every distinct digest as a fresh layer. Bundle once with ocx package create (see Bundle Anatomy), then reference that archive across every subsequent push.

Zero-layer pushes need explicit --metadata

A push with zero file layers is valid: it produces a config-only OCI artefact (the same shape OCX uses internally for the __ocx.desc description tag written by ocx package description push). With no file layer next to which to look for <stem>-metadata.json, --metadata is mandatory — the same rule applies whenever every layer is a sha256:… digest reference.

Resolving Dependency Pins

A package that declares dependencies needs each one pinned to a manifest digest before ocx package push will publish it — an unpinned dependency is rejected (exit 65). Writing that digest by hand does not scale past one dependency, and pinning the wrong kind of digest breaks silently later: it is tempting to pin a dependency's OCI Image Index digest so a single identifier could resolve per-platform at install time, but a tag's index is rewritten on every platform push — the old index digest becomes untagged and the registry eventually garbage-collects it, so an index-pinned dependency 404s the moment that happens. See Manifest Pins, Never Index Pins for the full hazard.

ocx package create is the compiler for this problem. Write each dependency tag-only in the metadata.json sidecar, then resolve with --platform:

sh
ocx package create build -i mytool:1.0.0 -p linux/amd64 -m metadata.json -o .

create resolves every unpinned dependency against the selected index and rewrites the sidecar in place with the resolved manifest pin — the rewritten file, not the one you hand-wrote, is what you push. Index selection follows the same --remote / --offline / --frozen routing as every other resolution: the default checks the local index first and fetches on a miss; --offline/--frozen refuse to resolve a dependency tag that is not already cached (exit 81); a dependency tag absent from the selected index entirely fails with exit 79.

ocx package push then makes no resolution decisions of its own — it is a gate. It reads the sidecar create wrote, verifies every dependency carries a manifest pin covering the single platform being published, and verifies each unique pin actually resolves in its registry:

FailureExit codeMeaning
No --platform, and no platform in the build receipt beside the bundle64Pass --platform, or run ocx package create --platform <PLATFORM> so the build records one.
No --identifier, and no identifier in the build receipt64Pass --identifier, or run ocx package create --identifier <IDENTIFIER> so the build records one.
Dependency is not digest-pinned65Re-run ocx package create --platform to pin it.
Pin resolves to an image index65The dependency was pinned by hand against an index digest — re-run create.
A dependency of an any-targeted push pins a digest its own image index does not advertise as any65Re-run ocx package create --platform any against a refreshed index, or confirm the registry actually advertises that digest as any.
Pinned manifest not found in the registry79The dependency's manifest was deleted, or the digest is wrong.
Registry authentication failed80Refresh credentials for the dependency's registry.

A package with no dependencies, or one whose sidecar is already fully pinned, skips all of this — create does not touch the network when --platform is omitted and every dependency already carries a digest.

Building with --platform any pins the same way as a concrete platform — a single manifest digest, bare on the identifier — but over a narrower candidate set: an any-targeted package performs no platform-specific resolution of its own, so it can only depend on dependencies that themselves offer an any build. A dependency with no any manifest fails create outright, naming it. A leaf manifest carries no platform descriptor of its own, so push later re-verifies the pin against the dependency's own image index rather than trusting the sidecar's word for it. See Multi-Platform Packages.

Cascading Rolling Tags

Most publishers ship versioned releases (1.0.0, 1.0.1) and want users to pin against rolling aliases (1.0, 1, latest) without maintaining the alias graph by hand. The --cascade flag does that bookkeeping at push time: when you push acme/mytool:1.0.1, OCX consults the existing tags and re-points each ancestor (1.0, 1, latest) to the new digest — but only when the new tag is genuinely the latest at that specificity level. Push a backport 0.9.5 after 1.0.1 is live and --cascade won't touch latest, because 1.0.1 is still the newer release.

Cascade is a publisher convention, not a registry-enforced rule. The registry sees only tag-to-digest writes; OCX synthesises the alias semantics on top. Cascade decisions are evaluated per platform — a backport that is the latest for linux/amd64 but trails the head on darwin/arm64 will only re-point the rolling tags it actually leads on. The full alias model lives in versioning in depth → cascades.

Cascading rolling tags across releases

Linking the Source Repository

A published package tells a consumer nothing about where it came from. Registries do not infer it: the path ghcr.io/acme/tools/widget names a package, not a repository, and a registry that guessed otherwise would be wrong the moment a publisher mirrors someone else's software under their own namespace — which is exactly what a mirror repository does.

The OCI image spec reserves org.opencontainers.image.source for the answer, and GHCR reads it: set it and the package page shows the repository link and the package inherits that repository's permissions; omit it and neither happens, whatever the path looks like. State it at push time with --annotation:

shell
ocx package push -c -p linux/amd64 -i ghcr.io/acme/tools/widget:1.2.3 \
  --annotation org.opencontainers.image.source=https://github.com/acme/widget \
  widget-1.2.3-linux-amd64.tar.xz

In CI the value is already in the environment — on GitHub Actions it is $GITHUB_SERVER_URL/$GITHUB_REPOSITORY. OCX deliberately does not read that variable itself: a package built anywhere other than the forge you assumed would then silently claim provenance it does not have, and OCX publishes to any registry from any forge.

The annotation lands on the image index of every tag the push writes, cascade tags included, so a rolling alias never advertises weaker provenance than the version tag it points at. Repeat the flag for other keys — org.opencontainers.image.revision for the commit, org.opencontainers.image.licenses for the SPDX expression. Leaving the flag off writes nothing and leaves any annotation an earlier push set in place.

Reusing Layers Across Packages

A package's content is the union of its layers — a base, optional middle layers, a top layer with the binary. Re-pushing a layer that already exists in the target registry is wasteful: the registry GC will dedupe in the background, but the publisher already spent the upload bandwidth and the consumer pays the download cost on first install.

OCX lets you reference any layer that is already in the target registry by digest:

<algo>:<hex>.<ext>

<algo> is one of sha256, sha384, or sha512, with the matching hex length (64, 96, or 128 chars). The extension (tar.gz / tar.xz / tar.zst / aliases tgz/txz/tzst/tar.zstd) is mandatory — OCI blob HEADs do not carry the original media type, so the publisher must declare it. A bare <algo>:<hex> without an extension is rejected. OCX HEADs the registry to verify the digest exists, then records the existing layer in the new manifest without re-uploading. This is the foundation of the three-tier storage model: shared base archives are written once, hardlinked into every package that references them, and never re-downloaded.

The hand-publishing pattern (the ocx_mirror tool currently always uploads file layers; cross-release digest reuse is something hand-driven publishers compose manually):

  1. Bundle the base archive once and capture its digest. Bundles are not byte-reproducible across runs (mtimes vary; see Bundle anatomy → stable archives), so the digest is a property of the run that produced the archive, not of the source tree. Record it after creating the file (sha256sum base.tar.xz) — the local file digest equals the layer digest the registry stores.
  2. Push the first release with the file layer. OCX uploads it under the digest captured in step 1.
  3. Push later releases by digest. Re-reference the same blob via sha256:<hex>.<ext> — no re-bundle, no re-upload, no extra storage, no consumer re-download.
Reusing a base layer across two releases

Pathological filenames

If a file in your working directory is literally named sha256:abc….tar.gz, prefix it with ./ to force file interpretation. Bare <algo>:<hex>.<ext> tokens are always parsed as digest references.

Signing after push

A pushed manifest is identified by its digest, but nothing prevents a registry operator or network attacker from substituting a different binary under the same tag. Sigstore keyless signing closes that gap: it binds the manifest digest to your OIDC identity (GitHub Actions workflow, Google account, email) via a short-lived certificate, logs the entry in Rekor's append-only transparency log, and attaches the resulting bundle to the manifest as an OCI Referrers artifact. No long-lived signing keys, no key management.

After pushing, sign the platform manifest:

sh
ocx package sign -p linux/amd64 my/cmake:3.28

Consumers verify by supplying the expected signer identity — verification fails loudly rather than silently if the bundle is absent or the identity does not match.

For implementation detail (TUF trust root loading, referrers-capability cache, how a referrer is published, bundle storage paths, and slice boundaries) see Signing In Depth.

For command flags, token-source precedence, and exit codes see the package sign reference and package verify reference.

See Also