From 540dca6e11723e05ce52935e3a0bd021a5c40702 Mon Sep 17 00:00:00 2001 From: libops-agent <115990865+libops-agent@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:19:27 +0000 Subject: [PATCH] [minor] Require first-customer release tuple --- .../action.yaml | 4 +- .../validate.mjs | 124 ++++++++++++++---- .github/compatibility/README.md | 37 ++++-- .../platform-release.owners.json | 50 ++++++- .../platform-release.schema.json | 79 ++++++++++- README.md | 24 ++-- .../compatibility_manifest_contract_test.go | 65 ++++++++- .../testdata/platform-release.valid.json | 43 +++++- 8 files changed, 372 insertions(+), 54 deletions(-) diff --git a/.github/actions/validate-platform-compatibility/action.yaml b/.github/actions/validate-platform-compatibility/action.yaml index f6f1d94..ad6e75f 100644 --- a/.github/actions/validate-platform-compatibility/action.yaml +++ b/.github/actions/validate-platform-compatibility/action.yaml @@ -1,5 +1,5 @@ -name: Validate LibOps platform compatibility manifest -description: Validate an immutable platform release tuple and its test evidence. +name: Validate LibOps first-customer platform manifest +description: Validate the immutable first-customer release tuple and its hosted evidence. inputs: manifest: description: Repository-relative path to the platform compatibility manifest. diff --git a/.github/actions/validate-platform-compatibility/validate.mjs b/.github/actions/validate-platform-compatibility/validate.mjs index 53cb46c..e3ee55c 100644 --- a/.github/actions/validate-platform-compatibility/validate.mjs +++ b/.github/actions/validate-platform-compatibility/validate.mjs @@ -43,7 +43,7 @@ function readJSON(path, name) { } const schema = readJSON(schemaPath, "schema"); -if (schema.$id !== "https://libops.io/schemas/platform-release.v1.json") { +if (schema.$id !== "https://libops.io/schemas/platform-release.v2.json") { die("unsupported schema identity"); } const owners = readJSON(ownersPath, "owner map"); @@ -90,17 +90,23 @@ function schemaLeafPaths(node, path = "") { return [path]; } -exactKeys(owners, "owner map", ["schemaVersion", "schemaId", "fieldOwners", "applicationFamilyOwners", "signingOwners"]); -if (owners.schemaVersion !== 1 || owners.schemaId !== schema.$id) die("owner map must bind schema version 1 and its exact identity"); +exactKeys(owners, "owner map", ["schemaVersion", "schemaId", "fieldOwners", "applicationFamilyOwners", "platformComponentOwners", "signingOwners"]); +if (owners.schemaVersion !== 2 || owners.schemaId !== schema.$id) die("owner map must bind schema version 2 and its exact identity"); if (!Array.isArray(owners.fieldOwners) || owners.fieldOwners.length === 0) die("owner map fieldOwners must not be empty"); const allowedOwners = new Set([ "application-family-owner", + "libops-api-core", + "libops-customer-vault", "libops-developer-experience", "libops-devsecops", + "libops-edge-routing", "libops-platform-coo", "libops-platform-engineering", + "libops-provisioning-control-plane", "libops-site-reliability", + "libops-task-agent-platform", + "platform-component-owner", ]); const declaredOwnerPaths = new Set(); for (const [index, field] of owners.fieldOwners.entries()) { @@ -147,6 +153,40 @@ for (const family of families) { } } +const platformComponents = new Set([ + "api", + "api-init", + "api-vault-agent", + "cli-sandbox", + "control-plane", + "controller-ingress", + "edge-controller", + "edge-provider-mutator", + "gcp-vm-ip-controller", + "ppb", + "site-controller", + "site-router", + "task-agent-model-gateway", + "terraform-runner", + "vault-init", + "vault-proxy", + "vault-server", +]); +const platformOwnerSkills = new Set([ + "libops-api-core", + "libops-customer-vault", + "libops-edge-routing", + "libops-platform-engineering", + "libops-provisioning-control-plane", + "libops-task-agent-platform", +]); +exactKeys(owners.platformComponentOwners, "owner map platformComponentOwners", [...platformComponents]); +for (const component of platformComponents) { + if (!platformOwnerSkills.has(owners.platformComponentOwners[component])) { + die(`owner map platformComponentOwners.${component} is not accountable`); + } +} + function source(value, path, extra = []) { exactKeys(value, path, ["repository", "commit", ...extra]); if (!repository.test(value.repository)) die(`${path}.repository must be an exact GitHub repository URL`); @@ -159,11 +199,67 @@ function packageSource(value, path) { if (!semver.test(value.version)) die(`${path}.version must be exact SemVer without a v prefix`); } -exactKeys(manifest, "manifest", ["schemaVersion", "release", "sitectl", "sharedSmokeWorkflow", "applications"]); -if (manifest.schemaVersion !== 1) die("schemaVersion must be 1"); +function validateImage(item, path) { + exactKeys(item, path, ["service", "reference", "source", "attestations"]); + if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.service)) die(`${path}.service is invalid`); + if (!image.test(item.reference)) die(`${path}.reference must contain an exact tag and sha256 digest`); + source(item.source, `${path}.source`); + const attestationsPath = `${path}.attestations`; + exactKeys(item.attestations, attestationsPath, ["certificateIdentity", "callerWorkflowRef", "sbom", "provenance"]); + if (!certificateIdentity.test(item.attestations.certificateIdentity)) die(`${attestationsPath}.certificateIdentity must bind the exact shared publisher commit`); + if (!callerWorkflowRef.test(item.attestations.callerWorkflowRef)) die(`${attestationsPath}.callerWorkflowRef must identify the caller workflow and ref`); + exactKeys(item.attestations.sbom, `${attestationsPath}.sbom`, ["predicateType", "platforms", "verificationRun"]); + if (item.attestations.sbom.predicateType !== "https://spdx.dev/Document") die(`${attestationsPath}.sbom.predicateType is invalid`); + if (!Array.isArray(item.attestations.sbom.platforms) || [...item.attestations.sbom.platforms].sort().join("\0") !== "linux/amd64\0linux/arm64") { + die(`${attestationsPath}.sbom.platforms must contain exactly linux/amd64 and linux/arm64`); + } + if (!runURL.test(item.attestations.sbom.verificationRun)) die(`${attestationsPath}.sbom.verificationRun is invalid`); + exactKeys(item.attestations.provenance, `${attestationsPath}.provenance`, ["predicateType", "verificationRun"]); + if (item.attestations.provenance.predicateType !== "https://slsa.dev/provenance/v1") die(`${attestationsPath}.provenance.predicateType is invalid`); + if (!runURL.test(item.attestations.provenance.verificationRun)) die(`${attestationsPath}.provenance.verificationRun is invalid`); +} + +exactKeys(manifest, "manifest", ["schemaVersion", "release", "terraform", "platformImages", "skillsBundle", "hostedEvidence", "sitectl", "sharedSmokeWorkflow", "applications"]); +if (manifest.schemaVersion !== 2) die("schemaVersion must be 2"); exactKeys(manifest.release, "release", ["id", "status"]); if (!/^[0-9]{4}\.[0-9]+(?:\.[0-9]+)?$/.test(manifest.release.id)) die("release.id is invalid"); if (!["candidate", "promoted", "revoked"].includes(manifest.release.status)) die("release.status is invalid"); +source(manifest.terraform, "terraform"); +if (!Array.isArray(manifest.platformImages)) die("platformImages must be an array"); +const seenPlatformComponents = new Set(); +for (const [index, item] of manifest.platformImages.entries()) { + const path = `platformImages[${index}]`; + exactKeys(item, path, ["component", "image", "contractTestRun"]); + if (!platformComponents.has(item.component)) die(`${path}.component is not required for the first-customer tuple`); + if (seenPlatformComponents.has(item.component)) die(`${path}.component duplicates ${item.component}`); + seenPlatformComponents.add(item.component); + validateImage(item.image, `${path}.image`); + const expectedService = item.component === "task-agent-model-gateway" ? "task-agent-ollama-glm-5-2-cloud" : item.component; + if (item.image.service !== expectedService) die(`${path}.image.service must be ${expectedService}`); + if (!runURL.test(item.contractTestRun)) die(`${path}.contractTestRun is invalid`); +} +const missingPlatformComponents = [...platformComponents].filter((component) => !seenPlatformComponents.has(component)); +if (missingPlatformComponents.length > 0 || seenPlatformComponents.size !== platformComponents.size) { + die(`platformImages must contain every required component exactly once (missing: ${missingPlatformComponents.join(", ") || "none"})`); +} +exactKeys(manifest.skillsBundle, "skillsBundle", ["source", "manifestDigest", "contractTestRun"]); +source(manifest.skillsBundle.source, "skillsBundle.source"); +if (!digest.test(manifest.skillsBundle.manifestDigest)) die("skillsBundle.manifestDigest must be sha256"); +if (!runURL.test(manifest.skillsBundle.contractTestRun)) die("skillsBundle.contractTestRun is invalid"); +const hostedEvidenceKeys = [ + "onboardingCujRun", + "githubInstallCujRun", + "slackInstallCujRun", + "vaultRecoveryRun", + "edgeRoutingCujRun", + "taskAgentCujRun", + "mariadbRecoveryRun", + "rollbackRun", +]; +exactKeys(manifest.hostedEvidence, "hostedEvidence", hostedEvidenceKeys); +for (const name of hostedEvidenceKeys) { + if (!runURL.test(manifest.hostedEvidence[name])) die(`hostedEvidence.${name} is invalid`); +} packageSource(manifest.sitectl, "sitectl"); source(manifest.sharedSmokeWorkflow, "sharedSmokeWorkflow"); if (!Array.isArray(manifest.applications) || manifest.applications.length === 0) die("applications must not be empty"); @@ -187,25 +283,9 @@ for (const [index, app] of manifest.applications.entries()) { const services = new Set(); for (const [imageIndex, item] of app.images.entries()) { const imagePath = `${path}.images[${imageIndex}]`; - exactKeys(item, imagePath, ["service", "reference", "source", "attestations"]); - if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.service)) die(`${imagePath}.service is invalid`); if (services.has(item.service)) die(`${imagePath}.service duplicates ${item.service}`); services.add(item.service); - if (!image.test(item.reference)) die(`${imagePath}.reference must contain an exact tag and sha256 digest`); - source(item.source, `${imagePath}.source`); - const attestationsPath = `${imagePath}.attestations`; - exactKeys(item.attestations, attestationsPath, ["certificateIdentity", "callerWorkflowRef", "sbom", "provenance"]); - if (!certificateIdentity.test(item.attestations.certificateIdentity)) die(`${attestationsPath}.certificateIdentity must bind the exact shared publisher commit`); - if (!callerWorkflowRef.test(item.attestations.callerWorkflowRef)) die(`${attestationsPath}.callerWorkflowRef must identify the caller workflow and ref`); - exactKeys(item.attestations.sbom, `${attestationsPath}.sbom`, ["predicateType", "platforms", "verificationRun"]); - if (item.attestations.sbom.predicateType !== "https://spdx.dev/Document") die(`${attestationsPath}.sbom.predicateType is invalid`); - if (!Array.isArray(item.attestations.sbom.platforms) || [...item.attestations.sbom.platforms].sort().join("\0") !== "linux/amd64\0linux/arm64") { - die(`${attestationsPath}.sbom.platforms must contain exactly linux/amd64 and linux/arm64`); - } - if (!runURL.test(item.attestations.sbom.verificationRun)) die(`${attestationsPath}.sbom.verificationRun is invalid`); - exactKeys(item.attestations.provenance, `${attestationsPath}.provenance`, ["predicateType", "verificationRun"]); - if (item.attestations.provenance.predicateType !== "https://slsa.dev/provenance/v1") die(`${attestationsPath}.provenance.predicateType is invalid`); - if (!runURL.test(item.attestations.provenance.verificationRun)) die(`${attestationsPath}.provenance.verificationRun is invalid`); + validateImage(item, imagePath); } exactKeys(app.evidence, `${path}.evidence`, ["contractTestRun", "smokeTestRun", "verifyChecks"]); if (!runURL.test(app.evidence.contractTestRun)) die(`${path}.evidence.contractTestRun is invalid`); diff --git a/.github/compatibility/README.md b/.github/compatibility/README.md index 2cc714e..b1b0d83 100644 --- a/.github/compatibility/README.md +++ b/.github/compatibility/README.md @@ -1,12 +1,19 @@ # Platform compatibility manifests Every promoted platform release must publish a manifest conforming to -`platform-release.schema.json`. The manifest is the exact, reviewable set that -was tested together: sitectl and plugin package versions, source commits, -Compose template contract digest, cloud-compose preset commit, container image -digests and source commits, exact shared/caller publisher identities, verified -SPDX SBOMs for both native platforms, verified SLSA v1 provenance, the shared -smoke workflow commit, and links to contract and behavioral test evidence. +`platform-release.schema.json`. Version 2 is the first-customer release +contract. The manifest is the exact, reviewable set that was tested together: + +- the Terraform source commit; +- all 17 required API, controller, runner, Vault, edge, PPB, Task Agent, and + sandbox images, each with source, digest, publisher identity, SBOM, + provenance, and contract-test evidence; +- the canonical skills source commit and embedded-manifest digest; +- hosted onboarding, GitHub-install, Slack-install, Vault-recovery, + edge-routing, Task Agent, MariaDB-recovery, and rollback runs; +- sitectl and plugin package versions, source commits, Compose template + contract digests, cloud-compose preset commits, and the shared smoke workflow + commit. Validate a candidate before promotion: @@ -16,19 +23,21 @@ Validate a candidate before promotion: manifest: .libops/platform-release.json ``` -Tags without digests, images without exact source and attestation evidence, -movable source commits, incomplete native-platform SBOM coverage, missing -strict verifier checks, and evidence that is not a GitHub Actions run URL are -rejected. Application family and image service names must also be unique. A +Tags without digests, missing first-customer images, images without exact +source and attestation evidence, mutable skills sources, incomplete hosted +evidence, movable source commits, incomplete native-platform SBOM coverage, +missing strict verifier checks, and evidence that is not a GitHub Actions run +URL are rejected. Application family and image service names must also be unique. A candidate may be superseded; a promoted manifest is immutable and must be marked `revoked` rather than edited if a released tuple proves unsafe. `platform-release.owners.json` assigns every leaf field in the schema to one accountable owner. The validator derives the leaf paths from the schema and fails if the owner map is missing a field, names an extra field, duplicates a -path, or uses an unrecognized owner. `application-family-owner` resolves through -the required `family` value to exactly one specialist skill in the same file. -Schema changes and ownership changes therefore cannot drift independently. +path, or uses an unrecognized owner. `application-family-owner` and +`platform-component-owner` resolve through the required family or component to +exactly one specialist skill in the same file. Schema changes and ownership +changes therefore cannot drift independently. ## Signing and approval ownership @@ -37,6 +46,8 @@ Schema changes and ownership changes therefore cannot drift independently. - `libops-platform-coo` approves promotion after the release gates are met. - The resolved application-family owner approves that application's source, package, template, image, and contract evidence. +- The resolved platform-component owner approves that runtime image's source + and contract evidence. - `libops-site-reliability` approves smoke, recovery, and hosted-canary evidence. diff --git a/.github/compatibility/platform-release.owners.json b/.github/compatibility/platform-release.owners.json index c96cf3f..ff2c261 100644 --- a/.github/compatibility/platform-release.owners.json +++ b/.github/compatibility/platform-release.owners.json @@ -1,10 +1,37 @@ { - "schemaVersion": 1, - "schemaId": "https://libops.io/schemas/platform-release.v1.json", + "schemaVersion": 2, + "schemaId": "https://libops.io/schemas/platform-release.v2.json", "fieldOwners": [ {"path": "/schemaVersion", "owner": "libops-devsecops"}, {"path": "/release/id", "owner": "libops-devsecops"}, {"path": "/release/status", "owner": "libops-devsecops"}, + {"path": "/terraform/repository", "owner": "libops-provisioning-control-plane"}, + {"path": "/terraform/commit", "owner": "libops-provisioning-control-plane"}, + {"path": "/platformImages/*/component", "owner": "platform-component-owner"}, + {"path": "/platformImages/*/image/service", "owner": "platform-component-owner"}, + {"path": "/platformImages/*/image/reference", "owner": "platform-component-owner"}, + {"path": "/platformImages/*/image/source/repository", "owner": "platform-component-owner"}, + {"path": "/platformImages/*/image/source/commit", "owner": "platform-component-owner"}, + {"path": "/platformImages/*/image/attestations/certificateIdentity", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/callerWorkflowRef", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/sbom/predicateType", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/sbom/platforms/*", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/sbom/verificationRun", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/provenance/predicateType", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/image/attestations/provenance/verificationRun", "owner": "libops-devsecops"}, + {"path": "/platformImages/*/contractTestRun", "owner": "platform-component-owner"}, + {"path": "/skillsBundle/source/repository", "owner": "libops-task-agent-platform"}, + {"path": "/skillsBundle/source/commit", "owner": "libops-task-agent-platform"}, + {"path": "/skillsBundle/manifestDigest", "owner": "libops-task-agent-platform"}, + {"path": "/skillsBundle/contractTestRun", "owner": "libops-task-agent-platform"}, + {"path": "/hostedEvidence/onboardingCujRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/githubInstallCujRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/slackInstallCujRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/vaultRecoveryRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/edgeRoutingCujRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/taskAgentCujRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/mariadbRecoveryRun", "owner": "libops-site-reliability"}, + {"path": "/hostedEvidence/rollbackRun", "owner": "libops-site-reliability"}, {"path": "/sitectl/repository", "owner": "libops-developer-experience"}, {"path": "/sitectl/commit", "owner": "libops-developer-experience"}, {"path": "/sitectl/package", "owner": "libops-developer-experience"}, @@ -47,6 +74,25 @@ "omeka-s": "omeka-expert", "wordpress": "wordpress-expert" }, + "platformComponentOwners": { + "api": "libops-api-core", + "api-init": "libops-api-core", + "api-vault-agent": "libops-api-core", + "cli-sandbox": "libops-task-agent-platform", + "control-plane": "libops-provisioning-control-plane", + "controller-ingress": "libops-provisioning-control-plane", + "edge-controller": "libops-edge-routing", + "edge-provider-mutator": "libops-edge-routing", + "gcp-vm-ip-controller": "libops-platform-engineering", + "ppb": "libops-edge-routing", + "site-controller": "libops-provisioning-control-plane", + "site-router": "libops-edge-routing", + "task-agent-model-gateway": "libops-task-agent-platform", + "terraform-runner": "libops-provisioning-control-plane", + "vault-init": "libops-customer-vault", + "vault-proxy": "libops-customer-vault", + "vault-server": "libops-customer-vault" + }, "signingOwners": { "candidateProducer": "libops-devsecops", "promotedManifestSigner": "libops-devsecops", diff --git a/.github/compatibility/platform-release.schema.json b/.github/compatibility/platform-release.schema.json index ab8fd91..5941006 100644 --- a/.github/compatibility/platform-release.schema.json +++ b/.github/compatibility/platform-release.schema.json @@ -1,13 +1,22 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://libops.io/schemas/platform-release.v1.json", + "$id": "https://libops.io/schemas/platform-release.v2.json", "title": "LibOps platform compatibility release", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "release", "sitectl", "sharedSmokeWorkflow", "applications"], + "required": ["schemaVersion", "release", "terraform", "platformImages", "skillsBundle", "hostedEvidence", "sitectl", "sharedSmokeWorkflow", "applications"], "properties": { - "schemaVersion": { "const": 1 }, + "schemaVersion": { "const": 2 }, "release": { "$ref": "#/$defs/release" }, + "terraform": { "$ref": "#/$defs/source" }, + "platformImages": { + "type": "array", + "minItems": 17, + "maxItems": 17, + "items": { "$ref": "#/$defs/platformImage" } + }, + "skillsBundle": { "$ref": "#/$defs/skillsBundle" }, + "hostedEvidence": { "$ref": "#/$defs/hostedEvidence" }, "sitectl": { "$ref": "#/$defs/packageSource" }, "sharedSmokeWorkflow": { "$ref": "#/$defs/source" }, "applications": { @@ -96,6 +105,70 @@ "attestations": { "$ref": "#/$defs/attestations" } } }, + "platformImage": { + "type": "object", + "additionalProperties": false, + "required": ["component", "image", "contractTestRun"], + "properties": { + "component": { + "enum": [ + "api", + "api-init", + "api-vault-agent", + "cli-sandbox", + "control-plane", + "controller-ingress", + "edge-controller", + "edge-provider-mutator", + "gcp-vm-ip-controller", + "ppb", + "site-controller", + "site-router", + "task-agent-model-gateway", + "terraform-runner", + "vault-init", + "vault-proxy", + "vault-server" + ] + }, + "image": { "$ref": "#/$defs/image" }, + "contractTestRun": { "$ref": "#/$defs/runURL" } + } + }, + "skillsBundle": { + "type": "object", + "additionalProperties": false, + "required": ["source", "manifestDigest", "contractTestRun"], + "properties": { + "source": { "$ref": "#/$defs/source" }, + "manifestDigest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "contractTestRun": { "$ref": "#/$defs/runURL" } + } + }, + "hostedEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "onboardingCujRun", + "githubInstallCujRun", + "slackInstallCujRun", + "vaultRecoveryRun", + "edgeRoutingCujRun", + "taskAgentCujRun", + "mariadbRecoveryRun", + "rollbackRun" + ], + "properties": { + "onboardingCujRun": { "$ref": "#/$defs/runURL" }, + "githubInstallCujRun": { "$ref": "#/$defs/runURL" }, + "slackInstallCujRun": { "$ref": "#/$defs/runURL" }, + "vaultRecoveryRun": { "$ref": "#/$defs/runURL" }, + "edgeRoutingCujRun": { "$ref": "#/$defs/runURL" }, + "taskAgentCujRun": { "$ref": "#/$defs/runURL" }, + "mariadbRecoveryRun": { "$ref": "#/$defs/runURL" }, + "rollbackRun": { "$ref": "#/$defs/runURL" } + } + }, "attestations": { "type": "object", "additionalProperties": false, diff --git a/README.md b/README.md index 358daa9..cd696b0 100644 --- a/README.md +++ b/README.md @@ -113,20 +113,24 @@ merged workflow SHA to callers. ## Platform compatibility manifests `.github/compatibility/platform-release.schema.json` defines the promotion -record for one tested platform set. It binds exact sitectl and plugin package -versions to source commits, a Compose template contract digest, a cloud-compose -preset commit, container image tag-and-digest references, the shared smoke -workflow commit, and links to contract/strict-verification runs. +record for one tested first-customer platform set. It binds the Terraform and +skills source commits, embedded skills-manifest digest, all required managed +runtime images and attestations, hosted CUJ/recovery/rollback runs, exact +sitectl and plugin package versions, Compose template contract digests, +cloud-compose preset commits, and the shared smoke workflow commit. Call the SHA-pinned `.github/actions/validate-platform-compatibility` action on a candidate -manifest before promotion. The validator rejects mutable branches and image -tags, duplicate application families or service entries, unsupported app -families, missing verifier checks, and non-run evidence URLs. See +manifest before promotion. The validator rejects missing first-customer +components, mutable branches and image tags, duplicate application families or +service entries, unsupported app families, incomplete hosted evidence, missing +verifier checks, and non-run evidence URLs. See `.github/compatibility/README.md` for the caller example and lifecycle rules. -The adjacent machine-validated owner map covers every schema leaf and names the -candidate producer, promoted-manifest signer, promotion approver, signature -verifier, application evidence approver, and recovery evidence approver. +The adjacent machine-validated owner map covers every schema leaf, resolves +every platform component and application family to an accountable skill, and +names the candidate producer, promoted-manifest signer, promotion approver, +signature verifier, application evidence approver, and recovery evidence +approver. ## Pull request status aggregation diff --git a/ci/github/compatibility_manifest_contract_test.go b/ci/github/compatibility_manifest_contract_test.go index 765e2d6..076460b 100644 --- a/ci/github/compatibility_manifest_contract_test.go +++ b/ci/github/compatibility_manifest_contract_test.go @@ -20,7 +20,7 @@ func TestPlatformReleaseSchemaAndValidatorContract(t *testing.T) { if err := json.Unmarshal(schemaBytes, &schema); err != nil { t.Fatalf("schema is invalid JSON: %v", err) } - if schema["$id"] != "https://libops.io/schemas/platform-release.v1.json" { + if schema["$id"] != "https://libops.io/schemas/platform-release.v2.json" { t.Fatalf("unexpected schema id: %v", schema["$id"]) } @@ -37,6 +37,69 @@ func TestPlatformReleaseSchemaAndValidatorContract(t *testing.T) { } } +func TestPlatformReleaseValidatorRejectsMissingFirstCustomerImage(t *testing.T) { + root := githubRepositoryRoot(t) + validPath := filepath.Join(root, "ci/github/testdata/platform-release.valid.json") + contents, err := os.ReadFile(validPath) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err := json.Unmarshal(contents, &manifest); err != nil { + t.Fatal(err) + } + images := manifest["platformImages"].([]any) + manifest["platformImages"] = images[1:] + contents, err = json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + invalidPath := filepath.Join(t.TempDir(), "missing-platform-image.json") + if err := os.WriteFile(invalidPath, contents, 0o600); err != nil { + t.Fatal(err) + } + + validator := filepath.Join(root, ".github/actions/validate-platform-compatibility/validate.mjs") + schemaPath := filepath.Join(root, ".github/compatibility/platform-release.schema.json") + ownersPath := filepath.Join(root, ".github/compatibility/platform-release.owners.json") + command := exec.Command("node", validator, "--schema", schemaPath, "--owners", ownersPath, invalidPath) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("incomplete first-customer image tuple unexpectedly accepted: %s", output) + } + if !strings.Contains(string(output), "missing: api") { + t.Fatalf("unexpected validator failure: %s", output) + } +} + +func TestPlatformReleaseValidatorRejectsMutableSkillsSource(t *testing.T) { + root := githubRepositoryRoot(t) + validPath := filepath.Join(root, "ci/github/testdata/platform-release.valid.json") + contents, err := os.ReadFile(validPath) + if err != nil { + t.Fatal(err) + } + contents = []byte(strings.Replace(string(contents), + `"commit": "5555555555555555555555555555555555555555"`, + `"commit": "refs/heads/main"`, 1)) + invalidPath := filepath.Join(t.TempDir(), "mutable-skills.json") + if err := os.WriteFile(invalidPath, contents, 0o600); err != nil { + t.Fatal(err) + } + + validator := filepath.Join(root, ".github/actions/validate-platform-compatibility/validate.mjs") + schemaPath := filepath.Join(root, ".github/compatibility/platform-release.schema.json") + ownersPath := filepath.Join(root, ".github/compatibility/platform-release.owners.json") + command := exec.Command("node", validator, "--schema", schemaPath, "--owners", ownersPath, invalidPath) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("mutable skills source unexpectedly accepted: %s", output) + } + if !strings.Contains(string(output), "skillsBundle.source.commit must be a 40-character lowercase commit SHA") { + t.Fatalf("unexpected validator failure: %s", output) + } +} + func TestPlatformReleaseValidatorRejectsMutableImage(t *testing.T) { root := githubRepositoryRoot(t) validPath := filepath.Join(root, "ci/github/testdata/platform-release.valid.json") diff --git a/ci/github/testdata/platform-release.valid.json b/ci/github/testdata/platform-release.valid.json index 871e996..208b399 100644 --- a/ci/github/testdata/platform-release.valid.json +++ b/ci/github/testdata/platform-release.valid.json @@ -1,6 +1,47 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "release": { "id": "2026.8", "status": "candidate" }, + "terraform": { + "repository": "https://github.com/libops/api", + "commit": "2222222222222222222222222222222222222222" + }, + "platformImages": [ + {"component":"api","image":{"service":"api","reference":"ghcr.io/libops/api:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/200"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/300"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/400"}, + {"component":"api-init","image":{"service":"api-init","reference":"ghcr.io/libops/api-init:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/201"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/301"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/401"}, + {"component":"api-vault-agent","image":{"service":"api-vault-agent","reference":"ghcr.io/libops/api-vault-agent:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/202"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/302"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/402"}, + {"component":"cli-sandbox","image":{"service":"cli-sandbox","reference":"ghcr.io/libops/cli-sandbox:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/cli-sandbox","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/cli-sandbox/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/cli-sandbox/actions/runs/203"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/cli-sandbox/actions/runs/303"}}},"contractTestRun":"https://github.com/libops/cli-sandbox/actions/runs/403"}, + {"component":"control-plane","image":{"service":"control-plane","reference":"ghcr.io/libops/control-plane:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/204"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/304"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/404"}, + {"component":"controller-ingress","image":{"service":"controller-ingress","reference":"ghcr.io/libops/controller-ingress:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/205"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/305"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/405"}, + {"component":"edge-controller","image":{"service":"edge-controller","reference":"ghcr.io/libops/edge-controller:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/206"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/306"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/406"}, + {"component":"edge-provider-mutator","image":{"service":"edge-provider-mutator","reference":"ghcr.io/libops/edge-provider-mutator:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/207"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/307"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/407"}, + {"component":"gcp-vm-ip-controller","image":{"service":"gcp-vm-ip-controller","reference":"ghcr.io/libops/gcp-vm-ip-controller:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/208"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/308"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/408"}, + {"component":"ppb","image":{"service":"ppb","reference":"ghcr.io/libops/ppb:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/ppb","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/ppb/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/ppb/actions/runs/209"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/ppb/actions/runs/309"}}},"contractTestRun":"https://github.com/libops/ppb/actions/runs/409"}, + {"component":"site-controller","image":{"service":"site-controller","reference":"ghcr.io/libops/site-controller:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/210"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/310"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/410"}, + {"component":"site-router","image":{"service":"site-router","reference":"ghcr.io/libops/site-router:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/211"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/311"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/411"}, + {"component":"task-agent-model-gateway","image":{"service":"task-agent-ollama-glm-5-2-cloud","reference":"ghcr.io/libops/task-agent-ollama-glm-5-2-cloud:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/212"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/312"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/412"}, + {"component":"terraform-runner","image":{"service":"terraform-runner","reference":"ghcr.io/libops/terraform-runner:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/api","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/api/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/api/actions/runs/213"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/api/actions/runs/313"}}},"contractTestRun":"https://github.com/libops/api/actions/runs/413"}, + {"component":"vault-init","image":{"service":"vault-init","reference":"ghcr.io/libops/vault-init:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/vault-init","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/vault-init/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/vault-init/actions/runs/214"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/vault-init/actions/runs/314"}}},"contractTestRun":"https://github.com/libops/vault-init/actions/runs/414"}, + {"component":"vault-proxy","image":{"service":"vault-proxy","reference":"ghcr.io/libops/vault-proxy:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/vault-proxy","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/vault-proxy/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/vault-proxy/actions/runs/215"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/vault-proxy/actions/runs/315"}}},"contractTestRun":"https://github.com/libops/vault-proxy/actions/runs/415"}, + {"component":"vault-server","image":{"service":"vault-server","reference":"ghcr.io/libops/vault-server:2026.8@sha256:4444444444444444444444444444444444444444444444444444444444444444","source":{"repository":"https://github.com/libops/terraform-vault-cloudrun","commit":"2222222222222222222222222222222222222222"},"attestations":{"certificateIdentity":"https://github.com/libops/.github/.github/workflows/build-push.yaml@3333333333333333333333333333333333333333","callerWorkflowRef":"libops/terraform-vault-cloudrun/.github/workflows/build.yml@refs/heads/main","sbom":{"predicateType":"https://spdx.dev/Document","platforms":["linux/amd64","linux/arm64"],"verificationRun":"https://github.com/libops/terraform-vault-cloudrun/actions/runs/216"},"provenance":{"predicateType":"https://slsa.dev/provenance/v1","verificationRun":"https://github.com/libops/terraform-vault-cloudrun/actions/runs/316"}}},"contractTestRun":"https://github.com/libops/terraform-vault-cloudrun/actions/runs/416"} + ], + "skillsBundle": { + "source": { + "repository": "https://github.com/libops/skills", + "commit": "5555555555555555555555555555555555555555" + }, + "manifestDigest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "contractTestRun": "https://github.com/libops/api/actions/runs/501" + }, + "hostedEvidence": { + "onboardingCujRun": "https://github.com/libops/api/actions/runs/601", + "githubInstallCujRun": "https://github.com/libops/api/actions/runs/602", + "slackInstallCujRun": "https://github.com/libops/api/actions/runs/603", + "vaultRecoveryRun": "https://github.com/libops/api/actions/runs/604", + "edgeRoutingCujRun": "https://github.com/libops/api/actions/runs/605", + "taskAgentCujRun": "https://github.com/libops/api/actions/runs/606", + "mariadbRecoveryRun": "https://github.com/libops/api/actions/runs/607", + "rollbackRun": "https://github.com/libops/api/actions/runs/608" + }, "sitectl": { "repository": "https://github.com/libops/sitectl", "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",