Skip to content
ocx
install

Promote packages between environments

Promote a tested package from one registry to the next with ocx package copy, so the build that QA approved is the one that ships.

You need credentials for both registries: read access to the source and write access to the target. Authenticate with a private registry covers ocx login and the OCX_AUTH_<REGISTRY>_* variables.

Your organization runs three registries. Developers publish to dev, QA validates in staging, and only what QA signed off reaches prod. The build that was tested is the one that has to ship.

Without a way to move a published artifact, the only route to production is to build it again. A rebuild produces different bytes. Compression is not byte-stable, archive timestamps move, and the platform manifest that names the result gets a new digest.

That digest is load-bearing twice over. A Sigstore signature’s subject is the platform manifest digest, and every ocx.lock entry pins it. A rebuilt production package therefore arrives without the staging signature applying to it. Every downstream lock then points at an artifact that no longer exists in the pipeline, while the release job reports success.

ocx package copy moves the bytes instead. It transfers the platform manifests and their blobs verbatim, so the digest at production is the digest dev published. Everything anchored to that digest travels with it, including signatures, SBOMs and attestations.

A published package is three different kinds of object, and only one of them is content:

ObjectWhat a copy does with it
The platform manifest and its blobsTransfers them byte for byte. The digest does not change, and that is the point.
The tag’s image indexMerges one platform at a time. Promoting linux/amd64 never removes a darwin/arm64 the target already offers.
Rolling tags (1.4, 1, latest)Recomputes them against the target’s tag list, never carries the source’s over.

The last two are why a promotion is not docker pull followed by docker push. An image index is a mutable set keyed by platform. Copying one wholesale would delete every platform the target had and the source did not.

Whether 1.4 should point at 1.4.2 also depends on what the target publishes. A staging registry that is one release ahead of production has a different answer. Promoting 1.4.1 into production must not drag 1.4 backwards.

The copy is also written in two phases. Manifests, blobs and referrers land first. They are pure additions and stay invisible until a tag names them. Only then do the index merges and the rolling tags move. An interruption partway leaves the target’s tags exactly as they were.

--to rewrites only the registry host and keeps the repository path and the tag. That is the shape a promotion almost always has:

Terminal window
# dev -> staging
ocx package copy --to staging.example.com --cascade acme/mytool:1.4.2
# staging -> prod, once QA signs off
ocx package copy --to prod.example.com --cascade staging.example.com/acme/mytool:1.4.2

Each run reports one row per platform, saying what happened to it:

ResultMeaning
addedProduction had no entry for this platform.
unchangedProduction already pointed at this exact digest.
replacedProduction pointed at a different digest for this platform.
kept (not in source)Production offers this platform and the source does not, so the merge left it alone.

Read that last row rather than skimming it. Promoting a subset of platforms into a registry that offers more is legitimate. It is also a common mistake, and the row list is the only thing that tells the two apart.

Re-running a finished promotion is idempotent, not free. Every row reads unchanged. OCX still re-verifies each platform’s leaf manifest and referrer set against the target instead of trusting the index entry. A retry therefore re-fetches and re-PUTs the manifest, and the referrer chain too when signatures are attached.

OCX skips only blob content, using a HEAD request against the target. Pipelines can retry the step without special-casing it. See ocx package copy for the mechanism.

Signatures travel by default. Everything anchored to a manifest through the OCI Referrers API is copied along with it. That covers Sigstore bundles, SBOMs and attestations. OCX follows referrer chains recursively, so a signature over an SBOM arrives too. Verify at the target exactly as you would at the source:

Terminal window
ocx package verify -p linux/amd64 prod.example.com/acme/mytool:1.4.2

This works only because the digest did not move. It is also why OCX refuses a target registry without the Referrers API, with exit 82. Such a registry takes a referrer manifest as an ordinary upload and then never lists it, so the provenance would be lost silently. Pass --no-referrers to promote the package alone, deliberately.

Descriptions do not travel by default. The README, logo and catalog annotations on the __ocx.desc tag are repository-level prose, not part of the version being promoted. Environments legitimately carry different ones. A staging catalog page that says “not for production use” should not follow the package to production. Add --with-description to copy it along, or promote it on its own once it is right:

Terminal window
ocx package description push --from staging.example.com/acme/mytool prod.example.com/acme/mytool

A copy is not a re-sign. The signature that travels still names the identity that signed it in the source environment. If your policy requires a production-specific attestation, sign again at the target. Promotion preserves provenance and does not manufacture it.

--platform filters a tag’s platforms. A release that ships four can be promoted one at a time as each one clears validation:

Terminal window
ocx package copy --to prod.example.com --platform linux/amd64 staging.example.com/acme/mytool:1.4.2

Against a digest the same flag means something different. It declares the platform, and exactly one is required. A platform manifest carries no platform of its own, because OCX records that in the index entry and never in the manifest. There is nothing to read it from, and guessing would file the package under a platform nobody built it for.

A digest source also needs --identifier, because a digest carries no tag for --to to preserve:

Terminal window
ocx package copy \
--identifier prod.example.com/acme/mytool:1.4.2 \
--platform linux/amd64 \
staging.example.com/acme/mytool@sha256:<hex>

OCX refuses to name an image index by digest (exit 64). An index digest is a snapshot of a mutable set. There is no honest way to merge “the platform list as it was” into a target that has moved on, so name the tag instead.

--dry-run reports the per-platform rows and writes nothing, so a release job can show the plan before it acts. Plain output labels each row would add or would replace, and --format json keeps added and replaced:

Terminal window
ocx package copy --to prod.example.com --dry-run staging.example.com/acme/mytool:1.4.2

The preview stops at the per-platform disposition. With --cascade, --dry-run does not compute the rolling tags that would move at the target. It does not compute the __ocx.keep.<algorithm>-<hex> keep tag that --keep-tag (the default) would write either. Both are decided in the second phase of a copy, which --dry-run never runs.

A pipeline that gates on --dry-run --format json sees empty tag arrays regardless of --cascade. That is a dry-run limitation, not a report that nothing would move.