Quickstart¶
This walks an index repository from empty to serving. The reference deployment is ocx-sh/index — read it alongside this page; every file named here exists there.
What an index repository holds¶
An OCX index is a static tree. The bot writes it; a static host serves it. Nothing here is a server, and there is no database.
p/<namespace>/<package>.json package root: governance + tags
p/<namespace>/<package>/o/sha256/<hex>.json the OCI image index a tag resolved to
config.json {"format_version": 1}
c/index.json every package -> its root's digest
.github/index-policy.json THIS index's registry-host allowlist
.github/maintainers.yml reviewers the governance gate assigns
p/** is committed by contributors through pull requests. config.json and
c/index.json are rendered at deploy time and never committed.
Install¶
In CI, pin it. The bot runs in privileged jobs, so its version belongs in a committed lockfile where a bump is a reviewed pull request:
# bot-tools/pyproject.toml
[project]
name = "index-bot-tools"
version = "0"
requires-python = ">=3.12"
dependencies = ["ocx-indexbot==0.2.0"]
--frozen is the half that makes the lockfile binding: without it (or
--locked), uv run re-locks whenever the lock is stale against
pyproject.toml. indexbot ci refuses to render a pipeline whose ci.run
lacks it, and WF-08 fails the audit if a
hand-written one does.
1. State your registry policy¶
.github/index-policy.json is the allowlist of registry hosts a package root
may point at. It is a committed file, never a repository or Actions
variable: widening registry trust is a supply-chain decision, and "extend only
via reviewed pull request" is the control.
name is the logical prefix every root under this index carries, and the
registry key an ocx client configures. name_segments is how deep p/**
nests below it. Both are required with no default: an index that does not
declare its own identity would publish under another deployment's. Everything
else the file accepts is in Deployment policy.
There is no compiled-in default. An index that never states a policy fails closed rather than silently inheriting someone else's.
A private registry states where it lives and which variable holds its credential, instead of a bare host string:
"registry_hosts": [
{
"host": "artifactory.corp",
"base_url": "https://oci-prod.artifactory.corp:8443",
"credentials_env": "OCX_REGISTRY_ARTIFACTORY"
}
]
The variable name is committed; its value is a repository secret or a masked CI/CD variable, read only by the privileged lanes. Anonymous registries need none of this — see Registries.
2. Seed a package¶
seed-import builds a first package root from a mirror's own metadata:
indexbot seed-import \
--catalog-md mirrors/kitware/cmake/CATALOG.md \
--mirror-yml mirrors/kitware/cmake/mirror.yml \
--owner-login someone --owner-id 1234567 \
--out p
Commit the result. From here on, tags arrive by announce.
--owner-login is a forge username, never a display name: it is what the
forge API resolves to a user id when this bot requests review. --owner-id is
that numeric id, and it — not the login — is the ownership key G-19 matches a
pull request author against. A login can be renamed and, once released,
recycled; an id cannot.
3. Announce a tag¶
indexbot does not publish. The publisher runs
ocx package announce — from a fork, with no
write access to the index:
It reads the tags from the physical registry, writes the image index it resolved to as a content-addressed CAS object, updates the root, and opens a pull request. Everything from here on — validating that pull request, classifying it, gating it, merging it, rendering the result — is indexbot's half.
The tag list is owner-curated: the index records what the owner announces,
and CI (indexbot's validate-pr) verifies each claim against registry truth.
Nothing enumerates a registry, and nothing invents a tag.
That list is the whole set, not an addition
An announce writes the root's tag map from what that run names. A tag the
root already carries and the run omits is dropped. Announcing one tag per
push — --tags "$CI_COMMIT_TAG" — therefore publishes that tag and
deletes the rest. Pass every tag you want kept, every time; --tags-file
exists so that list can be a committed file rather than a shell variable.
4. Gate the pull request¶
Two workflows, deliberately in two files (see Workflow invariants WF-03):
- unprivileged (
pull_request, no secrets, checks out PR head) —indexbot validate <changed roots>re-derives every claim the PR makes. - privileged (
pull_request_target, holds a token, never checks out PR head) —indexbot classify-prroutes the PR to the machine or human lane, andindexbot governance-checkdecides whether it may auto-merge.
Authorization comes from the base branch's committed owners[].id,
never from the PR's own content. A pull request editing its own owners[] is
a human-lane change and cannot self-authorize.
5. Render and deploy¶
Emits config.json, the /p/** mirror, and c/index.json into dist.
Publish that directory to any static host.
Before the first package lands there is nothing under p/ to render, and a
render that discovers no roots is refused: the same thing happens when
--index-dir has a typo, and the result — a valid, empty index deployed over
a populated one — is the same too. Say you mean it:
Two operational rules for whatever serves it:
- Never cache
*.json. The freshness contract is originETag+If-None-Match; a CDN cache in front of it breaks conditional GETs. - Serve
c/index.json— whole-catalog sync is a conditional GET plus a digest diff, not a crawl.
6. Keep it honest¶
Nightly. It re-reads every committed root against the registry and verifies
only — it never writes a correction. A mismatch is an integrity anomaly, so
it files an issue and exits 65, because a bot that silently "fixes" a
divergence destroys the evidence of how it happened.
Audit the workflows themselves in the same pipeline: