Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
83 changes: 82 additions & 1 deletion .github/actions/validate-platform-compatibility/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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`);
Expand Down
23 changes: 23 additions & 0 deletions .github/compatibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 58 additions & 0 deletions .github/compatibility/platform-release.owners.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 34 additions & 3 deletions ci/github/compatibility_manifest_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}
}