diff --git a/.github/actions/validate-platform-compatibility/action.yaml b/.github/actions/validate-platform-compatibility/action.yaml index 53bb07e..f6f1d94 100644 --- a/.github/actions/validate-platform-compatibility/action.yaml +++ b/.github/actions/validate-platform-compatibility/action.yaml @@ -15,4 +15,5 @@ runs: set -euo pipefail node "${GITHUB_ACTION_PATH}/validate.mjs" \ --schema "${GITHUB_ACTION_PATH}/../../compatibility/platform-release.schema.json" \ + --owners "${GITHUB_ACTION_PATH}/../../compatibility/platform-release.owners.json" \ "${COMPATIBILITY_MANIFEST}" diff --git a/.github/actions/validate-platform-compatibility/validate.mjs b/.github/actions/validate-platform-compatibility/validate.mjs index e750141..53cb46c 100644 --- a/.github/actions/validate-platform-compatibility/validate.mjs +++ b/.github/actions/validate-platform-compatibility/validate.mjs @@ -7,17 +7,20 @@ function die(message) { const args = process.argv.slice(2); let schemaPath; +let ownersPath; let manifestPath; for (let i = 0; i < args.length; i += 1) { if (args[i] === "--schema") { schemaPath = args[++i]; + } else if (args[i] === "--owners") { + ownersPath = args[++i]; } else if (!manifestPath) { manifestPath = args[i]; } else { die(`unexpected argument ${args[i]}`); } } -if (!schemaPath || !manifestPath) die("usage: validate.mjs --schema SCHEMA MANIFEST"); +if (!schemaPath || !ownersPath || !manifestPath) die("usage: validate.mjs --schema SCHEMA --owners OWNERS MANIFEST"); function regularFile(path, name) { let stat; @@ -43,6 +46,7 @@ const schema = readJSON(schemaPath, "schema"); if (schema.$id !== "https://libops.io/schemas/platform-release.v1.json") { die("unsupported schema identity"); } +const owners = readJSON(ownersPath, "owner map"); const manifest = readJSON(manifestPath, "manifest"); const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value); @@ -66,6 +70,83 @@ function exactKeys(value, path, required) { } } +function resolveSchema(node) { + if (!object(node) || typeof node.$ref !== "string") return node; + if (!node.$ref.startsWith("#/$defs/")) die(`schema contains unsupported reference ${node.$ref}`); + const name = node.$ref.slice("#/$defs/".length); + const resolved = schema.$defs?.[name]; + if (!object(resolved)) die(`schema reference ${node.$ref} does not resolve`); + return resolved; +} + +function schemaLeafPaths(node, path = "") { + const resolved = resolveSchema(node); + if (object(resolved.properties)) { + return Object.entries(resolved.properties).flatMap(([name, property]) => schemaLeafPaths(property, `${path}/${name}`)); + } + if (resolved.type === "array" && object(resolved.items)) { + return schemaLeafPaths(resolved.items, `${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"); +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-developer-experience", + "libops-devsecops", + "libops-platform-coo", + "libops-platform-engineering", + "libops-site-reliability", +]); +const declaredOwnerPaths = new Set(); +for (const [index, field] of owners.fieldOwners.entries()) { + exactKeys(field, `owner map fieldOwners[${index}]`, ["path", "owner"]); + if (typeof field.path !== "string" || !field.path.startsWith("/")) die(`owner map fieldOwners[${index}].path is invalid`); + if (!allowedOwners.has(field.owner)) die(`owner map fieldOwners[${index}].owner is not accountable`); + if (declaredOwnerPaths.has(field.path)) die(`owner map duplicates ${field.path}`); + declaredOwnerPaths.add(field.path); +} + +const schemaPaths = [...new Set(schemaLeafPaths(schema))].sort(); +const ownerPaths = [...declaredOwnerPaths].sort(); +if (schemaPaths.join("\0") !== ownerPaths.join("\0")) { + const missing = schemaPaths.filter((path) => !declaredOwnerPaths.has(path)); + const extra = ownerPaths.filter((path) => !schemaPaths.includes(path)); + die(`owner map must cover every schema field exactly (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`); +} + +const signingRoles = [ + "candidateProducer", + "promotedManifestSigner", + "promotionApprover", + "signatureVerifier", + "applicationEvidenceApprover", + "recoveryEvidenceApprover", +]; +exactKeys(owners.signingOwners, "owner map signingOwners", signingRoles); +for (const role of signingRoles) { + if (!allowedOwners.has(owners.signingOwners[role])) die(`owner map signingOwners.${role} is not accountable`); +} + +const applicationOwnerSkills = new Set([ + "archivesspace-expert", + "drupal-expert", + "islandora-expert", + "ojs-expert", + "omeka-expert", + "wordpress-expert", +]); +exactKeys(owners.applicationFamilyOwners, "owner map applicationFamilyOwners", [...families]); +for (const family of families) { + if (!applicationOwnerSkills.has(owners.applicationFamilyOwners[family])) { + die(`owner map applicationFamilyOwners.${family} 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`); diff --git a/.github/compatibility/README.md b/.github/compatibility/README.md index d314409..2cc714e 100644 --- a/.github/compatibility/README.md +++ b/.github/compatibility/README.md @@ -22,3 +22,26 @@ 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. + +## Signing and approval ownership + +- `libops-devsecops` produces candidate manifests, owns the keyless promoted- + manifest signer, and verifies its signature. +- `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. +- `libops-site-reliability` approves smoke, recovery, and hosted-canary + evidence. + +These are accountable roles, not long-lived signing keys. The promoted-manifest +signer must use a short-lived GitHub Actions OIDC identity bound to a reviewed, +SHA-pinned workflow. A candidate manifest is not promoted merely because it +validates. Until that signer and its verification receipt are present, retain +the manifest as `candidate`; do not represent it as an immutable signed release. diff --git a/.github/compatibility/platform-release.owners.json b/.github/compatibility/platform-release.owners.json new file mode 100644 index 0000000..c96cf3f --- /dev/null +++ b/.github/compatibility/platform-release.owners.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "schemaId": "https://libops.io/schemas/platform-release.v1.json", + "fieldOwners": [ + {"path": "/schemaVersion", "owner": "libops-devsecops"}, + {"path": "/release/id", "owner": "libops-devsecops"}, + {"path": "/release/status", "owner": "libops-devsecops"}, + {"path": "/sitectl/repository", "owner": "libops-developer-experience"}, + {"path": "/sitectl/commit", "owner": "libops-developer-experience"}, + {"path": "/sitectl/package", "owner": "libops-developer-experience"}, + {"path": "/sitectl/version", "owner": "libops-developer-experience"}, + {"path": "/sharedSmokeWorkflow/repository", "owner": "libops-devsecops"}, + {"path": "/sharedSmokeWorkflow/commit", "owner": "libops-devsecops"}, + {"path": "/applications/*/family", "owner": "application-family-owner"}, + {"path": "/applications/*/plugin/repository", "owner": "application-family-owner"}, + {"path": "/applications/*/plugin/commit", "owner": "application-family-owner"}, + {"path": "/applications/*/plugin/package", "owner": "application-family-owner"}, + {"path": "/applications/*/plugin/version", "owner": "application-family-owner"}, + {"path": "/applications/*/composeTemplate/repository", "owner": "application-family-owner"}, + {"path": "/applications/*/composeTemplate/commit", "owner": "application-family-owner"}, + {"path": "/applications/*/composeTemplate/contractRevision", "owner": "application-family-owner"}, + {"path": "/applications/*/composeTemplate/contractDigest", "owner": "application-family-owner"}, + {"path": "/applications/*/cloudComposePreset/repository", "owner": "libops-platform-engineering"}, + {"path": "/applications/*/cloudComposePreset/commit", "owner": "libops-platform-engineering"}, + {"path": "/applications/*/cloudComposePreset/preset", "owner": "libops-platform-engineering"}, + {"path": "/applications/*/images/*/service", "owner": "application-family-owner"}, + {"path": "/applications/*/images/*/reference", "owner": "application-family-owner"}, + {"path": "/applications/*/images/*/source/repository", "owner": "application-family-owner"}, + {"path": "/applications/*/images/*/source/commit", "owner": "application-family-owner"}, + {"path": "/applications/*/images/*/attestations/certificateIdentity", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/callerWorkflowRef", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/sbom/predicateType", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/sbom/platforms/*", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/sbom/verificationRun", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/provenance/predicateType", "owner": "libops-devsecops"}, + {"path": "/applications/*/images/*/attestations/provenance/verificationRun", "owner": "libops-devsecops"}, + {"path": "/applications/*/evidence/contractTestRun", "owner": "application-family-owner"}, + {"path": "/applications/*/evidence/smokeTestRun", "owner": "libops-site-reliability"}, + {"path": "/applications/*/evidence/verifyChecks/*", "owner": "application-family-owner"} + ], + "applicationFamilyOwners": { + "archivesspace": "archivesspace-expert", + "drupal": "drupal-expert", + "islandora": "islandora-expert", + "ojs": "ojs-expert", + "omeka-classic": "omeka-expert", + "omeka-s": "omeka-expert", + "wordpress": "wordpress-expert" + }, + "signingOwners": { + "candidateProducer": "libops-devsecops", + "promotedManifestSigner": "libops-devsecops", + "promotionApprover": "libops-platform-coo", + "signatureVerifier": "libops-devsecops", + "applicationEvidenceApprover": "application-family-owner", + "recoveryEvidenceApprover": "libops-site-reliability" + } +} diff --git a/README.md b/README.md index 0389368..358daa9 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,9 @@ 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 `.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. ## Pull request status aggregation diff --git a/ci/github/compatibility_manifest_contract_test.go b/ci/github/compatibility_manifest_contract_test.go index a994fec..765e2d6 100644 --- a/ci/github/compatibility_manifest_contract_test.go +++ b/ci/github/compatibility_manifest_contract_test.go @@ -25,8 +25,9 @@ func TestPlatformReleaseSchemaAndValidatorContract(t *testing.T) { } validator := filepath.Join(root, ".github/actions/validate-platform-compatibility/validate.mjs") + ownersPath := filepath.Join(root, ".github/compatibility/platform-release.owners.json") valid := filepath.Join(root, "ci/github/testdata/platform-release.valid.json") - command := exec.Command("node", validator, "--schema", schemaPath, valid) + command := exec.Command("node", validator, "--schema", schemaPath, "--owners", ownersPath, valid) output, err := command.CombinedOutput() if err != nil { t.Fatalf("valid compatibility manifest rejected: %v\n%s", err, output) @@ -51,7 +52,8 @@ func TestPlatformReleaseValidatorRejectsMutableImage(t *testing.T) { validator := filepath.Join(root, ".github/actions/validate-platform-compatibility/validate.mjs") schemaPath := filepath.Join(root, ".github/compatibility/platform-release.schema.json") - command := exec.Command("node", validator, "--schema", schemaPath, invalidPath) + 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 image unexpectedly accepted: %s", output) @@ -78,7 +80,8 @@ func TestPlatformReleaseValidatorRejectsIncompleteSBOMCoverage(t *testing.T) { validator := filepath.Join(root, ".github/actions/validate-platform-compatibility/validate.mjs") schemaPath := filepath.Join(root, ".github/compatibility/platform-release.schema.json") - command := exec.Command("node", validator, "--schema", schemaPath, invalidPath) + 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 SBOM coverage unexpectedly accepted: %s", output) @@ -87,3 +90,31 @@ func TestPlatformReleaseValidatorRejectsIncompleteSBOMCoverage(t *testing.T) { t.Fatalf("unexpected validator failure: %s", output) } } + +func TestPlatformReleaseValidatorRejectsIncompleteOwnerMap(t *testing.T) { + root := githubRepositoryRoot(t) + ownersPath := filepath.Join(root, ".github/compatibility/platform-release.owners.json") + contents, err := os.ReadFile(ownersPath) + if err != nil { + t.Fatal(err) + } + contents = []byte(strings.Replace(string(contents), + " {\"path\": \"/release/status\", \"owner\": \"libops-devsecops\"},\n", + "", 1)) + invalidOwnersPath := filepath.Join(t.TempDir(), "incomplete-owners.json") + if err := os.WriteFile(invalidOwnersPath, 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") + validPath := filepath.Join(root, "ci/github/testdata/platform-release.valid.json") + command := exec.Command("node", validator, "--schema", schemaPath, "--owners", invalidOwnersPath, validPath) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("incomplete owner map unexpectedly accepted: %s", output) + } + if !strings.Contains(string(output), "missing: /release/status") { + t.Fatalf("unexpected validator failure: %s", output) + } +}