- docs
- Concepts
- How patches work
How patches work
A patch changes the environment a package runs in, without changing the package. An operator publishes a small descriptor and one or more companion packages to a registry they control. OCX composes the companions into the environment of every matching package at execution time. The base package on disk stays exactly as its publisher shipped it.
This page explains how a descriptor matches packages, how a companion joins its target, and where its pins live. To apply patches, read Patch packages for your infrastructure. To author them, read Maintain patch companions.
How it works
Section titled “How it works”A patch descriptor is a small JSON document stored in your organization’s OCI registry. It declares rules: when an installed package’s identifier matches a glob pattern, apply these companion packages to its execution environment.
At ocx exec time, OCX fetches the descriptor and identifies the matching companions. It then
composes their environment entries as part of the package they match. Each entry lands on the
surface the package itself is read through, as if the package had declared it. The base package
is never modified. See Companions are part of their target.
A companion’s integrations block, if it declares one, contributes the same way any package’s
does. The rows are attributed to the companion’s own identifier. They are never merged into
anything the base or another companion declared. The environment composition reference gives
the row shape and ordering rules in Patch companions contribute too.
{ "version": 1, "rules": [ { "match": "*", "packages": ["registry.corp.example/infra/ca-bundle:latest"] }, { "match": "ocx.sh/java:*", "packages": ["registry.corp.example/infra/jdk-truststore:1.0"], "required": true } ]}The match field is a flat glob with three shapes:
registry/repo(no tag, no digest) matches every tag and digest of that repository.registry/repo:<tag-glob>matches by tag. A reference with no tag and no digest counts aslatest. A digest-only reference has no tag.registry/repo@<digest-glob>matches by digest, whatever advisory tag the reference carries.registry/repo@*matches any digest-pinned reference.
* matches any character including /, :, and @, so * alone matches every package.
ocx.sh/java:* matches any version of the JDK hosted at ocx.sh, including a reference
with no tag. A glob such as ocx.sh/java* also matches ocx.sh/javafx.
| Rule | ocx.sh/java:21 | ocx.sh/java | ocx.sh/java@sha256:H | ocx.sh/java:21@sha256:H |
|---|---|---|---|---|
ocx.sh/java | match | match | match | match |
ocx.sh/java:* | match | match | no | match |
ocx.sh/java:latest | no | match | no | no |
ocx.sh/java:21* | match | no | no | match |
ocx.sh/java@* | no | no | match | match |
ocx.sh/java@sha256:H | no | no | match | match |
The columns are the forms a reference takes:
ocx.sh/java:21:ocx package install ocx.sh/java:21.ocx.sh/java:ocx package install ocx.sh/java, with no tag.ocx.sh/java@sha256:H: a digest-onlyocx.tomlentry, with its resolved digest.ocx.sh/java:21@sha256:H: a taggedocx.tomlentry, with its resolved digest.
H is a concrete digest. A digest in a pattern matches the platform-specific manifest digest,
not the multi-platform index digest.
A bare ocx.toml entry such as java = "ocx.sh/java" counts as :latest, so it takes the
fourth form: ocx.sh/java:latest@sha256:H.
ocx package install ocx.sh/java:21 discovers on the reference as you typed it: the first two
forms. ocx package install ocx.sh/java@sha256:H matches the third. The lock-driven commands
(ocx lock, ocx add, ocx update, ocx pull) and composing the environment at exec or env
match the pinned reference with its digest: the last two forms. That is why an ocx.sh/java@* rule
matches at exec but not when you install ocx.sh/java:21 by tag.
A registry-wide glob such as ocx.sh/*:* also matches digest references, because the : in
sha256: satisfies it. *:* can match a registry port as well (localhost:5000/java), so scope
a rule to a registry host unless matching everything is the intent.
When one package is declared under two tags, the tag of the first binding in selection
order is the one matched. Selection order is the groups in the order you select them
(all puts default first), then bindings sorted by name. ocx direnv export and the
global toolchain ignore the selection order and take the first binding in lock order
(groups, then bindings, by name).
Tags are advisory here. A companion that must always apply should match on the bare
repository, or a digest, rather than a tag. When the lock is stale, ocx direnv export and the global
toolchain match patch rules without the declared tag until ocx lock runs.
Rules are evaluated in order and unioned. A Java install matched by both rules above gets both companions composed in.
Companions are part of their target
Section titled “Companions are part of their target”A companion’s env var carries the same visibility any package’s var
does. It is read on the surfaces that visibility names for the package it patches. A package has
two surfaces. The interface surface is what its consumers see. The
private surface is what its own generated launchers see, also shown by
ocx package env --self and ocx package exec --self.
| Companion var | Target’s consumers, ocx package env, shells, dependents | Target’s own launchers, --self |
|---|---|---|
private | no | yes, when the target is a root of the composition |
interface | yes | no |
public | yes | yes, when the target is a root of the composition |
A var that declares no visibility is private.
The private column applies only when the target is a root of the composition. A target that is
a dependency of the package being composed is admitted through its interface alone. Its
companion then contributes its interface and public vars wherever that dependency’s
interface surface reaches, and its private vars never load. A private var therefore cannot
leak into the consumers of a package that depends on the target.
Three more rules follow from “as if the target had declared it”:
- A companion’s own dependencies are admitted the same way. Its private dependencies load only where its private side loads.
- A catch-all rule that matches a root and its dependencies composes the union of the two
sides under
--selfand in launchers. Each entry applies once. The consumer view takes the interface side only. ${installPath},${deps.*}and${self.env.*}inside a companion resolve against the companion, not the target.
For example, plantuml has a private dependency on a JRE, and its entrypoint runs
java -jar ${installPath}/plantuml.jar. A companion matched to plantuml declares
JDK_JAVA_OPTIONS as private:
{ "env": [ { "key": "JDK_JAVA_OPTIONS", "type": "list", "separator": " ", "value": "-Dhttps.proxyHost=proxy.corp.example", "visibility": "private" } ]}Only plantuml’s launcher sees the flag. The JRE is shared by other Java packages, and neither it nor plantuml’s consumers receive it.
Patch entries apply after all package env, so site policy wins over what a package
declares. Project [env] and --env apply after them.
Conflicts and launchers
Section titled “Conflicts and launchers”A companion brings its own dependencies into the environment, so it can name a repository the environment already carries. Suppose its closure reaches that repository at a different digest than the base, or than an earlier companion. The later companion is refused.
A required companion fails the command with exit 65. An optional one is skipped with a
warning.
Two companion roots of the same repository at different tags are not a conflict. Both apply, each as its own companion. A dependency the base or an earlier companion already composed is emitted once.
A companion’s own entrypoint launchers are never put on PATH, since they run the companion’s
environment rather than its target’s. Its own binaries claim is admitted, and so are the
claims and launchers of its dependencies, as for any dependency.
A generated launcher finds its package by directory, and one directory serves every name that
resolves to its digest. The command that composed the environment passes the names it used in
OCX_LAUNCH_IDENTITIES. The launcher matches rules against each of
them.
So ocx.sh/java:* reaches the JDK’s launchers when the JDK runs through ocx exec,
ocx package exec, ocx package test or a lazy tool’s shim. It also reaches them from a PATH
that ocx env --shell, ocx direnv export or the shell hook composed, since those exports carry
the names too.
A launcher run by its absolute path, or from a PATH no ocx command composed, matches only *
rules. A digest-only composition passes a name without a tag. Tag-anchored rules skip it, as they
do everywhere else.
Execution time only
Section titled “Execution time only”Patches are composed when OCX builds an environment to run something: ocx exec,
ocx package exec, ocx package test, ocx env, ocx package env and generated launchers.
Metadata-only views do not model them. ocx package inspect --closure, ocx inspect --closure
and the name sets a toolchain render derives for PATH describe the closure the packages
declare, without companions.
One companion per runtime
Section titled “One companion per runtime”The descriptor above ships two companions for what is conceptually one CA bundle. A generic
ca-bundle companion matches every package (*). A separate jdk-truststore companion matches
only ocx.sh/java:*. Every CA-bundle descriptor needs this split, because each language runtime
discovers trusted CAs its own way.
SSL_CERT_FILE is the closest thing to a universal override. It is an OpenSSL
convention, not a language standard. curl, git and Python’s ssl
module honor it because they link against OpenSSL or defer to its lookup.
Go’s crypto/x509 honors SSL_CERT_FILE and SSL_CERT_DIR only on Linux and the BSDs.
On Windows and macOS it calls the OS-native certificate store and ignores both variables.
Java never reads SSL_CERT_FILE. It has no environment variable for its default
trust store at all.
| Runtime | CA override |
|---|---|
OpenSSL-linked tools (curl, git, Python ssl) | SSL_CERT_FILE / SSL_CERT_DIR |
Go crypto/x509 | SSL_CERT_FILE / SSL_CERT_DIR on Linux/BSD only; ignored on Windows and macOS |
| Java | No environment variable. Set the javax.net.ssl.trustStore system property via JAVA_TOOL_OPTIONS, or keytool -importcert the certificate into the JVM’s cacerts keystore directly. |
A descriptor rule’s match glob is how a patches tier says “this runtime needs its own
companion”. Write one rule per runtime whose CA mechanism differs. Each rule points at the
package that knows how to install it. No single companion reaches every runtime at once.
JAVA_TOOL_OPTIONS is a space-separated option list, not a single value. A constant entry
would erase the flags the base JDK package or another companion already put there. A
path entry would join with the platform path separator instead of a
space, and it would prepend instead of append. The jdk-truststore companion above declares the
variable as list, so its trust-store flag appends after anything already
contributed:
{ "env": [ { "key": "JAVA_TOOL_OPTIONS", "type": "list", "separator": " ", "value": "-Djavax.net.ssl.trustStore=${installPath}/cacerts", "visibility": "public" } ]}visibility is public: the flag reaches the JDK’s consumers, and the JDK’s own launchers
when the JDK is the package being run. A JDK pulled in as a dependency receives the flag
through its interface alone. See Appending option lists for when
to reach for list in your own packages.
A catch-all rule such as "match": "*" matches every root and every dependency. Under
--self and in launchers it composes the union of the private and interface sides. The
consumer view takes the interface side only.
A value meant for everyone, like a CA bundle path or a truststore flag, belongs on a public
var. A private var misses consumers, and an interface var reaches a launcher only through
a dependency the rule matches.
Where companion pins live
Section titled “Where companion pins live”OCX writes a companion’s tag-to-digest binding to $OCX_HOME/state/patch-companions/, one JSON
file per patch-registry repository. The local index ($OCX_HOME/index/) never receives it. A companion repository owns
zero bytes there, with no root document and no dispatch object. So committing an index tree to git never picks one up.
A companion is a package the descriptor named on the operator’s behalf, not one you asked for by
name. Its binding therefore belongs to the patch tier’s own state, not to the package-tier pin
that ocx index update maintains. Two consequences follow:
ocx index updatecan never make a companion resolvable.--frozenfreezes that index and does not reach a companion at all.
Patches float by design.
A companion’s pin advances only on ocx patch sync, including for a rolling
tag the descriptor keeps naming. Composition reads the pin. It records one only when a required
companion has none, and it never advances an existing pin. Under a patch snapshot no command
records a pin, including install, pull and lock.
To pick up a changed descriptor or a new companion, run ocx patch sync while online. Then
freeze again, as described in Freeze for reproducible builds.
In depth
Section titled “In depth”- Environment reference:
OCX_PATCHES: how the resolved tier is forwarded to subprocesses. - Environment reference:
OCX_PATCH_SNAPSHOT: the snapshot path variable. - Environment composition: how companion entries compose onto the base package’s interface and private surfaces.
[mirrors]reference: the transport-level sibling to the patch tier.- Command reference:
patch:publish,sync,freeze,test,why. - Command reference:
--frozen: the package tier’s freeze, and why it leaves the patch tier floating. - Indices: Local Index: where the package-tier pin
ocx index updatemaintains lives, and how it differs from a companion’s pin.