Promoting packages between environments
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 — and 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. So a rebuilt production package silently arrives with the staging signature no longer applying to it and every downstream lock pointing at an artifact that no longer exists in the pipeline — all while the release job reports success.
ocx package copy moves the bytes instead. The platform manifests and their blobs are transferred verbatim, so the digest at production is the digest dev published, and everything anchored to it — signatures, SBOMs, attestations — travels with it.
Analogy: promotion and patches
Both tiers keep an upstream artifact intact rather than forking it. [patches] adapts what environment a tool runs in without touching the package; promotion moves a package between registries without touching its bytes. Neither rebuilds anything.
How it works
A published package is three different kinds of object, and only one of them is content:
| Object | What a copy does with it |
|---|---|
| The platform manifest and its blobs | Transfers them byte for byte. The digest does not change — that is the point. |
| The tag's image index | Merges 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 + docker push. An image index is a mutable set keyed by platform, so copying one wholesale would delete every platform the target had and the source did not. And whether 1.4 should point at 1.4.2 depends on what the target publishes: a staging registry that is one release ahead of production has a different answer, so 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 — all pure additions, invisible until a tag names them — and only then do the index merges and the rolling tags move. An interruption partway leaves the target's tags exactly as they were.
Promoting a release
--to rewrites only the registry host and keeps the repository path and the tag, which is the shape a promotion almost always has:
# 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.2Each run reports one row per platform, saying what happened to it:
| Result | Meaning |
|---|---|
added | Production had no entry for this platform. |
unchanged | Production already pointed at this exact digest. |
replaced | Production 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. |
That last row is worth reading rather than skimming. Promoting a subset of platforms into a registry that offers more is a legitimate thing to do and 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, but each platform's leaf manifest and referrer set are still re-verified against the target rather than trusted from the index entry, so a retry still re-fetches and re-PUTs the manifest (and, with signatures attached, the referrer chain). Only blob content is skipped, via a HEAD against the target. Pipelines can retry the step without special-casing it — see ocx package copy for the mechanism.
What travels, and what does not
Signatures travel by default. Everything anchored to a manifest through the OCI Referrers API — Sigstore bundles, SBOMs, attestations — is copied along with it, following referrer chains recursively so a signature over an SBOM arrives too. Verify at the target exactly as you would at the source:
ocx package verify -p linux/amd64 prod.example.com/acme/mytool:1.4.2This works only because the digest did not move. It is also the reason a target registry without the Referrers API is refused with exit 84 rather than accepted: 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 rather than part of the version being promoted, and environments legitimately carry different ones — a staging catalog page that says "not for production use" should not follow the package to production. Add --description to copy it along, or promote it on its own once it is right:
ocx package description push --from staging.example.com/acme/mytool prod.example.com/acme/mytoolA 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, it does not manufacture it.
Promoting a single platform
--platform filters a tag's platforms, so a release that ships four can be promoted one at a time as each one clears validation:
ocx package copy --to prod.example.com --platform linux/amd64 staging.example.com/acme/mytool:1.4.2Against 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 — OCX records that in the index entry, never in the manifest — so 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:
ocx package copy \
--identifier prod.example.com/acme/mytool:1.4.2 \
--platform linux/amd64 \
staging.example.com/acme/mytool@sha256:<hex>Naming an image index by digest is refused (exit 64). An index digest is a snapshot of a mutable set, and there is no honest way to merge "the platform list as it was" into a target that has moved on — name the tag instead.
Checking before committing
--dry-run reports the same per-platform rows and writes nothing, so a release job can show the plan before it acts:
ocx package copy --to prod.example.com --dry-run staging.example.com/acme/mytool:1.4.2The preview stops at the per-platform disposition, though. With --cascade, the rolling tags that would move at the target are not computed under --dry-run, and neither is the __ocx.keep.<algorithm>-<hex> keep tag that --keep-tag (the default) would write — both are decided in the second phase of a copy, which --dry-run never runs. A pipeline gating 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.
In depth
ocx package copy— every flag, and the full exit-code table.- Signing and verification — why the manifest digest is the signature's subject.
- Locking — how
ocx.lockpins a platform manifest digest. - Versioning and cascades — what the rolling tags mean and when they move.