Skip to content

mirror.yml Reference

mirror.yml describes one tool to mirror — where to fetch upstream releases, which platforms to build for, how to test each bundle, and how to report results. The file is consumed by ocx-mirror package sync, ocx-mirror package check, and all ocx-mirror package pipeline subcommands.

Top-level keys

Key Type Required Purpose
name string Yes Tool name, used in log output and notify messages
target object Yes OCI registry and repository to push to
source object Yes Upstream release source: GitHub Releases, URL index, a committed PEP 751 pylock.toml, or an index-discovered PyPI package
assets object Yes* Platform → regex list mapping for selecting upstream release archives. *Mutually exclusive with variants — exactly one of the two required for github_release/url_index sources. Not used by source.type: pylock/pypi (see wheels).
variants array No* Alternate asset sets for the same tool (per-variant assets/metadata/asset_type), each producing its own version-tag prefix. *Mutually exclusive with assets — exactly one of the two required for github_release/url_index sources; rejected for env sources (pylock/pypi). See variants.
metadata object No Path(s) to the package metadata JSON, with optional per-platform overrides. See metadata.
bin_scan string No off (default), auto, or verify — derive the published binaries claim from the extracted bundle. See bin_scan.
libc_lint boolean No Check a Linux build's declared os.features against the libc its binaries link against (true by default). See libc_lint.
asset_type object No How a downloaded asset becomes package content: archive (default, extracted) or binary (placed under a name), uniformly or per platform. See asset_type. Not used by source.type: pylock/pypi.
python object No* Interpreter version/ABI + interpreter_package, plus optional lock and entrypoints config. Required for source.type: pylock or pypi. See Python apps.
wheels object No* Per-platform wheel selection for env sources. Required for source.type: pylock/pypi; keys may carry +libc.glibc/+libc.musl (published as OCI os.features). See wheels.
wheel_scope string No Repo-naming scope prefix for shared wheel layers (source.type: pylock/pypi). Default pip-packages.
build_timestamp string No Per-build tag suffix: datetime (default), date, or none. See build_timestamp & GC-safe publishing.
cascade boolean or object No Cascade rolling tags on push (true by default), and optionally put the generated repair workflow on a timer. See cascade.
versions object No Version filter (min/max bounds, new_per_run, backfill order). Bounds may be resolved at run time from a URL or a command. See versions.
verify object No Checksum verification options. See verify.
concurrency object No Parallel download limits, source rate limiting, push retry policy. See concurrency.
tests array No* Commands to run against each installed bundle. Required when pipeline generate ci is used.
platforms object No* GHA runner and container matrix. Required when pipeline generate ci is used.
ocx_mirror object No Provenance of the ocx-mirror behind a plan. Pins nothing.
sign object No Publish-side package signing: keyless Sigstore or a signing key — exactly one mode tag is required. See sign.
notify object No Discord webhook notification settings
announce object No Index announce settings. See announce.
annotations object No Extra OCI annotations written onto every published image index. See annotations.
catalog object No README + logo published as registry catalog metadata. See catalog.

The tests, platforms, ocx_mirror, notify, announce, and catalog keys are used only by ocx-mirror package pipeline subcommands. sync and check ignore them.

target

The registry and repository a push writes to — the physical path.

target:
  registry: ghcr.io
  repository: ocx-contrib/bazelbuild/bazelisk

Path segments are separated by / and are never flattened into a hyphen: ocx-contrib/bazelbuild/bazelisk, not ocx-contrib/bazelbuild-bazelisk. GHCR needs no repository of its own for a path segment — package-to-repository linkage comes from the org.opencontainers.image.source annotation, which the pipeline writes automatically.

The physical path and the logical index package are related by convention, not by a rule: a mirror publishing to ghcr.io/ocx-contrib/bazelbuild/bazelisk announces the logical package bazelbuild/bazelisk. Spell both out.

When registry is ghcr.io, generated workflows log in with the run's own GITHUB_TOKEN and github.actor, and the push job declares an explicit permissions: block. The shared OCX_MIRROR_REGISTRY_USER / OCX_MIRROR_REGISTRY_TOKEN organisation secrets carry ocx.sh credentials and are not read on that path.

The first path segment must be the mirror repository's own owner. GITHUB_TOKEN authorises packages owned by the repository it runs in; docker login ghcr.io succeeds regardless — logging in is not authorisation — and the push then fails with denied: installation not allowed to Create organization package. So a mirror in ocx-contrib/mirror-bazelisk can publish ghcr.io/ocx-contrib/… without any secret being configured, and cannot publish under another owner without one. generate ci warns when it can see the mismatch (it reads GITHUB_REPOSITORY, so on a runner always, and locally only when that variable is set).

Declaring any permission sets every unnamed scope to none, so the generated block names every scope the push job's steps need:

    permissions:
      contents: read          # checkout, setup-ocx
      packages: write         # docker login + ocx package push
      actions: read           # resolving the job URL for the notification links
      checks: write           # test-result check run
      pull-requests: write    # test-result pull-request comment

source

Where upstream versions and their download URLs come from. source.type picks one of four discovery models; every other key under source: belongs to exactly one of them, and an unrecognised key is a hard parse failure rather than a silent no-op.

type Discovery Asset selection
github_release GitHub Releases API for owner/repo assets regexes over release asset names
url_index A JSON index document — fetched, inline, or generated assets regexes over the index's asset names
pylock A committed PEP 751 pylock.toml wheels — see Python apps
pypi A Simple Repository API index wheels — see source.type: pypi

source.type: github_release

source:
  type: github_release
  owner: Kitware
  repo: CMake
  tag_pattern: "^v(?P<version>\\d+\\.\\d+\\.\\d+)$"

tag_pattern is a regex matched against each release's tag. It must contain a named capture group (?P<version>...), whose text becomes the mirrored version; an optional (?P<prerelease>...) group is appended as -<suffix> and marks the version as a pre-release. Drafts and non-matching tags are skipped. The default pattern is ^v?(?P<version>\d+\.\d+\.\d+)(?:-(?P<prerelease>[0-9a-zA-Z]+))?$.

Release listing uses GITHUB_TOKEN when set — without it, the unauthenticated 60-requests/hour quota applies, which a release-heavy backfill exhausts.

source.type: url_index

Exactly one of url, versions, or generator — never two, never none:

source:
  type: url_index
  url: "https://example.com/versions.json"       # fetch the document
# versions: { ... }                               # or write it inline
# generator: { command: [...] }                   # or produce it on stdout

generator runs a command whose stdout must be the same JSON document: command (a non-empty argv list), optional working_directory (relative to the spec directory), and timeout_seconds (default 60). Empty output and a non-zero exit are both hard errors.

The document is url-index v1 (https://ocx.sh/schemas/url-index/v1.json, emitted by ocx-mirror schema url-index):

{
  "versions": {
    "1.0.0": {
      "prerelease": false,
      "assets": {
        "tool-1.0.0-linux-amd64.tar.gz": "https://example.com/tool-1.0.0-linux-amd64.tar.gz",
        "tool-1.0.0-darwin-arm64.tar.gz": {
          "url": "https://example.com/tool-1.0.0-darwin-arm64.tar.gz",
          "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
        }
      }
    }
  }
}

An asset value is either a bare URL string or an object carrying the URL plus the publisher's sha256 (which is required in the object form — an object without it is a verbose string). The two forms mix freely inside one version. This is additive: the schema renders them as alternatives, so the document stays url-index v1 under the same $id and every existing generator keeps validating.

The digest is normalised at crawl time — a bare hex string becomes sha256:<hex>, an already-prefixed value passes through — and validated there, so a malformed one fails before any bytes move rather than per platform after the download. What it then obliges the download to do is verify.url_index_digest.

The same two forms apply to the inline versions: map, which is the identical shape written directly in mirror.yml.

source.url_rewrite

A prefix substitution applied to every upstream asset URL the source produces. Discovery stays where it is; only the download host moves.

source:
  type: github_release
  owner: Kitware
  repo: CMake
  url_rewrite:
    from: "https://github.com/"
    to: "https://artifactory.example.com/artifactory/githubcom-remote/"

A release asset at https://github.com/Kitware/CMake/releases/download/v3.29.0/cmake.tar.gz is then fetched from https://artifactory.example.com/artifactory/githubcom-remote/Kitware/CMake/releases/download/v3.29.0/cmake.tar.gz. The GitHub API call that found that release still goes to GitHub — the rewrite is purely a download-side substitution.

A URL that does not start with from comes back unchanged, so a release hosting one asset on a CDN the proxy does not front still mirrors.

Both halves must be http(s) URL prefixes and neither may be empty. Neither may embed credentials (https://user:pass@…) — that is refused, not stripped, for the same reason source.indexes refuses it: a mirror.yml is committed.

Both halves are normalised before matching, so https://GitHub.com/, HTTPS://github.com/, https://github.com:443/ and https://github.com all name the same prefix and all match. Write whichever you like; the rewrite will not quietly go inert because a host was capitalised or a default port spelled out.

Credentials follow the request host, not the source type. The mirror resolves a credential from the URL it is actually requesting, so a rewritten host receives that host's own OCX_AUTH_<slug>_* variables or netrc entry — never GitHub's. An operator pointing downloads at Artifactory configures the Artifactory credential separately.

Supported on github_release and url_index. On pylock/pypi it is refused by name: an env source resolves wheels, not per-platform archives, so a proxy index belongs in source.indexes instead.

extends: is a shallow top-level merge, so a shared base cannot contribute source.url_rewrite alone — a child spec that declares source: replaces the whole block. That is what OCX_MIRROR_URL_REWRITE is for: it overrides this block entirely from the environment, keeping one spec byte-identical between a public repository and an internal fork.

TLS note: since v0.7.0 the GitHub Releases listing client is built through the mirror's own HTTP factory like every other leg, so OCX_EXTRA_CA_CERTS and the proxy variables reach it too — the API-here / downloads-there split no longer implies a trust-root split. On an older binary the listing leg honoured neither; see OCX_EXTRA_CA_CERTS for the workaround.

assets

Maps a platform key to an ordered list of regexes. Each regex is matched against upstream asset filenames; the first platform with exactly one distinct match resolves to that asset (zero matches = platform absent for that version, two or more = ambiguous error).

A platform key is <os>/<arch> with optional suffixes:

<os>/<arch>[/<variant>][+libc.<flavor>[,...]]
assets:
  linux/amd64:
    - "tool-.*-linux-x86_64\\.tar\\.gz"
  darwin/arm64:
    - "tool-.*-darwin-arm64\\.tar\\.gz"

libc variants

When a tool ships separate builds for different C libraries on the same os/arch (e.g. glibc and musl on linux/amd64), append a +libc.<flavor> tag to the key. The tag is published into the OCI image index as an os.features entry, so a client (ocx add) selects the build matching its host libc:

assets:
  "linux/amd64+libc.glibc":
    - "cpython-.*-x86_64-unknown-linux-gnu.*\\.tar\\.zst"
  "linux/amd64+libc.musl":
    - "cpython-.*-x86_64-unknown-linux-musl.*\\.tar\\.zst"

libc.glibc and libc.musl are the recognized flavors. The two keys are distinct platforms — each needs its own regex list, and each publishes as its own image-index entry. A key with no +libc. tag carries no libc requirement and resolves for any host (the pre-libc behavior). Quote keys containing + so YAML parses them as strings.

Python apps (source.type: pylock / pypi)

A pylock or pypi source mirrors a Python application into a runnable OCX environment package — the union of every resolved wheel plus a private interpreter, composed so it runs via ocx exec on a clean machine with no pip, uv, or venv at runtime. This replaces the assets/asset_type archive model (both source types ignore both fields). The two types differ only in where the PEP 751 lock comes from:

  • pylock — a lock file committed to the mirror repository; resolves exactly one version (the one recorded in the lock).
  • pypi — versions are discovered from a PyPI-compatible index, and a lock is derived in-pipeline per version (see source.type: pypi).

Everything downstream of "a lock is in hand" — wheel selection (wheels), entrypoint synthesis (python.entrypoints), composition, and shared wheel layers — is identical for both.

name: black                       # PEP 503-normalized to match the app package in the lock
target:
  registry: dev.ocx.sh
  repository: ocx/black
source:
  type: pylock
  path: black.pylock.toml         # repo-relative path to the PEP 751 lock
python:
  version: "3.14.6"               # interpreter version
  abi: cp314                      # target ABI tag
  interpreter_package: "ocx.sh/cpython:3.14.6"   # OCX package providing the interpreter
wheels:
  "linux/amd64+libc.glibc":       # glibc entry (default filter [manylinux, any])
tests:
  - name: smoke
    script: tests/black.smoke.star
platforms:
  linux/amd64:
    runner: ubuntu-latest

source.type: pypi — index-discovered apps

A pypi source discovers upstream versions directly from a package index instead of a committed lock file — useful for apps whose releases you want to track automatically rather than re-lock and commit by hand.

name: pycowsay
target:
  registry: dev.ocx.sh
  repository: ocx/pycowsay
source:
  type: pypi
  package: pycowsay                # PEP 503 name on the index; defaults to `name`
  indexes:                         # optional; defaults to pypi.org
    - url: https://pypi.org/simple
python:
  version: "3.13.1"
  abi: cp313
  interpreter_package: "ocx.sh/cpython:3.13.1"
  lock:
    universal: true                 # see python.lock below
platforms:
  linux/amd64:
    runner: ubuntu-latest

source fields (type: pypi):

Field Type Required Description
package string No PEP 503 name of the PyPI package to resolve. Defaults to the mirror's name.
indexes list No Simple Repository API bases, highest priority first. Each entry is { url: … }. Must be http/https and must not embed credentials. Default: https://pypi.org/simple.

The URL is the index base exactly as you would give it to pip or uv — nothing is appended to it, because every vendor lays its Simple API out differently:

Index url
PyPI https://pypi.org/simple
Artifactory https://art.corp.example/artifactory/api/pypi/pypi-remote/simple
Nexus https://nexus.corp.example/repository/pypi-remote/simple
devpi https://devpi.corp.example/root/pypi/+simple

Discovery semantics:

  • Both serializations are accepted: the PEP 691 JSON form is requested first through content negotiation, and a PEP 503 HTML page is parsed when that is all the index serves. Artifactory and Nexus serve only the latter.
  • Versions are read from the filenames the index lists, not from a separate version endpoint — PEP 700's versions key exists only at api-version 1.1+ and has no HTML equivalent.
  • A version is listed only when it has at least one file that is not yanked (PEP 592); a version whose every file is yanked is dropped entirely.
  • Prerelease detection is PEP 440-aware (uv_pep440), not the mirror's own semver-ish version parser — a 2.0.0.dev0 release is correctly flagged as a prerelease and respects the existing skip_prereleases/versions bounds the same as any other source.
  • With several indexes, the first one that has the project wins and the rest are not consulted. Candidates are never merged across indexes: that is what stops a public index from answering for an internal package name (dependency confusion). uv pip compile is pinned to the matching --index-strategy first-index.
  • An index that returns 404 for the package name does not have it, so the next index is tried; 404 from every index is a data error (malformed input, exit code 65). Any other failure — connection refused, timeout, 5xx, 401, malformed body — is a source-unavailable error (exit code 69), because it means "unknown", not "absent".

Authentication and TLS

No credentials belong in mirror.yml. A URL carrying userinfo (https://user:token@host/…) is refused when the spec loads. Credentials are resolved from the host being requested, in this order:

  1. OCX_AUTH_<slug>_TYPE, OCX_AUTH_<slug>_USER, OCX_AUTH_<slug>_TOKEN — <slug> is the host with every non-alphanumeric character replaced by _, so nexus.corp.example reads OCX_AUTH_nexus_corp_example_TOKEN. This is the same mechanism registry.yml and ocx itself use.
  2. netrc — $NETRC if set, else ~/.netrc, matched on an exact machine <host> line. This is the file uv already reads, so the mirror's own downloads and the resolver it shells out to agree on the hosts you named. One entry is deliberately ignored: a default line answers for every host, and every URL this ladder sees was named by an index or a lock — honouring it would send your credential to whatever host a hostile upstream chose. uv honours default; ocx-mirror does not, so an index whose credential came only from default now needs a machine <host> line or the OCX_AUTH_<slug>_* variables above.
  3. Anonymous.

One credential covers the whole run for that host: index discovery, uv pip compile, and the wheel downloads. Credentials are passed to uv through the environment, never on its command line. A lock naming wheels on several hosts resolves each host's credential separately, so a corporate token is never sent to pypi.org.

export OCX_AUTH_nexus_corp_example_USER=ci-mirror
export OCX_AUTH_nexus_corp_example_TOKEN="$NEXUS_TOKEN"
ocx-mirror package sync mirror.yml

Behind a TLS-intercepting proxy, point SSL_CERT_FILE at a bundle containing your corporate root, or SSL_CERT_DIR at a directory of PEM files. The public roots stay trusted alongside it, so a run that reaches both an internal index and pypi.org works with no verification disabled.

Per-version lock derivation (running uv pip compile) happens later, in pipeline plan — see python.lock and --locks-dir. A universal lock (the default) resolves via --python-version alone; only universal: false materializes the pinned interpreter_package on disk to resolve against it.

How the app is resolved

The lock lists every package in the resolved environment; ocx-mirror picks the one whose name PEP 503-normalizes (lowercase, runs of -_. → -) to the spec's name as the app, and mirrors its locked version. So a [full]-extras distribution keeps its distribution name: name: google-cloud-aiplatform (not aiplatform). A name that matches no locked package fails with exit 65. For pypi, the same source.package/name fallback selects which index package to resolve — there is no committed lock to cross-check the app name against until one is derived.

Set source.package to resolve a different app name than the mirror name — e.g. a pycowsay-musl mirror (distinct target repo + workflow) that resolves the pycowsay package from a shared lock:

source:
  type: pylock
  path: pycowsay.pylock.toml
  package: pycowsay               # resolve this package; defaults to the mirror name

python block

Required for source.type: pylock or pypi. Fields:

Key Purpose
version Interpreter version (e.g. 3.14.6). Feeds the PEP 508 marker environment used for wheel selection.
abi Target ABI tag (e.g. cp314). Every compiled wheel's ABI must match this (or be abi3/none), checked fail-closed at compose.
interpreter_package An OCX package that provides python3 (a python-build-standalone build). Pulled in as a private dependency and pinned by digest; its platform-agnostic index digest is resolved per-platform at materialize.
lock Lock-derivation options — source.type: pypi only. See python.lock.
entrypoints Which console scripts synthesize as OCX entrypoints. Default auto. See python.entrypoints.

python.lock — pypi lock derivation

source.type: pypi has no committed lock, so ocx-mirror derives one per version in-pipeline (pipeline plan, via uv pip compile). python.lock configures that derivation; it is meaningless for source.type: pylock (a committed lock is already resolved) and is rejected there with exit code 65: python.lock: only supported for source.type 'pypi' (a committed lock is already resolved).

python:
  version: "3.13.1"
  abi: cp313
  interpreter_package: "ocx.sh/cpython:3.13.1"
  lock:
    universal: true            # default: true
    extras: []                 # default: []
    exclude: []                # default: []
    timeout_seconds: 300       # default: 300
Field Type Default Description
universal boolean true Resolve a platform/interpreter-agnostic universal lock (uv pip compile --universal) rather than one pinned to the resolving host.
extras array of strings [] Extras to include when resolving the lock (e.g. ["full"] for app[full]).
exclude array of strings [] Package names to exclude from resolution (uv --no-emit-package).
timeout_seconds integer 300 Timeout for the uv pip compile subprocess.

Each derived lock is written under --locks-dir (a pipeline plan flag, default ./locks, relative to the command's working directory — the same directory plan.json is written to) as pylock.<package>-<version>.toml, with a relaxed requires-python floor (works around a known uv over-strict-patch-pin issue) and a provenance comment header. pipeline prepare --plan reads the path straight from the plan instead of re-deriving; a standalone pipeline prepare (no --plan) re-derives it from scratch.

Every dot in the <package>-<version> segment becomes a dash (pylock.black-26-5-1.toml): uv enforces PEP 751 on its -o argument, where the name between pylock. and .toml must be non-empty and dot-free. Nothing parses the name back — each plan entry's pylock field carries the full path — so the substitution is safe.

A uv resolution failure (unsolvable requirements, bad package metadata) is a data error, exit 65 — the version cannot produce a trustworthy lock. A missing/unspawnable uv binary, a timeout, or lock-file I/O failure is a subprocess execution failure, exit 1.

python.entrypoints

Controls which wheels' [console_scripts] entries synthesize as OCX entrypoints in the composed env.

Value Behavior
auto (default) Only the root package's own console scripts synthesize (root = source.package/mirror name). New default — previously every wheel's scripts synthesized unconditionally.
all Every wheel's console scripts synthesize — the pre-auto behavior.
explicit list Only the listed console-script names synthesize, each optionally windowed to an app-version range.
python:
  entrypoints: auto   # or: all

# or an explicit, version-windowed list:
python:
  entrypoints:
    - name: black
    - name: blackd
      min_version: "24.0.0"   # inclusive
      max_version: "25.0.0"   # exclusive

min_version/max_version follow the same inclusive-lower/exclusive-upper convention as versions: and per-platform bounds; an entry with neither is unbounded. An app version that fails to parse keeps every explicit entry (fail-open, same convention as platform excludes).

Fails closed in two cases, both surfaced as a compose/pylock error (exit 65):

  • Collision — two different wheels register a console script under the same entrypoint name and the selection mode admits both (only possible under all, or an explicit name two wheels both provide).
  • Miss — an explicit name that no admitted wheel's console scripts actually provide.

Nuance — auto removes dependency console-script shims. Under auto, a dependency wheel's own console script (e.g. a library the app depends on that ships its own CLI) no longer synthesizes as an entrypoint. If the app itself spawns that dependency's CLI as a subprocess (subprocess.run(["some-dep-cli", ...])), the spawn will fail to find it under auto — such an app needs all, or the dependency's script name listed explicitly.

wheels — per-platform wheel selection

Env sources declare their support envelope in a top-level wheels: map — the env analogue of the archive assets: map. It is required for source.type: pylock/pypi and rejected for every other source; variants: is rejected outright for env sources (libc is a platform os.features axis for env packages, never a variant/tag axis).

wheels:
  linux/amd64:                          # value omitted → key-derived default filter
  "linux/arm64+libc.glibc": ~           # glibc-stamped entry
  "linux/arm64+libc.musl": [musllinux, any]   # explicit filter
  darwin/arm64: ~
  windows/amd64: ~

Keys are OCI platform strings — a concrete os/arch, optionally carrying one +libc.glibc/+libc.musl suffix (Linux only; no OCI variant/os_version segments, no other feature namespaces). The key is published verbatim as the image-index platform entry: a +libc.* suffix lands in OCI os.features, which ocx ≥ 0.4.2 clients match against the host libc at install time. Two keys sharing one base (linux/amd64+libc.glibc + linux/amd64+libc.musl) publish two entries in one index under one bare tag — one package, no variant-prefixed tags, no per-variant repos.

The key is a declaration, not a computation. The mirror stamps nothing and infers nothing from wheel contents. A maintainer may legitimately publish glibc-only wheels under a plain linux/amd64 key with an explicit [manylinux, any] filter — that key then installs on musl hosts too (its entry carries no os.features); whether that is correct is the maintainer's support-envelope call, and no warning is emitted.

Values are ordered lists of PEP 425 platform-tag prefixes acting as admissibility filter + ranking: a tag-compatible wheel whose platform tags match no listed prefix is excluded (fail closed — e.g. the default ["any"] on a plain linux key errors with exit 65 if the lock demands a compiled wheel), and earlier prefixes outrank later ones among survivors. A ~/omitted value selects the key-derived default:

Key class Default filter
linux/* (plain) ["any"] — pure wheels only, runs on any libc
linux/*+libc.glibc ["manylinux", "any"]
linux/*+libc.musl ["musllinux", "any"]
darwin/* ["macosx", "any"]
windows/* ["win", "any"]

One key's filter must not mix manylinux* and musllinux* prefixes (a single env cannot need both libcs at runtime), and a +libc.* key's filter must not contradict its declared libc. The filter never re-admits a wheel that tag-compatibility already excluded.

Cross-validation with platforms:. Every wheels key's base os/arch must be declared under platforms: (it needs a CI test leg), and every declared platform leg must be covered by at least one wheels key. platforms: keys stay plain — the CI matrix is a base-platform axis.

Container gating. At push time, a +libc.glibc entry is gated only by the JUnit results of glibc container legs (debian/ubuntu/… — and native runners), a +libc.musl entry only by musl (alpine) legs, and a featureless entry by all legs of its base platform (it claims to run anywhere, so everything must be green). An entry whose declared libc no test leg covers fails closed. Pair a +libc.musl key with an alpine container leg.

Interpreter. The single python.interpreter_package serves every wheels key — there is no per-key interpreter override. A dual-libc app therefore needs an interpreter package that itself resolves per-libc (its own index carrying os.features entries), or a static/musl build that runs on both.

What is published

Each app version becomes an environment package: one content-addressed tar.zst layer per wheel (deterministic repack — see the conventions ADR), a composed metadata.json (private interpreter dependency, PYTHONPATH/PATH env, and a synthesized entrypoint per [console_scripts] entry). Those console-script entrypoints are the package's whole public surface: a library env with no console script of its own (e.g. google-cloud-aiplatform) publishes none. (Each launcher dispatches python, not python3: python-build-standalone ships a python3 binary only on POSIX platforms, while python resolves in-package everywhere.)

Catalog description & metadata:

The top-level metadata: key (and any per-variant metadata: override) is rejected for source.type: pylock/pypi with exit code 65:

metadata: not supported for source.type 'pylock' (env metadata is composed from the lock; use catalog:/CATALOG.md for the description)

An env package's metadata.json is composed from the resolved lock (interpreter dependency, env vars, entrypoints) — there is nothing for a hand-authored metadata: file to add, and it would only drift from what compose actually produces.

pipeline describe publishes the registry catalog description from CATALOG.md as usual. When no CATALOG.md exists on disk, it autogenerates one from the root package's wheel *.dist-info/METADATA (Summary as the lead paragraph, Keywords/License as trailer lines) instead of skipping — pylock reads the root wheel straight from its committed lock; pypi looks for a lock pipeline plan already derived under --locks-dir (any one is equivalent for this purpose — core metadata doesn't vary by version) and skips silently if none is reachable yet (no prior pipeline plan run). An on-disk CATALOG.md always wins over autogen.

Shared wheel layers

Two apps that both depend on the same numpy wheel do not need two copies of it in the registry. Each wheel layer is pushed once to a content-addressed repository and then cross-repository mounted into every app that depends on it, instead of being re-uploaded as a private layer per app.

Naming. A wheel's standalone repository is <wheel_scope>/<index-host>/<package>, tagged with its sha256 — e.g. pip-packages/files.pythonhosted.org/numpy:<sha256>. <wheel_scope> is the top-level wheel_scope spec key (default pip-packages); <index-host> groups wheels by the index they were downloaded from. The sha256 tag is content-addressed, so every wheel of a package — however its build tag / ABI / platform differ — lands in that one repo as a distinct tag, and byte-identical wheels (e.g. an abi3 wheel shared across CPython minors) dedupe onto a single tag. No per-wheel path segment is needed.

Push order. Before pushing an app's own env package, pipeline push registers each not-yet-published wheel layer standalone under its content-addressed reference (skipped if already present — checked via a tag-list lookup, deduped across the whole run so a wheel shared by many apps/platforms is checked once). The app's own layer positionals then each carry a :from=<wheel_repository> tail (ocx package push …/wheel.tar.zst:from=pip-packages/files.pythonhosted.org/numpy), so the push attempts a cross-repository blob mount against that standalone registration before falling back to a full upload on a miss — the fallback is load-bearing, not a bug.

Visibility. run-summary.json carries a layer_reuse counter per version, aggregated across all its pushed platforms:

Field Meaning
mounted Layers reused via cross-repository mount (no re-upload)
uploaded Layers freshly uploaded
verified Layers already present, verified rather than re-checked

Archive/binary mirrors have no shared-layer concept and always report all-zero counts.

Multi-platform

Add linux/arm64, darwin/arm64, etc. to platforms. A pure app reuses one lock across all platforms. A compiled app needs a universal lock (uv pip compile … --universal) so each per-platform leg selects the right wheel (manylinux_2_28_aarch64, macosx_11_0_arm64, …); where no compiled wheel exists for a platform the py3-none-any fallback is selected.

Overlap-free layer union

OCX composes the env as an overlap-free prefix-layer union, so two wheels must never install the same file. A valid resolved lock is collision-free by construction; a pathological [extras] closure that pulls mutually-exclusive distributions sharing a file (e.g. mlflow + mlflow-skinny + mlflow-tracing, which each ship an identical mlflow/__init__.py) is rejected with exit 65 — curate the lock (uv --no-emit-package <redundant>) to keep the superset.

variants

Some tools publish more than one build flavor from the same release — a PGO/LTO-optimized build alongside a regular one, a slim image alongside the full one. variants: replaces top-level assets: with a named list of asset sets, each becoming its own version-tag prefix (slim-3.13.9 vs. the bare 3.13.9), so the flavors share one mirror.yml and one generated pipeline instead of splitting into a spec per flavor.

variants:
  - default: true
    assets:
      linux/amd64: ["cpython-.*-x86_64-unknown-linux-gnu\\.tar\\.zst"]
  - name: pgo.lto
    assets:
      linux/amd64: ["cpython-.*-x86_64-unknown-linux-gnu-pgo\\+lto\\.tar\\.zst"]
    metadata:
      default: metadata-pgo-lto.json

Fields (per entry):

Field Type Required Description
name string Yes, unless default: true Tag prefix for this variant's versions. Must match ^[a-z][a-z0-9.]*$; latest is reserved and rejected — it would collide with the cascade alias the default variant already produces.
default boolean No Marks the variant whose tags publish unprefixed (3.13.9, not <name>-3.13.9). Exactly one variant must set this.
assets object Yes This variant's platform → regex mapping, same shape as top-level assets.
metadata object No Overrides the top-level metadata for this variant only.
bin_scan string No Overrides the top-level bin_scan for this variant only.
libc_lint boolean No Overrides the top-level libc_lint for this variant only.
asset_type object No Overrides the top-level asset_type for this variant only — the same three forms, including the per-platform map, whose keys are validated under variants.<name>.asset_type.

Rules:

  • Mutually exclusive with top-level assets: — a spec sets exactly one of the two.
  • At least one variant is required; exactly one must set default: true.
  • Only the default variant may omit name. A non-default variant without one is rejected.
  • Two variants with the same name (or two unnamed variants) are rejected as duplicates.
  • A variant that omits metadata, bin_scan, libc_lint or asset_type inherits the spec's top-level value. A slim variant ships a different binary set than the full one, so bin_scan: off on a variant is an override back to unscanned, not "unset" — and libc_lint: true on a variant of a spec that set libc_lint: false turns the check back on for that variant alone.

metadata

Points at the package metadata JSON that ocx package test and ocx add use to install a mirrored bundle — the env/type document, not anything CI-specific.

metadata:
  default: metadata.json
  platforms:
    darwin/amd64: metadata-darwin.json
    darwin/arm64: metadata-darwin.json

Fields:

Field Type Required Description
default string Yes, when metadata: is present Path to the metadata file, relative to the spec's own directory.
platforms object No Platform key → metadata path, relative to the spec's own directory. A listed platform uses this file instead of default — for a tool whose install layout differs by platform (e.g. a macOS .app bundle needs a different PATH entry than the Linux layout).

Both paths resolve against the directory holding the spec file, never the repository root — the same rule catalog follows, and the opposite of tests.script. Every path is checked for existence when the spec loads; a missing file is a spec-load failure (exit 65), not a runtime surprise.

Editing this file only changes what future pushes publish. pipeline plan compares it against what already-published versions record and reports any mismatch as a metadata-drift entry; pipeline patch then republishes the corrected metadata against those versions' existing layers, with no re-download and no re-upload. There is no key here for which versions to patch — that range is a flag on the patch command line, not spec state, since a stored range would need a ledger to track what it had already covered.

The file's binaries list also decides what prepare makes executable. A tar or zip member keeps whatever mode upstream shipped it with, and some upstreams ship their interface binary non-executable at 0644 — PowerShell's pwsh does — so after extraction every file in the tree whose name matches a declared binaries entry is chmodded to 0755 if it lacks any exec bit; a mode already broader than that (0775, a setuid bit) is never narrowed. Nothing else is touched: an undeclared file keeps whatever the archive gave it, and a declared name that resolves to a symlink is skipped rather than chmodded — the chmod must not follow a link out of the content tree onto whatever it points at. A declared name the archive does not ship is not an error — bin_scan: verify is where a missing binary is caught, on the specs that can use it. verify also wins over the chmod where the two overlap: under verify the chmod does not run at all, so a declared name found non-executable in an interface PATH directory fails the run with DeclaredNotExecutable — verify keeps asserting what upstream actually shipped and the chmod never papers over a mismatch it was set up to catch.

The fix only reaches a name hand-declared in binaries: the chmod runs before ocx package create, which is where bin_scan: auto fills an undeclared list. A fill only reports candidates it found already executable anyway, so a 0644 binary the archive ships is never in a filled claim and stays non-executable in the published bundle. A mirror hitting that case fixes it by hand-declaring the name in binaries, not by turning bin_scan on.

A dependency may name its package by tag alone — "identifier": "ocx.sh/adoptium/temurin:jre-25". prepare hands the file to ocx package create, which pins the tag to each platform's manifest digest, and the pinned form is what gets published (ocx 0.6.3 or newer, see the floor). pipeline plan treats a published pin for the same tag as current. If the spec moves the tag or adds a dependency, the affected versions read as metadata-drift, and pipeline patch refuses them: patch never runs create, so it has no way to pin the new tag, and prepare never re-processes a version the registry already holds. Delete those versions' published tags and re-mirror them.

As with libc_lint, a version already published keeps the modes it was pushed with. prepare never re-processes a version the registry already holds, and pipeline patch re-references an already-published version's existing layers by digest instead of re-extracting them, so it cannot repair an exec bit either — the only way to correct a version published with the wrong mode is to delete its tags and re-mirror it.

asset_type

How a downloaded asset becomes the package's content tree. Optional; the default is archive with no component stripping. Ignored by source.type: pylock/pypi, which compose from wheels instead.

Three forms. Uniform — one type for every platform:

asset_type:
  type: archive
  strip_components: 1
asset_type:
  type: binary
  name: shfmt        # `.exe` is appended on Windows when the asset carries it

A bare single-file .gz/.xz/.zst/.bz2 asset (one compressed executable, no tar layer) is also binary: it is decompressed before it is placed, detected by its leading bytes rather than its extension. archive would fail on it — there is no tar inside. Such a Windows asset carries no .exe in its name, so name the file explicitly:

asset_type:
  default:
    type: binary
    name: taplo      # assets: taplo-<os>-<arch>.gz on every platform
  platforms:
    windows/amd64:
      type: binary
      name: taplo.exe

Per-platform — a default: plus a platforms: map, for upstreams that ship an archive on one OS and a bare executable on another:

asset_type:
  default:
    type: archive
    strip_components: 0
  platforms:
    windows/amd64:
      type: binary
      name: lychee

Fields:

Field Type Required Description
type string Yes* archive or binary. *Required in the uniform form and inside default:/each platforms: entry.
strip_components integer or object No archive only. Leading path components to strip. Takes its own per-platform form: strip_components: 1, or {default: 1, platforms: {windows/amd64: 0}}.
name string Yes binary only. The filename the executable takes in the package.
default object Yes* The fallback asset type. *Required in the per-platform form.
platforms object No Per-platform overrides, keyed exactly as assets is.

Validation:

  • deny_unknown_fields on the per-platform form, and on strip_components's. The override map goes under platforms: — writing the platform keys as siblings of default: is a spec-load failure (exit 65), not a silently-ignored block. It used to be the latter, and the mirror published the default: entry on every platform without a word.
  • platforms: keys must parse as platform keys, by the same grammar assets uses. A key that is not a platform matches nothing and would fall through to default:, so it is rejected (exit 65) rather than left to be discovered in a tarball.
  • The type: value is checked in every position, default: and each platforms: entry included.

bin_scan

The binaries field of a package's metadata names the executables the package puts on the interface surface. Hand-listing it means keeping a list in step with whatever upstream ships in the archive; bin_scan derives it from the bundle instead, at mirror time.

bin_scan: verify
metadata:
  default: metadata.json
Value Behaviour
off (default) Never scan. binaries is exactly what the metadata file declares, or absent when it declares none.
auto Fill an absent binaries claim from the scan. A claim the metadata file already declares passes through unverified.
verify Fill an absent claim exactly as auto, and additionally check a declared one against the extracted tree. An executable on the interface surface that the file does not list, or a listed name present but not executable, fails the run.

The scan is not a directory walk. It reads the immediate entries of the directories reachable through the metadata's ${installPath}-rooted Path environment variables whose visibility reaches the interface — so a libexec directory behind a private variable never contributes, and neither does a subdirectory of a PATH entry. On a non-Windows target platform a candidate counts when it carries the Unix exec bit; on a Windows target the .exe, .com, .bat and .cmd extensions are stripped and the exec bit is ignored. Symlinks are followed and claim the name of the link.

The scan only looks below ${installPath}. A metadata file whose PATH variable is the bare ${installPath} — the usual shape for an asset_type: binary mirror, where the single executable sits at the content root — offers the scan no target directory at all, and auto would then publish binaries: []. That is not "undeclared": it is a positive claim that the package exposes nothing.

The spec is rejected at load rather than allowed to publish that. Enabling bin_scan on metadata with no ${installPath}/<dir> interface-visible PATH entry fails with exit 65, naming the file — and, on a multi-variant spec, the variant. Every file the metadata block can select is checked, so a per-platform override with no scan target is caught even when the default is fine. Such a mirror either points a PATH entry at a subdirectory, or leaves bin_scan: off and hand-lists binaries; off with the same metadata is a perfectly good spec and keeps loading.

A file that already declares binaries is exempt under auto only, because auto passes a declared list through without scanning at all. verify is not exempt: it does walk the tree, and with no scan target it inspects no file and passes green whatever the archive contains — a verification that cannot fail, on a spec that says the list is checked.

Watch the per-platform files specifically, because upstream archive layouts are rarely symmetric. python-build-standalone is the worked example: Linux and macOS extract to python/bin/, but the Windows archive puts python.exe at its root with no bin/ at all — so metadata-windows.json is the file that ends up with a bare ${installPath}, on a spec whose default is fine. That is exactly the case the per-file check exists for.

All interface-visible Path vars are scanned, not just PATH — a MANPATH set to ${installPath}/man is a second scan target, and the results merge into one claim. On a non-Windows target the exec bit keeps man pages out; a genuinely executable file parked under such a directory would be claimed.

verify is the mode a mirror wants once it does hand-list binaries: the list stops being documentation and becomes a regression test against upstream rearranging its archive. Platforms that genuinely differ get a per-platform metadata file — CMake's Windows zip has no ccmake.exe while its Linux and macOS archives both ship ccmake, so the Windows entry under metadata.platforms declares its own shorter list.

Interaction with pipeline patch

pipeline patch never downloads anything — re-referencing published layers by digest is its entire reason to exist — so it cannot re-run a scan. Whether that matters depends on where the claim comes from:

  • The metadata file declares binaries (including under verify, which checks the declaration rather than replacing it). Then the spec computes the whole document download-free, and plan and patch treat binaries like every other field: edit the list, and the next run reports drift and republishes the correction.
  • The metadata file declares none and the scan fills it. Then the expectation cannot carry the claim at all, so the drift comparison reads the published one and adopts it before comparing. Without that, plan would report metadata-drift on every such version on every run, and each patch would republish the metadata with binaries absent — silently deleting a correct claim.

Adoption is deliberately limited to the second case. Applying it to a declared list would rewrite the expectation to whatever is already published, so a corrected hand-written list could never register as drift and the fix would never reach the published versions.

The consequence worth knowing: turning bin_scan on does not retroactively populate binaries on already-published versions, and patch will not do it either. A version picks up its scanned claim when it is next actually built — either by a new push, or by deleting the tag and re-mirroring it.

libc_lint

A Linux binary that links against glibc cannot run on a musl-only host, and vice versa. Which one a package needs is stated in its platform key's os.features — linux/amd64+libc.glibc. libc_lint checks that statement against the binaries the bundle actually ships, at mirror time, and refuses to build a version whose declaration is false.

libc_lint: false
Value Behaviour
true (default) Every file on the package's interface PATH is read. One that needs a libc family the platform key does not declare fails that version's build, naming the target, the version, the platform, the file, its dynamic loader and the platform key that would be correct.
false The check does not run. Nothing else changes — the same bundle and the same metadata are written either way. A line naming the target and platform is logged wherever the check would have run, so the suppression is visible in the CI log.

Why an omitted libc is a claim, not a gap. os.features matching is subset matching: an artifact whose feature list is empty demands nothing of the host, so it resolves onto every host. Publishing a glibc-linked tile under a bare linux/amd64 is therefore a positive claim of libc universality, and a consumer on Alpine installs it happily and then gets No such file or directory naming a file that is plainly there — the kernel reporting the absent ELF interpreter. That has already happened to a published mirror.

The check reads only the ELF PT_INTERP header, so it costs nothing and understands nothing else: a binary needing libstdc++ or libicu passes, a glibc 2.38 build declared for a glibc 2.28 host passes, and a file not on an interface PATH directory is never read. Statically linked binaries have no PT_INTERP and satisfy any declaration — which is why the four bazelbuild specs publishing static Go binaries under bare linux/amd64 and linux/arm64 keys are correct as written and pass unchanged.

The opt-out is total. libc_lint: false skips the whole check, refusals and scan-scope failures alike. That is deliberate, and the same escape hatch ocx package create --no-libc-lint provides: a bug in one half of the check would otherwise block every publish of a spec with no way through, and a partial bypass would leave that half still able to stop the mirror. Reach for it when the check is wrong, not when the declaration is — the fix for a real refusal is the platform key the error message hands you.

The check runs during pipeline prepare, inside the ocx package create it spawns, between extracting the archive and compressing it — the only window in which the binaries exist on disk. pipeline patch never downloads anything, so it cannot run the check and never reports a libc finding; a version already published under a false declaration is corrected by fixing the platform key and re-mirroring it, not by patching metadata.

A bundle already in the work directory is never re-checked. prepare resumes from an existing bundle.tar.xz and its metadata.json without extracting anything, so the check cannot run — and a bundle on disk is not evidence it ever passed: it may have been built under libc_lint: false, or by an ocx-mirror predating the check. Turning libc_lint back on therefore has no effect on any (version, platform) leg whose bundle the work directory already holds — a version whose linux/amd64 leg bundled but whose linux/arm64 leg did not is checked on arm64 and not on amd64. Delete the bundles of the legs you want re-checked (or the work directory).

concurrency

Tunes how much of the pipeline runs in parallel, how gently the upstream source is polled, and how a flaky push is retried. Every field has a default, so a spec that never sets concurrency: still gets sensible behaviour.

concurrency:
  max_downloads: 8
  max_bundles: 4
  rate_limit_ms: 0
  max_retries: 3
  compression_threads: 0

Fields:

Field Type Default Description
max_downloads integer 8 Maximum number of asset downloads running at once, across every (version, platform) task the run has in flight.
max_bundles integer half the host's available CPU cores, minimum 1 (2 if the core count cannot be detected) Maximum number of extract-and-compress tasks running at once. Bundling is CPU-bound, so the default scales with the host rather than naming a fixed number — on a 2-core runner it is 1.
rate_limit_ms integer 0 Delay, in milliseconds, between paged upstream-listing requests (GitHub Releases pagination). Unrelated to push behaviour — see max_retries below for that. 0 means no delay. A page that fails transiently (GitHub 5xx or 429, or a transport fault) is retried up to 5 times with 1s-doubling, 30s-capped, jittered backoff — about half a minute in total, not governed by max_retries; a 403 or 404 fails at once with the API's status and message.
max_retries integer 3 Extra attempts a transient push failure is granted on top of the first — total attempts are max_retries + 1. 0 means a single attempt, no retry. See Push retry below.
compression_threads integer 0 (auto) Compression threads per bundle task. 0 splits the host's available cores across the max_bundles tasks running concurrently (at least 1 each); a positive value pins every bundle task to that many threads regardless of max_bundles.

max_pushes is accepted and silently ignored. Push is sequential by version — see pipeline push — so there has never been a push-parallelism knob to bound; keeping the key parsing, rather than rejecting it, is deliberate fleet compatibility for specs written before that was true.

Push retry

A push can fail transiently — a registry connect timeout, a blip mid-upload — with no fault in the bundle itself. pipeline push retries exactly that class of failure, bounded by max_retries.

What counts as transient. Only an ocx package push exit code of 75 (TempFail) is retried. Exit 69 (Unavailable) means the failure will not change on a rerun — a registry that is down stays down — so it is never retried; neither is a registry auth rejection (exit 80) nor a child process killed by a signal (no exit code at all). Getting this 75/69 split at all needs ocx ≥ 0.5.3 — specifically in the ocx binary that actually runs the push subprocess, which comes from whatever ocx.toml/ocx.lock toolchain the running ocx-mirror is co-located with. That is a separate pin from the ocx version a generated downstream workflow bakes into its own setup-ocx step (see ocx_mirror) — the two can drift out of step, and it is the co-located one that governs retry behaviour here. An older ocx maps every registry-client failure to exit 69, so a transient fault on a stale pin is never retried no matter what max_retries says, and there is deliberately no runtime warning for this.

The co-located ocx must be 0.5.5 or newer, and that is a hard floor, not a degradation like the retry split above. From 0.5.5 the metadata sidecar no longer carries a top-level platform key — the platform travels on the --platform flag instead. Only ocx package create rejects a sidecar that still carries the key; ocx package push and ocx package test parse the sidecar's published form directly and simply ignore an unknown field, so the key's presence or absence makes no difference to either. An older binary reads it the other way round: it demands the recorded key and fails with metadata sidecar has no recorded platform and exit 65 on every push leg, which is not a retried code, so the run ends with nothing published. The same floor applies to the setup-ocx pin in a generated downstream workflow — bumping the pinned ocx-mirror without regenerating CI leaves that repository failing every push.

Since ocx-mirror 0.6.0 the effective floor is ocx ≥ 0.6.0, and 0.5.5 survives only as the reason this paragraph exists. The describe, announce and cascade legs each spawn a verb or flag the 0.6 CLI rename introduced (package description push, announce --tags-file), and a 0.5.x binary rejects each with exit 64. See Forwarded OCX_* variables for the per-leg table. The bump-without-regenerate warning above applies with more force at this floor, not less: a downstream repository still rendering version: "0.5.8" fails its describe and announce steps outright.

Backoff. The first retry waits 1 second; each further attempt doubles the wait, capped at 30 seconds, with ±10% jitter layered on top of the cap — so a capped delay actually lands in the 27–33 second range, not exactly 30.

Per-attempt timeout. Each push attempt is bounded at 3600 seconds. This is a backstop against a wedged child process, not a throughput budget — ocx itself already bounds a registry request (30 seconds to connect, 120 seconds without a byte read), so a healthy upload never needs the full hour. The worst case for one tile at the default max_retries: 3 is four attempts, close to four hours, which fits inside GitHub Actions' default 360-minute job limit — but a run pushing two such tiles does not. The job timeout, not this per-attempt bound, is the real outer limit on a run.

Logging. A retried attempt logs push attempt {n}/{total}; a give-up message distinguishes an exhausted retry budget from an exit code that was never eligible for retry, and both name concurrency.max_retries so the fix is obvious from the log line.

pipeline patch's republish is bounded by the same 3600-second timeout but is deliberately never retried — it only re-emits a config blob against layers that are already published, so re-dispatching the workflow by hand is cheaper than a retry ladder.

cascade

Whether a push re-points the rolling tags X.Y, X and latest at the version it just published. Defaults to true; cascade: false publishes exact version tags only, and drops the generated cascade.yml repair workflow along with them — a mirror with no rolling alias has no graph to repair.

The map form is the same "on", with a schedule attached to that repair workflow:

cascade:
  schedule: "17 4 * * 1"   # optional; UTC cron, GitHub's syntax

A map always means enabled — cascade: {} is cascade: true. The cron string is passed through verbatim, exactly as versions.poll_interval is: a spec is rejected (exit 65) when the expression is empty or holds a character outside cron's 0-9 A-Z a-z * / , - charset, and GitHub validates everything beyond that. Give it a cron of its own — a cascade.schedule equal to versions.poll_interval collides in the shared concurrency group on every cycle, by construction.

Without a schedule (the default) cascade.yml is workflow_dispatch only, and its dry_run input defaults to true — a dispatch that names nothing audits.

With a schedule the dispatch stays, and each scheduled run repairs for real: dry_run has no value on a timer, so the workflow supplies false. A healthy scheduled run is silent green — the repair finds nothing, exits 0, announces nothing (the announce is never invoked with an empty tag set). Red means exit 65 — findings the run could not re-point, the state worth a notification — or exit 1, the repair failing to run.

Green does not prove a repair ran, though: the repair step is skipped when the registry credentials are missing, and a skipped step keeps the job green. A repo whose OCX_MIRROR_REGISTRY_TOKEN was never set or has since been rotated therefore produces the same silent green forever. Read the run's ::notice:: once after enabling the schedule, and again after every token rotation.

The repair shares the push workflow's concurrency group, so neither one ever runs while the other is mid-way through re-pointing the same aliases. GitHub keeps a single pending run per group, so the trade is that whichever of the two is queued gets cancelled when a newer run of either workflow arrives — a scheduled repair can be dropped (grey "cancelled", never red) by a busy publish, and a pending publish can now be dropped by a repair.

Cascading interacts with build_timestamp: re-pointing a rolling tag leaves the digest it used to name untagged, which is a GC hazard when build_timestamp: none.

versions

The global version window and rate limiter. It decides which upstream releases a run is allowed to mirror at all; per-platform holes — a platform introduced late, dropped, or broken at one release — belong in platforms.<p>, not here.

versions:
  min: "1.0.0"
  max: "3.0.0"
  new_per_run: 5
  backfill: newest_first
  poll_interval: "0 */6 * * *"
Key Type Required Purpose
min string or object No Lower bound. A bare string is inclusive. See Resolved bounds.
max string or object No Upper bound. A bare string is exclusive. See Resolved bounds.
new_per_run integer No Cap on how many not-yet-mirrored versions one run publishes. Unset = no cap.
backfill string No newest_first (default) or oldest_first — which end of the outstanding set new_per_run takes from.
poll_interval string No Cron expression for the generated workflow's schedule: trigger. Without it the workflow is workflow_dispatch + push: only.

An unknown key under versions: is rejected with exit 65, not dropped.

Resolved bounds

Either edge may be written as an object instead of a string, which is what lets it come from somewhere other than the spec file — a vendor's channel pointer, or a command:

versions:
  min: "2.1.267"
  max:
    version:
      url: https://downloads.claude.ai/claude-code-releases/stable
    inclusive: true
Key Type Required Purpose
version string or object Yes The edge's value: a literal, or where to get one.
inclusive boolean Yes Whether a candidate equal to the edge is inside the window. No default in this form.

version takes the same spellings source.url_index does, plus file:

Form Meaning
version: "3.0.0" A literal, identical to the shorthand except that inclusive is stated.
version: {url: <https url>} The response body, fetched once per run.
version: {generator: {command: [...], working_directory: ..., timeout_seconds: 60}} The command's stdout, run once per run. command is required; working_directory resolves from the spec directory; timeout_seconds defaults to 60.
version: {file: <path>} The file's contents, read once per run, relative to the spec directory.

Semantics:

  • A bare string keeps the meaning it always had: min inclusive, max exclusive — the convention shared with per-platform min_version/max_version and exclude ranges. The object form has no default: inclusive is required there, because an operator who reached for the long spelling is changing the edge's behaviour and should not inherit one they did not write.
  • inclusive: true on max exists because a vendor channel pointer names the version it wants mirrored, not the first one it does not.
  • Both edges accept url:/generator:, and both resolve once per run, in every crawling command — package sync, package check, package pipeline plan. Not at spec load: the value would otherwise be fetched by validate too.
  • Fail-closed. An unreachable URL, a non-2xx status, a body over 64 KiB, a generator that exits non-zero or times out, or a value that is not a version aborts the command with exit 69. There is no fallback to an unbounded window.
  • The resolved value is one version string, trimmed. Empty output is an error. A generator inherits the runner's environment and runs in the spec directory unless working_directory says otherwise.
  • Reported: pipeline plan --format json carries versions_resolved with {min?, min_inclusive, max?, max_inclusive} — an edge the spec does not set emits no version key, while its inclusivity flag always travels and carries the shorthand default. The plain renderer prints resolved max: 3.0.0 (inclusive, from url) above the table, including on a "nothing to do" run, and a non-literal bound is logged at info once per run.
  • A resolvable min is a widening knob as well as a narrowing one: a floor that moves down re-opens the backfill. The window is still bounded by what upstream actually published and new_per_run still caps the batch, but a downward move means work, not a no-op.
  • new_per_run throttles the rate, the bounds cap the target — with both set, the backfill walks toward the pointer instead of toward newest.
  • url: authenticates through the host-keyed ladder documented under Authentication and TLS (OCX_AUTH_<slug>_*, then netrc). Nothing in mirror.yml names a variable, and a URL carrying userinfo is refused (exit 65).
  • extends: merges shallowly: a child versions: replaces the parent's whole block, resolved bounds included.

build_timestamp & GC-safe publishing

build_timestamp controls the tag a mirrored version is published under. Each (version, platform) push writes a primary tag for that version; with cascade: true (the default) it also re-points the rolling tags X.Y, X, and latest to the newest build.

Value Primary tag for 3.28.0 Effect
datetime (default) 3.28.0_20260310142359 Unique per build (UTC YYYYMMDDHHMMSS). Never re-pointed.
date 3.28.0_20260310 Unique per build-day (UTC YYYYMMDD).
none 3.28.0 Bare version tag. Re-published in place on every rebuild.

Pre-releases keep their identifier: 3.28.0-rc1 → 3.28.0-rc1_20260310142359. A version that already carries a build suffix is rejected rather than double-stamped.

The garbage-collection hazard of build_timestamp: none

A digest is immutable, but a tag is not. Re-publishing a version under build_timestamp: none — or moving a rolling cascade tag to a newer build — re-points the tag and leaves the previous digest untagged. Once untagged, registry garbage collection can reap it, breaking any consumer ocx.lock pinned to that @sha256: digest. "Digests are immutable" only holds until GC runs.

With datetime or date, every build also lands under its own unique X.Y.Z_<ts> tag that is never re-pointed, so the digest stays permanently reachable even as the rolling cascade tags float. This is the GC-safe choice. Trade-off: storage grows with every build, and the version tag is no longer bare.

Choosing a value:

  • datetime (default) — GC-safe, no registry configuration required. Recommended for any mirror whose packages are pinned by digest downstream.
  • date — GC-safe across days with coarser tags. Caveat: a second build on the same UTC day re-points that day's tag, orphaning the earlier same-day digest — the within-day hazard remains.
  • none — bare tags only. Use exclusively when the target registry protects referenced digests from GC: a retention policy that keeps untagged manifests still referenced by consumers, an OCI referrers/lock guard, or a guarantee that a version is never re-published (each X.Y.Z treated as immutable upstream).

ocx-mirror emits a parse-time warning when build_timestamp: none is combined with cascade, so the hazard surfaces on every validate, check, sync, and pipeline run. It is advisory, not fatal — a registry with retention configured can use none safely.

verify

Integrity checks run against every downloaded asset, before it is unpacked or published.

verify:
  github_asset_digest: require       # off | if_present (default) | require, or true/false
  url_index_digest: if_present
  checksums_file: "https://example.com/SHA256SUMS"

An unrecognised key here is a parse error, not a silent ignore: a sha265_file: that parsed would leave a spec verifying nothing while reading as if it did.

Digest policies

Both digest keys take the same three-state value, describing what a publisher-declared digest obliges the download to do:

Value Meaning
off Ignore any declared digest. Also spelled false.
if_present Verify when the source declared one; a source that declares none is fine. Default. Also spelled true.
require Verify, and fail the asset when the source declared none.

true and false keep their exact pre-existing meaning, so a spec already carrying github_asset_digest: false behaves identically.

A mismatch fails that (version, platform) and reds the run (exit 1); nothing is published for it. A require failure fires after the download — the policy lives in one place, which costs one wasted fetch and makes the refusal loud rather than a silent skip.

verify.github_asset_digest

The digest the GitHub Releases API declares per asset. GitHub omits it on releases published before it added the field, which if_present tolerates and require does not.

This verified nothing before ocx-mirror 0.6.2

The field has existed and defaulted to on since the first release, but the digest never reached the download leg — the check was dead code. It is live now. A mirror whose bytes differ from GitHub's declared digest — a mutating proxy, a re-uploaded asset — starts failing. That is the intent; set off for a source where it is known not to hold.

verify.url_index_digest

The digest a url_index document declares per asset, in the object asset form {url, sha256}.

require is the guard against silent degradation: a sha265: typo in a generator turns the object form back into a plain string, which if_present accepts as "this asset declares no digest". Under require it fails instead.

verify.checksums_file

URL of a sha256sum-format sidecar (HASH FILENAME per line, # comments and blank lines skipped) listing the release's assets. The downloaded file is matched by asset name; an asset absent from the file fails. Independent of the digest policies — both run when both are configured.

Env sources ignore both digest policies

source.type: pylock and pypi verify every wheel against the PEP 751 lock's own hashes during preparation. VersionInfo.assets is empty for them by construction, so a digest policy there would describe a check that never runs.

A rewritten download host does not weaken any of this: the digest is compared against the bytes on local disk and never consults the URL, which is exactly what makes it the proof that a proxy served what the publisher declared. See source.url_rewrite.

A resumed run — a work directory that already holds bundle.tar.xz — re-checks the declared digest against the archive beside it, so tightening a policy to require or correcting an upstream digest does reach an existing work directory. If the archive is gone the bundle is refused rather than adopted: delete it to re-download and re-verify.

tests

Declares the smoke-test commands to run against each installed bundle. Every entry runs for every (version, platform, container) combination in the matrix.

tests:
  - name: version
    command: cmake --version
  - name: smoke
    script: tests/smoke.star

Each entry sets exactly one of three mutually exclusive fields:

Field Type Description
command string Single-line shell command, executed verbatim in the leg's configured shell. Multi-line logic must live in a repository file invoked via shell (bash ./tests/smoke.sh, pwsh -File ./tests/smoke.ps1).
script string Path to a Starlark .star file, run via ocx package test --script. Resolves from the repository root, not from the spec's own directory — see script: resolves from the repository root for where that matters in a multi-spec repository.
script_inline string Starlark source given inline (YAML \| block scalar), piped to ocx package test --script -.

Rules:

  • Required: must contain at least one entry when used with pipeline generate ci.
  • name must be unique within the file and must match ^[a-zA-Z][a-zA-Z0-9_-]*$. The name appears as the JUnit test-case name, so it must be stable across runs.
  • Exactly one of command, script, script_inline per entry — zero set or more than one set is rejected at validation time.

Environment exposed to every test command:

Variable Value
OCX_INSTALL_DIR Path where ocx package test materialized the package
OCX_VERSION Mirrored version string (e.g., 3.29.0)
OCX_PLATFORM Platform slug (e.g., linux/amd64)
OCX_IMAGE Container image; empty on native legs
OCX_TEST_NAME The tests[].name value for this invocation

platforms

Declares the runner and container matrix the CI legs run on. Each key is a platform key, in the same form assets uses — including the +libc.<flavor> suffix.

Nothing in this block is GitHub-specific. pipeline generate ci renders it as a GitHub Actions matrix because that is the renderer shipped in the box, and plan.json publishes the same resolved matrix as legs for any other forge to render — a GitLab child pipeline, a Jenkins job, a shell script. runner: is a label set, not a GitHub runner name, so declaring it costs a GitLab user nothing but their own tags:.

A platform without containers: runs its tests natively on the runner. A platform with containers: runs them once per image: the generated workflow fetches a libc-matched, statically-linked ocx release and executes every ocx package test inside docker run <image>, so the mirrored artifact is loaded and run by that image's own libc. That is the only way an os.features musl or glibc claim is actually verified — an artifact that links glibc reds its Alpine leg instead of shipping a false claim. Declaring setup on a container narrows that claim, honestly: not "runs on stock image X", but "runs on stock image X plus these named packages" — and the packages are named right next to the image they provision.

Container legs are linux-only, and run natively

Tests run via docker run, so containers: is rejected on a darwin/* or windows/* platform. A spec may mix freely: container legs on Linux, native legs everywhere else. No qemu is installed, so a linux/arm64 platform with containers: needs an arm64 runner: — the leg fails with that message rather than emulating.

Making a libc claim provable is the point of the container matrix, so the platform key carries it:

platforms:
  "linux/amd64+libc.musl":
    runner: ubuntu-latest
    containers:
      - { image: "alpine:3.20", shell: sh }

  "linux/amd64+libc.glibc":
    runner: ubuntu-latest
    containers:
      - { image: "ubuntu:24.04", shell: bash }

docker run --platform is handed the key with the +libc.* suffix stripped (linux/amd64); ocx package test --platform keeps the full key, which is what selects that entry out of the image index.

Provisioning the image (setup)

A stock base image sometimes lacks a shared library the mirrored artifact links against — pnpm's glibc build needs libatomic.so.1, its musl build needs libgcc_s. containers[].setup provisions the image before any test runs, per container:

platforms:
  "linux/amd64+libc.musl":
    runner: ubuntu-latest
    containers:
      - image: "alpine:3.20"
        shell: sh
        setup:
          - apk add --no-cache libstdc++

  "linux/amd64+libc.glibc":
    runner: ubuntu-latest
    containers:
      - image: "ubuntu:24.04"
        shell: bash
        setup:
          - apt-get update
          - apt-get install -y libatomic1
      - image: "fedora:40"
        shell: bash
        setup:
          - dnf install -y libatomic

Each entry becomes one Dockerfile RUN, handed verbatim to the container's own shell. The image is built once per leg with docker build — not once per test — and every ocx package test invocation on that leg, across every mirrored version, reuses the resulting tag. A setup command that exits non-zero reds the leg naming the setup step, rather than surfacing as a downstream test failure that reads as an artifact defect.

One command per entry. RUN shell-form passes each entry to the container's shell unparsed, so an embedded newline would split one RUN into a broken Dockerfile — write the extra step as its own list entry instead.

Deliberate scope limit. setup provisions the image so the artifact can load — it is not a general pre-test hook. A leg's value is the narrow, honest claim it makes: this artifact runs on stock image X plus these named packages. Growth past a handful of lines is a visible signal the leg is doing too much (fetching fixtures, starting daemons, seeding services), and it stays visible precisely because the commands sit next to the image they provision.

Reuse across legs — the same setup needed on more than one platform — comes from YAML anchors, the same mechanism the fleet already uses for shared assets: and source: blocks; setup: is a plain list with nothing anchor-specific about it.

Container legs for Python env sources (pylock/pypi)

The job still runs on the host runner — GitHub mounts a glibc node for JS actions, which Alpine's musl userland cannot execute — and only ocx package test is wrapped in docker run <image>, with the runner's CA bundle mounted so the gnu ocx binary can verify TLS inside a minimal image. Use an alpine leg to validate a libc: musl env end-to-end and a debian/ubuntu leg to sanity-check the glibc floor. The env under test is self-contained (local wheel layers); only its private interpreter is pulled from the registry.

platforms:
  linux/amd64:
    runner: ubuntu-latest
    containers:
      - { image: "ubuntu:24.04", shell: bash }
      - { image: "alpine:3.20",  shell: sh }
      - { image: "fedora:40",    shell: bash }

  linux/arm64:
    runner: ubuntu-24.04-arm
    containers:
      - { image: "ubuntu:24.04", shell: bash }
      - { image: "alpine:3.20",  shell: sh }

  darwin/arm64:
    runner: macos-latest

  darwin/amd64:
    runner: macos-latest
    prefix: ["arch", "-x86_64"]

  windows/amd64:
    runner: windows-latest
    shell: pwsh
    tests:
      - name: version
        command: cmake.exe --version
      - name: smoke
        command: pwsh -File ./tests/smoke.ps1

Fields:

Field Type Required Description
runner string or array of strings Yes Runner label set — the job runs on a runner carrying all of these. See Runner labels.
containers array No Container matrix entries. Absent = native mode. Must have ≥1 entry when present.
containers[].image string Yes Valid OCI image reference (e.g. ubuntu:24.04)
containers[].shell string No* Shell to invoke inside the container. *Required when image name does not match a known default (see below).
containers[].id string No Stable ID used to construct JUnit filenames and GHA matrix check names. Defaults to the slugified image (: and / → _).
containers[].setup array of strings No Shell commands baked into the container's image before any test runs, one per entry. See Provisioning the image (setup). At least one entry when the key is present — an empty list is rejected.
shell string No Default shell for native legs. Defaults: pwsh on Windows, bash elsewhere.
prefix array of strings No Command prefix applied before every test invocation. Defaults: ["arch", "-x86_64"] on darwin/amd64 with a macos-* runner; empty otherwise.
tests array No Per-platform test override. When present, replaces the top-level tests: array entirely (no partial merge).
min_version string No Inclusive lower bound: the first upstream version this platform applies to. See Version applicability.
max_version string No Exclusive upper bound: the first upstream version this platform no longer applies to.
exclude array No Individual (version[, range]) holes within the window. See Version applicability.

Runner labels

runner: is a label set, not a runner name: the job runs on a runner carrying every label listed. Write one label or a list —

platforms:
  linux/amd64:
    runner: ubuntu-latest
  linux/arm64:
    runner: [self-hosted, linux, arm64]

Both spellings mean the same thing to ocx-mirror; the single label is the set of one. Which is why this is a set and not a string: it is the one shape every forge already has. GitHub renders it as runs-on — a bare scalar for one label, a flow sequence for more — and GitLab reads the same list as tags. plan.json always carries it as a list, so a renderer for any other forge never has to handle two shapes.

An empty list, or a label that is only whitespace, is rejected (exit 65). A missing runner: is a parse error — there is no default.

Platform key validation:

  • Must parse as a platform key: <os>/<arch>[/<variant>][+libc.<flavor>[,...]] — the same grammar assets uses. Quote any key containing +.
  • A key declaring a libc must be tested under that libc: every image on linux/amd64+libc.musl has to be a musl base (Alpine), and every image on a +libc.glibc key a glibc base. The mismatch is rejected at generate time with exit 65 — a musl-static binary runs fine under glibc, so an Alpine claim tested in Ubuntu goes green having verified nothing.
  • containers[].setup, when present, must declare at least one command — an empty list is rejected (exit 65: drop the key instead).
  • Every setup entry must be non-blank and a single line. Each entry becomes one Dockerfile RUN; a blank entry or one containing a newline is rejected (exit 65) rather than emitted as a broken Dockerfile.
  • No setup entry may end in a backslash. A trailing \ is a line continuation that would absorb the following RUN as its own arguments — the build exits 0 with that layer never applied — so it is rejected (exit 65).
  • A key other than runner, containers, prefix, shell, tests, min_version, max_version, or exclude under platforms.<p> — and a key other than image, shell, id, or setup under containers[] — is rejected at parse time (deny_unknown_fields, exit 65), not silently dropped. This is what makes a setup: written one level too high, on the platform instead of the container, a loud error.

Version applicability

Not every platform applies to every release. A platform may be introduced late upstream (its first binary ships at some 0.11.7), dropped at a later release (the upstream stops shipping that OS/arch), or carry a known-broken build for one specific version. Without a per-platform lever, the only knob is the global versions.min/max, which moves the window for all platforms at once — so a single broken (version, platform) either reds the run forever or forces a global version bump that strands the other platforms.

min_version, max_version, and exclude constrain which versions a platform applies to. A (version, platform) pair outside a platform's window — or matched by an exclude entry — is never resolved, scheduled, built, tested, or pushed, and never reds the run. This supersedes the old workaround of bumping the global versions.min to dodge a late-added or dropped platform.

platforms:
  windows/arm64:
    runner: windows-11-arm
    shell: pwsh
    min_version: "0.11.7"          # platform's first upstream release (inclusive)
    exclude:
      - version: "0.16.0"          # one known-broken release
        reason: "aarch64-windows build-exe segfault"
        severity: broken           # 🔒 row in the Discord report (default)

  darwin/amd64:
    runner: macos-14
    max_version: "11.1.0"          # dropped upstream at 11.1.0 (exclusive)
    exclude:
      - max_version: "9.4.0"       # never built anything below 9.4.0
        severity: skip             # silent — no 🔒 row

exclude entry fields:

Field Type Required Description
version string One of version / range Exclude exactly this version. Mutually exclusive with min_version/max_version.
min_version string One of version / range Inclusive lower bound of an excluded range.
max_version string One of version / range Exclusive upper bound of an excluded range. A range may set either bound alone (open-ended).
reason string No Surfaced in the 🔒 row for broken excludes.
severity broken | skip No broken (default) drops the pair and surfaces a 🔒 row (plus reason); skip drops it silently.

Semantics:

  • min_version is inclusive, max_version is exclusive — the same convention as the top-level versions bounds' shorthand. There is no per-platform opt-out: only versions.min/max take the object form that states its own inclusivity.
  • An exclude entry must set either a single version or a min_version/max_version range, not both.
  • To re-enable a previously-excluded pair, delete the entry — the next clean run backfills it.
  • Validation rejects unparseable bounds and conflicting exclude shapes with exit code 65 (DataError).

Container shell defaults:

  • alpine* → sh
  • ubuntu*, debian*, fedora*, rocky*, opensuse* → bash
  • Any other image: shell is required.

ocx_mirror

Records which ocx-mirror produced a plan. It pins nothing — see the box below for where the binaries actually come from.

ocx_mirror:
  rev: abc123def0123456789012345678901234567890

Fields:

Field Type Required Description
rev string No Full 40-character git SHA, echoed back as ocx_mirror_rev in pipeline plan output for traceability. Must match ^[0-9a-f]{40}$.

Where the binaries come from

Generated jobs install the toolchain via the ocx-sh/setup-ocx action, which activates the mirror repository's project toolchain (ocx.toml / ocx.lock) onto PATH — ocx-mirror and ocx both come from there. Every generated job pins one ocx version end to end: setup-ocx is called with an explicit version: input, and container test legs download the statically-linked release of that same version. The version is a constant in the renderer, not a spec field, so the whole fleet tests against one binary and it advances when the repository's pinned ocx-mirror does.

sign

Configures publish-side signing of every package this mirror produces: keyless Sigstore or a signing key — exactly one mode tag is required.

sign:
  keyless:                      # tag. `keyless: {}` = public Sigstore.
    fulcio: <ref>               # optional; default https://fulcio.sigstore.dev
    rekor:  <ref>               # optional; default https://rekor.sigstore.dev
    identity_token: <ref>       # optional; env:// or file:// only. Only for CIs ocx cannot auto-detect.
  # xor
  key: <ref>                    # string form: the ocx --key reference
  key:                          # map form
    ref: <ref>                  # required
    passphrase: <ref>           # optional; env:// or file:// only
    rekor: <ref>                # optional; present = --rekor-upload --rekor-url, absent = --no-rekor-upload

A present sign: carries exactly one mode tag, keyless or key, never both. Every value under it is a Ref: a literal, env://NAME, or file://PATH. What each spelling means per field:

Field literal env://NAME file://PATH
key / key.ref a bare path, as ocx passed verbatim to --key (ocx resolves) passed verbatim
passphrase, identity_token refused, 64 resolved by the mirror resolved by the mirror (≤ MAX_SECRET_FILE_BYTES)
fulcio, rekor the URL resolved by the mirror resolved by the mirror

Refused at load, exit 64, naming the field and never the value: a bare sign: {} or a null sign:; both keyless and key tags present; key: {} or a key map with no ref; a ref that is empty or contains BEGIN (a literal PEM); a literal passphrase/identity_token (the secret-class fields refuse the literal form); an env:// name outside ^[A-Z_][A-Z0-9_]*$; an env:// name that is OCX_IDENTITY_TOKEN, OCX_KEY_PASSWORD or OCX_SIGNING_KEY; an empty file:// path. An unknown key under sign: stays a schema error, exit 65.

Why those three names are refused. ocx's plugin dispatch strips exactly them from the child's environment (environment reference), so under ocx mirror package pipeline push the reference resolves to nothing however it is spelled. What that costs depends on the seat, per the table above: key/key.ref pass verbatim, so ocx package push --sign tags the whole cascade before the signature fails and publishes it unsigned; passphrase/identity_token are resolved by the mirror before the first push and fail closed at exit 78 with nothing published. Refusing the spec keeps both unreachable. The remedy is a rename: pick a name ocx does not own, for example env://MIRROR_SIGNING_KEY, and export the value under it. This costs one working case — a direct, unwrapped ocx-mirror invocation, as a generated workflow's own push step makes, can read OCX_SIGNING_KEY — but a spec that works when run one way and cannot resolve its key when run the other is a trap, and the rename is one line.

Keyless endpoints are always passed to ocx. Under keyless:, the mirror renders both --fulcio-url and --rekor-url on every ocx package push --sign and ocx package sign invocation — from fulcio/rekor when the spec sets them, otherwise from the mirror's own DEFAULT_FULCIO_URL/DEFAULT_REKOR_URL constants (the public Sigstore instances). A machine's [trust.sigstore] table is therefore never consulted for publishing, even when the spec omits both fields — that table governs ocx's consumer-side (verify) resolution only.

Key references. A key: ref naming a KMS scheme — awskms://, gcpkms://, azurekms://, hashivault://, k8s:// — is passed through to ocx unvalidated. ocx exits 82 (unsupported, detail unsupported_key_backend) on all five; only file and env:// key references are implemented.

Key-mode Rekor. key.rekor present renders --rekor-upload --rekor-url <U>; absent renders --no-rekor-upload. The mirror emits that flag explicitly rather than leaving it unset: ocx's own resolution ladder falls through flag → [trust.sigstore].rekor_upload → off, so an unset flag would silently inherit a fleet-wide rekor_upload = true and push a private digest to the public log.

What a sign: block renders into a generated workflow. pipeline generate ci gives the push step and the patch step an env: line for every env://NAME the block names — ${{ secrets.NAME }} for key, key.ref, key.passphrase and keyless.identity_token, ${{ vars.NAME }} for keyless.fulcio, keyless.rekor and key.rekor, so each name is a repository secret or a repository variable accordingly (one name claimed by both classes resolves to secrets.). A file:// ref names a path on the runner and a literal is already the value, so neither renders a line. Under keyless: those two jobs additionally declare id-token: write, the OIDC scope the Fulcio exchange needs; key mode exchanges no token and is granted no such scope. No other job is touched.

Signing needs an ocx that carries the endpoint flags. keyless: renders --fulcio-url and --rekor-url onto ocx package push --sign, and key: renders --rekor-url whenever key.rekor is set. ocx package push gained those two flags after 0.6.0, so an ocx at 0.6.0 or older fails the push leg with exit 64 and unexpected argument '--fulcio-url'. Two pins decide which ocx runs, and they move independently: the co-located ocx.toml/ocx.lock toolchain for a local package sync or pipeline push, and OCX_CONTAINER_CLI_TAG — the constant pipeline generate ci bakes into every rendered setup-ocx step's version: — for a run in CI. Neither is checked when the workflow is rendered, and the rendered YAML names no flag, because the argv is assembled at run time. ocx package sign has carried both flags since 0.6.0, so pipeline sign and the closing sweep are unaffected; so is a key: with no key.rekor, which renders only --no-rekor-upload.

notify

Configures Discord webhook notifications. The webhook fires after the push job completes.

notify:
  discord:
    webhook_secret: DISCORD_WEBHOOK_URL
    user_id: "123456789012345678"

Fields:

Field Type Required Description
discord.webhook_secret string Yes (when notify: is present) Name of a GitHub Actions secret whose value is the Discord webhook URL. Must match ^[A-Z][A-Z0-9_]+$.
discord.user_id string No Discord user ID (snowflake) to mention on failures. Non-secret — inlined into the workflow as OCX_MIRROR_DISCORD_USER_ID. Must match ^[0-9]{17,20}$.

Validation:

  • webhook_secret must be a secret name, not a URL. Values containing discord.com, discordapp.com, or matching ^https?:// are rejected at parse time with exit code 64 (UsageError). This prevents accidental commit of a live webhook URL into the repository.
  • user_id must be the numeric snowflake. A URL or @mention paste is rejected with exit code 64 (UsageError); any other malformed value is rejected with exit code 65 (DataError).

Messages:

The report posts one Discord message per published version — a single embed each (so a release-heavy run never trips Discord's 1024-character field cap, and each release reads as its own notification). Consecutive messages are paced and a 429 Too Many Requests is retried per Discord's retry_after, so a large backfill stays under the webhook rate limit. Each embed lists that version's platforms with a status chip:

Chip Meaning
🟢 Pushed
🔴 Test or push failure
🚫 Expected artifact never arrived (missing bundle / JUnit)
🔒 Deliberately excluded for this version (a broken exclude entry), with its reason

When user_id is set, any message that carries a partial or failed version is prefixed with an in-message <@id> mention — scoped to that one user, so @everyone and role pings never fire. Messages with only successful versions never ping.

Notification conditions:

Condition Action
All versions already existed in the registry, no failures Silent (no POST sent)
New versions published, no failures Green per-version embeds with published platforms; no mention
New versions published, some platforms failed Yellow/red embeds for the affected versions; mention if user_id set
No new versions published, all platforms failed Red embeds with failure details and run URL; mention if user_id set

annotations

OCI annotations written onto the image index of every tag a push writes — the version tag and each rolling cascade tag alike.

Two keys are filled in automatically from the workflow environment, so a mirror running in GitHub Actions needs no configuration at all:

Key Value
org.opencontainers.image.source $GITHUB_SERVER_URL/$GITHUB_REPOSITORY — the mirror repository itself
org.opencontainers.image.revision $GITHUB_SHA

image.source names the mirror repository, not the upstream project. Registries use this key for package-to-repository linkage: on GHCR it is what attaches the package to a repository and lets it inherit that repository's permissions, so it has to name a repository the publisher controls.

Outside CI the variables are absent and the annotations are simply not written — ocx package push then leaves whatever the registry already holds untouched, so a local run never clears a link an earlier CI run established.

The annotations: block adds further keys and overrides an auto-detected one:

annotations:
  org.opencontainers.image.licenses: Apache-2.0
  org.opencontainers.image.vendor: OCX

A key listed here replaces the auto-detected value for that key only; the others still apply. Values are taken verbatim — nothing is read from the environment beyond the three variables above (see Variables read by ocx-mirror).

Validation:

  • A key must be non-empty and must not contain =. Annotations reach ocx package push as --annotation KEY=VALUE, so a = in the key would be re-split at the wrong place and publish a different key than configured. Violations are rejected with exit code 65 (DataError).

announce

Publishes this mirror's tags into the OCX index after a push run. Opt-in — without an announce: block nothing is announced.

announce:
  package: bazelbuild/bazelisk
  fork: ocx-contrib/index

Fields:

Field Type Required Description
package string Yes Logical index package as <namespace>/<package>. Not derived from target.repository — the physical path and the logical name are related by convention only.
fork string No Fork the index request is opened from, as [HOST/]NAMESPACE/PROJECT. Absent, the branch is pushed to index_repo itself and the request opened from there — which needs push access on the index repository, and is the only shape a GitLab CI job token can write.
index_repo string No Index repository the request targets, as [HOST/]NAMESPACE/PROJECT. The host names a self-hosted GitHub Enterprise or GitLab instance; the namespace may be a nested GitLab group path. Defaults to ocx-sh/index.
forge string No github or gitlab. Inferred by ocx for github.com and gitlab.com; required for a self-hosted host, whose name says nothing about what runs there.
transport string No api (default — the forge's REST API) or git (clone, commit, one authenticated push carrying the merge-request options). git is GitLab-only and the only transport a CI job token can open a merge request through. See Announcing from GitLab below.
schedule string No UTC cron putting the generated announce-from-registry.yml catch-up workflow on a timer. Absent → that workflow is dispatch-only. See Catching up an existing mirror below.

Behaviour:

The push job makes one ocx package announce call per run, after every version has been pushed — never one per version or per platform. It carries the union of every cascade tag the run wrote, deduplicated: each platform's push report re-lists the same cascade hierarchy, and consecutive versions share the rolling X.Y / X / latest tags. Versions that only failed, or that were already present in the registry, contribute nothing.

Tags are handed over with --tags-file, which adds to the already-curated index entry and never removes a committed tag. The alternative, --tags, replaces the curated set — for a mirror that would delete every previously announced version the moment one run published a new one.

A run that published nothing makes no call at all.

Partially published versions:

A rolling alias — latest, X, X.Y — means "the best build of this line", so it has to resolve to a complete platform set. A version any platform of which failed never gets one: the push job decides every (version, platform) pair before it pushes anything, and passes --cascade only once every platform of that version is green. The green platforms of a partial version publish under the exact version tag X.Y.Z alone.

When the version is whole, every one of its pushes carries --cascade — not just the last. A cascade push merges its own platform into each rolling tag and leaves every other platform's entry on that tag exactly as it found it, so cascading once per version would strand the remaining platforms on X.Y.Z and leave each alias still pointing at the previous version for them. latest would become a mixed-version index and those platforms would never advance.

So a partial version announces X.Y.Z and nothing else — not because the announce filters aliases, but because the registry never received any. Filtering them at announce time cannot work: ocx package announce re-observes every tag the index entry already curates, so an alias an earlier run committed is re-fetched from the registry and re-committed against whatever it points at now. Withholding an alias only ever blocks its first addition, and an established mirror already has all of them.

Three gaps remain, all narrower than the registry write they replace:

  • A version already published by an earlier run keeps whatever aliases that run wrote. Nothing here retracts them, and neither does the catch-up workflow below — it is additive. Retracting an alias needs a manual ocx package push --cascade of a whole version, or a manual ocx package announce --tags.
  • A platform the workflow never built a bundle for is invisible to the push job, which sees only the bundles that arrived. A version whose prepare leg failed outright can therefore still look whole.
  • A version decided whole whose push then fails part-way leaves the aliases carrying the platforms that landed before the failure, and the previous version for the rest. The remaining platforms are withheld from cascading the moment the failure is seen, but a registry write already made cannot be taken back. Re-running the version repairs it.

run-summary.json reports the tags the registry actually received. cascade_tags_written for a partial version holds only X.Y.Z because that is all that was written.

Credentials:

The announce needs an OCX_ANNOUNCE_TOKEN secret with push access to the fork and permission to open the pull request. Generated workflows thread it into the push step's environment.

Without the secret the run still pushes and still reports its results; the announce is skipped, a GitHub notice is emitted, and run-summary.json records it:

announce.status in run-summary.json Meaning
(key absent) No announce: block — the mirror never opted in
announced Index pull request opened or updated, with the tags listed under tags
nothing_to_announce Configured, but the run produced no new tag
skipped_no_credential Configured, but no OCX_ANNOUNCE_TOKEN — a valid configuration for forks and test repos
failed The call ran and failed, with the detail under error
interrupted The run was killed while the announce was in flight — a reclaimed runner, a cancelled backfill. Whatever pushed is live in the registry and the index state is unknown.

interrupted is written before the announce runs and overwritten by whichever of the others it reaches. Its presence, rather than an absent key, is the signal: an absent key already means "this mirror has no announce: block", and a killed run must not read as one that never opted in.

Exit code and job output:

failed fails the push job, on the same reasoning as a red platform: the images are in the registry and the index does not know about them. Left green, an expired OCX_ANNOUNCE_TOKEN keeps every nightly passing while the index drifts arbitrarily far behind the registry, and no scheduled-run alert ever fires because nothing failed. skipped_no_credential does not fail the job — a mirror without the secret is a valid configuration.

The push job exports the outcome as an announce job output, so notify and any branch protection can branch on it. Its value is the announce.status above, or unconfigured when the mirror has no announce: block — plus not_run when the push step itself was skipped for lack of registry credentials.

Whichever of these a run lands on is also rendered as an Index row on the run's Discord notification, so a skipped, failed or interrupted announce cannot look like a successful one.

Catching up an existing mirror:

The announce only ever carries what the current run published, so adding announce: to a mirror that has already published everything reports nothing_to_announce on every run, indefinitely — there is nothing new to trigger it. The same applies after an announce failure: the next run has nothing to retry with.

Every mirror with an announce: block gets a second generated workflow, announce-from-registry.yml, for exactly this. It lists every tag the target repository currently holds, then unions them onto the committed index entry. It is never triggered by a push. Dispatch it from the repository's Actions tab, or:

gh workflow run announce-from-registry.yml --repo <owner>/<mirror> -f dry_run=false

dry_run defaults to true: the run reports whether the index would change (updated or unchanged) and discards the rebuilt entry without opening a pull request. Pass dry_run=false to open it for real.

On a timer. announce.schedule adds a schedule: trigger to that workflow, keeping the dispatch:

announce:
  package: bazelbuild/bazelisk
  fork: ocx-contrib/index
  schedule: "23 5 * * 2"   # optional; UTC cron, GitHub's syntax

The cron string is passed through verbatim, exactly as cascade.schedule is: a spec is rejected (exit 65) when the expression is empty or holds a character outside cron's 0-9 A-Z a-z * / , - charset, and GitHub validates everything beyond that.

dry_run has no value outside a dispatch, so the workflow resolves it itself — false on a schedule event, the input's value on a dispatch. A scheduled run therefore announces for real. A run that finds nothing new is silent: an unchanged announce commits nothing, and opens no pull request unless an earlier run left unmerged commits on the announce branch, which it then ensures a pull request for (unchanged with a pull request URL in pipeline announce's log). A caught-up mirror with nothing stranded produces one green run per cycle and no index traffic.

Green is not proof an announce ran, though: on a target other than ghcr.io — whose credential probe is constant — the announce step is skipped when the registry credentials are missing, and a skipped step keeps the job green. A repo whose OCX_MIRROR_REGISTRY_TOKEN was never set or has since been rotated therefore produces the same silent green forever. Read the run's ::notice:: once after enabling the schedule, and again after every token rotation.

The workflow keeps a concurrency group of its own rather than joining the push workflow's the way cascade.yml does: it writes index pull requests only, never registry tags, so concurrent announce writers contend on a per-package index branch rather than on tags — the fast-forward path is compare-and-swap with a retry, and the spent-branch reset path can drop a racing branch commit, which the next full from-registry run re-adds. Joining the publish group would instead let a queued push cancel the pending catch-up. Give announce.schedule a cron of its own all the same: sharing one with versions.poll_interval schedules the catch-up against the push job's own closing announce.

The catch-up is additive, on the same footing as the push job's --tags-file: it cannot drop a tag the index already commits, and yank markers survive. Running it against a mirror that is already current is a no-op, so it is safe to dispatch on suspicion.

Its ocx-mirror entry point is pipeline announce; the same command runs locally against a checkout.

(--refresh on ocx package announce solves a different problem — it re-observes the tags already committed, picking up a digest that moved, and never adds one.)

Announcing from GitLab

The generated workflows are GitHub Actions, but the announce itself is one command any CI can run — the product principle. On a self-hosted GitLab the index lives in a GitLab project, a job token cannot open a merge request through the API, and there is no fork: the spec names the coordinate, the forge and the git transport, and the job carries nothing but its own identity.

announce:
  package: bazelbuild/bazelisk
  index_repo: gitlab.corp.example/tools/ocx/index
  forge: gitlab
  transport: git
# .gitlab-ci.yml — the job. ocx and ocx-mirror come from the project
# toolchain (ocx.toml): install ocx on the runner, then `ocx exec`/PATH
# activation puts ocx-mirror on PATH — the same shape as the sign job in
# cli.md#pipeline-sign.
announce:
  script:
    - ocx-mirror package pipeline announce --spec mirror.yml

No OCX_ANNOUNCE_TOKEN: under transport: git inside a GitLab job, ocx's credential ladder falls through to the job's CI_JOB_TOKEN, which may push to the index project when that project allows job-token pushes and its job-token allowlist admits the publishing project. The push job's closing announce, pipeline patch and pipeline cascade use the same ladder, so the same spec announces from all of them. A push-only deploy token for the write half goes in OCX_ANNOUNCE_GIT_TOKEN; a missing capability is ocx exit 82 (detail forge_capability_unavailable) and an allowlist miss exit 77 (detail forge_publisher_not_allowlisted), named in the message; pipeline announce then exits 1, as do push, patch and cascade (push also raises a ::warning annotation first) — the tags are live and the index is behind, so a rerun, not a reading. Only a missing credential degrades to a notice. The api transport on GitLab needs a real access token in OCX_ANNOUNCE_TOKEN instead — a job token cannot open the merge request there.

Validation:

  • package must be a <namespace>/<package> pair of lowercase alphanumerics with ., _ or -. A bare tool name is rejected with exit code 65 (DataError).
  • fork and index_repo must each parse as [HOST/]NAMESPACE/PROJECT — the grammar ocx package announce applies to --fork and --index-repo, so a coordinate the spec accepts is one ocx accepts. A pasted URL is rejected with exit code 65.
  • forge must be github or gitlab, and is required when index_repo names a host other than github.com or gitlab.com — anything else is rejected with exit code 65.
  • A coordinate must be valid for the resolved forge: GitHub refuses a nested namespace (org/sub/index), rejected with exit code 65.
  • fork must live on the same host as index_repo; a fork on another host is rejected with exit code 65.
  • transport must be api or git; git is GitLab-only and cannot be combined with fork — either pairing is rejected with exit code 65. Each of these is the refusal ocx package announce itself would make of the same flags, moved to spec validation so it fires before a single tag is pushed.
  • schedule, when present, must be non-empty and hold only cron's 0-9 A-Z a-z * / , - charset. Anything else is rejected before a workflow is written, on the same reasoning as cascade.schedule.

catalog

Configures the README and logo pipeline describe publishes to the target registry as the __ocx.desc referrer tag. Optional — omit the block and the defaults below apply.

catalog:
  readme: docs/catalog.md
  logo: brand/logo.png

Fields:

Field Type Required Description
readme string No Path to the README, relative to the spec's own directory. Defaults to CATALOG.md.
logo string No Path to the logo, relative to the spec's own directory. When unset, the resolver probes logo.svg then logo.png in that same directory — SVG wins when both exist.

Both paths resolve against the directory holding the spec file, never the repository root — see catalog: resolves from the spec's own directory for where that matters in a multi-spec repository.

Validation:

  • deny_unknown_fields — a key other than readme or logo under catalog: is a spec-load failure (exit 65), not a silently-ignored typo.

When readme: is unset and no CATALOG.md exists, pipeline describe logs and exits 0 — the generated workflow is a no-op until catalog content lands in the repository.

A readme: or logo: that is set and resolves to nothing is a spec error (exit 65), reported by package validate like a missing metadata.default. The forgiving default exists for a repository that has not written its catalog yet; a path somebody typed is a typo every time, and the one that keeps happening is the repository-root reading of a spec-directory-relative field — see catalog: resolves from the spec's own directory.

Spec inheritance

mirror.yml files support an extends: key for shallow merge from a parent spec. Child keys override parent keys at the top level. This is useful for sharing source and assets across variants of the same tool.

extends: ./base-cmake.yml
target:
  registry: private.registry.example.com
  repository: internal/cmake

A base is part of its children's effective content, so pipeline generate ci adds every file in the extends: chain to the paths: trigger of each child's generated workflows — editing a shared base re-runs every package that inherits from it, and the drift guard covers it too.

The chain must live inside the repository. A base above the repository root is a spec-usage error (exit 64): a paths: trigger can only name files the workflow's own repository contains, so the workflow would silently never run when that base changed.

Multi-spec repositories

A mirror repository can hold more than one mirror.yml — one per package, each in its own directory. Some upstream projects release several standalone tools from a single tag: bazelbuild/buildtools ships buildifier, buildozer, and unused-deps from one release. Mirroring each as its own repository would triplicate the CI plumbing — and the drift guard, and the secrets — for tools that share one upstream release cadence. Putting each package's spec in its own directory and passing --spec once per spec keeps them in one repository: pipeline generate ci renders an independent workflow set per spec and exactly one drift guard for the whole repository.

Repository layout. ocx-contrib/mirror-bazelbuild already mirrors bazelisk from a mirror.yml at its root. Adding the three buildtools binaries means one directory per package, each holding its own mirror.yml and CATALOG.md, sharing the repository's one logo:

mirror-bazelbuild/
├── mirror.yml                # bazelisk — stays at the repo root, unchanged
├── logo.svg                  # shared by every spec in the repository
├── buildifier/
│   ├── mirror.yml
│   └── CATALOG.md
├── buildozer/
│   ├── mirror.yml
│   └── CATALOG.md
└── unused-deps/
    ├── mirror.yml
    └── CATALOG.md
ocx-mirror package pipeline generate ci \
  --spec mirror.yml \
  --spec buildifier/mirror.yml \
  --spec buildozer/mirror.yml \
  --spec unused-deps/mirror.yml

writes:

.github/workflows/
├── mirror.yml                            # bazelisk — byte-identical to before
├── describe.yml
├── patch.yml
├── cascade.yml
├── announce-from-registry.yml
├── mirror-buildifier.yml
├── describe-buildifier.yml
├── patch-buildifier.yml
├── cascade-buildifier.yml
├── announce-from-registry-buildifier.yml
├── mirror-buildozer.yml
├── describe-buildozer.yml
├── patch-buildozer.yml
├── cascade-buildozer.yml
├── announce-from-registry-buildozer.yml
├── mirror-unused-deps.yml
├── describe-unused-deps.yml
├── patch-unused-deps.yml
├── cascade-unused-deps.yml
├── announce-from-registry-unused-deps.yml
└── verify-generated.yml                  # one guard, names all four specs

Naming a nested spec file mirror.yml is convention, not a requirement — the generated filenames derive from the spec's directory, never its filename (below). Keep the filename anyway: it matches every other spec in the repository, and it is the directory — not the name — that --repo-root's default and the collision check both reason about.

Generated file names. A spec at the repository root keeps today's filenames byte for byte — mirror.yml, describe.yml, patch.yml, cascade.yml, announce-from-registry.yml — so a repository that adds its first nested spec never has to touch the workflows it already published. A spec anywhere else gets every filename suffixed with its directory, / joined by -:

Spec path (relative to repo root) Suffix mirror.yml becomes
mirror.yml (none) mirror.yml
buildifier/mirror.yml -buildifier mirror-buildifier.yml
a/b/mirror.yml -a-b mirror-a-b.yml

Because the suffix comes from the directory alone, a directory may hold only one spec — two specs sharing a directory, whatever their filenames, would render the same workflow set and silently overwrite each other. generate ci rejects this with exit 64 before writing anything.

Every generated pipeline invocation in a nested spec's workflows names its own spec explicitly — pipeline plan --spec buildifier/mirror.yml, and likewise for prepare, push, describe, announce, patch, cascade. The root spec's invocations never carry --spec: its path is exactly what every subcommand already defaults to, which is what keeps the root workflows byte-identical.

--repo-root. Generated files are written under --repo-root, and every filename above is computed relative to it. Left unset, it defaults to the deepest directory every --spec given shares — for a single spec that is simply its parent directory, so generate ci --spec /elsewhere/repo/mirror.yml still writes into that repository rather than the current directory. A spec that does not resolve under --repo-root (explicit or inferred) is rejected with exit 64, naming --repo-root as the fix.

CI triggers per spec. The root spec's workflow keeps the repository-wide trigger list it has always had (its own spec file, scripts/**, tests/**, metadata*.json) plus its own workflow file. A nested spec's workflow instead triggers only on its own subtree — buildifier/** plus .github/workflows/mirror-buildifier.yml — never the repository-wide list, so editing buildozer/ never wakes buildifier's workflow. The generated describe-<dir>.yml follows the same rule for its own triggers (CATALOG.md / logo.* at the root, <dir>/** when nested); patch-<dir>.yml, cascade-<dir>.yml and announce-from-registry-<dir>.yml have no path triggers at all — they are dispatched, or run on a timer when their spec opts in (cascade.schedule, announce.schedule). Each carries a distinct name: — sibling describe workflows sharing a name would share a concurrency.group too, since it keys on github.workflow.

One drift guard per repository. verify-generated.yml is emitted once no matter how many specs the repository holds. Its committed paths: list is the union of every spec's own triggers, and the generate ci --check command line it bakes in names every spec explicitly with --spec as soon as there is more than one — --spec appends rather than replaces, so a guard naming only a subset would silently stop checking the rest while staying green. The guard also reds when a .github/workflows/*.yml file carries the # Generated by ocx-mirror header but is not in the current spec set's output — the file a dropped spec leaves behind, which would otherwise keep running on schedule against a spec that no longer exists. Hand-written workflows without that header are never inspected.

allow_manual_edits: true disarms the guard only when every spec in the repository sets it; a partial opt-out still emits the guard — covering every spec, including the ones that opted out — and generate prints a warning naming the dissenters.

Which directory a path resolves against

The spec mixes three base directories, and which one a key uses is not guessable from the key. Relative paths in a single-spec repository make all three look the same; in a multi-spec one they diverge, and a path under the wrong reading resolves to nothing — sometimes loudly, sometimes not.

Key Resolves against
metadata.default, metadata.platforms.<key> the spec's own directory
catalog.readme, catalog.logo the spec's own directory
source.generator.working_directory the spec's own directory (and defaults to it)
tests[].script, including per-platform platforms.<key>.tests[].script the repository root
sign file:// references the process working directory — where the command was invoked, not where either file lives

extends: is not on the list: it resolves relative to the child spec but is additionally required to stay inside the repository, which is a containment rule rather than a base.

Every one of these is checked. A missing metadata.default, metadata.platforms.<key>, catalog.readme or catalog.logo is a spec-load failure (exit 65) under package validate; a script: that resolves to nothing is the same exit code under pipeline generate ci, which is where the repository root is known.

script: resolves from the repository root

A tests entry's script: path is read relative to where the workflow checks the repository out — the repository root — never the spec's own directory. In a single-spec repository the two coincide, so the distinction is invisible. In a multi-spec repository it is not: buildifier/mirror.yml must write

tests:
  - name: smoke
    script: buildifier/tests/smoke.star

not tests/smoke.star. metadata.default and catalog.readme / catalog.logo work the other way — both resolve against the spec's own directory — so the same-looking relative path means something different depending on which key it sits under (the full table). The nested workflow's own trigger-path comment says as much, so the gap is visible without opening this page:

    paths:
      # `script:` paths resolve from the repository root, not from buildifier/ —
      # keep this spec's scripts under buildifier/ so editing one triggers this run.
      - buildifier/**
      - .github/workflows/mirror-buildifier.yml

pipeline generate ci rejects a script: that resolves to nothing (exit 65, like a missing metadata.default) — top-level and per-platform entries alike. When the file exists where a spec-directory-relative reading would put it, the error says so and names the path to write instead.

catalog: resolves from the spec's own directory

catalog.readme and catalog.logo resolve against the directory holding the spec file — the opposite of script: above. A logo shared at the repository root is invisible to a nested spec's default probe (logo.svg, then logo.png, looked for only in buildifier/), so every nested spec that wants the shared logo has to say so explicitly:

# buildifier/mirror.yml
catalog:
  logo: ../logo.svg

catalog: is deny_unknown_fields, so a key from the wrong block — default: where readme: was meant, for instance — is a spec-load failure (exit 65), not a silently-ignored typo.

Example: complete spec

name: cmake
target:
  registry: ocx.sh
  repository: cmake

source:
  type: github_release
  owner: Kitware
  repo: CMake
  tag_pattern: "^v(?P<version>\\d+\\.\\d+\\.\\d+)$"

assets:
  linux/amd64:
    - "cmake-.*-linux-x86_64\\.tar\\.gz$"
  darwin/arm64:
    - "cmake-.*-macos-universal\\.tar\\.gz$"
  windows/amd64:
    - "cmake-.*-windows-x86_64\\.zip$"

cascade: true

tests:
  - name: version
    command: cmake --version
  - name: ctest
    command: ctest --version

platforms:
  linux/amd64:
    runner: ubuntu-latest

  darwin/arm64:
    runner: macos-latest

  windows/amd64:
    runner: windows-latest
    shell: pwsh
    min_version: "3.20.0"          # cmake windows/amd64 mirrored from 3.20 on
    exclude:
      - version: "3.27.0"
        reason: "windows zip repacked upstream"
        severity: broken
    tests:
      - name: version
        command: cmake.exe --version

notify:
  discord:
    webhook_secret: DISCORD_WEBHOOK_URL
    user_id: "123456789012345678"