feat(ql3): gate cluster releases through published catalogs

This commit is contained in:
whyour
2026-08-18 00:24:13 +08:00
parent 8496b56bed
commit 9cd42ff454
9 changed files with 737 additions and 15 deletions
+104 -6
View File
@@ -410,6 +410,8 @@ function auditReleaseWorkflow(source) {
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
const publishJob = workflow?.jobs?.publish;
const releaseSetJob = workflow?.jobs?.['release-set'];
const catalogDeploymentJob =
workflow?.jobs?.['release-catalog-deployment-live'];
if (
publishJob?.strategy?.matrix?.include !==
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
@@ -471,6 +473,12 @@ function auditReleaseWorkflow(source) {
'id-token': 'write',
attestations: 'write',
'artifact-metadata': 'write',
}) ||
JSON.stringify(catalogDeploymentJob?.permissions) !==
JSON.stringify({
contents: 'read',
packages: 'read',
attestations: 'read',
})
) {
throw new Error(
@@ -769,6 +777,85 @@ function auditReleaseWorkflow(source) {
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
);
}
const catalogDeploymentSteps = catalogDeploymentJob?.steps;
if (
catalogDeploymentJob?.needs !== 'release-set' ||
catalogDeploymentJob?.if !==
"always() && needs.release-set.result == 'success' && inputs.release_scope != 'local'" ||
catalogDeploymentJob?.['runs-on'] !== 'ubuntu-24.04' ||
catalogDeploymentJob?.['timeout-minutes'] !== 30 ||
!Array.isArray(catalogDeploymentSteps) ||
catalogDeploymentSteps.length !== 11 ||
catalogDeploymentSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
catalogDeploymentSteps[0]?.with?.['persist-credentials'] !== false ||
catalogDeploymentSteps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
catalogDeploymentSteps[1]?.with?.['node-version'] !== '24.18.0' ||
!/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts/u.test(
catalogDeploymentSteps[2]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test(
catalogDeploymentSteps[3]?.run ?? '',
) ||
catalogDeploymentSteps[4]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
!/v1\.34\.3\/bin\/linux\/amd64\/kubectl[\s\S]*kubectl\.sha256[\s\S]*sha256sum/u.test(
catalogDeploymentSteps[5]?.run ?? '',
) ||
catalogDeploymentSteps[6]?.id !== 'catalog-consumption' ||
!/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
catalogDeploymentSteps[6]?.run ?? '',
) ||
!/docker pull[\s\S]*rancher\/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c[\s\S]*docker tag[\s\S]*rancher\/k3s:v1\.34\.3-k3s1/u.test(
catalogDeploymentSteps[7]?.run ?? '',
) ||
JSON.stringify(catalogDeploymentSteps[8]?.env) !==
JSON.stringify({
QL3_KUBECTL_BIN: '${{ runner.temp }}/kubectl',
QL3_DEPLOYMENT_LIVE_REPORT:
'${{ runner.temp }}/ql3-release-catalog-deployment/report.json',
QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE:
'${{ steps.catalog-consumption.outputs.bundle }}',
QL3_RELEASE_SOURCE_REVISION: '${{ github.sha }}',
QL3_RELEASE_SOURCE_REF: '${{ github.ref }}',
QL3_RELEASE_SCOPE: '${{ inputs.release_scope }}',
QL3_RELEASE_REPOSITORY_OWNER:
'${{ steps.catalog-consumption.outputs.repository-owner }}',
QL3_RELEASE_SOURCE_REPOSITORY:
'${{ steps.catalog-consumption.outputs.source-repository }}',
}) ||
!/ql3-kubernetes-deployment-live-contract\.cjs[\s\S]*verified_release_catalog[\s\S]*releaseSetDigest[\s\S]*catalogManifestDigest[\s\S]*catalogConsumptionDigest/u.test(
catalogDeploymentSteps[8]?.run ?? '',
) ||
catalogDeploymentSteps[9]?.if !== 'always()' ||
!/docker ps -aq --filter name=ql3-deploy-live-[\s\S]*docker network ls -q --filter name=ql3-deploy-live-/u.test(
catalogDeploymentSteps[9]?.run ?? '',
) ||
catalogDeploymentSteps[10]?.if !== 'always()' ||
catalogDeploymentSteps[10]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(catalogDeploymentSteps[10]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-catalog-deployment/report.json',
'if-no-files-found': 'warn',
'retention-days': 90,
'compression-level': 9,
overwrite: false,
'include-hidden-files': false,
}) ||
catalogDeploymentSteps.some(
(step) =>
/(?:artifact put|\bimage copy\b|cosign sign)/u.test(step.run ?? '') ||
step.uses?.startsWith('actions/attest@') ||
step.uses?.startsWith('docker/login-action@'),
)
) {
throw new Error(
'cluster release must read the newly published immutable catalog, reconstruct its deployment lock and prove install plus fenced retirement on isolated K3s without publication authority',
);
}
if (
!Array.isArray(evidenceJob?.steps) ||
evidenceJob.steps.length !== 3 ||
@@ -856,20 +943,20 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
6,
7,
'all release jobs must pin the reviewed immutable checkout action',
);
requireOccurrences(
source,
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
6,
7,
'all release jobs must pin the reviewed immutable Node setup action',
);
requireOccurrences(
source,
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
2,
'image and release-catalog publishers must both pin the reviewed Cosign installer',
3,
'image publisher, catalog publisher and catalog consumer must pin the reviewed Cosign installer',
);
requirePattern(
source,
@@ -909,8 +996,8 @@ function auditReleaseWorkflow(source) {
requireExactOccurrences(
source,
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
2,
'both image and release-set publishers must checksum-pin the exact regctl OCI copier',
3,
'image publisher, release-set publisher and catalog consumer must checksum-pin the exact regctl OCI copier and reader',
);
requirePattern(
source,
@@ -1103,6 +1190,15 @@ function auditReleaseWorkflow(source) {
immutableDigestAuthority: 'verified',
receiptAttested: true,
},
catalogDeploymentGate: {
scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
deploymentLockReconstructed: true,
k3sNodes: 3,
installReceiptAudited: true,
fencedRetirementReceiptAudited: true,
publicationAuthority: false,
},
localRolloutPreflight: true,
localRolloutApply: true,
postPublishVerification: [
@@ -1114,6 +1210,8 @@ function auditReleaseWorkflow(source) {
'release-candidate',
'release-set',
'durable-catalog',
'catalog-consumption',
'catalog-bound-k3s-deployment',
'release-tags',
],
};
@@ -15,6 +15,10 @@ const {
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
const { K3sDockerLiveFixture } = require('./lib/ql3-k3s-docker-live.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const { createKubernetesLock } = require('./ql3-deployment-lock-contract.cjs');
const {
auditCeremonyBundle,
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
const ROOT = path.resolve(__dirname, '..');
const VERSION = readReleaseIdentity(ROOT).version;
@@ -203,6 +207,162 @@ function lockedArtifacts() {
});
}
function catalogSourceManifest(imageReferences) {
const annotations = {
'qinglong.io/live-contract': 'verified-release-catalog',
};
const deployment = (role) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: `ql3-${role}`,
namespace: NAMESPACE,
annotations,
},
spec: {
replicas: 0,
selector: { matchLabels: { 'app.kubernetes.io/name': `ql3-${role}` } },
template: {
metadata: {
labels: { 'app.kubernetes.io/name': `ql3-${role}` },
annotations,
},
spec: {
containers: [
{
name: role,
image: imageReferences[role],
command: ['/bin/false'],
},
],
},
},
},
});
return `${[
{
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: NAMESPACE },
},
...ROLE_ORDER.map(deployment),
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-plugin-package-secret-action-admission',
namespace: NAMESPACE,
annotations,
},
data: { image: imageReferences.admin },
},
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-retirement-live-target',
namespace: NAMESPACE,
annotations,
},
data: { purpose: 'catalog-bound-retirement-live-contract' },
},
]
.map((resource) => JSON.stringify(resource))
.join('\n---\n')}\n`;
}
function catalogLockedArtifacts(options, dependencies = {}) {
const audit = dependencies.auditCeremonyBundle ?? auditCeremonyBundle;
const materialize = dependencies.createKubernetesLock ?? createKubernetesLock;
const identity = Object.freeze({
version: VERSION,
sourceRevision: options?.sourceRevision,
sourceRef: options?.sourceRef,
releaseScope: options?.releaseScope,
repositoryOwner: options?.repositoryOwner,
sourceRepository: options?.sourceRepository,
});
if (!['cluster', 'all'].includes(identity.releaseScope)) {
fail('release catalog live deployment requires cluster or all scope');
}
const consumption = audit({
...identity,
outputDirectory: options?.consumptionBundle,
});
const imageReferences = Object.freeze(
Object.fromEntries(
ROLE_ORDER.map((role) => {
const images = consumption.releaseSet?.images?.filter(
(image) => image.name === role,
);
if (images?.length !== 1) {
fail(`verified release catalog is missing one ${role} image`);
}
return [role, images[0].reference];
}),
),
);
const sourceManifest = catalogSourceManifest(imageReferences);
const created = materialize(consumption.releaseSet, sourceManifest, {
...identity,
consumption,
requiredImages: ROLE_ORDER.join(','),
});
return Object.freeze({
manifest: created.outputManifest,
report: created.report,
imageReferences,
releaseAuthority: Object.freeze({
mode: 'verified_release_catalog',
version: VERSION,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: identity.releaseScope,
releaseSetDigest: consumption.releaseSetDigest,
catalogManifestDigest: consumption.catalogManifestDigest,
catalogConsumptionDigest: consumption.contentDigest,
immutableReference: consumption.immutableReference,
}),
});
}
function deploymentArtifacts(environment = process.env, dependencies = {}) {
const configuration = {
consumptionBundle: environment.QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE,
sourceRevision: environment.QL3_RELEASE_SOURCE_REVISION,
sourceRef: environment.QL3_RELEASE_SOURCE_REF,
releaseScope: environment.QL3_RELEASE_SCOPE,
repositoryOwner: environment.QL3_RELEASE_REPOSITORY_OWNER,
sourceRepository: environment.QL3_RELEASE_SOURCE_REPOSITORY,
};
const values = Object.values(configuration);
const configured = values.filter(
(value) => typeof value === 'string' && value.length > 0,
).length;
if (configured === 0) {
const artifacts = lockedArtifacts();
return Object.freeze({
...artifacts,
releaseAuthority: Object.freeze({
mode: 'synthetic_live_fixture',
version: artifacts.report.release.version,
sourceRevision: artifacts.report.release.sourceRevision,
sourceRef: artifacts.report.release.sourceRef,
scope: artifacts.report.release.scope,
releaseSetDigest: artifacts.report.releaseSetDigest,
catalogManifestDigest: artifacts.report.catalog.manifestDigest,
catalogConsumptionDigest:
artifacts.report.catalog.consumptionReportDigest,
immutableReference: artifacts.report.catalog.immutableReference,
}),
});
}
if (configured !== values.length) {
fail('release catalog live deployment configuration is incomplete');
}
return catalogLockedArtifacts(configuration, dependencies);
}
function writeCommand(directory, name, operation, request) {
return privateFile(
directory,
@@ -216,10 +376,10 @@ function writeCommand(directory, name, operation, request) {
);
}
async function main() {
async function main(environment = process.env) {
const fixture = new K3sDockerLiveFixture({
prefix: 'ql3-deploy-live',
kubectl: process.env.QL3_KUBECTL_BIN,
kubectl: environment.QL3_KUBECTL_BIN,
});
let evidence;
let cleanupComplete = false;
@@ -241,7 +401,7 @@ async function main() {
kind: 'Namespace',
metadata: { name: NAMESPACE },
});
const artifacts = lockedArtifacts();
const artifacts = deploymentArtifacts(environment);
const manifestPath = privateFile(
ceremonyDirectory,
'locked.yaml',
@@ -503,6 +663,7 @@ async function main() {
deploymentDigest: deploymentHead.deployment.deploymentDigest,
resourceInventoryCount: deploymentHead.deployment.resources.length,
},
releaseAuthority: artifacts.releaseAuthority,
preflightDigest: preflight.preflightDigest,
receiptDigest: receipt.receiptDigest,
receiptAuditCompatible: audit.compatible,
@@ -545,4 +706,9 @@ if (require.main === module) {
});
}
module.exports = Object.freeze({ lockedArtifacts, main });
module.exports = Object.freeze({
catalogLockedArtifacts,
deploymentArtifacts,
lockedArtifacts,
main,
});