- docs
- Projects
- Lock and reproduce builds
Lock and reproduce builds
Use this page to freeze version discovery with a lock file, so that a rebuild gets the same bytes as the first build.
Pick a pinning level
Section titled “Pick a pinning level”Reproducibility in OCX has three levels, listed from the strongest guarantee to the easiest to maintain.
Pin the digest. This is the strongest lock. kitware/cmake@sha256:abc123… bypasses tag resolution entirely. The bytes are content-addressed, so the digest is the binary. You can pin every package this way with no lockfile and no registry query.
Pin the index. The next level, and the one most users want, freezes the local index’s entry for a tag. The tag resolves to the digest recorded at the last ocx index update, and that mapping does not change until you refresh. A CI runner that never refreshes its index gets the same binary on every run, even if the registry re-pushes the tag. This is version-choice determinism and not ocx.lock.
A lock records the exact digest it pinned and never reads the index back to confirm it.
Pin a bundled index. This is the most ergonomic option for authors of Actions, Bazel rules and other consumers. A local index subtree holds only metadata, which is small JSON files and no binaries. You can ship it inside a GitHub Action, a Bazel rule or a DevContainer feature. Pinning the action version pins the bundled index, which pins the binary:
- uses: ocx-actions/setup-cmake@v2.1.0 # pins action → pins index → pins binary with: version: "3.28"A version bump to the action advances the bundled index. Dependabot or Renovate can propose that bump automatically. Users get the updated binary with no config changes. Compare that with maintaining a hand-curated URL matrix, which needs one filename → checksum entry per version × os × arch.
Create and commit the lock
Section titled “Create and commit the lock”A project declares its tools in ocx.toml, as the project toolchain guide shows. To reproduce a build from that file:
- Run
ocx lockin the project root. It resolves every tag to a digest and writesocx.lockbesideocx.toml. - Commit
ocx.lockwithocx.toml. - On the machine that rebuilds, run
ocx --frozen pull. It installs the digests the lock records, and an unknown tag errors instead of being fetched.
ocx lock --check exits non-zero when ocx.toml and ocx.lock have drifted, which makes it a CI gate.
Freeze version discovery
Section titled “Freeze version discovery”Set OCX_FROZEN to a truthy value to freeze tag-to-digest resolution to the local index. It is the same as the global --frozen flag, and the flag takes precedence. A tag already in the local index resolves from cache. A digest-pinned identifier (repo@sha256:…, or a tag pinned by ocx.lock) still fetches its content over the network.
An unpinned tag missing from the local index errors with exit 81. OCX does not fetch and record it, which guarantees that no unknown version is installed. Populate the index first with ocx index update and the variable unset. Recording a new mapping is itself discovery, so a frozen index update is refused with exit 81.
Unlike OCX_OFFLINE, this is not a network ban. Known and digest-pinned content is still fetched. Combining it with OCX_OFFLINE is accepted, with offline taking effect. Combining it with OCX_REMOTE is a usage error (exit 64).
The freeze scopes to the package tier, whose pin is the local index. A patch companion resolves live regardless, because patches float by design. It pins in the patch tier’s own record under $OCX_HOME/state/patch-companions/. ocx patch sync advances that record, and ocx patch freeze plus OCX_PATCH_SNAPSHOT freeze it deliberately. The managed-configuration tier is unaffected too: ocx config setup and ocx config update behave identically with and without the variable.
Find --locked and --frozen
Section titled “Find --locked and --frozen”Users coming from uv, Cargo or pnpm often look for --locked and --frozen flags on read-path commands. OCX folds the lock-freshness guarantee into the defaults. Read paths refuse stale locks unconditionally, and only explicit mutators touch ocx.lock. OCX exposes the no-new-versions guarantee as the global --frozen flag.
| You used to write… | OCX equivalent |
|---|---|
uv lock --check | ocx lock --check |
uv sync --locked | ocx pull / ocx exec (default, exit 65 on drift) |
uv sync --frozen | ocx --frozen pull / ocx --frozen exec |
cargo build --locked | ocx exec / ocx pull (default) |
cargo build --frozen | --offline (subsumes --frozen: no unknown tags, no network) |
pnpm install --frozen-lockfile | ocx pull (default) |
--frozen and --offline sit on different axes. --frozen freezes version discovery. A tag already in the local index, or a digest-pinned reference, resolves. An unknown tag errors instead of being fetched, and known content still downloads over the network. --offline bans the network entirely, so even a digest-pinned blob that is not already cached fails.
Use --frozen to guarantee that no unfamiliar version slips in. Use --offline for a fully air-gapped run. Combine both for the strictest mode, where offline wins where they overlap.
The asymmetry has a reason. OCX is a backend tool, so read paths refuse stale locks unconditionally and CI scripts cannot drift silently. The mutating commands (ocx add, ocx remove, ocx lock, ocx update and ocx upgrade) are the only commands that touch ocx.lock. If you do not run them, the lock cannot change.
To verify that a subset would not change, without writing, use ocx update --check. It mirrors ocx lock --check but evaluates the partial-resolve candidate against the predecessor.