feat(oci): add --oci-name-prefix for registries that require a path prefix

What this does

Adds --oci-name-prefix, which prepends a caller-supplied path prefix to every OCI conformance repository name:

before   conformance/{runID}/{slug-mapped}
after    {prefix}/conformance/{runID}/{slug-mapped}

An empty prefix, the default, yields the previous name byte-for-byte. This is purely additive: no existing target needs the flag.

Why

The OCI repository name was unreachable by configuration, and that made the whole OCI catalog unrunnable against a registry that requires a fixed prefix inside <name>.

Neither existing flag can supply one:

  • --registry-url cannot, because the client inserts the /v2/ literal between the base URL and the name. A prefix on the base URL lands on the wrong side of /v2/, and the registry's route needs /v2/ as the first path segment.
  • --run-id cannot, for two independent reasons. normalizeRunID rewrites every non-alphanumeric character to -, so a run ID can never introduce a path separator; the name is therefore always exactly three segments. And Artifact Registry needs at least four (<slug> + container + <repository_name> + at least one <image_name>). Three segments cannot satisfy a four-segment minimum even in the contrived case of a namespace whose slug is literally conformance.

Artifact Registry routes /v2/{slug}/container/{repository_name}/{image_name...} and requires the literal container at segment index 1, where the suite puts the run ID. All 47 OCI rows failed at its router before reaching a handler.

Design notes for review

Prepending, not substituting. The conformance top-level prefix, the run-ID segment and the slug-mapped segment keep their positions relative to one another, so per-run isolation and the two-repository oci.blob.cross-repo-mount pair keep working with no per-row changes.

repoNameFor instead of threading a parameter. Every per-AC Fn now calls repoNameFor(e, runID, segment), which reads the prefix off the env, rather than repoNameForRunID directly. That is 48 one-token call-site edits and keeps the Fns prefix-unaware. An env that does not implement ociEnv contributes no prefix, so the in-process fakes the row tests use are untouched.

Grammar validation is in NewEnv, not at the flag boundary. This is the one deliberate departure from how every other non-trivial flag here works, so it is worth a reviewer's attention. The value must satisfy the OCI <name> grammar, and architecture.md §Code placement puts that grammar in the client layer and states the runner "does not know any specific protocol". Validating in internal/cli would mean either importing pkg/client/oci there, which no file in that package does, or copying nameComponentRE, which CLAUDE.md forbids. So the OCI module checks it with client.ValidateName and returns a *ConfigError, which the CLI already maps to a "<field>: <reason>" stderr line and exit 2:

$ regconf run --format oci --oci-name-prefix 'AR-Registry/container' ...
create env: oci-name-prefix: each path segment must match the OCI <name>
grammar (lowercase alphanumeric runs joined by '.', '_', '__' or '-')
exit 2

Same observable outcome as a flag Validator, one layer down.

Not registered on list. It changes how a run addresses a registry and nothing about which rows exist, so list --oci-name-prefix is an unknown flag rejection by the same rule that keeps --upstream-url off that subcommand.

Spec change

S07 §Repository naming gains §Configurable name prefix, since that section pinned the fixed shape this changes. It states the prepend-not-substitute property, where the grammar is validated and why, and why --registry-url cannot express this. The pre-existing table row now points at it.

Tests

pkg/conformance/oci/name_prefix_test.go, eight tests:

  • the join, including that an empty prefix leaves the old form byte-for-byte and that no // or leading / is ever produced
  • the normalizeRunID error still surfaces, and surfaces identically, with a prefix configured, so a prefix cannot mask it
  • repoNameFor reads the prefix off the env
  • an env without the accessor is unprefixed rather than an error, which is the in-process-fake path every row test depends on
  • NewEnv propagates the value, and empty is the default
  • six rows of grammar rejection, each asserting the *ConfigError field is the kebab-case flag name
  • a property test walking every repoSegment* constant, asserting the name built for each carries the prefix. It cannot prove each Fn calls repoNameFor (only a compile-time ban could), but it does prove no segment is structurally exempt, so a missed call site surfaces as a wire-level 404 rather than a plausible-looking unprefixed name in a log.

Two existing tests updated for the new signature, and the two pinned flag inventories updated for the new flag.

go test ./... green across 18 packages; golangci-lint reports 0 issues; the full pre-commit set passes.

Verified against a real registry

Run against Artifact Registry staging, which is the registry that motivated this:

Before After
OCI rows executed 0 of 47 47 of 47
Passed 0 32
Failed 0 11
Skipped 0 4

The router rejection and the handler answer are distinguishable by envelope, which is what confirms the prefix is doing its job: the unprefixed name gets the registry's own {"error":{"code":"not_found"}} from its router, while the prefixed name gets the OCI {"errors":[{"code":"NAME_UNKNOWN"}]} from a handler.

The 11 failures are all suite-side, not registry-side, and are filed separately so this MR stays one change:

  • #57 (closed)Docker-Content-Digest is read from the response after the blob-GET redirect, where storage does not set it. 7 rows.
  • #58 (closed) — two not-found rows assert MANIFEST_UNKNOWN / BLOB_UNKNOWN against a name nothing was pushed to, where NAME_UNKNOWN is correct. 2 rows.
  • #59 (closed) — two manifest-PUT rows expect 400 MANIFEST_BLOB_UNKNOWN on a name with no prior blob upload and get 404. The precedence is unsettled and needs a registry-side decision. 2 rows.

Worth stating plainly for anyone reading the 32/47 as a verdict on the registry: 15 rows produced no conclusion about it. Eleven failed before reaching their assertion and four did not run, so the honest reading is "no defect found in what was reached", not "the registry's OCI surface is clean". A re-run after #57 (closed) to #59 (closed) land is what would settle those.

The 4 skips are legitimate. Two are the suite's crane driver refusing a Bearer credential (skipReasonBearerCredentialUnsupportedByCrane), which matters here because this registry's /v2/ accepts only Bearer; crane is installed and working, so "driver unavailable" would be the wrong reason to record. The other two are conditional rows that cannot be observed against a registry that supports deletion and serves the referrers API.

Merge request reports

Loading
Loading