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
4 changes: 2 additions & 2 deletions .github/actions/validate-platform-compatibility/action.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
124 changes: 102 additions & 22 deletions .github/actions/validate-platform-compatibility/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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`);
Expand All @@ -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");
Expand All @@ -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`);
Expand Down
37 changes: 24 additions & 13 deletions .github/compatibility/README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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

Expand All @@ -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.

Expand Down
50 changes: 48 additions & 2 deletions .github/compatibility/platform-release.owners.json
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down Expand Up @@ -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",
Expand Down
Loading