Announce a Package
Announcing is how a publisher records newly pushed tags in this index: you
assemble a canonical package root and open (or update) a pull request against
ocx-sh/index from your own fork, authored under your own GitHub identity.
There is no publisher-held index credential — the PR is the announcement,
and CI re-derives every claim in it from the physical registry before anything
can merge (see Governance Contracts).
For an owner refreshing a package they already own, the whole flow is machine-to-machine: no human clicks anything between your release workflow pushing a tag and the index PR merging.
Prerequisite
Three things need to be true before your first announce:
- The namespace is already claimed in this index.
Announce refreshes an existing package root; it cannot create one. Against
an unclaimed package it refuses outright —
unclaimed namespace: no committed root at p/<ns>/<pkg>.json on main— because a new package is a human-lane act (see PR and Auto-Merge Semantics). OCX_ANNOUNCE_TOKENis set to a token capable of opening pull requests against a public repository. A classic PAT works out of the box — see Classic PAT Setup.- A fork of
ocx-sh/indexexists under your account. If it doesn’t yet, theocxCLI creates one for you — see Fork Auto-Create.
The ocx CLI Path
Announcing is a two-command handoff: ocx package push records what you just
published, ocx package announce turns that record into a pull request.
ocx package push --announce-file <path> appends the primary tag you pushed,
plus any cascade tags it moved, to a scratch file at <path> — a
comma/newline list of tag names, byte-compatible with the reference tool’s
own --tags-file format. That file is a per-package, per-pipeline-run
scratch artifact, never a persistent one: a file left over from a previous
run could silently re-add a tag you deliberately deleted (C2).
ocx package announce --package <ns>/<pkg> --tags-file <path> --fork <owner>/index [--index-repo ocx-sh/index]
turns the scratch file into the actual PR. It reads the committed root —
from main on a first run, or from the head of your own still-open announce
branch if one already exists from an earlier run in the same cycle, so two
announces in a row accumulate into one PR instead of racing each other’s
diff (C4) — and unions the tags file’s contents into it. --tags-file
only adds, it never deletes; dropping a tag is the separate, explicit
--tags <a,b,c>, which replaces the curated set outright and drops every
committed tag it does not name (C3). The command then builds the canonical
root plus any new CAS objects — each
observed tag’s OCI image index, stored verbatim under the digest the registry
served it as — and opens or updates exactly one pull request against
ocx-sh/index, on a branch named indexbot-announce-<ns>-<pkg> — the fork’s
topic branch is created directly at the upstream base SHA, and if the fork
itself doesn’t exist yet, it’s created first (C8). That branch-naming
convention matches the Python reference tool, so the two
implementations dedupe against each other’s open PRs instead of opening
duplicates (C9).
Two more things round out the surface:
--refreshre-observes every tag already in the committed root — useful for picking up a moved digest under a rolling tag (latest, a cascade target) without turning announce into a full registry scan. It is the third way to name the curated set, so it is mutually exclusive with both--tagsand--tags-file; the set stays owner-curated either way (C5).- Re-announcing an already-current state is a no-op: if the serialized root
would come out byte-identical to what’s already committed, and there are no
new CAS objects to add, the command exits
0reportingstatus: "unchanged"— nothing is committed, and no warning is printed (C6). It still reports the pull request when your announce branch is already ahead of the index’smainfrom an earlier run in the same cycle.
Yanking and un-yanking are their own, deliberately separate actions:
--yank <tag> --yank-reason <text> and --unyank <tag> mark or clear the
grace marker on a tag row that’s still present in the set. Yank is never set
automatically, and --refresh never touches it — it’s an owner decision a
routine refresh can’t accidentally trigger or clear (C7).
Copy-Paste GitHub Actions Snippet
This block is meant to slot into an existing GitHub Actions release workflow, right after whatever step builds or downloads your artifacts.
The scratch file behind --announce-file lives under $RUNNER_TEMP, never
/tmp. On a GitHub-hosted runner the two are similar, but on a self-hosted
runner /tmp is host-wide and outlives the job — shared across whatever
concurrent jobs happen to land on that machine, which breaks the file’s
per-package, per-run scratch lifecycle the moment two runs overlap (C2).
$RUNNER_TEMP is scoped to the job by construction. The filename folds in
the package name, the run id, and the run attempt, so retries and concurrent
packages in the same workflow never collide over the same path. The prepare
step both truncates it (: >) and publishes the resolved path to
$GITHUB_ENV, so the later steps name one variable instead of rebuilding the
same path expression three times and drifting. Truncation matters because
GitHub reuses $RUNNER_TEMP across retries of the same run, and a stale
leftover file could resurrect a tag you meant to drop. The cleanup step runs
if: always(), so a failed push or announce still leaves the runner’s temp
directory clean instead of accumulating scratch files across retries.
# At job level, alongside `runs-on:`.
env:
OCX_ANNOUNCE_TOKEN: ${{ secrets.OCX_ANNOUNCE_TOKEN }}
REF_NAME: ${{ github.ref_name }}
steps:
- uses: ocx-sh/setup-ocx@<sha> # v1.x.y
- name: Prepare announce-tags scratch file
run: |
tags_file="$RUNNER_TEMP/announce-tags-<pkg>-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.txt"
: > "$tags_file"
echo "TAGS_FILE=$tags_file" >> "$GITHUB_ENV"
- name: Push and announce
if: ${{ env.OCX_ANNOUNCE_TOKEN != '' }}
run: |
ocx package push -i "<ns>/<pkg>:$REF_NAME" --cascade \
--announce-file "$TAGS_FILE" <artifact-layers>...
ocx package announce --package <ns>/<pkg> \
--tags-file "$TAGS_FILE" --fork <your-account>/index
- name: Clean up announce-tags scratch file
if: always()
run: rm -f "$TAGS_FILE"
Two details in that block are load-bearing rather than stylistic, and both are easy to “simplify” back into bugs.
No ${{ }} inside any run: script. Everything a
script needs arrives as an environment variable — REF_NAME and the token
from the job’s env: block, TAGS_FILE via $GITHUB_ENV, and
$RUNNER_TEMP / $GITHUB_RUN_ID / $GITHUB_RUN_ATTEMPT from the runner’s
own defaults. ${{ }} is textual substitution performed
before the shell sees the script, so a value
containing $(...) or a backtick inside run: executes as code. A git tag is
a perfectly legal place to hide such a payload, and github.ref_name on a
tag-triggered release workflow is exactly that tag. Quoting the expansion does
not help; only keeping it out of the script text does.
The token gate reads env, not secrets. secrets is not one of the
contexts available to a step-level if: —
if: ${{ secrets.X != '' }} is not a subtly weaker check,
it is a workflow that fails to parse. Mapping the secret
into the job’s env: once and testing env.OCX_ANNOUNCE_TOKEN is both valid
and the reason the env: block sits at job level.
Every flag in that block matches the shipped binary (ocx package push --help, ocx package announce --help). Pin setup-ocx to a commit SHA from
the ocx-sh/setup-ocx README.
Classic PAT Setup
Fine-grained PATs cannot open pull requests against a public repository they
don’t own — a known GitHub Actions platform limitation, tracked
at github/roadmap#600, not an OCX restriction. Until
that changes, OCX_ANNOUNCE_TOKEN has to be a classic PAT, scoped to
public_repo, stored as a repository or organization secret named
OCX_ANNOUNCE_TOKEN. The token is read from the environment only — ocx
never writes it into the credential store it otherwise uses for registry
auth (X6). See Machine-Account Recommendation for who
should hold that PAT.
Machine-Account Recommendation
Issue the classic PAT under a dedicated bot GitHub account rather than a
person’s own account — the same pattern OCX’s own mirror fleet uses with its
ocx-bot account. A bot account keeps PR authorship and token rotation
independent of who’s currently on the team: nobody’s departure revokes the
token, and nobody’s personal PAT-rotation habits silently break the pipeline.
Missing-Token Degrade
The push-and-announce step in the snippet above is gated on the
OCX_ANNOUNCE_TOKEN secret being non-empty (see its if: condition in the
snippet above) — deliberately, so a fork of your own repository, which
GitHub never hands the secret to, doesn’t hard-fail its CI just by existing.
When the token genuinely is required for a run and it’s absent, ocx package announce --fork exits 80 (AuthError) with a message naming the
missing variable, not a stack trace (C13). If you have no PR-capable token at
all, see No-Token Manual Fallback — that’s a different
situation from this one.
No-Token Manual Fallback
This is distinct from the degrade above: it’s for
publishers who genuinely have no classic PAT or machine-account token
available, not for a fork’s CI run that’s supposed to stay silent.
--out <dir> is mutually exclusive with --fork:
ocx package announce --package <ns>/<pkg> --tags-file <path> --out <dir>
writes the updated root and any new CAS objects to <dir> on disk instead of
opening a PR. You commit that directory’s diff to your own fork by hand and
open the pull request yourself through GitHub’s UI. It’s the same shape as
winget-create’s --output flag, which writes a
manifest to disk instead of submitting it — a local-write mode that hands the
actual submission step back to the publisher.
Fork Auto-Create
If <your-account>/index doesn’t exist yet, ocx package announce --fork
creates it — idempotently, so re-running the same command is safe — before it
pushes anything. There’s no separate “fork this repository” step to remember
(C8); the first announce run does it for you.
The Reference Tool (indexbot announce)
This is an aside, not the primary path — most publishers should use
the ocx CLI above. indexbot announce, the Python tool
living in this repo’s bot/, is the executable spec the Rust client is
byte-conformance-tested against: whatever it produces for a given curated tag
set is the ground truth ocx package announce has to match (FP-9). It’s
handy for local dry-run debugging via its own --out <dir> local-write mode,
but it isn’t required for ordinary publishing.
PR and Auto-Merge Semantics
An announce PR is classified into one of two lanes (see Governance Contracts):
- Machine lane, eligible for auto-merge: the change is an owner-authored
tag content refresh and/or an owner-authored tag add/remove, the PR
author’s
github_idis in the target root’s committedowners[]on every root the PR touches, no human-review-required key (G-05) is touched, and it isn’t a new package. Once required checks are green, GitHub merges it — no human clicks anything, and the merge commit is authored bygithub-actions[bot](FP-5). End to end, a release workflow pushing a tag to an already-claimed package lands in the index in about three minutes. - Human lane, everything else: a new package (G-04), a change to a
human-review-required key, or a PR authored by someone who isn’t a listed
owner. These route to a reviewer assigned from
.github/maintainers.ymlplus an idempotent review-request comment, and wait for approval — no matter how green the automated checks are (FP-6).
Future
::: details Future
ocx dist, a planned cargo-dist-style workflow renderer, will eventually
generate the snippet above for you instead of copy-paste — it hasn’t been
built yet (S11).
A GitLab CI/CD Component is a confirmed future track for publishers who build on GitLab instead of GitHub Actions. It needs no new server-side capability: even a GitLab-CI publisher only ever talks to the GitHub API against this GitHub-hosted index, the same way the snippet above does.
Signing and attestation for announced packages is tracked separately, not part of this flow yet — see ocx-sh/ocx#199 and #203. :::
See Also
- Claim a Namespace — the one-time step before your first announce
CONTRIBUTING.md— PR lanes, spam posture, and what auto-merges- Governance Contracts