Skip to content
ocx
install

Announce a package

This page gets a pushed package resolvable as <ns>/<pkg>. You claim the name once, then announce each release. You need the package pushed to a registry and a credential with push access to the index repository. With --fork, push access to your fork is enough. To run the announce step from a CI job, continue with Announce from CI.

A registry holds anything: container images, Helm charts, whatever else your organization stores there. ocx package push puts bytes in it. The registry cannot tell which repositories are packages, which you meant to publish, or which tags are still fit to install.

Announcing writes that into an index, the software catalog over the registry. The entry records that acme/widget exists, which registry repository holds it and which tags it carries. It also records which tags are yanked or deprecated. With the entry, ocx package install acme/widget resolves for someone who was never told the host.

Two commands write the entry, and the split is deliberate. Once the mapping exists, every later update from acme/* is a routine refresh nobody reviews. So a human looks at the mapping itself, because someone has to judge that whoever claims acme plausibly is ACME.

CommandWhenReviewed by
ocx package claimOnce per package, before its first announceA person, always
ocx package announceEvery release afterwardsThe index’s auto-merge rule, once the owners match

announce never creates an entry. A package with no committed entry exits 79 and points you at claim. Neither command commits to the index’s default branch. Both arrive as a pull request (GitHub) or a merge request (GitLab).

A claim writes one JSON entry into the index repository, at p/<namespace>/<package>.json. Four things go in it, and only two come from you directly:

FieldWhere it comes fromWhy it matters
The logical name<namespace>/<package>, prefixed with the default registryWhat a consumer types: ocx add acme/widget, ocx package install acme/widget
The physical repository--repository oci://HOST/PATHThe registry repository every later announce resolves tags against
The owners--owner, else the CI environment, else the credential’s identityThe governance key, see owners
The upstream block--upstream-org and its two optional siblingsOnly for a package that repackages someone else’s software

The --repository pointer names your registry, not the index. They are different systems: the registry holds the bytes, and the index holds the mapping. A claim that points at the index repository would resolve to nothing.

Every later announce rewrites the entry’s tag set in place. The owners and the repository pointer stay as the claim wrote them. Changing either is a governance-sensitive edit that goes back to a person.

Terminal window
export OCX_ANNOUNCE_TOKEN=<forge access token>
ocx package claim --repository oci://ghcr.io/acme/widget acme/widget

That is the whole minimum: a logical name and the registry repository behind it. Flags come before the positional. The full grammar is in the claim reference.

The claim opens a pull request, so it needs a forge credential in OCX_ANNOUNCE_TOKEN. Without one, a bare claim exits 80. Add --output to write the entry without opening anything. Choosing a posture compares the options, including --fork.

--owner names the accounts recorded as owners. On a fresh claim it replaces the detected list. On a re-claim of an already-claimed package it adds them to the committed list instead. --upstream-org, with its optional --upstream-repository-url and --upstream-disclaimer, marks a package that repackages someone else’s software.

Every refusal that is decidable from the command line alone is decided before a credential is resolved and before anything is dialled. That covers mutually exclusive write flags, a malformed --repository and an --upstream-* flag with no anchor. A bad invocation costs you no round trip through a token you did not need.

The claimed unit is the package, not the namespace prefix. The entry lives at p/<namespace>/<package>.json, so acme/gadget needs its own claim run even after acme/widget merged. A shared namespace prefix grants nothing on the ocx side.

Claiming acme/widget a second time, once its own entry has merged, is not an error. It is how a new maintainer adds themself to owners[], and it costs the release pipeline nothing to run unconditionally. See who reviews it.

An announce run re-observes a set of registry tags and rebuilds the entry from what it finds. Which tags make up that set is the one required choice. The four modes differ in whether they replace the committed set or act only on the tags they are given:

ModeEffect on the committed set
--tags 1.2.0,latestReplaces it. A committed tag not named here is dropped
--tags-file <path>Adds, updates or removes the file’s tags (comma- or newline-separated). A listed tag the registry no longer has is removed. A row the file does not list is left as it is
--tags-from-registryAdds every tag the registry repository currently holds. Removes an ephemeral row whose tag is gone, and reports a durable one
--refreshAdds no tag. Re-observes them all, picking up a digest that moved under a rolling tag like latest. Removes an ephemeral row whose tag is gone, and reports a durable one

--tags-file is the mode a release pipeline wants, because ocx package push --tags-file <path> writes that file as it pushes. The push records which tags it produced, and the announce step consumes the record instead of restating it.

Terminal window
ocx package push --tags-file tags.txt …
ocx package announce --tags-file tags.txt --fork acme-bot/index acme/widget

--fork opens the request from your own fork of the index. This is the same trust model as any open-source contribution, and no index-side credential is ever required. Omit it and the announce branch is pushed to the index repository itself, which needs push access there. When the publishing repository and the index share an owner, omit it. A repository cannot be forked into the namespace that already owns it.

A run that changes nothing makes no commit and reports unchanged. A second announce for the same package updates the open request in place rather than opening a second one. Announcing also picks up whatever ocx package description push last published (title, summary, keywords, README and logo), so a description refresh needs no separate step.

Every announce also cleans up after itself, in the same commit. A moved tag’s old dispatch object, a replaced README and a replaced logo are deleted the moment the rebuilt entry stops referencing them. No flag is required, and the removals show up only in the diff. Only an object the previously committed entry named is ever a candidate. An object orphaned by an announce that ran before this behaviour shipped is not swept retroactively.

Yanking runs through the same command. --yank <tag> with a --yank-reason marks a tag as one that should no longer be installed, and --unyank <tag> clears the marker. It is a publisher signal, not a delete. Resolving a yanked tag is refused by default, and OCX_ALLOW_YANKED opts back in. A resolve already pinned to the digest never needs that opt-in, because immutable content cannot itself be yanked.

A snapshot build is a tag meant to disappear: a canary, or the build of an open change request. The index should list it while it exists and stop listing it once the registry drops it. A durable row must never vanish unseen, so the index has to tell the two kinds apart.

--ephemeral marks each row the run adds with "ephemeral": true. The marker never changes on a row already in the index, and it has no effect on removals. Push ephemeral builds with --no-keep-tag, or prune frees no storage. The keep tag pins the manifest after the tag itself is gone.

Removal follows the registry. Announce reads the canonical registry, never a mirror or a cache. A given tag it reports as MANIFEST_UNKNOWN is gone.

Row in the committed entryReached byResult
Ephemeralany modeRemoved
Durable--tags, --tags-fileRemoved, because you named it. Review still applies on a governed index
Durable--refresh, --tags-from-registryKept and listed in durable_missing, exit 0
Noneany modeExit 79, so a typo stays an error

Schedule --refresh to converge the index, never --tags-from-registry. A snapshot that was pushed but not yet announced with --ephemeral becomes a durable row under --tags-from-registry, and every later prune of it is refused. The whole loop, with CI examples, is in Snapshot tracks.

An owner is a login:id pair, and the id is the load-bearing half. Logins get renamed and recycled, but a numeric account id does not. That is why the entry records both and why governance keys on the number.

Without --owner, ocx works down a short ladder. First come the CI environment’s user variables (GITLAB_USER_LOGIN + GITLAB_USER_ID, or GITHUB_ACTOR + GITHUB_ACTOR_ID, both halves required). Then comes the identity behind the credential.

Passing --owner replaces that result rather than adding to it. The invoking identity is not appended, so a run that names owners names all of them. A bot account is refused at exit 64, because a package owned by a service account has nobody to ask when something goes wrong.

The report’s owner_identity_source says which rule produced the list, and it is not uniform across forges:

ValueWhat it meansWhere it happens
resolvedThe forge’s users API answered. Its canonical spelling, id and bot flag were taken from itGitHub and GitLab
ci_environmentThe CI variables named the list and the users API was out of reach, so nothing confirmed itGitLab only, and only under a job token
assertedThe users API was out of reach and a LOGIN:ID pair was taken on your wordGitLab only, and only under a job token

asserted and ci_environment are unreachable on GitHub. Both need the users API to be closed, and the only credential that closes it is a GitLab job token. asserted applies when a LOGIN:ID was supplied, and ci_environment when the CI variables named the list.

That is why the job-token recipe passes --owner explicitly. Without an id, a bare login with no reachable users API is a usage error naming the LOGIN:ID form. Guessing an id would write a stranger into a governance field.

The report also carries author. That is who opened the request, not who owns the package.

Do not read author as an attestation. Only the first rung is the forge speaking. That rung is the forge’s answer about the credential. The second rung is an ordinary read of GITLAB_USER_LOGIN or GITHUB_ACTOR, which an earlier pipeline step can set to anything. The key does not say which rung answered.

On the public ocx-sh/index, a claim request is labelled new-package and carries a red governance/review-required status that does not auto-resolve. A human approves and merges it. That review is the whole point of the human lane. Judging whether whoever claims a name under acme plausibly is ACME is not automatable, and the claim command deliberately does not try.

ocx verifies no ownership of its own at any point, and every privileged check runs in the index’s CI. How much scrutiny a second package under an already-reviewed prefix gets is that index’s policy, not something ocx decides.

What the owner list buys you is everything after that merge. The index’s auto-merge rule requires the announcing identity to own every root a request touches. An ocx package announce run whose credential resolves to an account id in owners[] merges without a human. One that does not waits for a reviewer.

A change touching a governance-sensitive field always goes back to a person, whichever command made it. The governance contracts reference documents which fields route to which lane. Getting the owner list right at claim time is what makes your release pipeline unattended later. A self-hosted index sets its own rules, so check with whoever runs it.

Removing a row has its own lane. On a bot-governed index, the bot merges the removal of an ephemeral row once it confirms MANIFEST_UNKNOWN from the canonical registry. If the tag is still present, or the bot cannot check, a person reviews it.

The removal of a durable row always goes to a person, whichever identity announced it. Adding or changing the ephemeral marker on an existing row is reviewed too. With --output, or on an index no bot governs, nothing reviews the change. A durable tag you name is then removed on your word, and a durable row you did not name is never touched.

Re-running a claim is always safe, before or after its request merges. Nothing new to say reports status: unchanged and opens no request. A genuine change, such as a new --owner or a moved description, adds itself to the committed entry and reports updated.

Neither path ever fails just because the package is already claimed. Two refusals still exist: a name mismatch and a repository mismatch on a re-claim. Both name what disagreed rather than pointing at announce.

Nothing. The compiled-in defaults name https://index.ocx.sh as the index for the ocx.sh namespace. So ocx package install <ns>/<pkg> resolves through the public index on a machine with no OCX config at all. A consumer opts out, not in: index = "", or a [mirrors] entry pinning ocx.sh at a registry endpoint.

That authority is exclusive, and it cuts both ways. The index is the sole resolver for ocx.sh/…, which is what lets a yank reach everyone who installs the package. So an index.ocx.sh outage fails those installs outright rather than falling through to whatever registry happens to serve a repository under the same name. An announced package inherits the index’s availability along with its guarantees.

Once the request has merged, install the package by its logical name:

Terminal window
ocx package install acme/widget

A successful install resolves acme/widget through the index to the registry repository you claimed, and exits 0. A name the index does not know yet fails instead, so a failure before the merge is expected.

Every failure below is an exit code you can branch on, not a string to grep. Codes marked claim or announce are reachable from that command only. The rest are shared.

ExitWhat ocx saysWhat to do
64--output and --fork cannot both be given, or --transport git was given with --fork, with --output, or against a GitHub forgeDrop one. --output opens no request, so a fork has nothing to open from. The git transport pushes to the index itself and is GitLab-only
64--forge is required for a self-hosted hostA hostname says nothing about which forge runs behind it, and a wrong guess would send your credential to the wrong API. Say --forge gitlab or --forge github
64claim: malformed --repository, expected oci://host/pathThe pointer names the registry repository, scheme included: oci://ghcr.io/acme/widget, not ghcr.io/acme/widget and not the index
64claim: --owner is neither a LOGIN nor a LOGIN:ID pairThe id is a non-negative whole number, split on the first colon. alice:7, not alice:7:8 and not :7
64claim: --upstream-repository-url must be an http or https URL without embedded credentialsThe refusal never echoes the value, because the most likely one is a forwarded CI_REPOSITORY_URL whose userinfo is a live job token. Pass the public URL
64claim: no acting identity, the forge users API is not reachable with this credential; give the owner as LOGIN:ID, or owner … is a bot accountPass --owner LOGIN:ID naming a human. On a job token the users API is closed, so the pair is required, as the job-token recipe shows
64announce: a --tags or --tags-file curated set resolved to nothing but reserved tagsEvery tag named was an OCX-internal __ocx or legacy keep tag. Name a real version. With --refresh or --tags-from-registry, an empty or wholly reserved set is not a refusal. The run proceeds as a description-only pass and reports the drop in reserved_tags_dropped
65claim: the committed entry names another packageCheck the identifier. <namespace>/<package> decides the expected name, and a wrong registry or a renamed package both surface here
65claim: --repository disagrees with the committed pointerThe pointer decides where a package’s bytes come from and is never repointed as a side effect of adding an owner. Pass the current value, which the error names. If the repository genuinely changed, reach an existing owner to move the pointer deliberately
65announce: the committed root’s name disagrees with the identifier this run announcesEither the registry you typed differs from the one the root already committed, or the identifier itself changed. A publisher whose configured default registry differs from the index domain must type the full identifier. No [registries."<domain>"] entry is required to announce
65announce: the recorded description no longer exists on the registry, or an unchanged run’s open request can no longer mergeRepublish the description with ocx package description push. For the second case, close the request or delete the branch and announce again
69The forge returned a 5xx other than 502, 503 or 504, dropped the connection after accepting it, or presented a TLS certificate the verifier refused. Or no git was found, or it is older than 2.31.0Check the forge’s status. For the git case, install a newer git. The floor is checked before the forge is even constructed
74Writing under --output failed, or --tags-file could not be readCheck the path’s permissions. Parent directories are created for you, but a parent that is a regular file is not
75The forge’s API could not be connected to, timed out, or answered 408, 429, 502, 503 or 504 (a git clone or push that cannot reach it exits 1). Or the registry could not be resolved or connected to, or timed out. Or a concurrent run kept winning the branchRetry with backoff
77The push was refused by the forge’s own policyA protected branch or a push rule on the index project. Its administrator has to relax it, or use --transport api with a token that may push
77forge_publisher_not_allowlisted: neither of the index project’s job-token allowlists admits your project or one of its groupsOnly an administrator of the index project can add it. --transport api with a stored token is the way around it
78announce: a curated tag’s physical host resolves to a private, loopback, link-local or metadata addressAdd it to that namespace’s trusted_hosts. The entry is keyed on the package name’s domain, not the registry host: [registries."ocx.corp.example"] trusted_hosts = ["gitlab.corp.example"]. The error names the exact key
79announce: a curated tag does not resolve on the registry, or the package is unclaimedCheck the tag for a typo. For the second case, run ocx package claim first
79claim: unknown owner …: the forge has no such accountCheck the spelling, or pass LOGIN:ID to skip the lookup
80No credential, a rejected one, or one that cannot push to --index-repoSet OCX_ANNOUNCE_TOKEN. Without --fork the credential also needs push access, which ocx checks up front and names
82A capability the transport needs is absentJob-token pushes are disabled on the index project. Only an administrator of the index project can enable them. --transport api with a stored token is the way around it

--output is the way to see what a run would commit without opening anything. It needs no credential and writes the whole entry every time, including on a run that changes nothing.