feat(oci): address a remote repository and its upstream on one host

Adds --oci-upstream-name-prefix, so a remote OCI run can address a registry that serves the remote repository and the upstream it proxies on one host. Closes the configuration half of #60 (closed); see What this does not do.

The problem

OCI puts /v2/ first in the path, so --registry-url can only be the host and the repository lives inside <name>. --oci-name-prefix (!268 (merged)) is a single value and cannot name both sides. On a registry serving both repositories on one host the two base URLs are necessarily identical, and Config.Validate rejected that before any request:

upstream-url: must differ from registry-url
exit 2

The rejection was the right outcome, not the defect. Had it passed, one prefix would have pointed both clients at the same repository, so seeding would write into the repository under test and every relay row would pass with no proxying having occurred. That silent green is worse than the exit 2.

Maven and npm carry the repository in the base URL, so their two URLs differ naturally and one shared name is correct. Neither needs an equivalent flag.

What this does

Layer Change
internal/cli/flags.go, run.go --oci-upstream-name-prefix, run-only, REGISTRY_CONFORMANCE_OCI_UPSTREAM_NAME_PREFIX
pkg/conformance/config.go Config.OCIUpstreamNamePrefix
pkg/conformance/validate.go Two cross-flag rules; the URL-equality rule now reads the format
pkg/conformance/oci/{env,module}.go ociEnv.UpstreamNamePrefix(), the one site resolving the empty-means-reuse default, and a shared grammar checker both prefixes go through
docs/specs/S08 New §Addressing two repositories on one host, plus the flag, its validation, its error rows, and AC #4 (closed), #9 (closed), #12 (closed) and #38 (closed)
docs/specs/S04, S07 The amendments S08's tables promise
README.md The flag, the same-host invocation, and what its exit 0 is not evidence of

The invocation the issue could not express:

registry-conformance run --format oci --repository-kind remote \
  --registry-url "https://<ar-host>" \
  --upstream-url  "https://<ar-host>" \
  --oci-name-prefix          "<slug>/container/conformance-oci-remote" \
  --oci-upstream-name-prefix "<slug>/container/conformance-oci-upstream" \
  --credential "bearer:env:AR_TOKEN"

Three decisions worth reviewing

Empty means reuse, not "no prefix". An unset --oci-upstream-name-prefix resolves to --oci-name-prefix, so a registry whose two base URLs already differ stays configurable with one flag. Resolving it to "" instead would address the upstream at the /v2/ root of a registry that requires a prefix, and the run would report a broken upstream rather than a configuration error. The default is resolved once, in NewEnv.

The equality rule is lifted only when the two resolved names differ, not for every OCI run. ociNamesDistinguishUpstream therefore tests emptiness and inequality: an unset upstream prefix alongside a set --oci-name-prefix differs textually while naming one repository, so reading the raw pair without resolving the default would admit exactly the silent green above.

The rejection names the remedy the format has. Under OCI the Reason becomes must differ from registry-url, or --oci-upstream-name-prefix must be set and differ from --oci-name-prefix; every other format keeps the unqualified string. An OCI operator on a one-host registry has no second base URL to point at, so the old wording read as "this registry shape is unsupported". Neither arm carries URL bytes, per S08's no-echo rule.

Consumer note: that Reason change reaches an existing --format=oci remote run with equal base URLs that sets no new flag. It is a prefix-extension of the old string, so a substring match still hits and only an exact-equality assertion breaks, and every affected run was already exiting 2.

Spec coverage

Spec: docs/specs/S08-remote-contracts.md, with the amendments it owns in docs/specs/S04-contracts.md.

Acceptance criteria

# Criterion Tests
S08 AC #4 (closed) URL-equality rejection, now lifted when two OCI names distinguish the repositories TestConfig_Validate_IdenticalBaseURLsUnderOCI, TestConfig_Validate_IdenticalBaseURLsOutsideOCI, TestRunCommand_SameHostRemoteOCIRejections, TestRunCommand_SameHostRemoteOCIAccepted, TestRunCommand_NonOCIKeepsTheUnqualifiedEqualityRejection
S08 AC #9 (closed) The flag resolves from REGISTRY_CONFORMANCE_OCI_UPSTREAM_NAME_PREFIX runFlagInventory row in internal/cli/flags_test.go
S08 AC #12 (closed), S04 AC #13 (closed) list rejects the flag with unknown flag: TestListCommand_RejectsRunOnlyFlags
S08 AC #38 (closed) Both cross-flag rejections, format reported before kind, and the same-host accept TestConfig_Validate_OCIUpstreamNamePrefixCrossFieldRules, TestConfig_Validate_OCIUpstreamNamePrefixReasonsNameTheFlagToSet, TestRunCommand_OCIUpstreamNamePrefixCrossFlagRejections
§Addressing two repositories, property 1 Empty means reuse, resolved once TestNewEnv_ResolvesTheUpstreamNamePrefix, TestUpstreamNamePrefix_ZeroValueEnvIsEmpty
§Addressing two repositories, property 2 Identical base URLs admitted only when the names differ TestConfig_Validate_IdenticalBaseURLsUnderOCI, TestConfig_Validate_DifferingBaseURLsNeedNoSecondPrefix, TestRunCommand_DifferingBaseURLsKeepOneFlagWorking
§Addressing two repositories, property 3 The rejection names the remedy the format has TestConfig_Validate_EqualityReasonWordingPerFormat
§Addressing two repositories, property 4 Each name bound to the client that addresses it Not testable in this MR. No OCI remote row is on main, so there is no call site to bind. Pinned as a contract in S08 and now also in repoNameFor's doc comment, which is where the row author will read it. The row MR owes the test.
§Addressing two repositories, property 5 The under-test name nests the upstream's path on a forwarding proxy Not testable. The rule constrains the value an operator passes, not anything the suite computes; property 5 is itself the statement that the suite deliberately does not compute the nesting. Measured instead: Artifact Registry staging, 2026-09-04, all four combinations of upstream base URL and prefix nesting.

Error cases

# Condition Tests
E-1 --oci-upstream-name-prefix under a non-oci format, exit 2 TestRunCommand_OCIUpstreamNamePrefixCrossFlagRejections, TestConfig_Validate_OCIUpstreamNamePrefixCrossFieldRules
E-2 --oci-upstream-name-prefix under a resolved hosted kind, exit 2 same two, both kind spellings
E-3 Equal base URLs, OCI, no differing prefix pair, exit 2 naming upstream-url TestRunCommand_SameHostRemoteOCIRejections (four spellings, including an explicitly empty flag)
E-4 Equal base URLs, non-OCI, unqualified Reason kept TestRunCommand_NonOCIKeepsTheUnqualifiedEqualityRejection, TestConfig_Validate_IdenticalBaseURLsOutsideOCI
E-5 Prefix outside the OCI <name> grammar, reported on its own field TestNewEnv_RejectsUpstreamPrefixOutsideTheNameGrammar, TestNewEnv_ReportsTheUnderTestPrefixOnItsOwnField, TestNewEnv_UnsetUpstreamPrefixInheritsNoSecondGrammarError
E-6 --upstream-free-only with the flag set TestConfig_Validate_UpstreamFreeOnlyAcceptsTheUpstreamPrefix. Accepted and inert; the row pins the answer rather than asserting it is the right one. See below.

Security considerations

# Concern Tests
S-1 The relaxation must not carry the https-parity rule out with it, or the one credential sent to both base URLs reaches an http upstream in cleartext TestConfig_Validate_OCIRelaxationKeepsTheHTTPSParityRule
S-2 No Reason carries URL bytes, on either arm of the new wording TestConfig_Validate_UpstreamURLReasonNeverEchoesURL, TestConfig_Validate_EqualityReasonWordingPerFormat
S-3 The prefix reaches a URL path, so it must not escape <name> Grammar-checked in NewEnv via client.ValidateName, which rejects .., %2F and empty components: TestNewEnv_RejectsUpstreamPrefixOutsideTheNameGrammar

What this does not do

This makes the run possible, not green, and it is the configuration half of #60 (closed) only. The issue's Option B, binding each repository name to the client that addresses it, is pinned as a contract in S08 §Addressing two repositories on one host property 4 and in repoNameFor's doc comment, not enforced by a type here, because no OCI remote row is on main: remote_seed.go and remote_relay.go live in !266 (merged) and !271 (merged), both still Draft. ociEnv.UpstreamNamePrefix() accordingly has no production caller yet, which its doc comment says. The rows carry the bound pair when they arrive.

The README's same-host block says the same thing to operators: that invocation's exit 0 is not proxying evidence today, because the run executes the hosted catalog and never reads --upstream-url.

--upstream-free-only with --oci-upstream-name-prefix is accepted and inert. That run has no upstream either, so the second name is never read, which is the outcome the two cross-flag rules exist to refuse. Closing it is a third cross-flag rule with its own S04 and S08 error rows, so it is left as a spec decision rather than added here; the comments no longer claim a completeness the rules do not have, and a test pins the current answer so a later change to it is deliberate.

Expect #57 (closed) next on that track.

Conflicts to expect

Every branch in the OCI remote stack (!266 (merged)!271 (merged)!272 (merged)!273 (merged)!276 (merged)) predates !268 (merged) and still carries repoNameForRunID, which main replaced with repoNameFor. Those branches need a rebase regardless of this MR. Once rebased they overlap this diff in pkg/conformance/oci/{env,module}.go, pkg/conformance/{config,validate}.go, internal/cli/{flags,run}.go and README.md; !273 (merged) and !276 (merged) also touch docs/specs/S08-remote-contracts.md. Landing this first keeps that to one rebase rather than two.

Testing

Mutation testing drove the validation and NewEnv tests: six mutations of the validation rules and five of the NewEnv wiring, all caught. One mutation initially survived, a grammar check reading the resolved value rather than the raw Config field, and turned out to be unobservable because the under-test check returns first; the comment claiming otherwise was corrected rather than a test written for a case that cannot occur.

Review found a twelfth that did survive: hoisting ociNamesDistinguishUpstream into an early return nil in compareUpstreamAgainstRegistry, which is the natural tidy-up once that function grows a third branch, also skips the https-parity check. Every OCI row with two differing prefixes used identical base URLs, where parity cannot fire because equal URLs share a scheme, and the only parity coverage was npm. TestConfig_Validate_OCIRelaxationKeepsTheHTTPSParityRule closes it.

  • internal/cli: the two cross-flag rejections at the operator surface, the same-host accept and reject matrices, the differing-base-URL run that still needs only one flag, npm keeping the unqualified rejection (including with the upstream prefix set, which is the row that catches a relaxation missing its format test), and the flag-to-Config mapping row.
  • pkg/conformance: the same rules at Config.Validate, both Reason wordings per format, the https-parity row above, the --upstream-free-only row, and the accept rows that a rule which over-rejects would fail.
  • pkg/conformance/oci: the resolution matrix through the accessor, the grammar rejection on its own field in both directions, and the zero-value contract for an env literal.

golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./... reports 0 issues. Full go test -race ./... is green.

No conformance row changed, so no catalog or inventory edit applies.

Related to #60 (closed)

Edited by Sylvia Shen

Merge request reports

Loading
Loading