Skip to content
ocx
install

Update the pinned tools

Move the tools declared in ocx.toml to later releases, review what moved, and keep every other pin where it is. Four commands cover the cycle: ocx status, ocx lock, ocx update and ocx upgrade.

ocx status shows what ocx.toml and ocx.lock declare before you change anything. It is offline and read-only, and it exits 0 even when the lock is missing or stale. Add --verbose to see each binding’s host digest and the number of locked platforms.

Terminal window
ocx status
ocx status --verbose

For what the declarations resolve to on this host, see Read the project state.

ocx lock resolves every tag to per-platform leaf digests and writes ocx.lock. The lock records every platform the publisher ships for each binding. Later runs of ocx pull and ocx exec read the lock for the host platform and never the registry. Two machines on the same commit get the same bytes.

The lock carries a hash of the canonicalized ocx.toml. If you edit ocx.toml and forget ocx lock, dependent commands refuse to run with stale digests and exit 65. Read paths never re-resolve silently, so CI cannot drift behind a stray editor save.

Terminal window
ocx lock # reconcile ocx.lock with ocx.toml
ocx lock --check # exit 0 when current, 65 when stale, 78 when the lock is missing

Three commands move a pin, and each moves a different thing.

CommandWhat it movesRegistry contactEdits ocx.toml
ocx lockNothing when the lock is current. A drifted config re-resolves every declared tag.Local indexNo
ocx updateThe lock, to where a declared tag points today.Every runNo
ocx upgradeThe declared tag itself, then the lock for the retagged bindings only.Tag listing every runYes

ocx update talks to the registry every time, because its purpose is to see where a moving tag such as :latest or :3 points today. With no arguments it re-resolves every declared tag, even when the lock is current. A resolution failure writes nothing.

By default ocx update also installs the versions it re-resolved. Pass --no-pull to write the lock only, and let ocx pull or the next ocx exec fetch them.

ocx upgrade rewrites the tag in ocx.toml, so cmake:3.28 becomes cmake:3.29. Each binding keeps its variant and its precision. A debug-3.28 binding considers only debug-* tags, and a two-component tag moves to a two-component tag. Comments and formatting in ocx.toml survive the edit.

A binding stays within its current major unless --major is given. The candidate must be strictly later, with no prerelease or build suffix.

Terminal window
ocx update # re-resolve every declared tag against the registry
ocx update ripgrep # advance just ripgrep
ocx update -g ci # advance the ci group, freezing the rest
ocx upgrade --check # see which tags would move, write nothing
ocx upgrade --major cmake # let cmake cross to a new major
ocx upgrade -g ci --verbose

A scoped ocx update and every ocx upgrade need an existing ocx.lock (exit 78). Both refuse a drifted ocx.toml (exit 65), so run ocx lock first when either happens.

Name bindings, pass -g/--group, or do both to advance only part of the toolchain. Every other pin is carried forward verbatim. An unknown name or group exits 64.

Terminal window
ocx update ripgrep shellcheck # two bindings
ocx update -g release # one group
ocx upgrade -g ci cmake # one group and one extra binding

Under --frozen, ocx update resolves against the local index snapshot only, and a tag missing from the snapshot exits 81. Under --offline it forbids network access. ocx upgrade is refused under either flag (exit 81), because its tag listings come from the registry every time.

Project Toolchain In Depth → Pin preservation explains why ocx add, ocx remove and ocx lock never move a pin they were not asked to move.

--check on ocx update or ocx upgrade prints the report and writes nothing. It exits 65 when something would move, which makes it the CI gate for stale pins. When nothing would move, both commands write neither file.

ocx update reports what moved, not only what is pinned. It is the one command that holds both the predecessor lock and the candidate at the same moment. Plain output is one row per tool that moved, with the release it moves from and to:

Binding From To
bun:1 1.4.0 → 1.4.2
git-cliff:2 2.14.1 → 2.14.2
python:3.14 ? → 3.14.8
3 tools would move; run `ocx update` to apply

Binding is name:tag as declared, or the bare name when digest-pinned. A Group column appears only when the moved tools span more than one group. ? marks a release that could not be named, and - marks a pin that did not exist on that side. When a tool’s platforms moved to different releases, the row shows the host platform’s release. If the host is not among them, it shows the release most platforms share.

A trailing hint summarises the tools that held still, such as 2 unchanged tools not shown; re-run with --verbose to list them. --verbose prints one row per platform under Binding | Group | Platform | From | To. From and To are sha256: plus the first 12 hex digits, with the release in parentheses when known. It also lists the pins that held still.

A scoped run lists only the bindings it examined as unchanged, because a pin outside the scope was never re-resolved. The closing line goes to stderr. Under --check it is the verdict behind exit 65, and no error is logged.

ocx --format json update emits { "changes": [...], "unchanged": [...], "metadata_changed": bool }, identical at both verbosities. Release names (from_version, to_version, version) are omitted whenever no release can be named. That happens for a digest-pinned binding, for a run under --offline or --frozen, and for a pin that no recent release matches. An omitted version never changes the exit code. --format json update --check on drift prints the same report and exits 65 with no error document, because the report is the verdict.

Plain output is one table, Binding | Group | From | To, with one row per moved tag. From and To are the tags as declared. Under --verbose, skipped bindings join the table with skipped: <reason> in the To column. Otherwise a hint names their count. A later major that --major was not asked to cross is listed as a hint with the newest tag at the same precision.

A binding that cannot move is a report row, never an error. Each carries one reason:

ReasonMeaning
digest_pinnedThe binding is pinned to a digest.
latestThe tag is latest, or the binding declares no tag.
not_a_versionThe tag is not a version, such as nightly.
prerelease_or_buildThe tag carries a prerelease or build suffix, such as 1.0.0-rc1.
up_to_dateNo later tag exists at that precision within the allowed major.

ocx --format json upgrade emits { "upgrades": [...], "skipped": [...], "beyond_major": [...], "lock": {...} }, ordered by group and then name. --check prints the same report and exits 65 with no error document.