CLI Reference¶
ocx-mirror mirrors upstream binary releases into OCI registries. Package-mirroring commands live under the package namespace and take a mirror.yml spec: package sync, package check, and package validate form the local loop, while the package pipeline family implements the generated CI pipeline job by job. schema is a top-level utility. A sibling registry namespace mirrors a whole upstream index into a corporate registry instead of one tool at a time — registry sync takes a registry.yml spec. A third dist namespace mirrors the bootstrap layer — ocx's own release archives and the dist.json manifest naming them — into a generic HTTP store rather than a registry; dist sync takes a dist.yml spec.
Global flags¶
| Flag | Values | Description |
|---|---|---|
--log-level <LEVEL> |
trace, debug, info, warn, error |
Log verbosity (default: info) |
--color <WHEN> |
auto, always, never |
When to use ANSI colors in output (default: auto) |
When --log-level is omitted, verbosity comes from the first of OCX_LOG_CONSOLE, OCX_LOG, RUST_LOG that is set, and from info when none is. Those accept full tracing filter directives, so RUST_LOG=info,ocx_mirror=debug,reqwest=trace narrows the noise to the legs you are debugging — which is what to reach for when a fetch fails and the error alone does not say why. Passing --log-level explicitly overrides all three.
package sync¶
Mirror packages from a spec file to an OCI registry: list upstream versions, resolve assets per platform, filter against tags already published, then download, verify, bundle (concurrent), and push (sequential by version, oldest first).
ocx-mirror package sync <SPEC> [OPTIONS]
| Argument / flag | Default | Description |
|---|---|---|
<SPEC> |
— | Path to the mirror spec YAML file |
--work-dir <DIR> |
./.ocx-mirror |
Working directory for downloads, bundles, and intermediate artifacts. Persists between runs so failed tasks resume without re-downloading; cleaned up per task after a successful push. |
--dry-run |
off | Only check what would be mirrored |
--version <V> |
— | Only mirror specific versions. Comma-separated or repeated (--version 3.28.0,3.29.0). Matched against the version string extracted from the source. |
--latest |
off | Only mirror the highest version. Applied after all other filters. |
--fail-fast |
off | Stop on first failure instead of continuing |
--format <FMT> |
plain |
Output format: plain (table + summary) or json |
package check¶
Dry-run alias for package sync: identical discovery and filtering, no downloads, no pushes. Accepts the same arguments and flags as package sync (--dry-run is forced on).
ocx-mirror package check <SPEC> [OPTIONS]
package validate¶
Validate a mirror spec file — YAML schema, regex syntax, required capture groups. No network access.
ocx-mirror package validate <SPEC>
schema¶
Generate a JSON Schema for mirror types and print it to stdout.
ocx-mirror schema <TARGET>
| Argument | Values | Description |
|---|---|---|
<TARGET> |
url-index |
Schema to generate (the url_index source document format) |
package pipeline¶
Subcommands implementing the per-mirror CI pipeline. Each maps to one job in the workflow rendered by pipeline generate ci: discover → prepare → test → push → notify. describe, announce, patch and cascade each own a standalone workflow outside that chain; the patch one is workflow_dispatch only, because it acts on an already-published mirror on a maintainer's decision; cascade and announce are dispatch too, each plus an optional schedule its spec opts into (cascade, announce). The test job runs ocx package test directly; everything else is an ocx-mirror invocation.
package pipeline generate ci¶
Render (or check) the CI workflow files for a mirror repository. A repository may hold several mirror specs — --spec repeats, once per spec (see Multi-spec repositories). Each spec gets its own mirror.yml / describe.yml / patch.yml set, plus cascade.yml when it publishes rolling tags (cascade left at its default, or set to a map) and announce-from-registry.yml when it has an announce: block; the repository gets exactly one verify-generated.yml drift guard, unless every spec sets allow_manual_edits: true. Generated filenames derive from where each spec sits relative to the repository root: the root spec keeps today's names byte for byte, any other spec gets every name suffixed with its own directory.
ocx-mirror package pipeline generate ci [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to a mirror spec file; repeat once per spec the repository holds |
--repo-root <DIR> |
the directory every --spec shares |
Repository root the workflows are written under, and generated filenames are computed relative to |
--check |
off | Verify generated files are up to date; exit 65 on drift |
--format <FMT> |
— | Output format for diagnostics (plain, json) |
Rendering is idempotent, and does not depend on the order repeated --spec flags are given in. Specs with hardcoded webhook URLs or an empty tests: list are rejected with exit 64 before any file is written — as are two specs sharing one directory, and a spec that does not resolve under --repo-root (see Multi-spec repositories).
Do not let a bot bump versions inside generated workflows
The mirror repository owns its action pins — the drift guard normalises uses: owner/action@<ref> away before comparing, so a Renovate or Dependabot digest bump on a generated workflow stays green. Nothing else in those files is bot-editable. In particular the ocx version each workflow pins (the version: input to setup-ocx, and the release the container test legs download) comes from the renderer, so an in-place bump makes the committed file differ from what the spec renders and the drift guard fails with exit 65. Exclude generated workflows from any such rule and update the version by bumping ocx-mirror and re-running generate ci.
package pipeline plan¶
Compute which versions need work. Side-effect-free: queries the upstream source and the target registry, then emits a plan document listing versions to mirror, including the resolved per-platform asset URLs.
ocx-mirror package pipeline plan [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--format <FMT> |
auto | plain or json. Without the flag, JSON is selected automatically when GITHUB_ACTIONS=true. |
--locks-dir <DIR> |
./locks |
Directory derived PEP 751 locks are written to (source.type: pypi only; unused for other source types). Each pypi plan entry's pylock field carries a path relative to this command's working directory — the same directory plan.json is written to. See the python.lock reference. |
Alongside new (not yet published) and backfill-partial (published for some platforms, missing for others), a plan entry can carry kind metadata-drift: a published (version, platform) whose config blob no longer matches what the spec would publish today. Drift is only ever reported, never acted on — a version already scheduled as new or backfill-partial is never also reported as drifted, since its next push writes current metadata anyway.
The JSON document is schema_version: 3 and adds a has_drift flag alongside has_new:
{
"schema_version": 3,
"has_new": true,
"has_drift": false,
"versions": [...],
"target": "ocx.sh/cmake",
"ocx_mirror_rev": "abc123..."
}
has_new deliberately ignores drift-only versions — the generated workflow's discover job gates the download-and-build jobs on it, and a drift fix has nothing to download. The discover job also drops every metadata-drift entry before building the prepare matrix: a drift entry carries no resolved assets, so a prepare leg for one would abort looking for a bundle nothing wrote. has_drift surfaces the finding for a human to act on with pipeline patch.
package pipeline prepare¶
Download, verify, and bundle one version across all declared platforms. Writes {work_dir}/{V}/{platform_slug}/bundle.tar.xz per platform plus {work_dir}/{V}/manifest.json with sizes and digests.
ocx-mirror package pipeline prepare --version <V> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--version <V> |
required | Version to prepare (e.g. 3.29.0) |
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--work-dir <DIR> |
./.ocx-mirror |
Working directory for intermediate artifacts |
--plan <PATH> |
— | A plan.json produced by pipeline plan. When set, tasks are built from the plan's resolved assets and the source is never queried — one crawl per pipeline run instead of one per prepare leg. |
package pipeline push¶
Aggregate JUnit results and publish passing platform packages. Single serial push driver and the sole writer of cascade tags in the pipeline: for each (version, platform) pair, all containers must be green for the bundle to publish.
ocx-mirror package pipeline push --bundles-dir <DIR> --junit-dir <DIR> --write-summary <PATH> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--bundles-dir <DIR> |
required | Directory containing bundle-{V}-{platform_slug}.tar.xz files |
--junit-dir <DIR> |
required | Directory containing junit-{V}-{platform_slug}-{container_id}.xml files |
--write-summary <PATH> |
required | Path to write the run-summary.json output file |
Exits 0 even when some versions fail — the summary records per-version outcomes. Exits 69 on registry unreachability mid-push, 74 on I/O failure reading JUnit/bundles or writing the summary. A transient push failure (exit 75) is retried with capped, jittered exponential backoff up to concurrency.max_retries, each attempt bounded by a timeout — see concurrency for the full policy.
package pipeline notify¶
Post a Discord webhook notification from run-summary.json. Silent (exit 0, no POST) when all versions were skipped as already existing and no test failures occurred. Reads the webhook URL from OCX_MIRROR_DISCORD_HOOK and the optional mention target from OCX_MIRROR_DISCORD_USER_ID.
ocx-mirror package pipeline notify --run-summary <PATH>
| Flag | Default | Description |
|---|---|---|
--run-summary <PATH> |
required | Path to the run-summary.json produced by pipeline push |
package pipeline describe¶
Publish catalog metadata (README + logo) to the registry by spawning ocx package describe. Reads the catalog: spec section; when the resolved README (default CATALOG.md) does not exist, the command logs and exits 0.
ocx-mirror package pipeline describe [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
package pipeline announce¶
Announce every tag the target repository currently holds into the index, by spawning ocx package announce --tags-from-registry. Additive: it cannot drop a tag the index already commits, and yank markers survive.
This is the catch-up path for a mirror that published before it gained an announce: block — the push job announces only what its own run wrote, so no future run ever covers that backlog. Driven by the generated announce-from-registry.yml workflow, which is dispatched, or run on a timer when the spec sets one.
ocx-mirror package pipeline announce [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--dry-run |
off | Write the rebuilt entry to a temporary directory and report updated / unchanged without opening a pull request |
Needs OCX_ANNOUNCE_TOKEN unless --dry-run is set. Exits 64 when the spec has no announce: block — there is no index package to announce into.
Schedule mode. A spec that sets announce: { schedule: … } also gets a schedule: trigger on that workflow, keeping the dispatch. dry_run has no value outside a dispatch, so the workflow resolves DRY_RUN itself — false on a schedule event, the input's value on a dispatch — and a scheduled run therefore announces for real. A run that finds nothing new is silent: an unchanged announce commits nothing, and opens a pull request only when an earlier run stranded commits on the announce branch (unchanged with a pull request URL in the log). Green is not proof an announce ran: on a target other than ghcr.io — whose credential probe is constant — the announce step is skipped when the registry credentials are absent, and a skipped step leaves the job green, so a scheduled run on a repo whose token was never set or has been rotated looks exactly like a caught-up one. The ::notice:: from the credential check is the only signal, and on a timer nobody reads it — check it after enabling the schedule, and again after any token rotation.
package pipeline cascade¶
Repair the target repository's rolling-tag graph by spawning ocx package cascade repair. A cascade breaks when a push lands out of order or dies half-way: 3.29.0 is published, but 3.29, 3 and latest still name the version before it, so everyone installing the unpinned name gets the older package while the registry looks complete. The repair re-points those aliases at content the registry already serves — no upstream download, no new layer.
Runs from the generated cascade.yml workflow, emitted for every spec that publishes rolling tags. Dispatch is always available, and its single dry_run input defaults to true, so a dispatch that changes nothing audits.
gh workflow run cascade.yml --repo <owner>/<mirror> -f dry_run=false
Schedule mode. A spec that sets cascade: { schedule: … } also gets a schedule: trigger on that workflow, keeping the dispatch. dry_run has no value outside a dispatch, so the workflow resolves DRY_RUN itself — false on a schedule event, the input's value on a dispatch — and a scheduled run therefore repairs for real. A healthy one is silent green: nothing to fix, exit 0, no announce. It reds on exit 65 — findings that remain — and on exit 1 when the repair could not run at all (see the exit-code note below). Green is not proof a repair happened: on a target other than ghcr.io — whose credential probe is constant — the repair step is skipped when the registry credentials are absent, and a skipped step leaves the job green, so a scheduled run on a repo whose token was never set or has been rotated looks exactly like a healthy one. The ::notice:: from the credential check is the only signal, and on a timer nobody reads it — check it after enabling the schedule, and again after any token rotation.
ocx-mirror package pipeline cascade [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--dry-run |
off | Print the repair plan without writing a tag or touching the index |
Requires ocx 0.5.4 or newer
ocx package cascade does not exist before 0.5.4, and an older binary rejects the verb as an unknown argument. Until the mirror repository's pinned ocx reaches 0.5.4, a dispatch fails with exit 1 and a message naming the requirement.
Exit 65 means findings remain, not that the tool broke. A --dry-run whose plan is non-empty exits 65, and so does a real repair that could not re-point everything it found — both are audit results a maintainer acts on. Every other non-zero outcome (ocx missing the verb, a registry refusal, a failed announce) is exit 1.
Announces what moved. A repaired alias points at a digest the index does not know, so a run that re-pointed anything ends by announcing those tags — including a run that exited 65, because the aliases it did move are live either way. The announce only happens for a real repair, never a dry run, and only for a spec with an announce: block. An absent OCX_ANNOUNCE_TOKEN is a valid configuration and degrades to a notice, exactly as in pipeline push; an announce that fails after tags moved fails the command, since that is the state where the index still points at what the repair replaced.
package pipeline patch¶
Correct the published metadata of versions the registry already holds, without re-downloading or re-uploading anything. Package metadata lives in the OCI config blob, never in a layer, so a fix is a manifest re-emission that re-references the existing layers by digest — the only bytes uploaded are a config blob the size of metadata.json. This is the retroactive counterpart to a metadata-drift entry in pipeline plan: fix metadata.json (or the spec's metadata: block) in the mirror repo, then run patch to reach every version already published under the old, wrong metadata — the alternative, deleting tags and re-mirroring, costs hours of upstream download and orphans anyone pinned to a digest.
Runs from the generated patch.yml workflow, which is workflow_dispatch only — never scheduled, and deliberately not wired to pipeline plan's has_drift output. Whether a drift finding is worth re-emitting manifests over is a maintainer's decision; the workflow exists so that acting on it does not need registry push credentials and an index token on somebody's laptop. Dispatch it from the repository's Actions tab, or:
gh workflow run patch.yml --repo <owner>/<mirror> -f version=3.29.0
Its three inputs are this command's selection flags. version takes one or several versions, separated by spaces or commas, and becomes one --version flag each; min_version and max_version pass through. An input left empty contributes no flag at all, so dispatching with every field blank patches every published version.
ocx-mirror package pipeline patch --metadata-only [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--spec <PATH> |
./mirror.yml |
Path to the mirror spec file |
--metadata-only |
— | Required. Republish the metadata blob and nothing else — currently the only mode. |
--version <VERSION> |
— | Patch this published version. Repeatable. Matches the leaf tag verbatim or by its version core, so --version 3.29.0 selects 3.29.0_20260610 on a build-stamped mirror. Composes with --min-version/--max-version as a union. A version the registry does not publish is a usage error (exit 64), not a silent no-op. |
--min-version <VERSION> |
— | Lowest published version to patch, inclusive. Compared on the version core, so it covers every build stamp of the version it names. |
--max-version <VERSION> |
— | Highest published version to patch, exclusive. Compared on the version core, so it excludes every build stamp of the version it names. |
Omitting --version, --min-version, and --max-version all at once patches every published version.
Only leaf tags are patched. A cascade alias (3.29, 3.29.0 on a build-stamped mirror, latest) shares its leaf's child manifests and re-cascades from it automatically once the leaf is patched, so naming an alias in --version is a usage error rather than a silent no-op or a wasted duplicate run.
Idempotent. A (version, platform) whose published config blob already matches what the spec would publish today is skipped — settled from the config descriptor's digest, with no registry blob fetch for the common case. There is no ledger and no stored range: the comparison against the currently published bytes is the entire mechanism, which is also why the version range lives only on this command line and not in mirror.yml — a stored range would need a ledger to know what it had already covered.
Announces on success. A run that republished anything ends by announcing (the same --tags-from-registry path as pipeline announce), because the re-emitted manifests are live under digests the index does not yet know. An announce that fails after a successful patch fails the command loudly: that is the state where the index still points at digests the patch just replaced. An absent OCX_ANNOUNCE_TOKEN does not fail it — a repository without the secret is a valid configuration, and the skip is recorded as a notice, exactly as in pipeline push.
Layer pins are never disturbed. Layer digests are unchanged by a metadata patch, so no layer is ever orphaned; the patched manifest gets its own canonical sha256:<hex> tag alongside the version tag.
registry sync¶
Copy one or more whole upstream index sources into a corporate registry you control, and write the servable index tree that points at them: pre-flight every source (fetch its catalog, filter and expand include/exclude into destination repositories), detect destination collisions across all sources, then copy each source's packages by digest and write the index documents last. A package's root document is written only after every byte it names is confirmed at the destination, so an interrupted run never leaves a partially-visible package.
ocx-mirror registry sync [SPEC] [OPTIONS]
| Argument / flag | Default | Description |
|---|---|---|
[SPEC] |
./registry.yml |
Path to the registry spec. Optional, unlike package sync's required <SPEC> — a corporate mirror repo holds exactly one spec |
--dry-run |
off | Report what would be copied, and how many bytes, without copying or writing anything — the cache digest included |
--fail-fast |
off | Stop at the first per-package failure, overriding the spec's on_error: |
--repair-catalog |
off | Re-derive c/index.json from the root documents already on disk. Wholesale — covers every root under p/, including ones the current filter excludes |
--cache-dir <DIR> |
${XDG_CACHE_HOME:-~/.cache}/ocx-mirror |
Directory for the source-catalog digest and the index lock files. Never inside output: |
--format <FMT> |
plain |
Output format: plain (table + summary) or json |
Full field reference — registry.yml.
dist sync¶
Mirror the OCX bootstrap layer — the ocx release archives plus the dist.json manifest naming them — into a generic HTTP store, and rewrite every manifest URL to point at your copy. Nothing here touches an OCI registry: install.sh runs curl with no token and no jq, so the bootstrap path can only read plain files.
ocx-mirror dist sync [SPEC] [OPTIONS]
| Argument / flag | Default | Description |
|---|---|---|
[SPEC] |
./dist.yml |
Path to the distribution spec. Optional, like registry sync's — a distribution mirror repo holds exactly one spec |
--dry-run |
off | Resolve and filter the manifest and report what would be mirrored, without downloading, writing, or uploading anything |
--format <FMT> |
plain |
Output format: plain (table + summary) or json |
There is no --cache-dir — the run keeps no delta state, and asks the destination with a HEAD instead — and no --fail-fast, because a failed archive already stops the run before anything is published.
Clobber-safe. A run that cannot place every selected archive writes no manifest at all. The destination keeps its previous, internally consistent manifest rather than gaining one that promises archives the store does not hold. Archives that did land stay on disk, so the corrected re-run is cheap.
Ordered publishes. Archives first, then the content-addressed snapshot, and the rolling dist.json last — a consumer reading mid-run resolves either the old manifest or the new one, and both are fully backed.
Idempotent. An archive already in output: whose bytes match the manifest digest is re-verified, not re-downloaded; a file the destination already holds is skipped on the strength of a HEAD, not a local ledger.
Digests are upstream's. sha256 is copied from the source manifest and never recomputed — re-deriving it from what arrived would verify the copy against itself.
Full field reference — dist.yml.
Exit codes¶
Codes align with BSD sysexits.h, shared with the ocx CLI.
| Code | Meaning | Raised by |
|---|---|---|
| 0 | Success | — |
| 1 | Pipeline execution failure (download, push, verify, republish, a cascade repair that could not run at all, or a failed post-patch / post-repair announce); for registry sync, one or more packages failed to copy (digest mismatch, malformed manifest, a rejected push, referrers present, or source content missing) — reported per package in the summary, the run continues per on_error; for dist sync, an upload.identity environment variable was unset or empty, one or more archives failed to download or verify, two releases rendered to the same publish.layout path, select: left no release at all, or an upload was rejected — no manifest is published in any of those cases |
sync, prepare, push, pipeline patch, pipeline cascade, registry sync, dist sync |
| 64 | Usage error: hardcoded webhook URL, empty tests:, ambiguous shell, no announce: block, two specs sharing one directory, a spec outside --repo-root, an unparseable --version/--min-version/--max-version, a --version the registry does not publish or that names a cascade alias; for registry sync, a credential-shaped key anywhere in registry.yml, a missing or wrong kind:, or sources[].index carrying userinfo; the same three rules apply to dist.yml under dist sync |
validate, pipeline generate ci, pipeline announce, pipeline patch, registry sync, dist sync |
| 65 | Data error: spec validation failed, renderer drift (--check) — including a generated workflow left behind by a spec dropped from --spec — JUnit/plan/run-summary malformed, cascade findings remain; for registry sync, registry.yml validation failed (bad target/destination/as:/glob syntax, duplicate as:, a missing {registry} placeholder with more than one source, an expanded destination that fails the OCI grammar or collides with another package) or a source declared an index format_version newer than this build supports; for dist sync, dist.yml validation failed (empty output, a plaintext or userinfo-bearing source/publish.base_url, an unknown publish.layout placeholder, an Authorization entry in upload.headers) or the upstream manifest declared a schema newer than this build supports |
all |
| 69 | Upstream source or target registry unreachable; Discord 5xx / timeout; for registry sync, a source's index tree could not be fetched (unreachable, an unparseable or hostless base URL, or a host refused by the SSRF floor), a root's repository pointer was unparseable or SSRF-refused, or the destination registry answered a blob-presence probe without a definite yes or no, aborting the whole run; for dist sync, the upstream dist.json could not be fetched or parsed, or its body exceeded the 8 MiB cap |
sync, check, plan, push, notify, pipeline patch, registry sync, dist sync |
| 74 | I/O error: template render or file write failure; for registry sync, a local write into the served index tree failed (root document, c/index.json, config.json, a dispatch object, or --repair-catalog); for dist sync, a write into output: failed (an archive, dist.json, or a snapshot) |
pipeline generate ci, push, registry sync, dist sync |
| 77 | Discord 401/403 — webhook secret likely rotated | pipeline notify |
| 79 | Spec file not found | all |