mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate release tags on deployment readiness
This commit is contained in:
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user