Deployment policy¶
.github/index-policy.json, at the root of the index repository — not of
this package. One index format, many copies: the public index serves bytes
from ghcr.io, a corporate copy from its own Harbor, Artifactory or ECR.
Each index states its own.
{
"$schema": "https://ocx-sh.github.io/indexbot/schema/index-policy-v1.schema.json",
"name": "acme.corp",
"name_segments": 2,
"registry_hosts": ["harbor.corp"],
"reserved_namespaces": ["acme"],
"governance": { "auto_merge": "owners" },
"ci": {
"forge": "gitlab",
"owner": "acme",
"run": "uv run --project bot-tools --frozen -- indexbot",
"setup": "./.github/actions/setup-bot"
}
}
Anything outside this grammar is a hard error: malformed JSON, a non-object document, an unknown key at any level, a missing required key, or a value that cannot be what it claims to be.
| Key | Required | Default | Meaning |
|---|---|---|---|
name |
yes | — | Logical prefix every published root carries. acme.corp/team/tool |
name_segments |
yes | — | Segments after the prefix, 1–8. Published in the rendered config.json |
registry_hosts |
yes | — | The registries this index admits and how to reach them — a bare host string, or an object (below) |
reserved_namespaces |
no | none | First segments this operator reserves |
governance.auto_merge |
no | owners |
owners | never | always — see governance-check |
ci.forge |
no | github |
github | gitlab — which pipeline files indexbot ci renders |
ci.owner |
for indexbot ci |
— | Repository owner / GitLab namespace, for the cron-upstream-only guard |
ci.run |
for indexbot ci |
— | How a generated job invokes the bot. Must not resolve the version at job start — see below |
ci.setup |
no | none | A step that installs what run needs — a uses: target on GitHub, an image: on GitLab |
ci.deploy_workflow |
no | none | The workflow a merged machine-lane PR dispatches. Empty renders no such job |
ci.schedules.reconcile |
no | 17 3 * * * |
GitHub only |
ci.schedules.stale |
no | 0 5 * * * |
GitHub only |
Why ci is not how the bot picks a forge¶
ci.forge says which pipeline files to render. It is not read to decide
which API to talk to at run time: that comes from the runner's own variables
($GITLAB_CI). The privileged subcommands read this very file through a
forge port, so a policy-derived port choice would need the port it was
choosing.
ci.owner has no default because every generated schedule carries a
cron-upstream-only guard keyed on it. A fork inherits every schedule in a
workflow file and would run it against its own stale copy, so indexbot ci
refuses to render rather than guess a name.
Why ci.run has no default, and must be pinned¶
ci.run used to default to uvx ocx-indexbot. It is the command every
generated job runs, and on GitHub one of those jobs — arm-auto-merge — holds
contents: write under pull_request_target, a token that can move an
unprotected base branch and squash-merge a pull request. uvx ocx-indexbot
fetches the latest release when the step starts: no version, no lockfile, no
hash. So an operator who committed the minimal policy above got a privileged
job running whatever was published that morning, and every gate this package
ships reported the pipeline clean.
There is no spelling of a default that is both zero-setup and pinned, so there
is no default. indexbot ci refuses to render without one, and refuses a value
it can see resolving at job start:
Any of these is accepted:
| Shape | Why it is pinned |
|---|---|
uv run --project bot-tools --frozen -- indexbot |
the lockfile binds, and --frozen forbids re-resolving it |
uv run --locked --project bot-tools -- indexbot |
the same, and it fails if the lock is stale |
uvx --from 'ocx-indexbot==0.2.0' indexbot |
an exact version specifier |
uvx --from git+https://…@<40-hex> indexbot |
a ref that cannot move |
indexbot, /opt/indexbot/bin/indexbot, poetry run indexbot |
names no resolver — your image or your vendored install decided the version, and this package is in no position to second-guess how |
--frozen is not decoration. Without it — or --locked — uv run re-locks
whenever the lockfile is stale against pyproject.toml, and re-locking a
[tool.uv.sources] git source moves the commit. uv run --project bot-tools
-- indexbot reads like a lockfile pin and is not one.
What this cannot check is a resolver it does not recognise, or one hidden
inside a ci.setup composite action. The rule refuses the shapes an operator
copies out of a README; keeping your own bootstrap pinned is yours to do.
WF-08 applies the same predicate to the rendered
tree, so a hand-written pipeline answers to it too.
Why name and name_segments have no defaults¶
They are the index's identity. A default of ocx.sh/2 would mean an index
that forgot to declare itself silently publishes under OCX's name, which is
exactly the hardcode this file exists to remove. Declaring them is one line
each and it is not optional.
name is also the registry key an ocx client configures — [registries."acme.corp"]
index = "https://index.acme.corp" — which is why it carries the registry-host
grammar rather than a looser one. It is a logical name and need not equal the
host serving it.
name_segments is an operator declaration no tree can be read for: a client
that finds p/a/b/c.json cannot tell a three-segment index from a
two-segment one holding a package called b/c. Publishing it lets a client
resolve a name of the wrong shape as plain OCI instead of reading the
unavoidable 404 as an authoritative refusal.
What is not configurable here¶
The structural reservations — p, o, c, config, schema, docs,
assets, api, static, data, index — follow from the served URL
shapes and hold for every index alike, so the bot applies them
unconditionally and reserved_namespaces cannot express them. That key is
for what this operator additionally wants held back: a brand, an internal
prefix.
The schema¶
The grammar above ships as a JSON Schema inside the wheel. Point your editor at the published copy for autocomplete, or pin the one your CI actually runs:
Nothing validates against it at runtime — the parser is the authority, and the
bot carries no schema validator (its only runtime dependency is httpx). The
two are held together by a shared fixture corpus in the package's test suite:
every accepted example must be accepted by both, every rejected one rejected
by both.
Registries¶
A registry_hosts entry is either a bare host string or an object. The entry
is the client's configuration: allowlisting a host and being able to fetch
from it are the same statement, so there is no such thing as a host this bot
admits and cannot reach.
| Field | Required | Default | Meaning |
|---|---|---|---|
host |
yes | — | The bare host, exactly as it appears in oci://<host>/…. No scheme, no port |
base_url |
no | https://<host> |
Where the Registry v2 API actually lives — scheme, port and path prefix |
realm |
no | <base_url>/token |
The token endpoint, for auth: "token" |
service |
no | host |
The service parameter the realm expects |
auth |
no | token |
token | basic — see below |
credentials_env |
no | none | Name of the environment variable holding user:password. Absent → anonymous |
A bare string is {"host": "…"} with every default taken, which is why
["ghcr.io"] keeps meaning what it always meant.
The two auth flows¶
token is the Docker/OCI dance: the bot asks the configured realm for a
repository:<path>:pull token — authenticating that one request with the
credential, if there is one — and sends the short-lived Bearer it gets back to
/v2/. This is ghcr.io, GitLab, Harbor, Artifactory's OCI endpoints.
basic sends RFC 7617 credentials on every /v2/ request and never asks for
a token. Some Nexus and ECR-alike deployments answer that way; there is no
realm to ask.
The realm is configured, never discovered. A 401 carries a
WWW-Authenticate header naming a realm, and following it would let whatever
answers base_url choose the address the operator's credential is sent to.
This bot ignores that header. Same reason a credential is dropped on any
cross-origin redirect.
Anonymous stays the default¶
No credentials_env means the exact code path a public index has always run:
no Authorization header is built at all. Public registries are first-class,
not a degraded case of the private one.
Built-in defaults¶
Three hosts carry facts no convention supplies, so a bare entry for them is enough:
| Host | What is filled in |
|---|---|
ghcr.io |
nothing — Registry v2 defaults are correct |
ocx.sh |
its Artifactory token path, not https://ocx.sh/token, which 404s |
registry.gitlab.com |
realm https://gitlab.com/jwt/auth, service the literal container_registry, not the host |
An entry that states its own base_url is not given these. Repointing
ocx.sh at an internal mirror and inheriting the public realm would send that
mirror's credentials to an address nobody wrote down.
A private registry, end to end¶
"registry_hosts": [
"ghcr.io",
{
"host": "artifactory.corp",
"base_url": "https://oci-prod.artifactory.corp:8443",
"realm": "https://oci-prod.artifactory.corp:8443/v2/token",
"auth": "token",
"credentials_env": "OCX_REGISTRY_ARTIFACTORY"
}
]
The name is committed and reviewed. The value never is: set it as a
repository secret (GitHub — indexbot ci renders it into the
reconcile job's env: for you) or a masked, protected CI/CD variable
(GitLab, ambient exactly like $GITLAB_TOKEN).
Which lanes hold it¶
| Lane | Credential | What it verifies |
|---|---|---|
validate / validate-pr (pull_request, merge_request_event) |
never | Shape and bytes. For a credentialed host, registry checks are skipped with a WARN naming the variable |
reconcile, seed-import (privileged) |
yes — refuses to start without it | Registry truth, including every root the fork lane could only shape-check |
A fork's pipeline holds no secret; that is the privileged/unprivileged split working as designed, not a gap. What would be a gap is skipping silently, so the skip is printed per root and the covering lane fails loudly rather than running anonymous.
That the fork lane holds no credential is a construction, not a
configuration: its registry clients are built from an empty environment. This
matters because validate reads the policy out of pull-request-head content,
and an entry names both where a credential is sent (base_url) and which
variable holds it — a pair a pull request must never get to choose.
Why the host shape is strict¶
The allowlist is matched against a URL's parsed hostname, which is always
lowercased and never carries a port. So https://harbor.corp, Harbor.Corp
and harbor.corp:5000 would each parse happily as policy entries and then
match nothing at all — an allowlist that silently admits no one. Rejecting
them at parse time is the difference between a loud error and an index where
every download fails for a reason nobody can see.
A registry on a non-standard port is allowlisted by its bare host:
harbor.corp admits oci://harbor.corp:5000/team/tool. The port belongs in
base_url, which is where the bot reads it from.
Why it is a committed file¶
Never an environment variable, a repository variable or an Actions
variable. repository is the pointer every client follows to fetch bytes,
so widening the allowlist is a supply-chain trust decision. "Extend only via
reviewed pull request" is the control — and a settings-page value can be
changed by anyone with settings access, silently, with no diff and no
reviewer.
Keeping it under .github/** also puts it on the same surface branch
protection and CODEOWNERS already guard, beside the other governance data the
bot reads.
Fail closed on a missing policy¶
An index copy that never stated a policy says so at startup, rather than inheriting the public index's hosts by accident.
There used to be a second early failure here — "a host no adapter can serve" —
because the servable hosts were a compiled-in triple and allowlisting
harbor.corp.internal produced roots that validated and then could not be
fetched. It has nothing left to guard: the allowlist entry is the client's
configuration, so a host cannot be admitted and unreachable at the same time.
The PR-head question¶
The unprivileged validation job runs against pull-request-head content by
design — it checks the PR's own claims, and holds no credential. It therefore
also loads the PR's own .github/index-policy.json. That is not a
self-authorization hole:
- The policy path is outside every package root's refresh scope, so a pull request touching it is classified human-lane and can never auto-merge. Merging a widened policy requires a human, which is precisely the control.
- The credential is out of reach independently: the unprivileged lane builds
its registry clients from an empty environment, so a PR-head policy can
name any
base_urland anycredentials_envit likes and there is nothing for either to move. What it buys is a WARN saying its own claims went unverified.
The privileged subcommands deliberately do not read a local policy — they never check the repository out, so they read the committed policy at the base ref over the API instead.