From 25143f8f197b228b4ec01546341036ee4add21fe Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:47:02 -0500 Subject: [PATCH 1/2] ci(accounts): the declared host-interface versions are held to the pinned conformance suite module.codefly.yaml declares what accounts implements and go.mod pins the codefly-dev/authority revision whose shared suite accounts' tests run; nothing held the two together. `module-package verify-host-interfaces` now reads interfaces//interface.codefly.yaml at the pinned commit (fetched by its full id in the conformance job, which already clones authority) and refuses a declared version above the published one, an interface the pin does not publish, authority libraries pinned at two revisions, or a HOST_INTERFACE.md naming another version. A declared version below the published one is a notice, so the pin can move before the declaration and never the reverse. Running the harness at the pin surfaced an undeclared `instance` key on the hop_admission_key_published audit payload, which the durable emitter drops with a warning; it is declared and held by the admission unit test. session is declared at 0.2.1, the version whose suite accounts passes; delivery and resources move to the revision the other authority libraries pin (empty diff). Tally at authority dea37afac9ad: 94 cases, 294 subtests, 0 failed, 0 skipped. Closes #1291. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 11 +- AGENTS.md | 3 +- RELEASE_GATES.md | 4 +- module/module.codefly.yaml | 2 +- module/services/accounts/HOST_INTERFACE.md | 42 +- module/services/accounts/code/go.mod | 4 +- module/services/accounts/code/go.sum | 8 +- .../adapters/host_interface_admission_test.go | 5 + .../code/pkg/business/audit_registry.go | 3 + module/tools/cmd/module-package/main.go | 15 +- module/tools/go.mod | 2 +- module/tools/modulepackage/host_interfaces.go | 384 ++++++++++++++++++ .../modulepackage/host_interfaces_test.go | 292 +++++++++++++ scripts/ci/host-interface-conformance-main.sh | 40 +- 14 files changed, 791 insertions(+), 24 deletions(-) create mode 100644 module/tools/modulepackage/host_interfaces.go create mode 100644 module/tools/modulepackage/host_interfaces_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c168a1bc4..605f28a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,6 +371,11 @@ jobs: # case the suite adds or a rule the authority core changes reaches accounts on # its next pull request, never silently on the next re-pin. The same suite at # the pinned revision runs with accounts' tests in the quality phases. + # Before the suite, the declaration gate (`module-package + # verify-host-interfaces`): every interface version module.codefly.yaml + # declares is at or below the version that interface publishes at the pinned + # authority revision, so a declaration names nothing the pinned suite did not + # prove. host-interface-conformance: name: Host interface conformance (authority main) runs-on: ubuntu-latest @@ -380,10 +385,12 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.27.3" - cache-dependency-path: module/services/accounts/code/go.sum + cache-dependency-path: | + module/services/accounts/code/go.sum + module/tools/go.sum - name: Install Codefly run: bash scripts/ci/install-codefly.sh - - name: Run the shared suite at authority main against accounts + - name: Hold the declared interface versions to the pin, then run the shared suite at authority main against accounts run: bash scripts/ci/host-interface-conformance-main.sh marketing: diff --git a/AGENTS.md b/AGENTS.md index eda9d7a03..65c599bba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,8 @@ Skills in `.claude/skills/`, loaded when the task calls for them: module verification job, authorization coverage, the release-gate contract, interface docs and story tests, the kit's version, marketing isolation, the SDK boundary, the immutable module package, the scoped pools' database - authority, and the shared host-interface conformance suite at authority main. [RELEASE_GATES.md § Repository-specific + authority, and the shared host-interface conformance suite at authority main + with the declared interface versions held to the pinned suite. [RELEASE_GATES.md § Repository-specific gates](./RELEASE_GATES.md#repository-specific-gates) says what each runs, and `release-gates.test.mjs` holds this prose to the enforced set. - More checks run as *steps* inside the `base-integrity` job than as gates of their diff --git a/RELEASE_GATES.md b/RELEASE_GATES.md index 4abbe76de..a077a291c 100644 --- a/RELEASE_GATES.md +++ b/RELEASE_GATES.md @@ -111,7 +111,7 @@ no per-track exemption. | `authz-coverage` | required | required | RBAC, audit, and no-broadening | | `release-contract` | required | required | this gating graph itself | | `docs-sync` | required | required | interface docs and story tests | -| `host-interface-conformance` | required | required | the shared host-interface suite at authority main | +| `host-interface-conformance` | required | required | the shared host-interface suite at authority main, and the declared interface versions held to the pin | | `kit-version` | required | required | published frontend kit version vs. content | | `marketing` | required | required | marketing isolation build | | `sdk-boundary` | required | required | Codefly SDK boundary and contracts | @@ -445,7 +445,7 @@ reverse, fails the `release-contract` job. | `authz-coverage` | the generated authorization catalog: RBAC coverage, audit coverage, and permission no-broadening against `main` | `node module/tools/authz-coverage-gate.mjs`, plus `go test` for the gateway header-lockstep and adapter enforcement tests | | `release-contract` | this gating graph itself, and the Dependabot configuration that feeds it | `node --test scripts/ci/release-gates.test.mjs`, `node --test scripts/ci/dependabot-coverage.test.mjs`, `node scripts/ci/release-gates.mjs check`, `node scripts/ci/dependabot-coverage.mjs check` | | `docs-sync` | the generated interface docs and the story-trace tests (#516) | `node module/tools/interface-docs-gate.mjs check`, `node module/tools/story-trace-gate.mjs tests` | -| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request | `bash scripts/ci/host-interface-conformance-main.sh`, which re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | +| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation, session, artifact) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request; and every interface version `module.codefly.yaml` declares is at or below the version that interface publishes at the pinned authority revision, so a declaration names nothing the pinned suite did not prove | `bash scripts/ci/host-interface-conformance-main.sh`, which fetches the pinned authority commit, runs `go run ./cmd/module-package verify-host-interfaces` (in `module/tools`) over its `interfaces/` tree, then re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | | `kit-version` | the published frontend kit's version moves whenever its content does, since a registry version is immutable once served | `node --test scripts/ci/kit-version.test.mjs`, `node scripts/ci/kit-version.mjs check` | | `marketing` | the marketing runtime builds in isolation from the app, and the public config is current | `node module/tools/generate-public-config.mjs --check`, `node module/tools/marketing-extraction.mjs` | | `sdk-boundary` | the root module's own tests, the Codefly SDK boundary, single-invocation protocol generation, the exported API contract, and the published module-authority client against the real host | `go test ./...` **in the root module only**, `go test codefly_sdk_boundary_test.go`, `codefly generate contracts saas-starter --check`, `codefly generate runnables saas-starter --check`, `go test ./...` in `qualification/module-authority` | diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index 7801c36e7..089ba8b42 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -28,7 +28,7 @@ interface: - codefly.dev/authority@0.3.5 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.4.0 - - codefly.dev/session@0.2.0 + - codefly.dev/session@0.2.1 - service: auth-gateway endpoint: grpc visibility: internal diff --git a/module/services/accounts/HOST_INTERFACE.md b/module/services/accounts/HOST_INTERFACE.md index 12af0f8f4..c6c11d5d4 100644 --- a/module/services/accounts/HOST_INTERFACE.md +++ b/module/services/accounts/HOST_INTERFACE.md @@ -1,10 +1,14 @@ # The host interfaces, served by accounts accounts implements the host interfaces published by `codefly-dev/authority`: -`codefly.dev/authority@0.3.5`, `codefly.dev/composition@0.2.0`, -`codefly.dev/installation@0.3.0` and `codefly.dev/session@0.2.0`. The local -authority implements the same four on a laptop. The shared conformance suite -(`libraries/conformance/go`) proves that the two agree. +`codefly.dev/artifact@0.1.0`, `codefly.dev/authority@0.3.5`, +`codefly.dev/composition@0.2.0`, `codefly.dev/installation@0.4.0` and +`codefly.dev/session@0.2.1`, as `module/module.codefly.yaml` declares them. The +local authority implements the same five on a laptop. The shared conformance +suite (`libraries/conformance/go`) proves that the two agree, and the +declaration gate (`module-package verify-host-interfaces`, under Proof below) +holds each version named here and in the manifest to the version the interface +publishes at the authority revision `go.mod` pins. ## One authority core, two thin hosts @@ -156,7 +160,35 @@ behavior-preserving rollback. - `.github/workflows/ci.yml` (`host-interface-conformance`, the required context "Host interface conformance (authority main)" and a release gate) runs the suite at authority main, whenever main contains the pinned revision, - on every pull request and merge-queue entry. + on every pull request and merge-queue entry; the same suite at the pinned + revision runs with accounts' tests in the Codefly quality phase. +- Before that suite, the same job runs the declaration gate, `module-package + verify-host-interfaces` (in `module/tools`, with its own tests in + `modulepackage/host_interfaces_test.go`). It reads + `interfaces//interface.codefly.yaml` at the pinned authority commit, + fetched by its full id from the clone the job already makes, and refuses: a + version `module/module.codefly.yaml` declares above the one published there + (a claim no suite proved); an interface the pin does not publish; authority + libraries pinned at two revisions (`go.mod` pins the suite, the core it + decides with, the client, delivery and resources at one commit); and an + opening sentence of this document naming another version than the manifest. + A declared version below the published one is a notice, never a failure: + the pin may move to run a newer suite before the declaration follows it, + and the declaration never moves first. The half that needs no network, one + revision in `go.mod` and this document agreeing with the manifest, also + runs with the `module/tools` tests in the module-package job. +- Last recorded run: authority `dea37afac9ad` (its main on 2026-10-30, the + revision `go.mod` pins for all five authority libraries), from + `module/services/accounts/code`, on 2026-11-03: + + GOWORK=off go test -count=1 -timeout 30m -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' -v ./pkg/adapters + + passed 94 cases (the shared suite, the solution-principal target and the + cases beyond the suite, as `go test -v` lists them one level under the + test), 294 subtests at every depth; failed 0; skipped 0; 74 s. The + admission-key case ("only the hop registers an admission key, published + apart from the Work Context keys") is among them, with the fixture's + `HopCredential`. The section below carries no red line from this run. - The core's store contract (`authority-core/go/storetest`) runs against this Postgres store in `pkg/infra/postgres_authority_core_test.go`. - The core's own decisions are unit tested in `codefly-dev/authority`. diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 74a0a12b6..ba96b70f4 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -10,8 +10,8 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef - github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 github.com/codefly-dev/interface-cache/go/cache v0.4.1 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 07a49bfd2..9788b4693 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -62,12 +62,12 @@ github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-202610302220 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:Cdk+10ebRh0tZA9ze2dDIrfQC34E+Y7LMi6UjSQ2dQU= github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad h1:DZt68NLXwaGYWXo1RUM0+JrvuXt/za/EyeHMGYFSix8= github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:or3dd9bqacXWMjke/n81l/QxRaLwq2lq/hL4V2qtAZo= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef h1:Mso6TDL7zLY5HwR+gq0pRSNN/zO8fv89F9kzcPf+qI4= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad h1:gq8WatMLdoqsfwafxj2ihYSl0jIEw/d7ZMTGRWu9qpo= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f h1:llYwKOBeCB6kS4faxICrR5YYlwnhiV+0qXjuT3IwBd4= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f/go.mod h1:/SZab4/ll1nAKTucP3h30rpcXo56BHmJWLkvs2ayaMs= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef h1:oJN5L6PkQB+VTVn1zsgXzSwiCO7eFUrd67PCkncrFI4= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad h1:ZuYv+9r6U08xLQ5c+AOSBzhkF7h3lqUj6kMa0S2OKKE= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 h1:U5HeCDUfftwVFbMYN1HGPUL+9HO+ske3nkExu1C2kYY= diff --git a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go index 63398c2d3..866727663 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go @@ -184,6 +184,11 @@ func TestTheHopsRegisteredKeyIsPublishedApartFromTheWorkContextKeys(t *testing.T require.Equal(t, id, published.ResourceID) require.Equal(t, id, published.Payload["key_id"]) require.Equal(t, "authority/hop", published.Payload["workload"]) + require.NotEmpty(t, published.Payload["instance"], "the hop instance the key is pinned to") + // The durable emitter writes the row and drops the whole payload when a + // key is not declared for the event, with a warning and nothing failing; + // the production check runs here so the declaration cannot drift. + require.NoError(t, business.ValidatePayload(published.EventType, published.Payload)) // A restart registers the same key again: one entry, renewed, and a // renewal is not an event. diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 624b7ee4d..8ac18d5c4 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -743,6 +743,9 @@ var auditEventCatalog = []AuditEventDefinition{ mutation(EventHopAdmissionKeyPublished, CategorySecurity, "Keys began publishing a hop admission key, registered by the hop's attested workload.", PayloadField{Name: "key_id", Kind: FieldString, Required: true}, PayloadField{Name: "workload", Kind: FieldString, Required: true}, + // The hop instance (its pod) the key is pinned to; a credential naming + // no pod is refused before anything is published. + PayloadField{Name: "instance", Kind: FieldString, Required: true}, PayloadField{Name: "expires_at", Kind: FieldString, Required: true}), mutation(EventModuleEnvelopeDelivered, CategorySecurity, "A release-signed revision of the module envelope was delivered.", PayloadField{Name: "revision", Kind: FieldInt, Required: true}, diff --git a/module/tools/cmd/module-package/main.go b/module/tools/cmd/module-package/main.go index 774dd3194..8ddaed53c 100644 --- a/module/tools/cmd/module-package/main.go +++ b/module/tools/cmd/module-package/main.go @@ -19,9 +19,22 @@ func main() { func run(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: module-package ") + return fmt.Errorf("usage: module-package ") } switch arguments[0] { + case "verify-host-interfaces": + flags := flag.NewFlagSet("verify-host-interfaces", flag.ContinueOnError) + moduleRoot := flags.String("module", ".", "module package root") + interfaces := flags.String("interfaces", "", "codefly-dev/authority's interfaces/ tree at the revision the services' go.mod pin") + revision := flags.String("revision", "", "the commit that tree was read at, held to the go.mod pin (optional)") + if err := flags.Parse(arguments[1:]); err != nil { + return err + } + return modulepackage.VerifyHostInterfaces(modulepackage.HostInterfaceOptions{ + ModuleRoot: *moduleRoot, + InterfacesDir: *interfaces, + Revision: *revision, + }, os.Stdout) case "validate": flags := flag.NewFlagSet("validate", flag.ContinueOnError) moduleRoot := flags.String("module", ".", "module package root") diff --git a/module/tools/go.mod b/module/tools/go.mod index 7aa33f5ae..5f6d50ce0 100644 --- a/module/tools/go.mod +++ b/module/tools/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/codefly-dev/core v0.19.7 github.com/yoheimuta/go-protoparser/v4 v4.14.2 + golang.org/x/mod v0.41.0 golang.org/x/tools v0.50.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -46,7 +47,6 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/module/tools/modulepackage/host_interfaces.go b/module/tools/modulepackage/host_interfaces.go new file mode 100644 index 000000000..94c98ce25 --- /dev/null +++ b/module/tools/modulepackage/host_interfaces.go @@ -0,0 +1,384 @@ +package modulepackage + +// The host-interface declaration gate. module.codefly.yaml declares what a +// service implements (`implements: codefly.dev/@`), and the +// service's go.mod pins the codefly-dev/authority revision whose conformance +// suite its tests run. The two are one fact only if each declared version is +// at or below the version the interface publishes at that revision: a +// declaration above it names a version no suite proved. A declaration below it +// under-claims, which is reported and never failed, because moving the pin to +// run a newer suite must not force the declaration before that suite is green. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/codefly-dev/core/resources" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "gopkg.in/yaml.v3" +) + +// AuthorityLibraryPrefix is the module path every published authority library +// shares: the conformance suite, the core that decides, the client, delivery +// and resources. +const AuthorityLibraryPrefix = "github.com/codefly-dev/authority/libraries/" + +// ConformanceLibrary is the shared suite itself; a service that pins no suite +// has nothing proving what it declares. +const ConformanceLibrary = AuthorityLibraryPrefix + "conformance/go" + +// authorityPublisher is the publisher of every interface codefly-dev/authority +// carries under interfaces//interface.codefly.yaml. +const authorityPublisher = "codefly.dev" + +// HostInterfaceDeclaration is one `implements` entry of a service's interface +// endpoint: /@. +type HostInterfaceDeclaration struct { + Service string + Publisher string + Name string + Version string +} + +// ID is the declaration as a consumer names it, publisher/name. +func (d HostInterfaceDeclaration) ID() string { return d.Publisher + "/" + d.Name } + +// AuthorityPin is the one codefly-dev/authority revision a go.mod pins its +// authority libraries at, read from their pseudo-versions. +type AuthorityPin struct { + // Revision is the commit the pseudo-versions abbreviate. + Revision string + // Libraries are the pinned authority modules, by path, sorted. + Libraries []string +} + +// PublishedInterface is interfaces//interface.codefly.yaml as authority +// publishes it at a revision. +type PublishedInterface struct { + Publisher string `yaml:"publisher"` + Name string `yaml:"name"` + Version string `yaml:"version"` +} + +// HostInterfaceFinding is the gate's answer for one declaration. +type HostInterfaceFinding struct { + Interface string + Declared string + Published string + // Failure says the declaration is not proven; otherwise Reason, when set, + // is a notice. + Failure bool + Reason string +} + +// HostInterfaceOptions name what the gate reads. +type HostInterfaceOptions struct { + // ModuleRoot holds module.codefly.yaml and services//. + ModuleRoot string + // InterfacesDir is authority's interfaces/ tree at the pinned revision. + InterfacesDir string + // Revision, when given, is the commit InterfacesDir was read at; it must be + // the commit the go.mod pseudo-versions abbreviate. + Revision string +} + +// DeclaredHostInterfaces reads every `implements` entry of the module's +// interface endpoints, through core's own module loader. +func DeclaredHostInterfaces(moduleRoot string) ([]HostInterfaceDeclaration, error) { + mod, err := resources.LoadModuleFromDir(context.Background(), moduleRoot) + if err != nil { + return nil, fmt.Errorf("read %s: %w", filepath.Join(moduleRoot, resources.ModuleConfigurationName), err) + } + var declared []HostInterfaceDeclaration + seen := map[string]bool{} + if mod.Interface == nil { + return nil, nil + } + for _, endpoint := range mod.Interface.Endpoints { + for _, entry := range endpoint.Implements { + d, err := parseDeclaration(endpoint.Service, entry) + if err != nil { + return nil, err + } + key := d.Service + " " + d.ID() + "@" + d.Version + if seen[key] { + continue + } + seen[key] = true + declared = append(declared, d) + } + } + sort.Slice(declared, func(i, j int) bool { + if declared[i].Service != declared[j].Service { + return declared[i].Service < declared[j].Service + } + return declared[i].ID() < declared[j].ID() + }) + return declared, nil +} + +func parseDeclaration(service, entry string) (HostInterfaceDeclaration, error) { + at := strings.LastIndex(entry, "@") + slash := strings.Index(entry, "/") + if at < 0 || slash < 0 || slash > at || slash == 0 || at == len(entry)-1 { + return HostInterfaceDeclaration{}, fmt.Errorf("service %s implements %q, which is not /@", service, entry) + } + return HostInterfaceDeclaration{Service: service, Publisher: entry[:slash], Name: entry[slash+1 : at], Version: entry[at+1:]}, nil +} + +// ReadAuthorityPin reads the authority revision a go.mod pins: every direct +// requirement under AuthorityLibraryPrefix must be a pseudo-version, and all of +// them must abbreviate one commit, because the suite, the core it decides +// with and the client are run together. +func ReadAuthorityPin(goModPath string) (AuthorityPin, error) { + data, err := os.ReadFile(goModPath) + if err != nil { + return AuthorityPin{}, err + } + file, err := modfile.ParseLax(goModPath, data, nil) + if err != nil { + return AuthorityPin{}, err + } + var pin AuthorityPin + revisions := map[string][]string{} + for _, req := range file.Require { + if req.Indirect || !strings.HasPrefix(req.Mod.Path, AuthorityLibraryPrefix) { + continue + } + if !module.IsPseudoVersion(req.Mod.Version) { + return AuthorityPin{}, fmt.Errorf("%s pins %s at %s, which names no commit: this gate reads the revision from a pseudo-version, so a tagged authority library needs the gate taught to resolve its tag", goModPath, req.Mod.Path, req.Mod.Version) + } + rev, err := module.PseudoVersionRev(req.Mod.Version) + if err != nil { + return AuthorityPin{}, fmt.Errorf("%s pins %s at %s: %w", goModPath, req.Mod.Path, req.Mod.Version, err) + } + revisions[rev] = append(revisions[rev], shortLibraries([]string{req.Mod.Path})...) + pin.Libraries = append(pin.Libraries, req.Mod.Path) + } + if len(pin.Libraries) == 0 { + return AuthorityPin{}, fmt.Errorf("%s pins no %s* library, so no conformance suite proves what the service declares", goModPath, AuthorityLibraryPrefix) + } + if len(revisions) > 1 { + var split []string + for rev, libraries := range revisions { + sort.Strings(libraries) + split = append(split, rev+" ("+strings.Join(libraries, ", ")+")") + } + sort.Strings(split) + return AuthorityPin{}, fmt.Errorf("%s pins the authority libraries at two revisions, %s: the suite, the core it decides with and the client are one revision or the pass proves nothing", goModPath, strings.Join(split, " and ")) + } + sort.Strings(pin.Libraries) + if !slices.Contains(pin.Libraries, ConformanceLibrary) { + return AuthorityPin{}, fmt.Errorf("%s pins no %s, so no shared suite runs against what the service declares", goModPath, ConformanceLibrary) + } + for rev := range revisions { + pin.Revision = rev + } + return pin, nil +} + +// ReadPublishedInterfaces reads //interface.codefly.yaml for every +// interface authority publishes at the revision dir was read from. +func ReadPublishedInterfaces(dir string) (map[string]PublishedInterface, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + published := map[string]PublishedInterface{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name(), "interface.codefly.yaml") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + var iface PublishedInterface + if err := yaml.Unmarshal(data, &iface); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if iface.Name != entry.Name() { + return nil, fmt.Errorf("%s names interface %q, not %q", path, iface.Name, entry.Name()) + } + published[iface.Publisher+"/"+iface.Name] = iface + } + if len(published) == 0 { + return nil, fmt.Errorf("%s publishes no interface (no /interface.codefly.yaml)", dir) + } + return published, nil +} + +var documentedInterface = regexp.MustCompile(`codefly\.dev/([a-z][a-z0-9-]*)@([0-9]+\.[0-9]+\.[0-9]+)`) + +// DocumentedHostInterfaces reads the versions a service's HOST_INTERFACE.md +// names in its opening section, before its first heading: the sentence that +// tells a reader what the service implements, held here to the manifest so it +// cannot drift from it. A service with no such document documents nothing. +func DocumentedHostInterfaces(path string) (map[string][]string, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + opening, _, _ := strings.Cut(string(data), "\n## ") + documented := map[string][]string{} + for _, match := range documentedInterface.FindAllStringSubmatch(opening, -1) { + id := authorityPublisher + "/" + match[1] + if !slices.Contains(documented[id], match[2]) { + documented[id] = append(documented[id], match[2]) + } + } + return documented, nil +} + +// CompareHostInterfaces judges each declaration against what the pinned +// revision publishes and what the service's document says. A failure is a +// declared version above the published one, an interface the revision does +// not publish, a version that is not semver, or a document naming another +// version; a declared version below the published one is a notice. +func CompareHostInterfaces(declared []HostInterfaceDeclaration, published map[string]PublishedInterface, documented map[string][]string) []HostInterfaceFinding { + var findings []HostInterfaceFinding + for _, d := range declared { + finding := HostInterfaceFinding{Interface: d.ID(), Declared: d.Version} + if d.Publisher != authorityPublisher { + finding.Reason = "not published by codefly-dev/authority; no shared suite here proves it" + findings = append(findings, finding) + continue + } + p, ok := published[d.ID()] + if !ok { + finding.Failure = true + finding.Reason = "the pinned authority revision publishes no such interface, so no suite proves the declaration" + findings = append(findings, finding) + continue + } + finding.Published = p.Version + declaredVersion, err := semver.StrictNewVersion(d.Version) + if err != nil { + finding.Failure = true + finding.Reason = fmt.Sprintf("the declared version is not semver: %v", err) + findings = append(findings, finding) + continue + } + publishedVersion, err := semver.StrictNewVersion(p.Version) + if err != nil { + finding.Failure = true + finding.Reason = fmt.Sprintf("the published version is not semver: %v", err) + findings = append(findings, finding) + continue + } + switch declaredVersion.Compare(publishedVersion) { + case 1: + finding.Failure = true + finding.Reason = "the declaration claims a version the pinned suite does not prove; move the pin to a revision publishing it and pass that suite first" + case -1: + finding.Reason = fmt.Sprintf("the pinned suite proves %s; declaring it is a manifest change backed by this pin", p.Version) + } + if versions, ok := documented[d.ID()]; ok { + if len(versions) != 1 || versions[0] != d.Version { + finding.Failure = true + finding.Reason = fmt.Sprintf("HOST_INTERFACE.md names %s where the manifest declares %s", strings.Join(versions, " and "), d.Version) + } + } else if documented != nil { + finding.Failure = true + finding.Reason = "HOST_INTERFACE.md does not name this interface in its opening section" + } + findings = append(findings, finding) + } + return findings +} + +// VerifyHostInterfaces runs the gate over the module: every service declaring +// a host interface, its go.mod pin, the interfaces published at that pin and +// its HOST_INTERFACE.md. It prints one line per declaration to out and returns +// an error naming every failure. +func VerifyHostInterfaces(options HostInterfaceOptions, out io.Writer) error { + if options.InterfacesDir == "" { + return errors.New("verify-host-interfaces needs --interfaces, authority's interfaces/ tree at the pinned revision") + } + declared, err := DeclaredHostInterfaces(options.ModuleRoot) + if err != nil { + return err + } + if len(declared) == 0 { + return fmt.Errorf("%s declares no host interface; the gate measures nothing", filepath.Join(options.ModuleRoot, resources.ModuleConfigurationName)) + } + published, err := ReadPublishedInterfaces(options.InterfacesDir) + if err != nil { + return err + } + byService := map[string][]HostInterfaceDeclaration{} + var services []string + for _, d := range declared { + if _, ok := byService[d.Service]; !ok { + services = append(services, d.Service) + } + byService[d.Service] = append(byService[d.Service], d) + } + sort.Strings(services) + var failures []string + for _, service := range services { + goMod := filepath.Join(options.ModuleRoot, "services", service, "code", "go.mod") + pin, err := ReadAuthorityPin(goMod) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("service %s declares host interfaces but has no Go module at %s, so no pinned suite proves them", service, goMod) + } + return err + } + if options.Revision != "" && !strings.HasPrefix(options.Revision, pin.Revision) { + return fmt.Errorf("the interfaces were read at %s, but %s pins %s", options.Revision, goMod, pin.Revision) + } + documented, err := DocumentedHostInterfaces(filepath.Join(options.ModuleRoot, "services", service, "HOST_INTERFACE.md")) + if err != nil { + return err + } + fmt.Fprintf(out, "%s: host interfaces declared by %s, held to authority %s (%s)\n", service, goMod, pin.Revision, strings.Join(shortLibraries(pin.Libraries), ", ")) + for _, finding := range CompareHostInterfaces(byService[service], published, documented) { + verdict := "ok" + switch { + case finding.Failure: + verdict = "FAIL: " + finding.Reason + failures = append(failures, fmt.Sprintf("%s %s declared %s, published %s: %s", service, finding.Interface, finding.Declared, orNone(finding.Published), finding.Reason)) + case finding.Reason != "": + verdict = "notice: " + finding.Reason + } + fmt.Fprintf(out, " %-28s declared %-8s published %-8s %s\n", finding.Interface, finding.Declared, orNone(finding.Published), verdict) + } + } + if len(failures) > 0 { + return fmt.Errorf("%d host interface declaration(s) are not proven by the pinned suite:\n %s", len(failures), strings.Join(failures, "\n ")) + } + return nil +} + +func shortLibraries(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, path := range paths { + out = append(out, strings.TrimSuffix(strings.TrimPrefix(path, AuthorityLibraryPrefix), "/go")) + } + return out +} + +func orNone(version string) string { + if version == "" { + return "none" + } + return version +} diff --git a/module/tools/modulepackage/host_interfaces_test.go b/module/tools/modulepackage/host_interfaces_test.go new file mode 100644 index 000000000..6f73381c1 --- /dev/null +++ b/module/tools/modulepackage/host_interfaces_test.go @@ -0,0 +1,292 @@ +package modulepackage + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func publishedFixture(t *testing.T, versions map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, version := range versions { + writeFile(t, filepath.Join(dir, name, "interface.codefly.yaml"), + "kind: interface\npublisher: codefly.dev\nname: "+name+"\nversion: "+version+"\ntype: connect\n") + } + return dir +} + +// The real tree: accounts declares the authority interface, pins the shared +// suite at one revision, and its document names what the manifest declares. +// This half of the gate needs no network, so it runs with the package tests; +// the published half runs in CI against authority's tree at the pin. +func TestAccountsDeclaresHostInterfacesItPinsOneSuiteFor(t *testing.T) { + moduleRoot := findModuleRoot(t) + declared, err := DeclaredHostInterfaces(moduleRoot) + if err != nil { + t.Fatal(err) + } + var authority *HostInterfaceDeclaration + for i := range declared { + if declared[i].Service == "accounts" && declared[i].ID() == "codefly.dev/authority" { + authority = &declared[i] + } + } + if authority == nil { + t.Fatalf("accounts declares no codefly.dev/authority interface; declared %+v", declared) + } + pin, err := ReadAuthorityPin(filepath.Join(moduleRoot, "services", "accounts", "code", "go.mod")) + if err != nil { + t.Fatal(err) + } + if len(pin.Revision) < 12 { + t.Fatalf("pin revision %q is not a commit abbreviation", pin.Revision) + } + documented, err := DocumentedHostInterfaces(filepath.Join(moduleRoot, "services", "accounts", "HOST_INTERFACE.md")) + if err != nil { + t.Fatal(err) + } + if documented == nil { + t.Fatal("accounts has no HOST_INTERFACE.md to hold to the manifest") + } + for _, d := range declared { + if d.Service != "accounts" { + continue + } + if versions := documented[d.ID()]; len(versions) != 1 || versions[0] != d.Version { + t.Errorf("HOST_INTERFACE.md names %s %v where the manifest declares %s", d.ID(), versions, d.Version) + } + } +} + +func TestReadAuthorityPinRequiresOnePseudoVersionedRevision(t *testing.T) { + cases := map[string]struct { + goMod string + want string // substring of the error, or "" for success + }{ + "one revision": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f // indirect +) +`}, + "two revisions": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261026111022-6870db87da8b +) +`, want: "two revisions, 6870db87da8b (conformance) and dea37afac9ad (authority-core)"}, + "a tag": {goMod: `module accounts + +require github.com/codefly-dev/authority/libraries/conformance/go v0.1.0 +`, want: "names no commit"}, + "no suite": {goMod: `module accounts + +require github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad +`, want: "pins no github.com/codefly-dev/authority/libraries/conformance/go"}, + "no authority": {goMod: "module accounts\n\nrequire github.com/google/uuid v1.6.0\n", want: "pins no github.com/codefly-dev/authority/libraries/* library"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "go.mod") + writeFile(t, path, tc.goMod) + pin, err := ReadAuthorityPin(path) + if tc.want == "" { + if err != nil { + t.Fatal(err) + } + if pin.Revision != "dea37afac9ad" || len(pin.Libraries) != 3 { + t.Fatalf("pin %+v", pin) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error %v, want it to contain %q", err, tc.want) + } + }) + } +} + +func TestReadPublishedInterfacesReadsEachInterfaceByItsDirectory(t *testing.T) { + dir := publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}) + writeFile(t, filepath.Join(dir, "notes.md"), "not an interface\n") + published, err := ReadPublishedInterfaces(dir) + if err != nil { + t.Fatal(err) + } + if published["codefly.dev/authority"].Version != "0.3.5" || published["codefly.dev/session"].Version != "0.2.1" || len(published) != 2 { + t.Fatalf("published %+v", published) + } + writeFile(t, filepath.Join(dir, "artifact", "interface.codefly.yaml"), "publisher: codefly.dev\nname: authority\nversion: 0.1.0\n") + if _, err := ReadPublishedInterfaces(dir); err == nil || !strings.Contains(err.Error(), `names interface "authority", not "artifact"`) { + t.Fatalf("a directory publishing another interface's manifest was read: %v", err) + } +} + +func TestDocumentedHostInterfacesReadsTheOpeningSectionAlone(t *testing.T) { + path := filepath.Join(t.TempDir(), "HOST_INTERFACE.md") + writeFile(t, path, "# Title\n\naccounts implements `codefly.dev/authority@0.3.5` and `codefly.dev/session@0.2.1`.\n\n## Later\n\nThe keys arrived in `codefly.dev/authority@0.3.3`.\n") + documented, err := DocumentedHostInterfaces(path) + if err != nil { + t.Fatal(err) + } + if got := documented["codefly.dev/authority"]; len(got) != 1 || got[0] != "0.3.5" { + t.Fatalf("authority %v: a later section's historical version was read as the claim", got) + } + if got := documented["codefly.dev/session"]; len(got) != 1 || got[0] != "0.2.1" { + t.Fatalf("session %v", got) + } + none, err := DocumentedHostInterfaces(filepath.Join(t.TempDir(), "absent.md")) + if err != nil || none != nil { + t.Fatalf("an absent document: %v %v", none, err) + } +} + +func TestCompareHostInterfacesJudgesEachDeclaration(t *testing.T) { + published := map[string]PublishedInterface{ + "codefly.dev/authority": {Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + "codefly.dev/session": {Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + "codefly.dev/installation": {Publisher: "codefly.dev", Name: "installation", Version: "0.4.0"}, + "codefly.dev/composition": {Publisher: "codefly.dev", Name: "composition", Version: "0.2.0"}, + } + declare := func(name, version string) HostInterfaceDeclaration { + return HostInterfaceDeclaration{Service: "accounts", Publisher: "codefly.dev", Name: name, Version: version} + } + findings := CompareHostInterfaces([]HostInterfaceDeclaration{ + declare("authority", "0.3.5"), // equal: ok + declare("session", "0.2.0"), // below: a notice + declare("installation", "0.5.0"), // above: not proven + declare("artifact", "0.1.0"), // not published at the pin + declare("composition", "0.2"), // not semver + {Service: "accounts", Publisher: "example.com", Name: "other", Version: "1.0.0"}, + }, published, nil) + if len(findings) != 6 { + t.Fatalf("%d findings: %+v", len(findings), findings) + } + expect := []struct { + failure bool + reason string + }{ + {false, ""}, + {false, "the pinned suite proves 0.2.1"}, + {true, "claims a version the pinned suite does not prove"}, + {true, "publishes no such interface"}, + {true, "not semver"}, + {false, "not published by codefly-dev/authority"}, + } + for i, want := range expect { + got := findings[i] + if got.Failure != want.failure || !strings.Contains(got.Reason, want.reason) { + t.Errorf("%s: failure=%v reason=%q, want failure=%v reason containing %q", got.Interface, got.Failure, got.Reason, want.failure, want.reason) + } + } +} + +func TestCompareHostInterfacesHoldsTheDocumentToTheManifest(t *testing.T) { + published := map[string]PublishedInterface{ + "codefly.dev/authority": {Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + "codefly.dev/session": {Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + } + declared := []HostInterfaceDeclaration{ + {Service: "accounts", Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + {Service: "accounts", Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + } + agreeing := map[string][]string{"codefly.dev/authority": {"0.3.5"}, "codefly.dev/session": {"0.2.1"}} + for _, f := range CompareHostInterfaces(declared, published, agreeing) { + if f.Failure { + t.Errorf("%s failed with an agreeing document: %s", f.Interface, f.Reason) + } + } + drifted := map[string][]string{"codefly.dev/authority": {"0.3.5"}, "codefly.dev/session": {"0.2.0"}} + findings := CompareHostInterfaces(declared, published, drifted) + if !findings[1].Failure || !strings.Contains(findings[1].Reason, "names 0.2.0 where the manifest declares 0.2.1") { + t.Fatalf("a document naming another version passed: %+v", findings[1]) + } + silent := map[string][]string{"codefly.dev/authority": {"0.3.5"}} + if findings := CompareHostInterfaces(declared, published, silent); !findings[1].Failure || !strings.Contains(findings[1].Reason, "does not name this interface") { + t.Fatalf("a document naming no version passed: %+v", findings[1]) + } +} + +// End to end over a module fixture: the manifest, the service's go.mod and its +// document, against a published tree. A declaration above the pin fails the +// run and is named with both versions. +func TestVerifyHostInterfacesFailsADeclarationAboveThePin(t *testing.T) { + moduleRoot := t.TempDir() + writeFile(t, filepath.Join(moduleRoot, "module.codefly.yaml"), `kind: module +name: fixture +interface: + endpoints: + - service: accounts + endpoint: connect + visibility: internal + implements: + - codefly.dev/authority@0.3.5 + - codefly.dev/session@0.2.1 +services: + - name: accounts +`) + // Core's module loader resolves every service an interface entry names and + // requires the endpoint it serves on, so the fixture carries the manifest. + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "service.codefly.yaml"), `name: accounts +version: 0.0.0 +agent: + kind: codefly:service + name: go-grpc + version: 0.1.72 + publisher: codefly.dev +endpoints: + - name: connect + visibility: internal +`) + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "code", "go.mod"), `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) +`) + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "HOST_INTERFACE.md"), + "# Host interfaces\n\naccounts implements `codefly.dev/authority@0.3.5` and `codefly.dev/session@0.2.1`.\n") + + var out bytes.Buffer + err := VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}), + Revision: "dea37afac9adda288c7b9fc7df0dc42a2746e47f"}, &out) + if err != nil { + t.Fatalf("an agreeing tree failed: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "codefly.dev/authority") || !strings.Contains(out.String(), "dea37afac9ad") { + t.Fatalf("the report names neither the interface nor the pin:\n%s", out.String()) + } + + out.Reset() + err = VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.4", "session": "0.2.1"})}, &out) + if err == nil || !strings.Contains(err.Error(), "codefly.dev/authority declared 0.3.5, published 0.3.4") { + t.Fatalf("a declaration above the pin passed: %v\n%s", err, out.String()) + } + + err = VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}), + Revision: "0000000000000000000000000000000000000000"}, &out) + if err == nil || !strings.Contains(err.Error(), "pins dea37afac9ad") { + t.Fatalf("a tree read at another revision than the pin passed: %v", err) + } +} diff --git a/scripts/ci/host-interface-conformance-main.sh b/scripts/ci/host-interface-conformance-main.sh index 08f5bb837..c0f7f77b3 100755 --- a/scripts/ci/host-interface-conformance-main.sh +++ b/scripts/ci/host-interface-conformance-main.sh @@ -5,13 +5,28 @@ # adds, or a rule the authority core changes, reaches accounts on its next pull # request instead of on the next re-pin. A main that does not contain the pin # (the pin is a branch not merged yet) runs at the pin and says so. +# +# Before the suite, at the pin and before any re-pin, the declaration gate: the +# interface versions module.codefly.yaml declares accounts implements +# (`implements: codefly.dev/@`) are held to the version each +# interface publishes at the pinned revision, the revision whose suite accounts' +# own tests run. "Declares 0.3.5" and "passes 0.3.5's suite" are one fact, or +# this job is red. set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" cd "$repo_root/module/services/accounts/code" export GOWORK=off pinned="$(go list -m -f '{{.Version}}' github.com/codefly-dev/authority/libraries/authority-core/go)" -pinned_sha="${pinned##*-}" +# The commit the pseudo-version abbreviates, in full, from the module cache's +# origin record: GitHub serves a commit by its full id alone, and the record is +# written when the module is downloaded, so this reads nothing new. +pinned_commit="$(go mod download -json "github.com/codefly-dev/authority/libraries/authority-core/go@${pinned}" \ + | sed -n 's/^[[:space:]]*"Hash": *"\([0-9a-f]\{40\}\)".*$/\1/p')" +if [ -z "$pinned_commit" ]; then + echo "::error::cannot resolve the commit behind the authority pin ${pinned}" + exit 1 +fi # Ancestry is read from a blobless clone of authority's main, never from the # GitHub API: an unauthenticated API call shares a small per-address budget on # hosted runners, and a check that fails when that budget runs out is a flake. @@ -30,20 +45,35 @@ for attempt in 1 2 3; do fi sleep $((attempt * 10)) done +# The pinned commit itself, whether or not main contains it: a pin on an +# unmerged branch is reachable through that branch, and GitHub serves any +# reachable commit by its full id. A commit authority no longer carries leaves +# the declaration unprovable at its source, which is a red, not a skip. +if ! git -C "$authority/main.git" fetch -q --filter=blob:none origin "$pinned_commit"; then + echo "::error::codefly-dev/authority no longer carries the pinned commit ${pinned_commit}; the declared interface versions cannot be read at the pin" + exit 1 +fi main_sha="$(git -C "$authority/main.git" rev-parse --short=12 HEAD)" -if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_sha" "$main_sha" 2>/dev/null; then +if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_commit" "$main_sha"; then status="ahead" - [ "$(git -C "$authority/main.git" rev-parse --short=12 "$pinned_sha")" = "$main_sha" ] && status="identical" + [ "$(git -C "$authority/main.git" rev-parse --short=12 "$pinned_commit")" = "$main_sha" ] && status="identical" else status="diverged" fi -echo "authority pinned at ${pinned_sha}; main at ${main_sha}; main is ${status} of the pin" +echo "authority pinned at ${pinned_commit:0:12}; main at ${main_sha}; main is ${status} of the pin" + +# The declaration gate reads interfaces//interface.codefly.yaml at the +# pin; the blobless clone fetches those few blobs on demand. +mkdir "$authority/pinned" +git -C "$authority/main.git" archive --format=tar "$pinned_commit" interfaces | tar -x -C "$authority/pinned" +(cd "$repo_root/module/tools" && go run ./cmd/module-package verify-host-interfaces --module .. --interfaces "$authority/pinned/interfaces" --revision "$pinned_commit") + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then for library in authority-core conformance authority-client delivery resources; do go get "github.com/codefly-dev/authority/libraries/${library}/go@${main_sha}" done go mod tidy else - echo "::notice::authority main does not contain the pinned revision ${pinned_sha}; the suite runs at the pin" + echo "::notice::authority main does not contain the pinned revision ${pinned_commit:0:12}; the suite runs at the pin" fi go test -count=1 -timeout 30m -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters From 83174b9d15c9aa8ebed791ef092e099c08df084c Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:49:35 -0500 Subject: [PATCH 2/2] ci(accounts): a declaration in another major.minor than the pinned suite is red, and a replaced authority library is refused Review findings on #1292: a declared version below the published one was a notice whatever its minor, yet a 0.4.0 declaration under a pin publishing 0.5.0 ran no 0.4.0 suite; only a patch below within the same major.minor is now a notice, every other mismatch fails. ReadAuthorityPin read the require lines alone, so a replace or exclude of an authority library (the shape authority's own services use to point at local directories) left the pin naming nothing that runs; both are refused, read with the strict modfile.Parse, since ParseLax drops those directives. The conformance script resolves the pinned commit in the clone it already has and falls back to the module cache's origin record, and the fetch by full id, only for a pin main does not contain. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 6 +-- RELEASE_GATES.md | 2 +- module/services/accounts/HOST_INTERFACE.md | 26 ++++++----- module/tools/modulepackage/host_interfaces.go | 43 ++++++++++++++----- .../modulepackage/host_interfaces_test.go | 29 ++++++++++++- scripts/ci/host-interface-conformance-main.sh | 38 ++++++++-------- 6 files changed, 100 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 605f28a8d..58d3c1f8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,9 +373,9 @@ jobs: # the pinned revision runs with accounts' tests in the quality phases. # Before the suite, the declaration gate (`module-package # verify-host-interfaces`): every interface version module.codefly.yaml - # declares is at or below the version that interface publishes at the pinned - # authority revision, so a declaration names nothing the pinned suite did not - # prove. + # declares is the version that interface publishes at the pinned authority + # revision, or a patch below it within the same major.minor, so a declaration + # names no suite the pin did not run. host-interface-conformance: name: Host interface conformance (authority main) runs-on: ubuntu-latest diff --git a/RELEASE_GATES.md b/RELEASE_GATES.md index a077a291c..20650158f 100644 --- a/RELEASE_GATES.md +++ b/RELEASE_GATES.md @@ -445,7 +445,7 @@ reverse, fails the `release-contract` job. | `authz-coverage` | the generated authorization catalog: RBAC coverage, audit coverage, and permission no-broadening against `main` | `node module/tools/authz-coverage-gate.mjs`, plus `go test` for the gateway header-lockstep and adapter enforcement tests | | `release-contract` | this gating graph itself, and the Dependabot configuration that feeds it | `node --test scripts/ci/release-gates.test.mjs`, `node --test scripts/ci/dependabot-coverage.test.mjs`, `node scripts/ci/release-gates.mjs check`, `node scripts/ci/dependabot-coverage.mjs check` | | `docs-sync` | the generated interface docs and the story-trace tests (#516) | `node module/tools/interface-docs-gate.mjs check`, `node module/tools/story-trace-gate.mjs tests` | -| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation, session, artifact) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request; and every interface version `module.codefly.yaml` declares is at or below the version that interface publishes at the pinned authority revision, so a declaration names nothing the pinned suite did not prove | `bash scripts/ci/host-interface-conformance-main.sh`, which fetches the pinned authority commit, runs `go run ./cmd/module-package verify-host-interfaces` (in `module/tools`) over its `interfaces/` tree, then re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | +| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation, session, artifact) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request; and every interface version `module.codefly.yaml` declares is the version that interface publishes at the pinned authority revision, or a patch below it within the same major.minor, so a declaration names no suite the pin did not run | `bash scripts/ci/host-interface-conformance-main.sh`, which resolves the pinned authority commit in its clone of main (fetching it by full id when main does not contain it), runs `go run ./cmd/module-package verify-host-interfaces` (in `module/tools`) over its `interfaces/` tree, then re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | | `kit-version` | the published frontend kit's version moves whenever its content does, since a registry version is immutable once served | `node --test scripts/ci/kit-version.test.mjs`, `node scripts/ci/kit-version.mjs check` | | `marketing` | the marketing runtime builds in isolation from the app, and the public config is current | `node module/tools/generate-public-config.mjs --check`, `node module/tools/marketing-extraction.mjs` | | `sdk-boundary` | the root module's own tests, the Codefly SDK boundary, single-invocation protocol generation, the exported API contract, and the published module-authority client against the real host | `go test ./...` **in the root module only**, `go test codefly_sdk_boundary_test.go`, `codefly generate contracts saas-starter --check`, `codefly generate runnables saas-starter --check`, `go test ./...` in `qualification/module-authority` | diff --git a/module/services/accounts/HOST_INTERFACE.md b/module/services/accounts/HOST_INTERFACE.md index c6c11d5d4..ceb43ef8e 100644 --- a/module/services/accounts/HOST_INTERFACE.md +++ b/module/services/accounts/HOST_INTERFACE.md @@ -166,17 +166,21 @@ behavior-preserving rollback. verify-host-interfaces` (in `module/tools`, with its own tests in `modulepackage/host_interfaces_test.go`). It reads `interfaces//interface.codefly.yaml` at the pinned authority commit, - fetched by its full id from the clone the job already makes, and refuses: a - version `module/module.codefly.yaml` declares above the one published there - (a claim no suite proved); an interface the pin does not publish; authority - libraries pinned at two revisions (`go.mod` pins the suite, the core it - decides with, the client, delivery and resources at one commit); and an - opening sentence of this document naming another version than the manifest. - A declared version below the published one is a notice, never a failure: - the pin may move to run a newer suite before the declaration follows it, - and the declaration never moves first. The half that needs no network, one - revision in `go.mod` and this document agreeing with the manifest, also - runs with the `module/tools` tests in the module-package job. + resolved in the clone the job already makes (a pin on an unmerged authority + branch is fetched by its full id instead), and refuses: a version + `module/module.codefly.yaml` declares above the one published there (a + claim no suite proved); a version in another major.minor than the published + one (passing 0.5.0's suite runs no 0.4.0 suite); an interface the pin does + not publish; authority libraries pinned at two revisions, replaced or + excluded (`go.mod` pins the suite, the core it decides with, the client, + delivery and resources at one commit, and the require line must be what + runs); and an opening sentence of this document naming another version than + the manifest. A declared patch below the published one, within the same + major.minor, is a notice, never a failure: the pin may move to run a newer + patch's suite before the declaration follows it, and the declaration never + moves first. The half that needs no network, one revision in `go.mod` and + this document agreeing with the manifest, also runs with the `module/tools` + tests in the module-package job. - Last recorded run: authority `dea37afac9ad` (its main on 2026-10-30, the revision `go.mod` pins for all five authority libraries), from `module/services/accounts/code`, on 2026-11-03: diff --git a/module/tools/modulepackage/host_interfaces.go b/module/tools/modulepackage/host_interfaces.go index 94c98ce25..34f171686 100644 --- a/module/tools/modulepackage/host_interfaces.go +++ b/module/tools/modulepackage/host_interfaces.go @@ -4,10 +4,12 @@ package modulepackage // service implements (`implements: codefly.dev/@`), and the // service's go.mod pins the codefly-dev/authority revision whose conformance // suite its tests run. The two are one fact only if each declared version is -// at or below the version the interface publishes at that revision: a -// declaration above it names a version no suite proved. A declaration below it -// under-claims, which is reported and never failed, because moving the pin to -// run a newer suite must not force the declaration before that suite is green. +// the version the interface publishes at that revision, or a patch below it +// within the same major.minor: a declaration above it names a version no suite +// proved, and a declaration in another major.minor names a suite that did not +// run (passing 0.5.0's suite says nothing about 0.4.0's). A patch below is +// reported and never failed, because moving the pin to run a newer patch's +// suite must not force the declaration before that suite is green. import ( "context" @@ -139,16 +141,29 @@ func parseDeclaration(service, entry string) (HostInterfaceDeclaration, error) { // ReadAuthorityPin reads the authority revision a go.mod pins: every direct // requirement under AuthorityLibraryPrefix must be a pseudo-version, and all of // them must abbreviate one commit, because the suite, the core it decides -// with and the client are run together. +// with and the client are run together. A replace or an exclude of an +// authority library is refused: the require line would then name nothing the +// suite runs (authority's own services replace these libraries with local +// directories, which is right there and never here). func ReadAuthorityPin(goModPath string) (AuthorityPin, error) { data, err := os.ReadFile(goModPath) if err != nil { return AuthorityPin{}, err } - file, err := modfile.ParseLax(goModPath, data, nil) + file, err := modfile.Parse(goModPath, data, nil) if err != nil { return AuthorityPin{}, err } + for _, replace := range file.Replace { + if strings.HasPrefix(replace.Old.Path, AuthorityLibraryPrefix) { + return AuthorityPin{}, fmt.Errorf("%s replaces %s with %s, so its require line names nothing the suite runs; a replaced authority library is refused", goModPath, replace.Old.Path, strings.TrimSpace(replace.New.Path+" "+replace.New.Version)) + } + } + for _, exclude := range file.Exclude { + if strings.HasPrefix(exclude.Mod.Path, AuthorityLibraryPrefix) { + return AuthorityPin{}, fmt.Errorf("%s excludes %s %s, which moves the authority libraries away from the revision the require lines name; an excluded authority library is refused", goModPath, exclude.Mod.Path, exclude.Mod.Version) + } + } var pin AuthorityPin revisions := map[string][]string{} for _, req := range file.Require { @@ -249,9 +264,11 @@ func DocumentedHostInterfaces(path string) (map[string][]string, error) { // CompareHostInterfaces judges each declaration against what the pinned // revision publishes and what the service's document says. A failure is a -// declared version above the published one, an interface the revision does -// not publish, a version that is not semver, or a document naming another -// version; a declared version below the published one is a notice. +// declared version above the published one, a declared version in another +// major.minor than the published one (no suite of that minor ran), an +// interface the revision does not publish, a version that is not semver, or a +// document naming another version; a declared patch below the published one, +// within the same major.minor, is a notice. func CompareHostInterfaces(declared []HostInterfaceDeclaration, published map[string]PublishedInterface, documented map[string][]string) []HostInterfaceFinding { var findings []HostInterfaceFinding for _, d := range declared { @@ -288,7 +305,13 @@ func CompareHostInterfaces(declared []HostInterfaceDeclaration, published map[st finding.Failure = true finding.Reason = "the declaration claims a version the pinned suite does not prove; move the pin to a revision publishing it and pass that suite first" case -1: - finding.Reason = fmt.Sprintf("the pinned suite proves %s; declaring it is a manifest change backed by this pin", p.Version) + if declaredVersion.Major() != publishedVersion.Major() || declaredVersion.Minor() != publishedVersion.Minor() { + finding.Failure = true + finding.Reason = fmt.Sprintf("the pinned suite is %s's and no %d.%d suite ran, so the declaration is unproven; pin a revision publishing %d.%d, or declare %s once its suite is green", + p.Version, declaredVersion.Major(), declaredVersion.Minor(), declaredVersion.Major(), declaredVersion.Minor(), p.Version) + } else { + finding.Reason = fmt.Sprintf("the pinned suite proves %s, a patch above the declaration; declaring it is a manifest change backed by this pin", p.Version) + } } if versions, ok := documented[d.ID()]; ok { if len(versions) != 1 || versions[0] != d.Version { diff --git a/module/tools/modulepackage/host_interfaces_test.go b/module/tools/modulepackage/host_interfaces_test.go index 6f73381c1..aaa54c9b2 100644 --- a/module/tools/modulepackage/host_interfaces_test.go +++ b/module/tools/modulepackage/host_interfaces_test.go @@ -83,8 +83,29 @@ require ( github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f // indirect + github.com/example/other v1.2.0 ) + +replace github.com/example/other => ../other `}, + "a replaced library": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) + +replace github.com/codefly-dev/authority/libraries/conformance/go => ../../../../../libraries/conformance/go +`, want: "replaces github.com/codefly-dev/authority/libraries/conformance/go with ../../../../../libraries/conformance/go"}, + "an excluded library": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) + +exclude github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026111022-6870db87da8b +`, want: "excludes github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026111022-6870db87da8b"}, "two revisions": {goMod: `module accounts require ( @@ -170,13 +191,15 @@ func TestCompareHostInterfacesJudgesEachDeclaration(t *testing.T) { } findings := CompareHostInterfaces([]HostInterfaceDeclaration{ declare("authority", "0.3.5"), // equal: ok - declare("session", "0.2.0"), // below: a notice + declare("session", "0.2.0"), // a patch below, same major.minor: a notice declare("installation", "0.5.0"), // above: not proven + declare("installation", "0.3.0"), // another major.minor below: no 0.3 suite ran + declare("authority", "0.2.9"), // another major.minor below, higher patch: no 0.2 suite ran declare("artifact", "0.1.0"), // not published at the pin declare("composition", "0.2"), // not semver {Service: "accounts", Publisher: "example.com", Name: "other", Version: "1.0.0"}, }, published, nil) - if len(findings) != 6 { + if len(findings) != 8 { t.Fatalf("%d findings: %+v", len(findings), findings) } expect := []struct { @@ -186,6 +209,8 @@ func TestCompareHostInterfacesJudgesEachDeclaration(t *testing.T) { {false, ""}, {false, "the pinned suite proves 0.2.1"}, {true, "claims a version the pinned suite does not prove"}, + {true, "no 0.3 suite ran"}, + {true, "no 0.2 suite ran"}, {true, "publishes no such interface"}, {true, "not semver"}, {false, "not published by codefly-dev/authority"}, diff --git a/scripts/ci/host-interface-conformance-main.sh b/scripts/ci/host-interface-conformance-main.sh index c0f7f77b3..8727f5ba5 100755 --- a/scripts/ci/host-interface-conformance-main.sh +++ b/scripts/ci/host-interface-conformance-main.sh @@ -11,22 +11,16 @@ # (`implements: codefly.dev/@`) are held to the version each # interface publishes at the pinned revision, the revision whose suite accounts' # own tests run. "Declares 0.3.5" and "passes 0.3.5's suite" are one fact, or -# this job is red. +# this job is red: a declaration above the published version, or in another +# major.minor than it, names a suite that did not run. A patch below it, within +# the same major.minor, is a notice. set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" cd "$repo_root/module/services/accounts/code" export GOWORK=off pinned="$(go list -m -f '{{.Version}}' github.com/codefly-dev/authority/libraries/authority-core/go)" -# The commit the pseudo-version abbreviates, in full, from the module cache's -# origin record: GitHub serves a commit by its full id alone, and the record is -# written when the module is downloaded, so this reads nothing new. -pinned_commit="$(go mod download -json "github.com/codefly-dev/authority/libraries/authority-core/go@${pinned}" \ - | sed -n 's/^[[:space:]]*"Hash": *"\([0-9a-f]\{40\}\)".*$/\1/p')" -if [ -z "$pinned_commit" ]; then - echo "::error::cannot resolve the commit behind the authority pin ${pinned}" - exit 1 -fi +pinned_short="${pinned##*-}" # Ancestry is read from a blobless clone of authority's main, never from the # GitHub API: an unauthenticated API call shares a small per-address budget on # hosted runners, and a check that fails when that budget runs out is a flake. @@ -45,13 +39,23 @@ for attempt in 1 2 3; do fi sleep $((attempt * 10)) done -# The pinned commit itself, whether or not main contains it: a pin on an -# unmerged branch is reachable through that branch, and GitHub serves any -# reachable commit by its full id. A commit authority no longer carries leaves +# The pinned commit in full. A pin main contains resolves in the clone. A pin +# on an unmerged authority branch does not: its full id is then read from the +# module cache's origin record (written when the module was downloaded, so +# this reads nothing new) and the commit is fetched by that id, which GitHub +# serves for any reachable commit. A commit authority no longer carries leaves # the declaration unprovable at its source, which is a red, not a skip. -if ! git -C "$authority/main.git" fetch -q --filter=blob:none origin "$pinned_commit"; then - echo "::error::codefly-dev/authority no longer carries the pinned commit ${pinned_commit}; the declared interface versions cannot be read at the pin" - exit 1 +if ! pinned_commit="$(git -C "$authority/main.git" rev-parse --verify --quiet "${pinned_short}^{commit}")"; then + pinned_commit="$(go mod download -json "github.com/codefly-dev/authority/libraries/authority-core/go@${pinned}" \ + | sed -n 's/^[[:space:]]*"Hash": *"\([0-9a-f]\{40\}\)".*$/\1/p')" + if [ -z "$pinned_commit" ]; then + echo "::error::cannot resolve the commit behind the authority pin ${pinned}: main does not contain ${pinned_short} and the module cache records no origin for it" + exit 1 + fi + if ! git -C "$authority/main.git" fetch -q --filter=blob:none origin "$pinned_commit"; then + echo "::error::codefly-dev/authority no longer carries the pinned commit ${pinned_commit}; the declared interface versions cannot be read at the pin" + exit 1 + fi fi main_sha="$(git -C "$authority/main.git" rev-parse --short=12 HEAD)" if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_commit" "$main_sha"; then @@ -64,7 +68,7 @@ echo "authority pinned at ${pinned_commit:0:12}; main at ${main_sha}; main is ${ # The declaration gate reads interfaces//interface.codefly.yaml at the # pin; the blobless clone fetches those few blobs on demand. -mkdir "$authority/pinned" +mkdir -p "$authority/pinned" git -C "$authority/main.git" archive --format=tar "$pinned_commit" interfaces | tar -x -C "$authority/pinned" (cd "$repo_root/module/tools" && go run ./cmd/module-package verify-host-interfaces --module .. --interfaces "$authority/pinned/interfaces" --revision "$pinned_commit")