FAQ
Versioning
Build Separator
Semantic Versioning uses + to delimit build metadata (e.g., 1.2.3+20260216). The OCI Distribution Specification restricts tags to [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127} — the + character is not allowed. This is a known ecosystem-wide issue (distribution-spec#154, open since 2020, unresolved). The + also encodes as a space in URL query strings, making it unsafe even if registries accepted it.
ocx uses _ as the build separator so that every version string is a valid OCI tag by construction:
{major}[.{minor}[.{patch}[-{prerelease}][_{build}]]]The grammar is fully unambiguous: . separates components, - introduces a pre-release, _ introduces build metadata.
Same convention as Helm
Helm adopted the same + → _ normalization when it moved chart distribution to OCI registries. See helm/helm#10250 for the original discussion.
Tolerant input: typing + works and auto-normalizes to _. For example, ocx package install cmake:3.28.1+20260216 installs the tag 3.28.1_20260216. This normalization happens at the earliest boundary — the identifier parser — so all downstream code sees _ only.
See Versioning in the user guide for the full tag hierarchy and cascade behavior.
Installation
How do I install a specific ocx version?
Pass a version to ocx self setup using the downloaded binary. The VERSION argument accepts three forms:
# Install by tag:
/tmp/ocx self setup 0.9.2
# Install by content digest (no tag resolution):
/tmp/ocx self setup sha256:ab12cd34ef56...
# Install by tag and verify the digest (immutability assertion):
/tmp/ocx self setup 0.9.2@sha256:ab12cd34ef56...The tag@digest form is the strongest reproducibility guarantee for CI. If the tag ever points to different content, the command exits 65 and names both digests. Note that a digest pin is platform-specific: the same tag resolves to a different digest on each OS and architecture, so a digest captured on one runner cannot be shared across platforms. For CI matrices, pin by tag. The digest value for a single platform comes from the JSON output of a prior run:
digest=$(/tmp/ocx --format json self setup 0.9.2 | jq -r .bootstrap.digest)
# Subsequent runs assert the exact same content:
/tmp/ocx self setup "0.9.2@$digest"That digest round-trips: re-running /tmp/ocx self setup 0.9.2@$digest exits 0 with status already_present when the same version is already installed and pointed to by current. No redundant download occurs.
When the specified version is already installed, ocx self setup is a no-op and exits 0 — the shims and profile blocks are only re-written when their content has drifted.
Offline and frozen environments
A digest-only pin works under --frozen when the blobs are cached locally. A tag-only pin under --frozen resolves from the local index; the command exits 81 if the tag is absent from the index — run ocx index update first.
Project Toolchain
When should I use ocx package exec vs ocx run?
Short rule: if you have an ocx.toml, use ocx run; if you do not, use ocx package exec.
ocx package exec is the OCI-tier command — its first argument is an OCI identifier (node:20, ocx.sh/cmake:3.28@sha256:…). It never reads ocx.toml or ocx.lock, so it behaves identically regardless of the current directory and regardless of any project file nearby. This makes it the right primitive for embedding in GitHub Actions, Bazel rules, and CI scripts that manage their own tool pins.
ocx run is the project-tier command — its symbols are binding names declared in ocx.toml (e.g. cmake, shellcheck). It resolves those names through ocx.lock, auto-installs missing packages, composes the declared environment, and spawns the child. A missing ocx.toml is a usage error (exit 64), not a fallback to OCI-tier behavior.
Both commands:
- Auto-install missing packages from the registry
- Compose and forward the package-declared environment
- Accept
--cleanto strip inherited shell state - Accept
--selfto expose private-visibility env entries - Forward the child's exit code byte-for-byte
Neither command is deprecated. They cover complementary use cases — running a tool by its project-assigned name (run) vs running it by its registry identity (package exec).
See Project Toolchain In Depth → Running tools for the full contract, composition order, and exit code table.
Dependencies
No Version Ranges
ocx dependencies are pinned by OCI digest, not by version ranges. There is no "give me any Java >= 21" logic. The publisher records the exact build they tested against, and that is what you get.
This is a deliberate design choice. Version ranges introduce a resolution algorithm — typically SAT-solving or Minimum Version Selection — that produces different results depending on what is available in the index at resolution time. Two machines with different index states can resolve the same dependency specification to different binaries. This directly contradicts ocx's reproducibility guarantee: the same metadata should always produce the same result.
Why not Minimum Version Selection?
Go modules and Bazel's Bzlmod use Minimum Version Selection (MVS) — the highest minimum version requested by any dependent wins. MVS is deterministic and polynomial-time, but it still requires an index to enumerate available versions. ocx dependencies work offline with no index at all: the digest is the pin. Future tooling will help publishers discover when their pinned dependencies have newer builds available, keeping the update decision explicit.
Automatic Security Patches
When a dependency receives a security fix, the publisher must release a new version of their package with the updated digest. ocx does not automatically substitute a newer build — doing so would break the reproducibility guarantee.
This tradeoff matches the Nix model: a security patch means rebuilding every affected derivation. The difference is that ocx has no build system — the publisher re-pins the digest and pushes a new tag.
Shared Dependencies
When multiple installed packages depend on the same package at the same digest, the dependency is stored once in the object store (content-addressed deduplication). Each dependent creates a back-reference, so the shared dependency is not garbage collected until all dependents are removed.
When two packages depend on the same tool at different digests, both versions are installed as separate objects. If both would contribute to the same environment — through ocx package env, ocx env, ocx package exec, or ocx run — composition fails: a single environment cannot expose two versions of one package, since PATH resolves only one. The error names the conflicting repository and the versions involved. Two tags that resolve to the same digest are the same version and never conflict. Use ocx package deps --flat to see the evaluation order and ocx package deps --why to trace the conflicting paths — deps reports the conflict as a non-fatal warning so the tree stays inspectable.
See Dependencies in the user guide for the full picture: automatic installation, environment composition, garbage collection, and inspection commands.
macOS
Code Signing
macOS requires all executable code to carry a valid code signature. On Apple Silicon the kernel terminates unsigned binaries immediately (Killed: 9). On Intel, Gatekeeper blocks them when a quarantine flag is present.
When a publisher never signed their binaries before packaging, the extracted files will be unsigned and macOS will refuse to run them. Signatures that were present before packaging survive the tar round-trip — they are part of the binary content, not extended attributes.
ocx handles this automatically: after extracting a package, it recursively walks the content directory, detects Mach-O binaries, and signs each one individually with an ad-hoc signature. Quarantine flags are stripped. No configuration required.
Same approach as Homebrew
Homebrew solves the identical problem with the same technique — per-file ad-hoc signing without bundle sealing — see codesign_patched_binary in their source. ocx applies signatures after extraction rather than after patching, but the codesign invocation is equivalent.
What ocx runs under the hood
Quarantine removal (applied to the entire content directory first):
xattr -dr com.apple.quarantine <content_path>For each Mach-O binary found in the content directory (recursive walk, symlinks not followed):
codesign --sign - --force --preserve-metadata=entitlements,flags,runtime <binary>entitlements, flags, and runtime are preserved from the original signature. requirements (the original certificate's Team ID constraint) is intentionally dropped — preserving it would cause dyld "different Team IDs" errors when loading third-party frameworks. Hardlinked files (same inode) are signed only once.
For package publishers
Signing binaries before packaging is ideal — the signatures survive tar archiving and ocx will leave them intact. But it is not required: ocx applies ad-hoc signatures automatically for any unsigned binary it encounters.
Disabling
Set OCX_NO_CODESIGN to a truthy value to skip automatic signing:
export OCX_NO_CODESIGN=1Manual Signing
If a binary still fails to launch after installation, sign it manually:
codesign --sign - --force --preserve-metadata=entitlements,flags,runtime /path/to/binaryxattr -dr com.apple.quarantine /path/to/contentCan I disable macOS code signing enforcement entirely?
macOS enforces code signatures through AMFI, which runs independently of Gatekeeper. Disabling it requires Recovery Mode, disabling SIP, and setting a boot argument — a configuration Apple does not support that significantly weakens system security.
The macOS Developer Mode setting (since Ventura 13) relaxes restrictions for development tools like Xcode and Instruments, but does not exempt unsigned binaries. Killed: 9 still occurs on Apple Silicon with Developer Mode enabled.
Ad-hoc signing via ocx is the simplest solution — no certificates, no system changes.
Windows
Executable Resolution
Windows does not treat scripts the same way Unix does. On Unix, any file with a #!/bin/sh shebang and the execute bit set can be launched directly. On Windows, the kernel's CreateProcessW API only searches for .exe files — it ignores .bat, .cmd, and other script types entirely.
This applies to the packaged third-party tool a command resolves to, not to OCX's own entry-point launchers — those are native <name>.exe shims, and .EXE is unconditionally in the default Windows PATHEXT, so an OCX launcher is always resolvable by bare name with no PATHEXT configuration. The concern here is the binary inside a package: metadata often exposes tools as scripts (.bat/.cmd on Windows). When [ocx exec][cmd-exec] runs a command, it must find that script by consulting the PATHEXT environment variable, just like a Windows shell would.
ocx resolves this automatically using the which crate: before spawning the child process, it searches PATH with PATHEXT-aware extension matching. If resolution fails — for example because PATHEXT is not set in a stripped-down CI environment — ocx logs a warning and falls back to the bare command name, letting the OS attempt its own lookup.
PATHEXT and packaged .bat/.cmd tools
This caveat is about a packaged tool that ships as a .bat/.cmd script — not about OCX's own launchers, which are .exe and always resolvable. In environments with a minimal set of environment variables (containers, CI runners, custom shells), PATHEXT may not be present. Without it, ocx exec cannot resolve a packaged .bat/.cmd tool by bare name. If you see Could not resolve 'bun' via PATH, ensure PATHEXT is set — the default Windows value is .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC.