Skip to content

Authoring packages

The author flow lives on Ocx.package — machine tier, alongside install/select/exec, not on Project. Three methods carry the core of it: create → test → push; the index side — announce, claim, and the rolling-tag audit cascade_check / cascade_repair — follows below.

# illustrative: needs a real ocx binary and a real package directory.
from ocx_sdk import Ocx

ocx = Ocx()
ocx.package.create("./my-tool", identifier="ocx.sh/me/my-tool:1.0.0", platform="linux/amd64")
result = ocx.package.test(
    "ocx.sh/me/my-tool:1.0.0",
    script="./test.star",
    metadata="./my-tool/metadata.json",
)
if result.passed:
    published = ocx.package.push("./my-tool.tar", identifier="ocx.sh/me/my-tool:1.0.0")
    print(published.manifest_digest)

create — bundle a directory

create bundles a local directory into a package archive, writing a build receipt beside it that test and push read back — identifier and platform given here don't need repeating on the later calls. There is nothing to return: ocx prints no payload for this command.

What it recorded reads back through receipt, which wraps ocx package receipt and returns a BuildReceipt. A bundle with no receipt beside it is ocx's exit 79 and answers None here — the ordinary state for a bundle handed over from elsewhere. A receipt ocx cannot read is exit 65 and stays a DataError: "unreadable" must never degrade into "absent", because that turns a recorded value into a usage error about a flag the publisher never needed. receipt.ref is the identifier as a PackageRef.

from ocx_sdk import BuildReceipt

receipt = BuildReceipt.from_json('{"identifier": "ocx.sh/me/my-tool:1.0.0"}')
assert receipt.platform is None
assert str(receipt.ref) == "ocx.sh/me/my-tool:1.0.0"

test — the --script envelope

Only the --script form of ocx package test is typed. The trailing -- CMD form prints the tested command's raw stdout verbatim, even under --format json, so nothing here could parse it reliably — reach for invoke if you need that form.

The --script form runs a Starlark test script against a materialized copy of the package and returns a TestResult — the stable v1 envelope, one of the durable anchors re-verified on every ocx version bump. status decides pass or fail; when it fails, assertion.kind is the stable, machine-readable reason (assertion is None on a pass):

from ocx_sdk import TestResult

result = TestResult.from_json(
    '{"status": "failed", '
    '"run": {"exit_code": 1, "stdout": "", "stderr": "", "duration_ms": 3, "truncated": false}, '
    '"assertion": {"kind": "exit_code_mismatch", "message": "expected 0, got 1"}}'
)
assert not result.passed
assert result.assertion is not None
assert result.assertion.kind == "exit_code_mismatch"

layers= takes layer archives or digest references, base first; metadata= is required whenever no file layers are given; private=True composes ocx's --self surface for testing a package's own private tooling.

push — publish, deliberately not retried by default

push publishes a package's layers and metadata to a registry and returns a PushResult — the published identifier, digest, and tags.

Like Ocx.login, push defaults its per-call retry to None regardless of session policy: a push is a registry write, and re-sending one after a timeout risks publishing twice. Pass retry= explicitly when the target registry is known to be idempotent-safe. cascade=True also advances the rolling tags above this version.

announce — publish the listing to the index

announce writes the package's tag listing into the ocx index on a forge (GitHub or GitLab) and returns an AnnounceReport — status is "updated" or "unchanged", pull_request_url is set when the forge path went through a PR, capability_checks records what the credential was allowed to do. Exactly one tag source is required — tags=, tags_file= (the file cascade_repair(announce_tags=...) writes) or tags_from_registry=True — and ocx, not the SDK, enforces that: none or two is a UsageError (64).

# illustrative: needs a forge credential and network access.
from ocx_sdk import Ocx, OcxConfig

ocx = Ocx(config=OcxConfig(forge_token="glpat-..."))
report = ocx.package.announce("me/my-tool", tags=["1.0.0", "latest"])
print(report.status, report.pull_request_url)

A registry write like push, so retry defaults to None. The credential comes from OcxConfig.forge_token (OCX_ANNOUNCE_TOKEN) or the ambient identity ladder described in Hermetic CI. --yank, --unyank, --fork and --out are not typed: reach them through invoke.

claim — register a package with the index

claim registers a package name and its owners in the index, returning a ClaimReport. repository is the OCI repository the claim points at; owners= lists forge logins beyond the caller.

A claim is not idempotent on the wire: claiming an already-claimed package exits 65 with no report, which the SDK surfaces as a plain DataError. The idempotent-CI shape is to catch it and read detail off the error envelope — "package_already_claimed", a frozen slug, never the message text:

# illustrative: needs a forge credential and network access.
from ocx_sdk import DataError, Ocx, error_envelope

try:
    Ocx().package.claim("me/my-tool", repository="oci://ghcr.io/me/my-tool")
except DataError as exc:
    envelope = error_envelope(exc)
    if envelope is None or envelope.detail != "package_already_claimed":
        raise

ocx's command reference tables the rest of claim's slugs (malformed_repository, owner_unknown, bot_identity, …), each pinned to its exit code.

cascade_check / cascade_repair — audit the rolling tags

push(cascade=True) advances the rolling tags (1, 1.0, latest) above the version it publishes; a push without it leaves them behind. cascade_check audits one or more repositories and cascade_repair rewrites what is stale. Both are report-then-fail: ocx exits 65 with the report whenever it found something, and the SDK hands that report back as a result rather than raising — the finding is the answer. clean is the one-word verdict; exit_code keeps what ocx said.

from ocx_sdk import CascadeCheckReport

report = CascadeCheckReport.from_json(
    '{"reports": [{"identifier": "ghcr.io/me/my-tool", "logical": null, "aliases": {}, '
    '"rows": [{"tag": "latest", "platform": {}, "status": "stale", "observed": null, "expected": null, '
    '"source": "1.0.1", "observed_source": "1.0.0"}], '
    '"index_findings": [], "ignored_tags": [], "unrepairable": []}]}',
    exit_code=65,
)
assert not report.clean
assert [(row.tag, row.status) for row in report.reports[0].rows] == [("latest", "stale")]

cascade_repair(dry_run=True, announce_tags=path) plans without writing and leaves the tags it would touch in path — the file announce(tags_file=path) consumes. Only a 65 without a report (an error envelope instead) raises.

Consuming what you just published

The loop closes through the ordinary consumer surface — package.install or a project's add — pointed at the identifier push returned. See Vendoring a dist.json for shipping a bootstrap manifest snapshot inside a package, which is a separate concern from publishing the package itself.