feat(ql3): gate release tags on deployment readiness

This commit is contained in:
whyour
2026-08-18 04:54:10 +08:00
parent a99f1508f7
commit 1c4e3269b4
15 changed files with 2071 additions and 267 deletions
@@ -257,9 +257,10 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'Attest durable release-catalog provenance',
'Verify the durable catalog and create its immutable receipt',
'Attest the immutable release-catalog receipt',
'Promote final tags only after the catalog receipt is attested',
'Close and audit the final public tag set',
'Attest the immutable release publication closure receipt',
'Attest deployment readiness before any final tag mutation',
'Promote final tags only after every required deployment gate',
'Close and audit the deployment-ready public tag set',
'Attest the deployment-ready release publication closure receipt',
],
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
);
+142 -38
View File
@@ -274,7 +274,7 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
);
requirePattern(
@@ -414,6 +414,7 @@ function auditReleaseWorkflow(source) {
workflow?.jobs?.['release-catalog-local-deployment-live'];
const catalogDeploymentJob =
workflow?.jobs?.['release-catalog-deployment-live'];
const finalizationJob = workflow?.jobs?.['release-finalization'];
if (
publishJob?.strategy?.matrix?.include !==
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
@@ -487,6 +488,14 @@ function auditReleaseWorkflow(source) {
contents: 'read',
packages: 'read',
attestations: 'read',
}) ||
JSON.stringify(finalizationJob?.permissions) !==
JSON.stringify({
contents: 'read',
packages: 'write',
'id-token': 'write',
attestations: 'write',
'artifact-metadata': 'write',
})
) {
throw new Error(
@@ -712,7 +721,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps;
if (
!Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 19 ||
releaseSetSteps.length !== 15 ||
releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -793,27 +802,11 @@ function auditReleaseWorkflow(source) {
JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
}) ||
releaseSetSteps[14]?.id !== 'final-publication' ||
!/plan="\$\{BUNDLE\}\/qinglong3-release-publication-plan-[^\n]+[\s\S]*observations="\$\{BUNDLE\}\/qinglong3-release-publication-tag-observation-[^\n]+[\s\S]*receipt="\$\{BUNDLE\}\/qinglong3-release-publication-closure-receipt-[^\n]+[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{RELEASE_SET\}"[\s\S]*--catalog-plan="\$\{CATALOG_PLAN\}"[\s\S]*--catalog-manifest="\$\{CATALOG_MANIFEST\}"[\s\S]*--catalog-manifest-digest="\$\{CATALOG_MANIFEST_DIGEST\}"[\s\S]*--catalog-receipt="\$\{CATALOG_RECEIPT\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[14]?.run ?? '',
) ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/.test(
releaseSetSteps[15]?.run ?? '',
) ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/.test(
releaseSetSteps[16]?.run ?? '',
) ||
releaseSetSteps[17]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[17]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.receipt }}',
}) ||
releaseSetSteps[18]?.uses !==
releaseSetSteps[14]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[18]?.with) !==
JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
name: 'ql3-release-catalog-publisher-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ steps.release-set.outputs.bundle }}',
'if-no-files-found': 'error',
'retention-days': 90,
@@ -823,7 +816,7 @@ function auditReleaseWorkflow(source) {
})
) {
throw new Error(
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
'release-set job must download only same-run records, independently inspect and durably publish one attested immutable catalog without final tag authority',
);
}
const localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps;
@@ -850,7 +843,7 @@ function auditReleaseWorkflow(source) {
localCatalogDeploymentSteps[4]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
localCatalogDeploymentSteps[5]?.id !== 'local-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(
!/private_root="\$\{RUNNER_TEMP\}\/ql3-release-catalog-local-deployment"[\s\S]*bundle="\$\{private_root\}\/consumption"[\s\S]*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(
localCatalogDeploymentSteps[5]?.run ?? '',
) ||
localCatalogDeploymentSteps[6]?.id !== 'local-selection' ||
@@ -915,7 +908,7 @@ function auditReleaseWorkflow(source) {
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(
!/private_root="\$\{RUNNER_TEMP\}\/ql3-release-catalog-deployment"[\s\S]*bundle="\$\{private_root\}\/consumption"[\s\S]*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(
@@ -949,7 +942,7 @@ function auditReleaseWorkflow(source) {
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',
path: '${{ runner.temp }}/ql3-release-catalog-deployment',
'if-no-files-found': 'warn',
'retention-days': 90,
'compression-level': 9,
@@ -967,6 +960,102 @@ function auditReleaseWorkflow(source) {
'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',
);
}
const finalizationSteps = finalizationJob?.steps;
if (
JSON.stringify(finalizationJob?.needs) !==
JSON.stringify([
'release-set',
'release-catalog-local-deployment-live',
'release-catalog-deployment-live',
]) ||
!/always\(\)[\s\S]*needs\.release-set\.result == 'success'[\s\S]*inputs\.release_scope == 'local'[\s\S]*release-catalog-local-deployment-live\.result == 'success'[\s\S]*release-catalog-deployment-live\.result == 'skipped'[\s\S]*inputs\.release_scope == 'cluster'[\s\S]*release-catalog-local-deployment-live\.result == 'skipped'[\s\S]*release-catalog-deployment-live\.result == 'success'[\s\S]*inputs\.release_scope == 'all'[\s\S]*release-catalog-local-deployment-live\.result == 'success'[\s\S]*release-catalog-deployment-live\.result == 'success'/u.test(
finalizationJob?.if ?? '',
) ||
finalizationJob?.['runs-on'] !== 'ubuntu-24.04' ||
finalizationJob?.['timeout-minutes'] !== 30 ||
!Array.isArray(finalizationSteps) ||
finalizationSteps.length !== 16 ||
finalizationSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
finalizationSteps[0]?.with?.['persist-credentials'] !== false ||
finalizationSteps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
finalizationSteps[1]?.with?.['node-version'] !== '24.18.0' ||
!/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts/u.test(
finalizationSteps[2]?.run ?? '',
) ||
finalizationSteps[3]?.if !== "inputs.release_scope != 'cluster'" ||
finalizationSteps[3]?.uses !==
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
JSON.stringify(finalizationSteps[3]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-finalization/deployment-evidence/local',
}) ||
finalizationSteps[4]?.if !== "inputs.release_scope != 'local'" ||
finalizationSteps[4]?.uses !==
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
JSON.stringify(finalizationSteps[4]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-finalization/deployment-evidence/cluster',
}) ||
!/chmod 0700 "\$\{directory\}"[\s\S]*find "\$\{FINALIZATION_ROOT\}" -type d[\s\S]*chmod 0600 "\$\{file\}"[\s\S]*find "\$\{FINALIZATION_ROOT\}" -type f[\s\S]*local\/edge\.json[\s\S]*local\/standalone\.json[\s\S]*cluster\/report\.json/u.test(
finalizationSteps[5]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test(
finalizationSteps[6]?.run ?? '',
) ||
finalizationSteps[7]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
finalizationSteps[8]?.id !== 'finalizer-consumption' ||
!/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]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
finalizationSteps[8]?.run ?? '',
) ||
finalizationSteps[9]?.id !== 'final-publication' ||
!/observations="\$\{FINALIZATION_ROOT\}\/qinglong3-release-publication-tag-observation-[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{release_set\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*readiness_args=\([\s\S]*--finalizer-consumption-bundle="\$\{FINALIZER_CONSUMPTION\}"[\s\S]*--local-consumption-bundle=[\s\S]*--edge-report=[\s\S]*--standalone-report=[\s\S]*--cluster-consumption-bundle=[\s\S]*--cluster-report=[\s\S]*ql3-release-deployment-readiness-contract\.cjs[\s\S]*--mode=create[\s\S]*--output="\$\{readiness\}"[\s\S]*ql3-release-deployment-readiness-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{readiness\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--deployment-readiness="\$\{readiness\}"[\s\S]*GITHUB_OUTPUT/u.test(
finalizationSteps[9]?.run ?? '',
) ||
finalizationSteps[10]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(finalizationSteps[10]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.readiness }}',
}) ||
finalizationSteps[11]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/u.test(
finalizationSteps[12]?.run ?? '',
) ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/u.test(
finalizationSteps[13]?.run ?? '',
) ||
finalizationSteps[14]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(finalizationSteps[14]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.closure }}',
}) ||
finalizationSteps[15]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(finalizationSteps[15]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.final-publication.outputs.root }}',
'if-no-files-found': 'error',
'retention-days': 90,
'compression-level': 0,
overwrite: false,
'include-hidden-files': false,
}) ||
finalizationSteps
.slice(0, 12)
.some((step) => /\bimage copy\b/u.test(step.run ?? ''))
) {
throw new Error(
'release finalization must independently verify exact scope deployment evidence, attest readiness and only then publish and close final tags',
);
}
if (
!Array.isArray(evidenceJob?.steps) ||
evidenceJob.steps.length !== 4 ||
@@ -1097,20 +1186,20 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
8,
9,
'all release jobs must pin the reviewed immutable checkout action',
);
requireOccurrences(
source,
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
8,
9,
'all release jobs must pin the reviewed immutable Node setup action',
);
requireOccurrences(
source,
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
4,
'image publisher, catalog publisher and both catalog consumers must pin the reviewed Cosign installer',
5,
'image publisher, catalog publisher, both deployment consumers and the finalizer must pin the reviewed Cosign installer',
);
requirePattern(
source,
@@ -1150,8 +1239,8 @@ function auditReleaseWorkflow(source) {
requireExactOccurrences(
source,
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
4,
'image publisher, release-set publisher and both catalog consumers must checksum-pin the exact regctl OCI copier and reader',
5,
'image publisher, release-set publisher, both deployment consumers and the finalizer must checksum-pin the exact regctl OCI copier and reader',
);
requirePattern(
source,
@@ -1171,8 +1260,8 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
7,
'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance',
9,
'release workflow must create four image attestations plus release-set, durable catalog, catalog receipt, deployment readiness and final closure provenance',
);
requireOccurrences(
source,
@@ -1266,8 +1355,8 @@ function auditReleaseWorkflow(source) {
);
requirePattern(
source,
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Materialize the catalog-authorized final tag publication plan[\s\S]*name: Promote final tags only after the catalog receipt is attested[\s\S]*name: Close and audit the final public tag set[\s\S]*name: Attest the immutable release publication closure receipt[\s\S]*name: Publish the deployment digest lock/,
'release tags and the final closure receipt must be published only after the durable catalog is verified and its receipt is attested',
/release-set:\s+name: Close the release set and publish its immutable catalog[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Upload same-run catalog publisher evidence[\s\S]*release-catalog-local-deployment-live:[\s\S]*name: Prove catalog-bound Edge and Standalone rollout[\s\S]*release-catalog-deployment-live:[\s\S]*name: Prove catalog-bound install and fenced retirement on three K3s nodes[\s\S]*release-finalization:[\s\S]*name: Attest deployment readiness before any final tag mutation[\s\S]*name: Promote final tags only after every required deployment gate[\s\S]*name: Close and audit the deployment-ready public tag set[\s\S]*name: Attest the deployment-ready release publication closure receipt[\s\S]*name: Upload the final deployment-ready release bundle/,
'release tags and the final closure receipt must be published only after the durable catalog and every required catalog-bound deployment gate are verified',
);
return {
trigger: 'explicit protected v3 tag dispatch',
@@ -1324,6 +1413,7 @@ function auditReleaseWorkflow(source) {
tagAfterVerification: true,
tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true,
tagAfterRequiredDeploymentGates: true,
boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only',
@@ -1336,7 +1426,7 @@ function auditReleaseWorkflow(source) {
privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'verified_immutable_catalog',
tagPromotionAuthority: 'verified_catalog_bound_deployments',
fileProvenanceAttested: true,
artifactRetentionDays: 90,
crossRepositoryAtomicity: false,
@@ -1364,10 +1454,11 @@ function auditReleaseWorkflow(source) {
receiptAttested: true,
},
finalPublicationClosure: {
planSchema: 'qinglong/release-publication-plan@v1',
planSchema: 'qinglong/release-publication-plan@v2',
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true,
tagsPerImage: 2,
conflictPolicy: 'fail_closed_before_any_tag_mutation',
@@ -1376,6 +1467,16 @@ function auditReleaseWorkflow(source) {
registryTagCas: false,
receiptAttested: true,
},
deploymentReadiness: {
receiptSchema: 'qinglong/release-deployment-readiness-receipt@v1',
scopes: ['local', 'cluster', 'all'],
localProfiles: ['edge', 'standalone'],
clusterNodes: 3,
independentFinalizerCatalogConsumption: true,
exactEvidenceBytesAudited: true,
jobResultOnlyAuthority: false,
receiptAttestedBeforeTagMutation: true,
},
catalogDeploymentGate: {
scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
@@ -1384,6 +1485,7 @@ function auditReleaseWorkflow(source) {
installReceiptAudited: true,
fencedRetirementReceiptAudited: true,
publicationAuthority: false,
requiredForFinalization: true,
},
localCatalogDeploymentGate: {
scopes: ['local', 'all'],
@@ -1393,6 +1495,7 @@ function auditReleaseWorkflow(source) {
rolloutReceiptAudited: true,
gracefulCleanup: true,
publicationAuthority: false,
requiredForFinalization: true,
},
localRolloutPreflight: true,
localRolloutApply: true,
@@ -1408,6 +1511,7 @@ function auditReleaseWorkflow(source) {
'catalog-consumption',
'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment',
'deployment-readiness',
'release-tags',
'release-publication-closure',
],
@@ -0,0 +1,754 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
SCHEMA: CATALOG_CONSUMPTION_SCHEMA,
auditCeremonyBundle,
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
const SCHEMA = 'qinglong/release-deployment-readiness-receipt@v1';
const MAX_REPORT_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const REVISION_PATTERN = /^[a-f0-9]{40}$/u;
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
const REPOSITORY_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
const LOCAL_REPORT_KEYS = Object.freeze([
'schemaVersion',
'profile',
'generation',
'exactRepoDigest',
'composeMerge',
'rolloutActive',
'durableReceipt',
'sqliteWriteContract',
'sqliteBackup',
'sqliteWriteObservation',
'sqliteRestorePrepared',
'sqliteRestoreCommitted',
'sqliteRestoreRolloutRecovered',
'sqliteRestoreReplayUnchanged',
'sqliteEvidenceCollected',
'sqliteCollectedRolloutReplayUnchanged',
'gracefulCleanup',
'releaseAuthority',
'compatible',
]);
const LOCAL_AUTHORITY_KEYS = Object.freeze([
'mode',
'sourceRevision',
'sourceRef',
'scope',
'releaseSetDigest',
'catalogManifestDigest',
'catalogConsumptionDigest',
'selectionDigest',
]);
const CLUSTER_REPORT_KEYS = Object.freeze([
'schemaVersion',
'schema',
'kubernetes',
'deployment',
'releaseAuthority',
'preflightDigest',
'receiptDigest',
'receiptAuditCompatible',
'retirement',
'serverSideDryRun',
'serverSideApply',
'convergenceRead',
'deploymentHeadCas',
'resourceInventoryClosed',
'crossResourceAtomicity',
'cleanupComplete',
]);
const CLUSTER_AUTHORITY_KEYS = Object.freeze([
'mode',
'version',
'sourceRevision',
'sourceRef',
'scope',
'releaseSetDigest',
'catalogManifestDigest',
'catalogConsumptionDigest',
'immutableReference',
]);
const VERIFICATION = Object.freeze({
finalizerCatalogConsumption: 'independently_verified',
deploymentEvidence: 'scope_exact_catalog_bound_live_reports',
reportEncoding: 'bounded_canonical_json',
reportBytesBound: true,
syntheticFixturesAccepted: false,
jobResultOnlyAuthority: false,
tagMutation: false,
});
class QingLong3ReleaseDeploymentReadinessError extends Error {
constructor(message) {
super(`QingLong 3 release deployment readiness failed: ${message}`);
this.name = 'QingLong3ReleaseDeploymentReadinessError';
}
}
function fail(message) {
throw new QingLong3ReleaseDeploymentReadinessError(message);
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function canonicalJson(value) {
return `${JSON.stringify(value)}\n`;
}
function exactKeys(value, expected) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
);
}
function releaseFromIdentity(identity) {
return Object.freeze({
version: identity.version,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: identity.releaseScope,
});
}
function validateIdentity(identity) {
if (
identity === null ||
typeof identity !== 'object' ||
typeof identity.version !== 'string' ||
identity.version.length < 1 ||
identity.version.length > 64 ||
!REVISION_PATTERN.test(identity.sourceRevision || '') ||
identity.sourceRef !== `refs/tags/v${identity.version}` ||
!['local', 'cluster', 'all'].includes(identity.releaseScope) ||
!OWNER_PATTERN.test(identity.repositoryOwner || '') ||
!REPOSITORY_PATTERN.test(identity.sourceRepository || '') ||
!identity.sourceRepository.startsWith(`${identity.repositoryOwner}/`)
) {
fail('release identity is invalid');
}
return releaseFromIdentity(identity);
}
function requiredFamilies(scope) {
return scope === 'local'
? ['local']
: scope === 'cluster'
? ['cluster']
: ['local', 'cluster'];
}
function validateConsumption(consumption, identity, label) {
const expectedImmutable = `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${consumption?.catalogManifestDigest}`;
if (
consumption === null ||
typeof consumption !== 'object' ||
consumption.compatible !== true ||
consumption.releaseScope !== identity.releaseScope ||
consumption.sourceRepository !== identity.sourceRepository ||
consumption.workflowIdentity !==
`https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}` ||
!DIGEST_PATTERN.test(consumption.releaseSetDigest || '') ||
!DIGEST_PATTERN.test(consumption.catalogManifestDigest || '') ||
consumption.immutableReference !== expectedImmutable ||
consumption.discoveryTagAuthority !== 'none' ||
consumption.externalToolResultsReplayed !== false ||
consumption.deploymentMutation !== false ||
!DIGEST_PATTERN.test(consumption.contentDigest || '') ||
consumption.releaseSet?.release?.version !== identity.version ||
consumption.releaseSet?.release?.sourceRevision !==
identity.sourceRevision ||
consumption.releaseSet?.release?.sourceRef !== identity.sourceRef ||
consumption.releaseSet?.release?.scope !== identity.releaseScope ||
consumption.releaseSet?.releaseSetDigest !== consumption.releaseSetDigest
) {
fail(`${label} catalog consumption is invalid`);
}
return consumption;
}
function sameCatalog(authority, consumption, identity, keys) {
return (
exactKeys(authority, keys) &&
authority.mode === 'verified_release_catalog' &&
authority.sourceRevision === identity.sourceRevision &&
authority.sourceRef === identity.sourceRef &&
authority.scope === identity.releaseScope &&
authority.releaseSetDigest === consumption.releaseSetDigest &&
authority.catalogManifestDigest === consumption.catalogManifestDigest &&
authority.catalogConsumptionDigest === consumption.contentDigest
);
}
function validateLocalReport(entry, profile, consumption, identity) {
const report = entry?.value;
if (
!DIGEST_PATTERN.test(entry?.digest || '') ||
!exactKeys(report, LOCAL_REPORT_KEYS) ||
report.schemaVersion !== 1 ||
report.profile !== profile ||
!Number.isSafeInteger(report.generation) ||
report.generation < 1 ||
report.exactRepoDigest !== true ||
report.composeMerge !== true ||
report.rolloutActive !== true ||
report.durableReceipt !== true ||
report.sqliteWriteContract !== 37 ||
report.sqliteBackup !== true ||
report.sqliteRestorePrepared !== true ||
report.sqliteRestoreCommitted !== true ||
report.sqliteRestoreRolloutRecovered !== true ||
report.sqliteRestoreReplayUnchanged !== true ||
report.sqliteEvidenceCollected !== true ||
report.sqliteCollectedRolloutReplayUnchanged !== true ||
report.gracefulCleanup !== true ||
report.compatible !== true ||
!sameCatalog(
report.releaseAuthority,
consumption,
identity,
LOCAL_AUTHORITY_KEYS,
) ||
!DIGEST_PATTERN.test(report.releaseAuthority.selectionDigest || '')
) {
fail(`${profile} deployment evidence is invalid`);
}
return report;
}
function validateClusterReport(entry, consumption, identity) {
const report = entry?.value;
if (
!DIGEST_PATTERN.test(entry?.digest || '') ||
!exactKeys(report, CLUSTER_REPORT_KEYS) ||
report.schemaVersion !== 1 ||
report.schema !== 'qinglong/kubernetes-deployment-live-evidence@v1' ||
report.kubernetes === null ||
typeof report.kubernetes !== 'object' ||
typeof report.kubernetes.serverVersion !== 'string' ||
!/^linux\/(?:amd64|arm64)$/u.test(report.kubernetes.architecture || '') ||
report.kubernetes.nodeCount !== 3 ||
typeof report.kubernetes.clusterUid !== 'string' ||
report.deployment === null ||
typeof report.deployment !== 'object' ||
report.deployment.replicas !== 0 ||
report.deployment.immutableImages !== true ||
report.deployment.headPhase !== 'committed' ||
report.deployment.headGeneration !== 2 ||
!DIGEST_PATTERN.test(report.deployment.deploymentDigest || '') ||
!sameCatalog(
report.releaseAuthority,
consumption,
identity,
CLUSTER_AUTHORITY_KEYS,
) ||
report.releaseAuthority.version !== identity.version ||
report.releaseAuthority.immutableReference !==
consumption.immutableReference ||
!DIGEST_PATTERN.test(report.preflightDigest || '') ||
!DIGEST_PATTERN.test(report.receiptDigest || '') ||
report.receiptAuditCompatible !== true ||
report.retirement === null ||
typeof report.retirement !== 'object' ||
report.retirement.receiptAuditCompatible !== true ||
report.retirement.targetAbsent !== true ||
report.retirement.uidResourceVersionDeletePreconditions !== true ||
report.retirement.deploymentHeadCas !== true ||
report.retirement.unixSocketProxy !== true ||
report.serverSideDryRun !== true ||
report.serverSideApply !== true ||
report.convergenceRead !== true ||
report.deploymentHeadCas !== true ||
report.resourceInventoryClosed !== true ||
report.crossResourceAtomicity !== false ||
report.cleanupComplete !== true
) {
fail('cluster deployment evidence is invalid');
}
return report;
}
function validateEvidenceCatalog(consumption, finalizer) {
if (
consumption.releaseSetDigest !== finalizer.releaseSetDigest ||
consumption.catalogManifestDigest !== finalizer.catalogManifestDigest ||
consumption.immutableReference !== finalizer.immutableReference
) {
fail('deployment evidence is detached from the finalizer catalog');
}
}
function createDeploymentReadinessReceipt(input) {
const identity = input?.identity;
const release = validateIdentity(identity);
const finalizer = validateConsumption(
input.finalizerConsumption,
identity,
'finalizer',
);
const evidence = [];
if (identity.releaseScope !== 'cluster') {
const localConsumption = validateConsumption(
input.local?.consumption,
identity,
'local deployment',
);
validateEvidenceCatalog(localConsumption, finalizer);
const edge = validateLocalReport(
input.local?.edge,
'edge',
localConsumption,
identity,
);
const standalone = validateLocalReport(
input.local?.standalone,
'standalone',
localConsumption,
identity,
);
if (
edge.releaseAuthority.selectionDigest !==
standalone.releaseAuthority.selectionDigest
) {
fail('local profiles used different release selections');
}
evidence.push({
family: 'local',
catalogConsumptionDigest: localConsumption.contentDigest,
selectionDigest: edge.releaseAuthority.selectionDigest,
reports: [
{
profile: 'edge',
digest: input.local.edge.digest,
generation: edge.generation,
},
{
profile: 'standalone',
digest: input.local.standalone.digest,
generation: standalone.generation,
},
],
verification: {
exactRepoDigest: true,
rolloutActive: true,
sqliteBackupRestore: true,
gracefulCleanup: true,
},
});
} else if (input.local !== undefined) {
fail('local deployment evidence is forbidden for cluster scope');
}
if (identity.releaseScope !== 'local') {
const clusterConsumption = validateConsumption(
input.cluster?.consumption,
identity,
'cluster deployment',
);
validateEvidenceCatalog(clusterConsumption, finalizer);
const cluster = validateClusterReport(
input.cluster?.report,
clusterConsumption,
identity,
);
evidence.push({
family: 'cluster',
catalogConsumptionDigest: clusterConsumption.contentDigest,
reportDigest: input.cluster.report.digest,
kubernetes: {
serverVersion: cluster.kubernetes.serverVersion,
architecture: cluster.kubernetes.architecture,
nodeCount: cluster.kubernetes.nodeCount,
},
verification: {
immutableImages: true,
serverSideDryRun: true,
serverSideApply: true,
receiptAudited: true,
retirementAudited: true,
cleanupComplete: true,
},
});
} else if (input.cluster !== undefined) {
fail('cluster deployment evidence is forbidden for local scope');
}
const unsigned = {
schemaVersion: 1,
schema: SCHEMA,
release,
sourceRepository: identity.sourceRepository,
catalog: {
schema: CATALOG_CONSUMPTION_SCHEMA,
immutableReference: finalizer.immutableReference,
manifestDigest: finalizer.catalogManifestDigest,
releaseSetDigest: finalizer.releaseSetDigest,
finalizerConsumptionDigest: finalizer.contentDigest,
},
requiredDeploymentFamilies: requiredFamilies(identity.releaseScope),
evidence,
verification: { ...VERIFICATION },
};
return Object.freeze({
...unsigned,
receiptDigest: sha256(JSON.stringify(unsigned)),
});
}
function validateDeploymentReadinessReceipt(receipt, binding = {}) {
if (
!exactKeys(receipt, [
'schemaVersion',
'schema',
'release',
'sourceRepository',
'catalog',
'requiredDeploymentFamilies',
'evidence',
'verification',
'receiptDigest',
]) ||
receipt.schemaVersion !== 1 ||
receipt.schema !== SCHEMA ||
!exactKeys(receipt.release, [
'version',
'sourceRevision',
'sourceRef',
'scope',
]) ||
receipt.release.sourceRef !== `refs/tags/v${receipt.release.version}` ||
!REVISION_PATTERN.test(receipt.release.sourceRevision || '') ||
!['local', 'cluster', 'all'].includes(receipt.release.scope) ||
!REPOSITORY_PATTERN.test(receipt.sourceRepository || '') ||
!exactKeys(receipt.catalog, [
'schema',
'immutableReference',
'manifestDigest',
'releaseSetDigest',
'finalizerConsumptionDigest',
]) ||
receipt.catalog.schema !== CATALOG_CONSUMPTION_SCHEMA ||
!DIGEST_PATTERN.test(receipt.catalog.manifestDigest || '') ||
!DIGEST_PATTERN.test(receipt.catalog.releaseSetDigest || '') ||
!DIGEST_PATTERN.test(receipt.catalog.finalizerConsumptionDigest || '') ||
!receipt.catalog.immutableReference.endsWith(
`@${receipt.catalog.manifestDigest}`,
) ||
JSON.stringify(receipt.requiredDeploymentFamilies) !==
JSON.stringify(requiredFamilies(receipt.release.scope)) ||
!Array.isArray(receipt.evidence) ||
JSON.stringify(receipt.evidence.map((entry) => entry.family)) !==
JSON.stringify(receipt.requiredDeploymentFamilies) ||
JSON.stringify(receipt.verification) !== JSON.stringify(VERIFICATION) ||
!DIGEST_PATTERN.test(receipt.receiptDigest || '')
) {
fail('deployment readiness receipt shape is invalid');
}
const { receiptDigest, ...unsigned } = receipt;
if (receiptDigest !== sha256(JSON.stringify(unsigned))) {
fail('deployment readiness receipt digest is invalid');
}
if (
(binding.release !== undefined &&
JSON.stringify(receipt.release) !== JSON.stringify(binding.release)) ||
(binding.sourceRepository !== undefined &&
receipt.sourceRepository !== binding.sourceRepository) ||
(binding.releaseSetDigest !== undefined &&
receipt.catalog.releaseSetDigest !== binding.releaseSetDigest) ||
(binding.catalogManifestDigest !== undefined &&
receipt.catalog.manifestDigest !== binding.catalogManifestDigest) ||
(binding.immutableReference !== undefined &&
receipt.catalog.immutableReference !== binding.immutableReference)
) {
fail('deployment readiness receipt authority is detached');
}
for (const entry of receipt.evidence) {
if (
!DIGEST_PATTERN.test(entry.catalogConsumptionDigest || '') ||
(entry.family === 'local' &&
(!exactKeys(entry, [
'family',
'catalogConsumptionDigest',
'selectionDigest',
'reports',
'verification',
]) ||
!DIGEST_PATTERN.test(entry.selectionDigest || '') ||
!Array.isArray(entry.reports) ||
entry.reports.length !== 2 ||
JSON.stringify(entry.reports.map((report) => report.profile)) !==
JSON.stringify(['edge', 'standalone']) ||
entry.reports.some(
(report) =>
!exactKeys(report, ['profile', 'digest', 'generation']) ||
!DIGEST_PATTERN.test(report.digest || '') ||
!Number.isSafeInteger(report.generation) ||
report.generation < 1,
) ||
JSON.stringify(entry.verification) !==
JSON.stringify({
exactRepoDigest: true,
rolloutActive: true,
sqliteBackupRestore: true,
gracefulCleanup: true,
}))) ||
(entry.family === 'cluster' &&
(!exactKeys(entry, [
'family',
'catalogConsumptionDigest',
'reportDigest',
'kubernetes',
'verification',
]) ||
!DIGEST_PATTERN.test(entry.reportDigest || '') ||
!exactKeys(entry.kubernetes, [
'serverVersion',
'architecture',
'nodeCount',
]) ||
typeof entry.kubernetes.serverVersion !== 'string' ||
entry.kubernetes?.nodeCount !== 3 ||
!/^linux\/(?:amd64|arm64)$/u.test(
entry.kubernetes?.architecture || '',
) ||
JSON.stringify(entry.verification) !==
JSON.stringify({
immutableImages: true,
serverSideDryRun: true,
serverSideApply: true,
receiptAudited: true,
retirementAudited: true,
cleanupComplete: true,
}))) ||
!['local', 'cluster'].includes(entry.family)
) {
fail('deployment readiness evidence summary is invalid');
}
}
return receipt;
}
function auditDeploymentReadinessReceipt(actual, input) {
const expected = createDeploymentReadinessReceipt(input);
validateDeploymentReadinessReceipt(actual);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('deployment readiness receipt differs from the exact live evidence');
}
return Object.freeze({
compatible: true,
releaseScope: actual.release.scope,
releaseSetDigest: actual.catalog.releaseSetDigest,
catalogManifestDigest: actual.catalog.manifestDigest,
requiredDeploymentFamilies: [...actual.requiredDeploymentFamilies],
reportCount: actual.evidence.reduce(
(count, entry) => count + (entry.family === 'local' ? 2 : 1),
0,
),
tagMutation: false,
});
}
function canonicalAbsolute(value, label) {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.resolve(value) !== value
) {
fail(`${label} path must be canonical and absolute`);
}
return value;
}
function readCanonicalReport(filePath, label) {
const resolved = canonicalAbsolute(filePath, label);
const stat = fs.lstatSync(resolved);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > MAX_REPORT_BYTES ||
fs.realpathSync(resolved) !== resolved ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) ||
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
(stat.mode & 0o077) !== 0
) {
fail(`${label} must be one owner-private bounded regular file`);
}
const contents = fs.readFileSync(resolved);
let value;
try {
value = JSON.parse(contents.toString('utf8'));
} catch {
fail(`${label} must contain valid JSON`);
}
if (!contents.equals(Buffer.from(canonicalJson(value), 'utf8'))) {
fail(`${label} must use canonical JSON encoding`);
}
return Object.freeze({ value, digest: sha256(contents) });
}
function writeNoReplace(filePath, value) {
const resolved = canonicalAbsolute(filePath, 'output');
const parent = fs.lstatSync(path.dirname(resolved));
if (
!parent.isDirectory() ||
parent.isSymbolicLink() ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail('output parent must be one canonical directory');
}
const descriptor = fs.openSync(resolved, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, canonicalJson(value));
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (!['create', 'audit'].includes(values.mode)) fail('arguments are invalid');
const common = [
'finalizer-consumption-bundle',
'mode',
'release-scope',
'repository-owner',
'source-ref',
'source-repository',
'source-revision',
'version',
];
const local =
values['release-scope'] === 'cluster'
? []
: ['edge-report', 'local-consumption-bundle', 'standalone-report'];
const cluster =
values['release-scope'] === 'local'
? []
: ['cluster-consumption-bundle', 'cluster-report'];
const terminal = values.mode === 'create' ? ['output'] : ['receipt'];
const expected = [...common, ...local, ...cluster, ...terminal].sort();
if (JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)) {
fail('arguments are invalid');
}
return Object.freeze({
mode: values.mode,
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
releaseScope: values['release-scope'],
repositoryOwner: values['repository-owner'],
sourceRepository: values['source-repository'],
finalizerConsumptionBundle: values['finalizer-consumption-bundle'],
localConsumptionBundle: values['local-consumption-bundle'],
edgeReport: values['edge-report'],
standaloneReport: values['standalone-report'],
clusterConsumptionBundle: values['cluster-consumption-bundle'],
clusterReport: values['cluster-report'],
output: values.output,
receipt: values.receipt,
});
}
function evidenceInput(options) {
const identity = {
version: options.version,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
releaseScope: options.releaseScope,
repositoryOwner: options.repositoryOwner,
sourceRepository: options.sourceRepository,
};
const auditOptions = {
...identity,
outputDirectory: options.finalizerConsumptionBundle,
};
const input = {
identity,
finalizerConsumption: auditCeremonyBundle(auditOptions),
};
if (options.releaseScope !== 'cluster') {
input.local = {
consumption: auditCeremonyBundle({
...identity,
outputDirectory: options.localConsumptionBundle,
}),
edge: readCanonicalReport(options.edgeReport, 'edge report'),
standalone: readCanonicalReport(
options.standaloneReport,
'standalone report',
),
};
}
if (options.releaseScope !== 'local') {
input.cluster = {
consumption: auditCeremonyBundle({
...identity,
outputDirectory: options.clusterConsumptionBundle,
}),
report: readCanonicalReport(options.clusterReport, 'cluster report'),
};
}
return input;
}
function runCli(argv, output = process.stdout) {
const options = parseArguments(argv);
const input = evidenceInput(options);
if (options.mode === 'create') {
const receipt = createDeploymentReadinessReceipt(input);
writeNoReplace(options.output, receipt);
output.write(canonicalJson(receipt));
return receipt;
}
const actual = readCanonicalReport(
options.receipt,
'deployment readiness receipt',
).value;
const audit = auditDeploymentReadinessReceipt(actual, input);
output.write(canonicalJson(audit));
return audit;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${
error instanceof Error
? error.message
: 'release deployment readiness failed'
}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
SCHEMA,
QingLong3ReleaseDeploymentReadinessError,
auditDeploymentReadinessReceipt,
createDeploymentReadinessReceipt,
parseArguments,
runCli,
validateDeploymentReadinessReceipt,
});
@@ -13,12 +13,16 @@ const {
auditCatalogPlan,
auditCatalogReceipt,
} = require('./ql3-release-catalog-contract.cjs');
const {
SCHEMA: DEPLOYMENT_READINESS_SCHEMA,
validateDeploymentReadinessReceipt,
} = require('./ql3-release-deployment-readiness-contract.cjs');
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v1';
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v2';
const TAG_OBSERVATION_SCHEMA =
'qinglong/release-publication-tag-observation@v1';
const CLOSURE_RECEIPT_SCHEMA =
'qinglong/release-publication-closure-receipt@v1';
'qinglong/release-publication-closure-receipt@v2';
const MAX_JSON_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u;
@@ -28,10 +32,11 @@ const REQUIRED_PREREQUISITES = Object.freeze({
releaseSetProvenance: 'attested_before_catalog_publication',
catalogSignature: 'verified_exact_workflow_identity',
catalogProvenance: 'verified_source_tag_and_revision',
catalogReceipt: 'attested_before_tag_promotion',
catalogReceipt: 'attested_before_deployment_gates',
deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion',
});
const PROMOTION_POLICY = Object.freeze({
authority: 'verified_immutable_catalog',
authority: 'verified_catalog_bound_deployments',
inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation',
recovery: 'reuse_exact_digest_only',
@@ -40,8 +45,9 @@ const PROMOTION_POLICY = Object.freeze({
registryTagCas: false,
});
const CLOSURE_VERIFICATION = Object.freeze({
authority: 'verified_immutable_catalog',
authority: 'verified_catalog_bound_deployments',
catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true,
inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation',
@@ -159,6 +165,7 @@ function createPublicationPlan(
releaseSet,
catalogPlan,
catalogReceipt,
deploymentReadiness,
catalogManifestContents,
catalogManifestDigest,
options,
@@ -172,6 +179,13 @@ function createPublicationPlan(
catalogManifestContents,
catalogManifestDigest,
);
validateDeploymentReadinessReceipt(deploymentReadiness, {
release: releaseSet.release,
sourceRepository: options.sourceRepository,
releaseSetDigest: releaseSet.releaseSetDigest,
catalogManifestDigest,
immutableReference: catalogReceipt.catalog.immutableReference,
});
const images = releaseSet.images.map((image) => {
const registryRepository = image.reference.slice(
0,
@@ -203,6 +217,15 @@ function createPublicationPlan(
manifestDigest: catalogManifestDigest,
immutableReference: catalogReceipt.catalog.immutableReference,
},
deploymentReadiness: {
schema: DEPLOYMENT_READINESS_SCHEMA,
receiptDigest: deploymentReadiness.receiptDigest,
finalizerConsumptionDigest:
deploymentReadiness.catalog.finalizerConsumptionDigest,
requiredDeploymentFamilies: [
...deploymentReadiness.requiredDeploymentFamilies,
],
},
requiredPrerequisites: { ...REQUIRED_PREREQUISITES },
promotionPolicy: { ...PROMOTION_POLICY },
images,
@@ -221,6 +244,7 @@ function validatePublicationPlan(plan) {
'release',
'releaseSet',
'catalog',
'deploymentReadiness',
'requiredPrerequisites',
'promotionPolicy',
'images',
@@ -262,6 +286,25 @@ function validatePublicationPlan(plan) {
!plan.catalog.immutableReference.endsWith(
`@${plan.catalog.manifestDigest}`,
) ||
!exactKeys(plan.deploymentReadiness, [
'schema',
'receiptDigest',
'finalizerConsumptionDigest',
'requiredDeploymentFamilies',
]) ||
plan.deploymentReadiness.schema !== DEPLOYMENT_READINESS_SCHEMA ||
!DIGEST_PATTERN.test(plan.deploymentReadiness.receiptDigest || '') ||
!DIGEST_PATTERN.test(
plan.deploymentReadiness.finalizerConsumptionDigest || '',
) ||
JSON.stringify(plan.deploymentReadiness.requiredDeploymentFamilies) !==
JSON.stringify(
plan.release.scope === 'local'
? ['local']
: plan.release.scope === 'cluster'
? ['cluster']
: ['local', 'cluster'],
) ||
JSON.stringify(plan.requiredPrerequisites) !==
JSON.stringify(REQUIRED_PREREQUISITES) ||
JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) ||
@@ -394,6 +437,12 @@ function createClosureReceipt(plan, observation) {
planDigest: plan.planDigest,
releaseSet: { ...plan.releaseSet },
catalog: { ...plan.catalog },
deploymentReadiness: {
...plan.deploymentReadiness,
requiredDeploymentFamilies: [
...plan.deploymentReadiness.requiredDeploymentFamilies,
],
},
tagObservationDigest: observation.observationDigest,
publishedTags: observation.tags.map((entry) => ({ ...entry })),
verification: { ...CLOSURE_VERIFICATION },
@@ -414,6 +463,7 @@ function auditClosureReceipt(actual, plan, observation) {
releaseSetDigest: actual.releaseSet.releaseSetDigest,
releaseScope: actual.release.scope,
catalogManifestDigest: actual.catalog.manifestDigest,
deploymentReadinessReceiptDigest: actual.deploymentReadiness.receiptDigest,
publishedTagCount: actual.publishedTags.length,
allTagsExactDigest: actual.verification.allTagsExactDigest,
registryTagCas: actual.verification.registryTagCas,
@@ -444,6 +494,7 @@ function parseArguments(argv) {
'catalog-manifest-digest',
'catalog-plan',
'catalog-receipt',
'deployment-readiness',
'mode',
'output',
'release-set',
@@ -470,6 +521,7 @@ function parseArguments(argv) {
releaseSet: values['release-set'],
catalogPlan: values['catalog-plan'],
catalogReceipt: values['catalog-receipt'],
deploymentReadiness: values['deployment-readiness'],
catalogManifest: values['catalog-manifest'],
catalogManifestDigest: values['catalog-manifest-digest'],
version: values.version,
@@ -490,6 +542,10 @@ function runCli(argv, output = process.stdout) {
options.catalogReceipt,
'catalog receipt',
);
const deploymentReadiness = readCanonicalJson(
options.deploymentReadiness,
'deployment readiness receipt',
);
const manifest = readBoundedFile(
options.catalogManifest,
'catalog manifest',
@@ -498,6 +554,7 @@ function runCli(argv, output = process.stdout) {
releaseSet,
catalogPlan,
catalogReceipt,
deploymentReadiness,
manifest,
options.catalogManifestDigest,
options,