diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 4c0c92ac..60feb16f 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -303,6 +303,7 @@ jobs: test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs + test/back/ql3ReleaseDeploymentReadinessContract.test.cjs test/back/ql3ReleasePublicationClosureContract.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3DeploymentLockContract.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index ca7a6328..4ede86df 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -673,7 +673,7 @@ jobs: include-hidden-files: false release-set: - name: Close and publish the complete deployment release set + name: Close the release set and publish its immutable catalog needs: - release-candidate - worker-management-release-evidence @@ -962,172 +962,10 @@ jobs: with: subject-path: ${{ steps.catalog-receipt.outputs.receipt }} - - name: Materialize the catalog-authorized final tag publication plan - id: final-publication - env: - RELEASE_VERSION: ${{ inputs.version }} - RELEASE_SCOPE: ${{ inputs.release_scope }} - RELEASE_SET: ${{ steps.release-set.outputs.report }} - CATALOG_PLAN: ${{ steps.release-set.outputs.plan }} - CATALOG_MANIFEST: ${{ steps.catalog.outputs.manifest }} - CATALOG_MANIFEST_DIGEST: ${{ steps.catalog.outputs.digest }} - CATALOG_RECEIPT: ${{ steps.catalog-receipt.outputs.receipt }} - BUNDLE: ${{ steps.release-set.outputs.bundle }} - run: | - set -euo pipefail - owner="${GITHUB_REPOSITORY_OWNER,,}" - source_repository="${GITHUB_REPOSITORY,,}" - plan="${BUNDLE}/qinglong3-release-publication-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" - observations="${BUNDLE}/qinglong3-release-publication-tag-observation-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" - receipt="${BUNDLE}/qinglong3-release-publication-closure-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" - node scripts/ql3-release-publication-closure-contract.cjs \ - --mode=plan \ - --version="${RELEASE_VERSION}" \ - --source-revision="${GITHUB_SHA}" \ - --source-ref="${GITHUB_REF}" \ - --release-scope="${RELEASE_SCOPE}" \ - --repository-owner="${owner}" \ - --source-repository="${source_repository}" \ - --release-set="${RELEASE_SET}" \ - --catalog-plan="${CATALOG_PLAN}" \ - --catalog-manifest="${CATALOG_MANIFEST}" \ - --catalog-manifest-digest="${CATALOG_MANIFEST_DIGEST}" \ - --catalog-receipt="${CATALOG_RECEIPT}" \ - --output="${plan}" > /dev/null - echo "plan=${plan}" >> "${GITHUB_OUTPUT}" - echo "observations=${observations}" >> "${GITHUB_OUTPUT}" - echo "receipt=${receipt}" >> "${GITHUB_OUTPUT}" - - - name: Promote final tags only after the catalog receipt is attested - env: - REGCTL: ${{ runner.temp }}/regctl - PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }} - TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }} - run: | - set -euo pipefail - umask 077 - node <<'NODE' - const fs = require('node:fs'); - const { spawnSync } = require('node:child_process'); - const { - createPublicationTagObservation, - } = require('./scripts/ql3-release-publication-closure-contract.cjs'); - const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8')); - const regctl = process.env.REGCTL; - const maxInventoryBytes = 1024 * 1024; - const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u; - const run = (args) => { - const result = spawnSync(regctl, args, { - encoding: 'utf8', - maxBuffer: maxInventoryBytes, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`regctl ${args.join(' ')} failed`); - } - return result.stdout; - }; - const states = []; - for (const image of plan.images) { - const source = run(['image', 'digest', image.immutableReference]).trim(); - if (source !== image.digest) { - throw new Error('source digest drifted before promotion'); - } - const inventoryContents = run([ - 'tag', - 'ls', - image.registryRepository, - '--format', - '{{ range .Tags }}{{ println . }}{{ end }}', - ]); - if ( - Buffer.byteLength(inventoryContents) > maxInventoryBytes || - (inventoryContents.length > 0 && !inventoryContents.endsWith('\n')) - ) { - throw new Error('release tag inventory is invalid or unbounded'); - } - const inventory = inventoryContents.length === 0 - ? [] - : inventoryContents.slice(0, -1).split('\n'); - if ( - inventory.some((tag) => !tagPattern.test(tag)) || - new Set(inventory).size !== inventory.length - ) { - throw new Error('release tag inventory is malformed'); - } - const inventorySet = new Set(inventory); - for (const tag of image.tags) { - const tagName = tag.reference.slice(image.registryRepository.length + 1); - const present = inventorySet.has(tagName); - if (present) { - const current = run(['image', 'digest', tag.reference]).trim(); - if (current !== image.digest) { - throw new Error('release tag already points at another digest'); - } - } - states.push({ image, tag, present }); - } - } - for (const state of states) { - if (!state.present) { - run([ - 'image', - 'copy', - state.image.immutableReference, - state.tag.reference, - ]); - } - } - const observedTags = []; - for (const state of states) { - const promoted = run(['image', 'digest', state.tag.reference]).trim(); - if (promoted !== state.image.digest) { - throw new Error('promoted tag does not resolve to the release-set digest'); - } - observedTags.push({ - image: state.image.name, - kind: state.tag.kind, - reference: state.tag.reference, - digest: promoted, - }); - } - const observation = createPublicationTagObservation(plan, observedTags); - const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600); - try { - fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`); - fs.fsyncSync(descriptor); - } finally { - fs.closeSync(descriptor); - } - NODE - - - name: Close and audit the final public tag set - env: - PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }} - TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }} - CLOSURE_RECEIPT: ${{ steps.final-publication.outputs.receipt }} - run: | - set -euo pipefail - node scripts/ql3-release-publication-closure-contract.cjs \ - --mode=close \ - --plan="${PUBLICATION_PLAN}" \ - --observations="${TAG_OBSERVATIONS}" \ - --output="${CLOSURE_RECEIPT}" > /dev/null - node scripts/ql3-release-publication-closure-contract.cjs \ - --mode=audit \ - --plan="${PUBLICATION_PLAN}" \ - --observations="${TAG_OBSERVATIONS}" \ - --receipt="${CLOSURE_RECEIPT}" > "${RUNNER_TEMP}/release-publication-closure-audit.json" - - - name: Attest the immutable release publication closure receipt - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 - with: - subject-path: ${{ steps.final-publication.outputs.receipt }} - - - name: Publish the deployment digest lock + - name: Upload same-run catalog publisher evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - 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 @@ -1191,8 +1029,8 @@ jobs: umask 077 owner="${REPOSITORY_OWNER,,}" source_repository="${SOURCE_REPOSITORY,,}" - private_root="${RUNNER_TEMP}/ql3-release-catalog-local-consumer" - bundle="${private_root}/bundle" + private_root="${RUNNER_TEMP}/ql3-release-catalog-local-deployment" + bundle="${private_root}/consumption" token="${private_root}/github-token" install -d -m 0700 "${private_root}" install -m 0600 /dev/null "${token}" @@ -1393,8 +1231,8 @@ jobs: umask 077 owner="${REPOSITORY_OWNER,,}" source_repository="${SOURCE_REPOSITORY,,}" - private_root="${RUNNER_TEMP}/ql3-release-catalog-consumer" - bundle="${private_root}/bundle" + private_root="${RUNNER_TEMP}/ql3-release-catalog-deployment" + bundle="${private_root}/consumption" token="${private_root}/github-token" install -d -m 0700 "${private_root}" install -m 0600 /dev/null "${token}" @@ -1473,9 +1311,399 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: 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 overwrite: false include-hidden-files: false + + release-finalization: + name: Publish final tags only after catalog-bound deployment readiness + needs: + - release-set + - release-catalog-local-deployment-live + - release-catalog-deployment-live + if: >- + always() && + needs.release-set.result == 'success' && + ( + ( + inputs.release_scope == 'local' && + needs.release-catalog-local-deployment-live.result == 'success' && + needs.release-catalog-deployment-live.result == 'skipped' + ) || + ( + inputs.release_scope == 'cluster' && + needs.release-catalog-local-deployment-live.result == 'skipped' && + needs.release-catalog-deployment-live.result == 'success' + ) || + ( + inputs.release_scope == 'all' && + needs.release-catalog-local-deployment-live.result == 'success' && + needs.release-catalog-deployment-live.result == 'success' + ) + ) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.18.0' + + - name: Install the frozen workspace without lifecycle scripts + run: | + set -euo pipefail + corepack enable + corepack prepare pnpm@8.3.1 --activate + pnpm install --frozen-lockfile --ignore-scripts + + - name: Download same-run Local deployment evidence + if: inputs.release_scope != 'cluster' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-release-finalization/deployment-evidence/local + + - name: Download same-run Cluster deployment evidence + if: inputs.release_scope != 'local' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-release-finalization/deployment-evidence/cluster + + - name: Normalize and close the downloaded evidence inventory + env: + FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization + RELEASE_SCOPE: ${{ inputs.release_scope }} + shell: bash + run: | + set -euo pipefail + umask 077 + while IFS= read -r -d '' directory; do + chmod 0700 "${directory}" + done < <(find "${FINALIZATION_ROOT}" -type d -print0) + while IFS= read -r -d '' file; do + chmod 0600 "${file}" + done < <(find "${FINALIZATION_ROOT}" -type f -print0) + if [[ "${RELEASE_SCOPE}" != 'cluster' ]]; then + test -f "${FINALIZATION_ROOT}/deployment-evidence/local/edge.json" + test -f "${FINALIZATION_ROOT}/deployment-evidence/local/standalone.json" + test "$(find "${FINALIZATION_ROOT}/deployment-evidence/local" -type f | wc -l | tr -d ' ')" = 5 + fi + if [[ "${RELEASE_SCOPE}" != 'local' ]]; then + test -f "${FINALIZATION_ROOT}/deployment-evidence/cluster/report.json" + test "$(find "${FINALIZATION_ROOT}/deployment-evidence/cluster" -type f | wc -l | tr -d ' ')" = 4 + fi + + - name: Install the exact checksum-pinned catalog reader + env: + REGCTL: ${{ runner.temp }}/regctl + run: | + set -euo pipefail + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "${REGCTL}" \ + https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${REGCTL}" | sha256sum --check --strict + chmod 0755 "${REGCTL}" + "${REGCTL}" version + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Independently consume the catalog in the finalizer + id: finalizer-consumption + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + SOURCE_REPOSITORY: ${{ github.repository }} + REGCTL: ${{ runner.temp }}/regctl + FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization + shell: bash + run: | + set -euo pipefail + umask 077 + owner="${REPOSITORY_OWNER,,}" + source_repository="${SOURCE_REPOSITORY,,}" + bundle="${FINALIZATION_ROOT}/catalog-consumption" + token="${FINALIZATION_ROOT}/github-token" + install -m 0600 /dev/null "${token}" + printf '%s' "${GH_TOKEN}" > "${token}" + trap 'rm -f "${token}"' EXIT + cosign="$(readlink -f "$(command -v cosign)")" + gh="$(readlink -f "$(command -v gh)")" + node scripts/ql3-release-catalog-consumption-ceremony.cjs \ + --mode=create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${owner}" \ + --source-repository="${source_repository}" \ + --output-directory="${bundle}" \ + --regctl="${REGCTL}" \ + --cosign="${cosign}" \ + --gh="${gh}" \ + --github-token-file="${token}" > /dev/null + node scripts/ql3-release-catalog-consumption-ceremony.cjs \ + --mode=audit \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${owner}" \ + --source-repository="${source_repository}" \ + --output-directory="${bundle}" > /dev/null + rm -f "${token}" + trap - EXIT + echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}" + echo "repository-owner=${owner}" >> "${GITHUB_OUTPUT}" + echo "source-repository=${source_repository}" >> "${GITHUB_OUTPUT}" + + - name: Materialize and audit deployment readiness and publication plan + id: final-publication + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + REPOSITORY_OWNER: ${{ steps.finalizer-consumption.outputs.repository-owner }} + SOURCE_REPOSITORY: ${{ steps.finalizer-consumption.outputs.source-repository }} + FINALIZER_CONSUMPTION: ${{ steps.finalizer-consumption.outputs.bundle }} + FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization + shell: bash + run: | + set -euo pipefail + umask 077 + release_set="${FINALIZER_CONSUMPTION}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + manifest="${FINALIZER_CONSUMPTION}/qinglong3-release-catalog-manifest-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + manifest_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)" + catalog_plan="${FINALIZATION_ROOT}/qinglong3-release-catalog-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + catalog_receipt="${FINALIZATION_ROOT}/qinglong3-release-catalog-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + readiness="${FINALIZATION_ROOT}/qinglong3-release-deployment-readiness-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + publication_plan="${FINALIZATION_ROOT}/qinglong3-release-publication-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + observations="${FINALIZATION_ROOT}/qinglong3-release-publication-tag-observation-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + closure="${FINALIZATION_ROOT}/qinglong3-release-publication-closure-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=plan \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${REPOSITORY_OWNER}" \ + --source-repository="${SOURCE_REPOSITORY}" \ + --release-set="${release_set}" \ + --output="${catalog_plan}" > /dev/null + node scripts/ql3-release-catalog-contract.cjs \ + --mode=receipt \ + --plan="${catalog_plan}" \ + --manifest="${manifest}" \ + --manifest-digest="${manifest_digest}" \ + --output="${catalog_receipt}" > /dev/null + readiness_args=( + --version="${RELEASE_VERSION}" + --source-revision="${GITHUB_SHA}" + --source-ref="${GITHUB_REF}" + --release-scope="${RELEASE_SCOPE}" + --repository-owner="${REPOSITORY_OWNER}" + --source-repository="${SOURCE_REPOSITORY}" + --finalizer-consumption-bundle="${FINALIZER_CONSUMPTION}" + ) + if [[ "${RELEASE_SCOPE}" != 'cluster' ]]; then + readiness_args+=( + --local-consumption-bundle="${FINALIZATION_ROOT}/deployment-evidence/local/consumption" + --edge-report="${FINALIZATION_ROOT}/deployment-evidence/local/edge.json" + --standalone-report="${FINALIZATION_ROOT}/deployment-evidence/local/standalone.json" + ) + fi + if [[ "${RELEASE_SCOPE}" != 'local' ]]; then + readiness_args+=( + --cluster-consumption-bundle="${FINALIZATION_ROOT}/deployment-evidence/cluster/consumption" + --cluster-report="${FINALIZATION_ROOT}/deployment-evidence/cluster/report.json" + ) + fi + node scripts/ql3-release-deployment-readiness-contract.cjs \ + --mode=create \ + "${readiness_args[@]}" \ + --output="${readiness}" > /dev/null + node scripts/ql3-release-deployment-readiness-contract.cjs \ + --mode=audit \ + "${readiness_args[@]}" \ + --receipt="${readiness}" > "${RUNNER_TEMP}/release-deployment-readiness-audit.json" + node scripts/ql3-release-publication-closure-contract.cjs \ + --mode=plan \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${REPOSITORY_OWNER}" \ + --source-repository="${SOURCE_REPOSITORY}" \ + --release-set="${release_set}" \ + --catalog-plan="${catalog_plan}" \ + --catalog-manifest="${manifest}" \ + --catalog-manifest-digest="${manifest_digest}" \ + --catalog-receipt="${catalog_receipt}" \ + --deployment-readiness="${readiness}" \ + --output="${publication_plan}" > /dev/null + echo "plan=${publication_plan}" >> "${GITHUB_OUTPUT}" + echo "observations=${observations}" >> "${GITHUB_OUTPUT}" + echo "closure=${closure}" >> "${GITHUB_OUTPUT}" + echo "readiness=${readiness}" >> "${GITHUB_OUTPUT}" + echo "root=${FINALIZATION_ROOT}" >> "${GITHUB_OUTPUT}" + + - name: Attest deployment readiness before any final tag mutation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: ${{ steps.final-publication.outputs.readiness }} + + - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote final tags only after every required deployment gate + env: + REGCTL: ${{ runner.temp }}/regctl + PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }} + TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }} + run: | + set -euo pipefail + umask 077 + node <<'NODE' + const fs = require('node:fs'); + const { spawnSync } = require('node:child_process'); + const { + createPublicationTagObservation, + } = require('./scripts/ql3-release-publication-closure-contract.cjs'); + const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8')); + const regctl = process.env.REGCTL; + const maxInventoryBytes = 1024 * 1024; + const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u; + const run = (args) => { + const result = spawnSync(regctl, args, { + encoding: 'utf8', + maxBuffer: maxInventoryBytes, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`regctl ${args.join(' ')} failed`); + } + return result.stdout; + }; + const states = []; + for (const image of plan.images) { + const source = run(['image', 'digest', image.immutableReference]).trim(); + if (source !== image.digest) { + throw new Error('source digest drifted before promotion'); + } + const inventoryContents = run([ + 'tag', + 'ls', + image.registryRepository, + '--format', + '{{ range .Tags }}{{ println . }}{{ end }}', + ]); + if ( + Buffer.byteLength(inventoryContents) > maxInventoryBytes || + (inventoryContents.length > 0 && !inventoryContents.endsWith('\n')) + ) { + throw new Error('release tag inventory is invalid or unbounded'); + } + const inventory = inventoryContents.length === 0 + ? [] + : inventoryContents.slice(0, -1).split('\n'); + if ( + inventory.some((tag) => !tagPattern.test(tag)) || + new Set(inventory).size !== inventory.length + ) { + throw new Error('release tag inventory is malformed'); + } + const inventorySet = new Set(inventory); + for (const tag of image.tags) { + const tagName = tag.reference.slice(image.registryRepository.length + 1); + const present = inventorySet.has(tagName); + if (present) { + const current = run(['image', 'digest', tag.reference]).trim(); + if (current !== image.digest) { + throw new Error('release tag already points at another digest'); + } + } + states.push({ image, tag, present }); + } + } + for (const state of states) { + if (!state.present) { + run([ + 'image', + 'copy', + state.image.immutableReference, + state.tag.reference, + ]); + } + } + const observedTags = []; + for (const state of states) { + const promoted = run(['image', 'digest', state.tag.reference]).trim(); + if (promoted !== state.image.digest) { + throw new Error('promoted tag does not resolve to the release-set digest'); + } + observedTags.push({ + image: state.image.name, + kind: state.tag.kind, + reference: state.tag.reference, + digest: promoted, + }); + } + const observation = createPublicationTagObservation(plan, observedTags); + const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600); + try { + fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + NODE + + - name: Close and audit the deployment-ready public tag set + env: + PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }} + TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }} + CLOSURE_RECEIPT: ${{ steps.final-publication.outputs.closure }} + run: | + set -euo pipefail + node scripts/ql3-release-publication-closure-contract.cjs \ + --mode=close \ + --plan="${PUBLICATION_PLAN}" \ + --observations="${TAG_OBSERVATIONS}" \ + --output="${CLOSURE_RECEIPT}" > /dev/null + node scripts/ql3-release-publication-closure-contract.cjs \ + --mode=audit \ + --plan="${PUBLICATION_PLAN}" \ + --observations="${TAG_OBSERVATIONS}" \ + --receipt="${CLOSURE_RECEIPT}" > "${RUNNER_TEMP}/release-publication-closure-audit.json" + + - name: Attest the deployment-ready release publication closure receipt + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: ${{ steps.final-publication.outputs.closure }} + + - name: Upload the final deployment-ready release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + 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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index cf8fe215..192da22d 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,28 @@ 最新增量证据(2026-08-18): +- D-351/ADR-0443(已接受;首份真实 GHCR deployment-ready finalization 待实际 release tag):D-350 的 catalog-ready + publication 继续收紧为 deployment-ready publication。`release-set` job 只发布、验签并 attested immutable catalog,不再登录 registry + 或修改最终 image tag;`local|all` 必须先完成 Edge 与 Standalone 的 catalog-bound 正式 Compose rollout,`cluster|all` 必须先完成三节点 + K3s install、Head 与 UID/resourceVersion fenced retirement。两个只读部署 job 现在连同 content-free report 保存各自 exact-three-file + catalog consumption bundle;终态 `release-finalization` 按 scope 强制精确 success/skipped 真值表、下载并复验 exact 文件集合,再第三次独立消费 + 公开 immutable catalog。新增 `qinglong/release-deployment-readiness-receipt@v1` 联合绑定 finalizer consumption、要求的部署族、每个 + consumption/report digest、release-set/catalog identity 与清理结果,拒绝 synthetic/incomplete/unclean、跨 scope 或 catalog-detached + evidence,并在任何 Docker login/tag mutation 前本地复审和单独 attested。publication plan/closure receipt 升为 v2,发布 authority 为 + `verified_catalog_bound_deployments`;D-350 的 bounded inventory、零写 conflict preflight、exact reuse、absent copy、终态 tag 回读和 + `crossRepositoryAtomicity=false`/`registryTagCas=false` 均保留。最终 90 天 bundle 闭合 finalizer catalog、部署 evidence、readiness、plan、 + observation 与 closure。该变化不新增 package、生产依赖、数据库、migration、Kubernetes object 或设备常驻资源;Local 不承担 K3s 门, + Cluster 不承担 Compose 门,Edge/Standalone/路由设备与集群节点稳态成本仍为零。阶段门已重跑:readiness/publication/workflow/Console + distribution 聚焦测试 112/112,完整 backend 1,393 pass + 2 条条件 skip/0 fail,18-package clean build/test 退出 0,14/14 静态审计与 + 14/14 artifact 档位全部 compatible;artifact 字节保持基础 Edge/Standalone `2589890/2589968`、adopted + `2809185/2809308`、application `3632769/3632889`、application-api `3800322/3800466`、AI `3069143/3069233`、 + application+AI `4493043/4493175`、MCP `7315930/7316038`。真实 Docker 三节点 K3s v1.34.3/arm64 fixture 完成 install、Head、 + UID/resourceVersion fenced retirement、receipt audit 与零残留;fixture 明确保持 `synthetic_live_fixture`,不冒充公开 catalog 证据。 + PostgreSQL 18.6/arm64 HA 再次通过 142/142、timeline `1→2`,报告 SHA-256 为 + `d7bb8164b46c1392518eb08bea338640639fe5eca71a7401d98d98ea94146fd2`,离线复审通过且容器/卷/网络残留为零。本机成功构建 exact Local + image,但 Local selection 有意只接受真实 `ghcr.io//...@sha256`,因此本地 loopback registry 和 image-id 伪装均失败关闭;首份真实 + Local catalog-bound rollout 与完整 deployment-ready finalization 仍只能由受保护 release tag 产生。 + - D-350/ADR-0442(已接受;首份真实 GHCR 部分 promotion/replay 证据待实际 release tag):正式 image tag 不再在完整 release-set 审计后、durable catalog 建立前提前公开。`versionTag/sourceTag` mutation 现位于 release-set provenance、catalog immutable round-trip、catalog signature/provenance 验证、catalog receipt 审计及其 attestation 全部成功之后。新的 diff --git a/docs/adr/ADR-0442-catalog-ready-terminal-release-tag-publication.md b/docs/adr/ADR-0442-catalog-ready-terminal-release-tag-publication.md index 4aa77f75..6ef4b82e 100644 --- a/docs/adr/ADR-0442-catalog-ready-terminal-release-tag-publication.md +++ b/docs/adr/ADR-0442-catalog-ready-terminal-release-tag-publication.md @@ -1,10 +1,11 @@ # ADR-0442:Catalog-ready 的终态 Release Tag 发布与闭合收据 -- 状态:Accepted +- 状态:Superseded by ADR-0443(bounded promotion/closure 机制继续有效,发布前置顺序被收紧) - 日期:2026-08-18 - 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-349、D-350 - 关联 ADR:ADR-0427、ADR-0428、ADR-0439、ADR-0441 - Supersedes:ADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序 +- Superseded by:ADR-0443 将最终 tag mutation 继续后移到 scope-exact deployment readiness attestation 之后 ## 上下文 diff --git a/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md b/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md new file mode 100644 index 00000000..d02793bd --- /dev/null +++ b/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md @@ -0,0 +1,83 @@ +# ADR-0443:Deployment-ready 的终态 Release Finalization + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-344、D-345、D-350、D-351 +- 关联 ADR:ADR-0436、ADR-0437、ADR-0441、ADR-0442 +- Supersedes:ADR-0442 中“catalog receipt attested 后即可 mutation 最终 image tag”的发布顺序 + +## 上下文 + +ADR-0442 已把最终 image tag 从 release-set 聚合点后移到 immutable catalog 建立并验签之后,但 Local Compose 与 Cluster K3s +下游门仍在该次 tag mutation 之后运行。若 catalog 可验证而 Edge、Standalone 或三节点 K3s 的真实部署链失败,公开 +`versionTag/sourceTag` 已经存在,却没有与本次 scope 对应的 catalog-bound 部署证据。此时“catalog-ready”只证明发布材料完整,不能证明 +该发布已进入要求的部署族。 + +同时,部署 job 原先只上传 content-free report,不保留生成 report 时独立消费的 catalog ceremony bundle。终态发布者无法证明 report +确实来自与自身复验相同的 immutable catalog,也无法在一个可离线审计的 readiness receipt 中闭合 catalog、scope 和部署结果。 + +## 决策 + +1. `release-set` job 只负责聚合、审计、签证 release-set,发布并验证 immutable OCI catalog,以及签证 catalog receipt。它不再拥有最终 + image tag mutation、registry login 或 publication closure 权限。 +2. `local|all` 必须成功完成 Edge 与 Standalone 两个正式 Compose rollout;`cluster|all` 必须成功完成 catalog-bound 三节点 K3s + install、Head 和 fenced retirement。终态 `release-finalization` 使用显式 needs/result 真值表,要求当前 scope 的 job 全部 `success`,不属于 + 当前 scope 的 job 必须为 `skipped`;`failure`、`cancelled` 或意外运行均失败关闭。 +3. 每个部署 evidence artifact 必须同时保留 owner-private、canonical、exact-three-file catalog consumption bundle 和 content-free report。 + Local artifact 精确为 consumption 三文件、`edge.json`、`standalone.json`;Cluster artifact 精确为 consumption 三文件、`report.json`。 + finalizer 下载后重新固定目录 `0700`、文件 `0600` 并拒绝缺失或额外文件。 +4. finalizer 不信任 publisher 或部署 job 的瞬时内存。它再次从 discovery ref 解析 immutable catalog,独立执行 Cosign、GitHub provenance、 + manifest round-trip 与 ceremony audit,并从自己读取的 bytes 重建 catalog plan/receipt。 +5. 新增 `qinglong/release-deployment-readiness-receipt@v1`。它联合审计 finalizer consumption、当前 scope 要求的每个 deployment + consumption bundle 和 report,绑定 release identity、immutable catalog reference、manifest/release-set digest、每份 consumption/report + digest、精确部署族和清理结果。它拒绝 synthetic fixture、不完整报告、catalog 脱离、跨 scope 证据和未清理现场。 +6. readiness receipt 必须在 Docker login 和任何 tag mutation 之前生成、复审并单独 attested。只有该 attestation 成功,finalizer 才生成并执行 + `qinglong/release-publication-plan@v2`。v2 plan 和 `qinglong/release-publication-closure-receipt@v2` 都绑定 readiness receipt digest、 + finalizer consumption digest 和精确部署族,发布 authority 升为 `verified_catalog_bound_deployments`。 +7. D-350 的 bounded inventory、全量 conflict preflight、exact-tag reuse、absent-tag copy、最终逐 tag 回读和无 CAS/跨仓库事务声明保持不变。 + 它们只从 catalog publisher 移入唯一的 finalizer,并发生在 readiness attestation 之后。 +8. 90 天最终 bundle 保存 finalizer consumption、scope-exact deployment evidence、readiness receipt、publication plan、tag observation 与 closure + receipt。部署 authority 仍是签名并 attested 的 immutable catalog digest;mutable tag 与 closure 只表示发布可见性和终态证据。 + +## 失败与恢复 + +- catalog 已发布但任一要求的部署门失败:没有最终 image tag mutation。该 catalog 是可复验候选材料,不是 deployment-ready tag authority。 +- evidence artifact 缺失、额外、权限过宽、包含 synthetic report 或引用不同 catalog:finalizer 在 registry login 前失败。 +- readiness attestation 失败:不发布 tag;同一 protected source tag 可重跑并重新取得现场证据。 +- tag promotion 中途 response loss:继续使用 ADR-0442 的 exact-tag reuse,仅补 absent tag;不同 digest 永远冲突失败。 +- readiness receipt 是本次部署运行的操作证据,其 digest 可以随新的真实 consumption/report 变化;它不重新定义 release-set 或 immutable + catalog identity,也不伪称跨重跑逐字节确定。 + +## 部署与资源影响 + +- Edge、Standalone、低配路由设备和 Cluster 节点不安装 Node、regctl、Cosign 或 GitHub CLI,不新增 watcher、timer、listener、updater、 + queue、缓存或常驻进程。 +- 不新增 workspace package、生产依赖、数据库、schema、migration、SQL、Kubernetes object、RBAC 或业务副本。 +- 增量 CPU、内存、网络和磁盘只发生在短生命周期 release runner。Local scope 不运行 K3s 门,Cluster scope 不运行 Compose 门;`all` 才运行两族。 + +## 被拒绝的替代方案 + +### catalog-ready 后继续立即发布最终 tag + +拒绝。catalog 完整不等于 scope 要求的部署路径已成功,仍会留下“公开 tag 成功、真实部署失败”的窗口。 + +### 只依赖 GitHub job result,不闭合部署证据 + +拒绝。瞬时调度状态不能证明 report 使用了哪个 immutable catalog,也无法让最终 bundle 独立审计。 + +### 让部署 job 自行发布 tag + +拒绝。会把 package write、attestation write 和 registry login 权限扩散到多个高成本 runner,并引入多写者竞争。 + +### 把完整 token、kubeconfig 或命令 transcript 上传给 finalizer + +拒绝。finalizer 只需要 owner-private catalog bundle 和 content-free bounded report;credential 与现场控制材料必须留在原 job 生命周期内。 + +## 验证 + +- readiness contract 覆盖 Local/Cluster/All 精确部署族、缺失/额外/跨 scope 证据、catalog 脱离、synthetic/incomplete/unclean report、 + receipt tamper、自摘要重算和 closed CLI; +- release workflow 静态门固定 `catalog receipt → scope-exact deployment jobs → finalizer independent consumption → readiness audit/attestation → + tag mutation → closure audit/attestation` 顺序,并拒绝 evidence bundle、needs/result、独立 attestation 或最终 bundle 漂移; +- 完整仓库、产物档位和部署 Gate 的阶段结果记录在 QL-RFC-0001 D-351;首份真实 GHCR deployment-ready finalization 仍须由受保护 + `v3` release tag 或受控 release repository 演练产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index b28782ed..19a95d27 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -445,7 +445,8 @@ | [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag) | | [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag) | | [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag) | -| [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Accepted(首份真实 GHCR 部分 promotion/replay 证据待实际 release tag) | +| [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Superseded by ADR-0443(bounded promotion/closure 机制保留) | +| [ADR-0443](./ADR-0443-deployment-ready-terminal-release-finalization.md) | Deployment-ready 的终态 Release Finalization | Accepted(首份真实 GHCR deployment-ready finalization 待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index 545ebbd7..a2c01c7b 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -36,28 +36,50 @@ cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 w ## 发布流水线内置 Local 与 Cluster 下游门 +durable catalog 建立只表示候选发布材料可独立验证,不再直接授予最终 image tag 发布权。最终 +`versionTag/sourceTag` 只能由 `release-finalization` 在当前 scope 的全部下游门成功、deployment readiness receipt 已复审并单独 +attested 后写入。catalog 先存在而部署门失败时,不会产生正式 image tag;运维者不得把 discovery tag 或未闭合 catalog 当作 +deployment-ready 版本公告。 + `local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog 重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON 检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest; 任一 Profile 失败都会阻断 Local release。 -该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。regctl、Cosign、GitHub CLI、 -Node workspace、token 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻 updater、 -listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。 +该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。成功 artifact 精确保存 +owner-private catalog consumption 三文件、`edge.json` 和 `standalone.json`,供终态 finalizer 复审;token 与命令材料不上传。regctl、Cosign、 +GitHub CLI、Node workspace 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻 +updater、listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。 ## 发布流水线内置 Cluster 下游门 -`ql3-image-release.yml` 在 durable catalog、签名、provenance、receipt 和 90 天便利 bundle 全部发布后,才启动独立 +`ql3-image-release.yml` 在 durable catalog、签名、provenance 与 receipt 全部发布后,才启动独立 `release-catalog-deployment-live`。该 job 只处理 `cluster|all`;Local-only 发布不会启动 K3s。它没有 publisher 的 package/attestation 写权限或 registry 登录态,而是按下文工作站协议重新消费公开 catalog,离线重建 deployment lock,并在隔离三节点 K3s 上完成 install、 Head commit、一个显式 ConfigMap 的 UID/resourceVersion 围栏退役和两个 receipt audit。任何公开读取、exact workflow identity、source tag/revision、digest、scope、角色闭包、server-side apply/Head 或清理失败都会让 Cluster release 失败。 该门的四个业务 Deployment 都为零副本:它证明真实 catalog image authority 已进入目标 API、lock、Head 与 receipt 闭包,不重复下载和启动 -完整业务数据面。应用启动、数据库、Secret、Worker 与 AI lifecycle 继续由各自 live/release gate 负责。成功 artifact 只保存 content-free -JSON evidence;consumption bundle、token、kubeconfig 和 command 文件不上传。首份真实成功记录必须来自实际受保护 release tag,普通 PR 中的 -synthetic live fixture 不能替代它。 +完整业务数据面。应用启动、数据库、Secret、Worker 与 AI lifecycle 继续由各自 live/release gate 负责。成功 artifact 精确保存 +owner-private catalog consumption 三文件和 `report.json`;token、kubeconfig 与 command 文件不上传。首份真实成功记录必须来自实际受保护 +release tag,普通 PR 中的 synthetic live fixture 不能替代它。 + +## Deployment-ready 终态发布 + +`release-finalization` 是唯一具有 `packages:write` 与 `attestations:write` 的最终 tag 写者。它使用严格 scope 真值表:`local` 要求 Local +成功且 Cluster 跳过,`cluster` 要求 Cluster 成功且 Local 跳过,`all` 要求两者都成功。下载的 Local evidence 必须精确为五个文件,Cluster +evidence 必须精确为四个文件;目录重新设为 `0700`、文件为 `0600`,缺失、额外、synthetic、未完成或未清理报告均失败关闭。 + +finalizer 随后独立重新消费公开 immutable catalog,而不是复用 publisher 的临时输出。`qinglong/release-deployment-readiness-receipt@v1` +把 finalizer consumption、scope 要求的每个 deployment consumption/report、immutable catalog、release-set 和清理结果闭合为一份 +self-digest receipt。只有该 receipt 完成本地 audit 和独立 attestation,`qinglong/release-publication-plan@v2` 才能进入 bounded tag +inventory、全量冲突预检与 promotion;`qinglong/release-publication-closure-receipt@v2` 最终绑定同一 readiness。90 天 bundle 包含 finalizer +consumption、部署 evidence、readiness、plan、tag observation 和 closure,能够离线复查“哪个 catalog、哪些部署族、哪些终态 tag”。 + +readiness 是本次 release workflow 的操作证据,不是新的镜像内容 identity。同一 protected source tag 因失败而重跑时,新的真实部署报告可使 +readiness digest 不同;release-set 与 immutable catalog digest 仍必须相同,既有 exact tag 只能复用,不同 digest 继续失败。OCI registry +没有跨 repository 事务或 tag CAS,因此 closure 证明的是观测到的终态,不是原子提交。 ## 选择 scope diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index e39b6e2c..abb7ffec 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -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', ); diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 563c3036..72e8af52 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -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', ], diff --git a/scripts/ql3-release-deployment-readiness-contract.cjs b/scripts/ql3-release-deployment-readiness-contract.cjs new file mode 100644 index 00000000..9ddb836c --- /dev/null +++ b/scripts/ql3-release-deployment-readiness-contract.cjs @@ -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, +}); diff --git a/scripts/ql3-release-publication-closure-contract.cjs b/scripts/ql3-release-publication-closure-contract.cjs index 7f42d4c6..4df195a5 100644 --- a/scripts/ql3-release-publication-closure-contract.cjs +++ b/scripts/ql3-release-publication-closure-contract.cjs @@ -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, diff --git a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs index 4810aa49..ef04cc6f 100644 --- a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs @@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => { '.github/workflows/ql3-image-release.yml', (source) => source.replace( - 'Promote final tags only after the catalog receipt is attested', + 'Promote final tags only after every required deployment gate', 'Promote mutable release tags', ), 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index e67a8cc3..585cb79e 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -95,6 +95,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { tagAfterVerification: true, tagAfterCompleteReleaseSet: true, tagAfterVerifiedCatalog: true, + tagAfterRequiredDeploymentGates: true, boundedRepositoryTagInventory: true, allTagConflictsCheckedBeforeMutation: true, responseLossRecovery: 'reuse_exact_digest_only', @@ -107,7 +108,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { privateEvidenceFreshnessRevalidatedAtClosure: true, exactScopeClosure: true, standaloneInspection: true, - tagPromotionAuthority: 'verified_immutable_catalog', + tagPromotionAuthority: 'verified_catalog_bound_deployments', fileProvenanceAttested: true, artifactRetentionDays: 90, crossRepositoryAtomicity: false, @@ -135,10 +136,11 @@ test('accepts the reviewed native CI and digest release contracts', () => { 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', @@ -147,6 +149,16 @@ test('accepts the reviewed native CI and digest release contracts', () => { 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', @@ -155,6 +167,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { installReceiptAudited: true, fencedRetirementReceiptAudited: true, publicationAuthority: false, + requiredForFinalization: true, }, localCatalogDeploymentGate: { scopes: ['local', 'all'], @@ -164,6 +177,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { rolloutReceiptAudited: true, gracefulCleanup: true, publicationAuthority: false, + requiredForFinalization: true, }, localRolloutPreflight: true, localRolloutApply: true, @@ -179,6 +193,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'catalog-consumption', 'catalog-bound-local-compose-deployment', 'catalog-bound-k3s-deployment', + 'deployment-readiness', 'release-tags', 'release-publication-closure', ], @@ -219,6 +234,17 @@ test('rejects removal of the final publication closure contract tests', () => { ); }); +test('rejects removal of the deployment readiness contract tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3ReleaseDeploymentReadinessContract.test.cjs', + 'test/back/deployment-readiness-tests-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /durable catalog, deployment-lock and workflow negative tests/, + ); +}); + test('rejects removal of the private release evidence receipt contract tests', () => { const mutated = ciSource.replace( 'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs', @@ -641,7 +667,7 @@ test('rejects a movable action tag in the privileged release job', () => { )}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`; assert.throws( () => auditReleaseWorkflow(mutated), - /privileged publisher|immutable checkout action|release-set job|cluster release must read/, + /privileged publisher|immutable checkout action|release-set job|cluster release must read|release finalization/, ); }); @@ -995,7 +1021,7 @@ test('rejects a deployment lock without standalone inspection', () => { )}`; assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1006,7 +1032,7 @@ test('rejects a catalog title that leaks the runner temporary path', () => { ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1017,7 +1043,7 @@ test('rejects a catalog publication without byte-exact round trip', () => { ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1028,7 +1054,7 @@ test('rejects direct overwrite publication to the catalog discovery tag', () => ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1039,7 +1065,7 @@ test('rejects catalog publication without a conflict-aware decision', () => { ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1050,7 +1076,7 @@ test('rejects catalog publication without bounded tag inventory classification', ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1061,7 +1087,7 @@ test('rejects response-loss recovery that cannot reuse an exact catalog digest', ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1072,7 +1098,7 @@ test('rejects using the mutable catalog discovery tag as deployment authority', ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1083,7 +1109,7 @@ test('rejects durable catalog provenance detached from its manifest digest', () ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); @@ -1094,30 +1120,62 @@ test('rejects a release-catalog receipt without file provenance', () => { ); assert.throws( () => auditReleaseWorkflow(mutated), - /independently inspect, durably publish/, + /independently inspect and durably publish/, ); }); -test('rejects final tag publication before catalog receipt attestation', () => { +test('rejects final tag publication before deployment readiness', () => { const mutated = releaseSource.replace( - 'Promote final tags only after the catalog receipt is attested', - 'Promote tags after release-set audit only', + 'Promote final tags only after every required deployment gate', + 'Promote tags before deployment readiness', ); assert.throws( () => auditReleaseWorkflow(mutated), - /release tags and the final closure receipt|release-set job must download/, + /release finalization|release tags and the final closure receipt/, ); }); +test('rejects finalization that ignores a required deployment job result', () => { + const mutated = releaseSource.replace( + "needs.release-catalog-local-deployment-live.result == 'success'", + "needs.release-catalog-local-deployment-live.result != 'failure'", + ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); +}); + +test('rejects deployment readiness without exact downloaded evidence bundles', () => { + const mutated = releaseSource.replace( + ' path: ${{ runner.temp }}/ql3-release-catalog-deployment', + ' path: ${{ runner.temp }}/ql3-release-catalog-deployment/report.json', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /cluster release must read the newly published immutable catalog/, + ); +}); + +test('rejects a publication plan detached from deployment readiness', () => { + const mutated = releaseSource.replace( + ' --deployment-readiness="${readiness}" \\', + ' --deployment-readiness="${catalog_receipt}" \\', + ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); +}); + +test('rejects deployment readiness without its own pre-promotion attestation', () => { + const mutated = releaseSource.replace( + ' subject-path: ${{ steps.final-publication.outputs.readiness }}', + ' subject-path: ${{ steps.final-publication.outputs.plan }}', + ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); +}); + test('rejects final tag promotion without bounded repository inventory', () => { const mutated = releaseSource.replace( " 'tag',\n 'ls',\n image.registryRepository,", " 'image',\n 'digest',\n image.registryRepository,", ); - assert.throws( - () => auditReleaseWorkflow(mutated), - /release-set job must download only same-run records/, - ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); test('rejects omission of the final publication closure audit', () => { @@ -1125,39 +1183,33 @@ test('rejects omission of the final publication closure audit', () => { ' --mode=close \\', ' --mode=audit \\', ); - assert.throws( - () => auditReleaseWorkflow(mutated), - /release-set job must download only same-run records/, - ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); test('rejects omitting tag observations from the durable closure bundle', () => { const mutated = releaseSource.replace( - 'observations="${BUNDLE}/qinglong3-release-publication-tag-observation-', + 'observations="${FINALIZATION_ROOT}/qinglong3-release-publication-tag-observation-', 'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-', ); - assert.throws( - () => auditReleaseWorkflow(mutated), - /release-set job must download only same-run records/, - ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); test('rejects a final closure receipt without its own attestation', () => { const mutated = releaseSource.replace( - ' subject-path: ${{ steps.final-publication.outputs.receipt }}', - ' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}', - ); - assert.throws( - () => auditReleaseWorkflow(mutated), - /release-set job must download only same-run records/, + ' subject-path: ${{ steps.final-publication.outputs.closure }}', + ' subject-path: ${{ steps.final-publication.outputs.readiness }}', ); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); test('rejects a short-lived deployment digest lock', () => { const marker = ' retention-days: 90'; assert.equal(releaseSource.includes(marker), true); const mutated = releaseSource.replace(marker, ' retention-days: 1'); - assert.throws(() => auditReleaseWorkflow(mutated), /deployment lock/); + assert.throws( + () => auditReleaseWorkflow(mutated), + /release-set job|release finalization/, + ); }); test('rejects removal of the digest-bound OS vulnerability attestation', () => { diff --git a/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs b/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs new file mode 100644 index 00000000..f3871588 --- /dev/null +++ b/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs @@ -0,0 +1,327 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const test = require('node:test'); +const { + SCHEMA, + auditDeploymentReadinessReceipt, + createDeploymentReadinessReceipt, + parseArguments, + validateDeploymentReadinessReceipt, +} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs'); + +const VERSION = '3.0.0-alpha.0'; +const REVISION = 'd'.repeat(40); +const OWNER = 'qinglong-release'; +const SOURCE_REPOSITORY = `${OWNER}/qinglong`; +const MANIFEST_DIGEST = `sha256:${'a'.repeat(64)}`; +const RELEASE_SET_DIGEST = `sha256:${'b'.repeat(64)}`; + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function identity(scope) { + return { + version: VERSION, + sourceRevision: REVISION, + sourceRef: `refs/tags/v${VERSION}`, + releaseScope: scope, + repositoryOwner: OWNER, + sourceRepository: SOURCE_REPOSITORY, + }; +} + +function consumption(scope, marker) { + return { + compatible: true, + releaseScope: scope, + sourceRepository: SOURCE_REPOSITORY, + workflowIdentity: `https://github.com/${SOURCE_REPOSITORY}/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`, + releaseSetDigest: RELEASE_SET_DIGEST, + catalogManifestDigest: MANIFEST_DIGEST, + immutableReference: `ghcr.io/${OWNER}/qinglong3-release-catalog@${MANIFEST_DIGEST}`, + imageCount: scope === 'all' ? 5 : scope === 'local' ? 1 : 4, + discoveryTagAuthority: 'none', + externalToolResultsReplayed: false, + deploymentMutation: false, + contentDigest: sha256(marker), + releaseSet: { + release: { + version: VERSION, + sourceRevision: REVISION, + sourceRef: `refs/tags/v${VERSION}`, + scope, + }, + releaseSetDigest: RELEASE_SET_DIGEST, + }, + }; +} + +function reportEntry(value) { + return { + value, + digest: sha256(`${JSON.stringify(value)}\n`), + }; +} + +function localReport(scope, catalog, profile) { + return reportEntry({ + schemaVersion: 1, + profile, + generation: 2, + exactRepoDigest: true, + composeMerge: true, + rolloutActive: true, + durableReceipt: true, + sqliteWriteContract: 37, + sqliteBackup: true, + sqliteWriteObservation: { committed: true }, + sqliteRestorePrepared: true, + sqliteRestoreCommitted: true, + sqliteRestoreRolloutRecovered: true, + sqliteRestoreReplayUnchanged: true, + sqliteEvidenceCollected: true, + sqliteCollectedRolloutReplayUnchanged: true, + gracefulCleanup: true, + releaseAuthority: { + mode: 'verified_release_catalog', + sourceRevision: REVISION, + sourceRef: `refs/tags/v${VERSION}`, + scope, + releaseSetDigest: RELEASE_SET_DIGEST, + catalogManifestDigest: MANIFEST_DIGEST, + catalogConsumptionDigest: catalog.contentDigest, + selectionDigest: sha256('selection'), + }, + compatible: true, + }); +} + +function clusterReport(scope, catalog) { + return reportEntry({ + schemaVersion: 1, + schema: 'qinglong/kubernetes-deployment-live-evidence@v1', + kubernetes: { + serverVersion: 'v1.34.3+k3s1', + architecture: 'linux/amd64', + nodeCount: 3, + clusterUid: 'fixture-cluster', + }, + deployment: { + namespace: 'qinglong3-system', + resourceCount: 7, + deploymentCount: 4, + replicas: 0, + fieldManager: 'qinglong3-catalog-lock', + immutableImages: true, + headName: 'ql3-deployment-head', + headPhase: 'committed', + headGeneration: 2, + deploymentDigest: sha256('deployment'), + resourceInventoryCount: 6, + }, + releaseAuthority: { + mode: 'verified_release_catalog', + version: VERSION, + sourceRevision: REVISION, + sourceRef: `refs/tags/v${VERSION}`, + scope, + releaseSetDigest: RELEASE_SET_DIGEST, + catalogManifestDigest: MANIFEST_DIGEST, + catalogConsumptionDigest: catalog.contentDigest, + immutableReference: catalog.immutableReference, + }, + preflightDigest: sha256('preflight'), + receiptDigest: sha256('receipt'), + receiptAuditCompatible: true, + retirement: { + preflightDigest: sha256('retirement-preflight'), + receiptDigest: sha256('retirement-receipt'), + receiptAuditCompatible: true, + targetCount: 1, + targetAbsent: true, + uidResourceVersionDeletePreconditions: true, + deploymentHeadCas: true, + inventoryCount: 6, + unixSocketProxy: true, + }, + serverSideDryRun: true, + serverSideApply: true, + convergenceRead: true, + deploymentHeadCas: true, + resourceInventoryClosed: true, + crossResourceAtomicity: false, + cleanupComplete: true, + }); +} + +function fixture(scope = 'all') { + const finalizerConsumption = consumption(scope, 'finalizer'); + const input = { + identity: identity(scope), + finalizerConsumption, + }; + if (scope !== 'cluster') { + const localConsumption = consumption(scope, 'local'); + input.local = { + consumption: localConsumption, + edge: localReport(scope, localConsumption, 'edge'), + standalone: localReport(scope, localConsumption, 'standalone'), + }; + } + if (scope !== 'local') { + const clusterConsumption = consumption(scope, 'cluster'); + input.cluster = { + consumption: clusterConsumption, + report: clusterReport(scope, clusterConsumption), + }; + } + return { input, receipt: createDeploymentReadinessReceipt(input) }; +} + +test('requires the exact deployment families for Local, Cluster and All', () => { + for (const [scope, families, reportCount] of [ + ['local', ['local'], 2], + ['cluster', ['cluster'], 1], + ['all', ['local', 'cluster'], 3], + ]) { + const { input, receipt } = fixture(scope); + assert.equal(receipt.schema, SCHEMA); + assert.deepEqual(receipt.requiredDeploymentFamilies, families); + assert.equal( + auditDeploymentReadinessReceipt(receipt, input).reportCount, + reportCount, + ); + assert.equal( + validateDeploymentReadinessReceipt(receipt, { + release: receipt.release, + sourceRepository: SOURCE_REPOSITORY, + releaseSetDigest: RELEASE_SET_DIGEST, + catalogManifestDigest: MANIFEST_DIGEST, + immutableReference: receipt.catalog.immutableReference, + }), + receipt, + ); + } +}); + +test('rejects missing, extra and cross-scope deployment evidence', () => { + const local = fixture('local').input; + assert.throws( + () => + createDeploymentReadinessReceipt({ + ...local, + local: undefined, + }), + /local deployment catalog consumption is invalid/, + ); + assert.throws( + () => + createDeploymentReadinessReceipt({ + ...local, + cluster: fixture('cluster').input.cluster, + }), + /cluster deployment evidence is forbidden/, + ); + const all = fixture('all').input; + assert.throws( + () => + createDeploymentReadinessReceipt({ + ...all, + cluster: undefined, + }), + /cluster deployment catalog consumption is invalid/, + ); +}); + +test('rejects reports detached from the independently consumed catalog', () => { + const current = fixture('all').input; + const drifted = structuredClone(current); + drifted.local.consumption.catalogManifestDigest = sha256('other-manifest'); + drifted.local.consumption.immutableReference = `ghcr.io/${OWNER}/qinglong3-release-catalog@${drifted.local.consumption.catalogManifestDigest}`; + drifted.local.edge.value.releaseAuthority.catalogManifestDigest = + drifted.local.consumption.catalogManifestDigest; + drifted.local.standalone.value.releaseAuthority.catalogManifestDigest = + drifted.local.consumption.catalogManifestDigest; + assert.throws( + () => createDeploymentReadinessReceipt(drifted), + /detached from the finalizer catalog/, + ); +}); + +test('rejects synthetic, incomplete and unclean deployment reports', () => { + for (const mutate of [ + (input) => { + input.local.edge.value.releaseAuthority.mode = 'synthetic_live_fixture'; + }, + (input) => { + input.local.standalone.value.gracefulCleanup = false; + }, + (input) => { + input.cluster.report.value.receiptAuditCompatible = false; + }, + (input) => { + input.cluster.report.value.cleanupComplete = false; + }, + ]) { + const input = structuredClone(fixture('all').input); + mutate(input); + assert.throws( + () => createDeploymentReadinessReceipt(input), + /deployment evidence is invalid/, + ); + } +}); + +test('rejects tampered, recomputed or authority-detached receipts', () => { + const { receipt } = fixture('all'); + const tampered = structuredClone(receipt); + tampered.receiptDigest = sha256('tampered'); + assert.throws( + () => validateDeploymentReadinessReceipt(tampered), + /receipt digest is invalid/, + ); + const recomputed = structuredClone(receipt); + recomputed.evidence[0].unexpected = true; + delete recomputed.receiptDigest; + recomputed.receiptDigest = sha256(JSON.stringify(recomputed)); + assert.throws( + () => validateDeploymentReadinessReceipt(recomputed), + /evidence summary is invalid/, + ); + assert.throws( + () => + validateDeploymentReadinessReceipt(receipt, { + releaseSetDigest: sha256('another-release-set'), + }), + /authority is detached/, + ); +}); + +test('accepts only closed scope-specific CLI argument sets', () => { + const common = [ + '--mode=create', + `--version=${VERSION}`, + `--source-revision=${REVISION}`, + `--source-ref=refs/tags/v${VERSION}`, + '--release-scope=local', + `--repository-owner=${OWNER}`, + `--source-repository=${SOURCE_REPOSITORY}`, + '--finalizer-consumption-bundle=/tmp/finalizer', + '--local-consumption-bundle=/tmp/local', + '--edge-report=/tmp/edge', + '--standalone-report=/tmp/standalone', + '--output=/tmp/receipt', + ]; + assert.equal(parseArguments(common).releaseScope, 'local'); + assert.throws( + () => parseArguments([...common, '--cluster-report=/tmp/cluster']), + /arguments are invalid/, + ); + assert.throws( + () => parseArguments(common.filter((entry) => !entry.startsWith('--edge'))), + /arguments are invalid/, + ); +}); diff --git a/test/back/ql3ReleasePublicationClosureContract.test.cjs b/test/back/ql3ReleasePublicationClosureContract.test.cjs index 1a028260..53ee5812 100644 --- a/test/back/ql3ReleasePublicationClosureContract.test.cjs +++ b/test/back/ql3ReleasePublicationClosureContract.test.cjs @@ -35,6 +35,9 @@ const { const { readReleaseIdentity, } = require('../../scripts/lib/ql3-release-identity.cjs'); +const { + createDeploymentReadinessReceipt, +} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs'); const { privateReleaseEvidenceReceipts, } = require('./ql3ReleaseEvidenceFixture.cjs'); @@ -106,6 +109,145 @@ function manifestFor(plan) { }); } +function catalogConsumption(set, manifestDigest, marker) { + return { + compatible: true, + releaseScope: set.release.scope, + sourceRepository: identity.sourceRepository, + workflowIdentity: `https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}`, + releaseSetDigest: set.releaseSetDigest, + catalogManifestDigest: manifestDigest, + immutableReference: `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${manifestDigest}`, + imageCount: set.images.length, + discoveryTagAuthority: 'none', + externalToolResultsReplayed: false, + deploymentMutation: false, + contentDigest: sha256(marker), + releaseSet: set, + }; +} + +function localReport(profile, consumption, marker) { + return { + value: { + schemaVersion: 1, + profile, + generation: 2, + exactRepoDigest: true, + composeMerge: true, + rolloutActive: true, + durableReceipt: true, + sqliteWriteContract: 37, + sqliteBackup: true, + sqliteWriteObservation: { rows: 1 }, + sqliteRestorePrepared: true, + sqliteRestoreCommitted: true, + sqliteRestoreRolloutRecovered: true, + sqliteRestoreReplayUnchanged: true, + sqliteEvidenceCollected: true, + sqliteCollectedRolloutReplayUnchanged: true, + gracefulCleanup: true, + releaseAuthority: { + mode: 'verified_release_catalog', + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + scope: consumption.releaseScope, + releaseSetDigest: consumption.releaseSetDigest, + catalogManifestDigest: consumption.catalogManifestDigest, + catalogConsumptionDigest: consumption.contentDigest, + selectionDigest: sha256('local-selection'), + }, + compatible: true, + }, + digest: sha256(marker), + }; +} + +function clusterReport(consumption) { + return { + value: { + schemaVersion: 1, + schema: 'qinglong/kubernetes-deployment-live-evidence@v1', + kubernetes: { + serverVersion: 'v1.34.3+k3s1', + architecture: 'linux/amd64', + nodeCount: 3, + clusterUid: 'cluster-fixture', + }, + deployment: { + replicas: 0, + immutableImages: true, + headPhase: 'committed', + headGeneration: 2, + deploymentDigest: sha256('deployment'), + }, + releaseAuthority: { + mode: 'verified_release_catalog', + version, + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + scope: consumption.releaseScope, + releaseSetDigest: consumption.releaseSetDigest, + catalogManifestDigest: consumption.catalogManifestDigest, + catalogConsumptionDigest: consumption.contentDigest, + immutableReference: consumption.immutableReference, + }, + preflightDigest: sha256('preflight'), + receiptDigest: sha256('deployment-receipt'), + receiptAuditCompatible: true, + retirement: { + receiptAuditCompatible: true, + targetAbsent: true, + uidResourceVersionDeletePreconditions: true, + deploymentHeadCas: true, + unixSocketProxy: true, + }, + serverSideDryRun: true, + serverSideApply: true, + convergenceRead: true, + deploymentHeadCas: true, + resourceInventoryClosed: true, + crossResourceAtomicity: false, + cleanupComplete: true, + }, + digest: sha256('cluster-report'), + }; +} + +function deploymentReadiness(set, manifestDigest) { + const scope = set.release.scope; + const finalizerConsumption = catalogConsumption( + set, + manifestDigest, + 'finalizer-consumption', + ); + const input = { + identity: { ...identity, releaseScope: scope }, + finalizerConsumption, + }; + if (scope !== 'cluster') { + const consumption = catalogConsumption( + set, + manifestDigest, + 'local-consumption', + ); + input.local = { + consumption, + edge: localReport('edge', consumption, 'edge-report'), + standalone: localReport('standalone', consumption, 'standalone-report'), + }; + } + if (scope !== 'local') { + const consumption = catalogConsumption( + set, + manifestDigest, + 'cluster-consumption', + ); + input.cluster = { consumption, report: clusterReport(consumption) }; + } + return createDeploymentReadinessReceipt(input); +} + function fixture(scope = 'all') { const set = releaseSet(scope); const options = { ...identity, releaseScope: scope }; @@ -117,10 +259,12 @@ function fixture(scope = 'all') { manifest, manifestDigest, ); + const readiness = deploymentReadiness(set, manifestDigest); const publicationPlan = createPublicationPlan( set, catalogPlan, catalogReceipt, + readiness, manifest, manifestDigest, options, @@ -141,6 +285,7 @@ function fixture(scope = 'all') { manifest, manifestDigest, catalogReceipt, + readiness, publicationPlan, observation, }; @@ -164,11 +309,11 @@ test('plans tag publication only from a verified immutable catalog', () => { assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA); assert.equal( publicationPlan.promotionPolicy.authority, - 'verified_immutable_catalog', + 'verified_catalog_bound_deployments', ); assert.equal( - publicationPlan.requiredPrerequisites.catalogReceipt, - 'attested_before_tag_promotion', + publicationPlan.requiredPrerequisites.deploymentReadiness, + 'scope_exact_receipt_attested_before_tag_promotion', ); assert.equal(publicationPlan.promotionPolicy.registryTagCas, false); assert.equal(publicationPlan.images.length, set.images.length); @@ -188,6 +333,7 @@ test('creates a deterministic final closure receipt for every exact tag', () => assert.deepEqual(first, replay); assert.equal(first.publishedTags.length, publicationPlan.images.length * 2); assert.equal(first.verification.catalogReadyBeforeTagMutation, true); + assert.equal(first.verification.deploymentReadyBeforeTagMutation, true); assert.equal(first.verification.allTagsExactDigest, true); assert.equal(first.verification.registryTagCas, false); assert.equal( @@ -223,6 +369,7 @@ test('rejects a publication plan detached from release-set or catalog evidence', current.set, other.catalogPlan, other.catalogReceipt, + current.readiness, other.manifest, other.manifestDigest, current.options, @@ -237,6 +384,7 @@ test('rejects a publication plan detached from release-set or catalog evidence', current.set, current.catalogPlan, weakenedReceipt, + current.readiness, current.manifest, current.manifestDigest, current.options, @@ -286,6 +434,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => { releaseSet: path.join(directory, 'release-set.json'), catalogPlan: path.join(directory, 'catalog-plan.json'), catalogReceipt: path.join(directory, 'catalog-receipt.json'), + readiness: path.join(directory, 'deployment-readiness.json'), manifest: path.join(directory, 'manifest.json'), publicationPlan: path.join(directory, 'publication-plan.json'), observation: path.join(directory, 'observation.json'), @@ -294,6 +443,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => { writeCanonical(files.releaseSet, value.set); writeCanonical(files.catalogPlan, value.catalogPlan); writeCanonical(files.catalogReceipt, value.catalogReceipt); + writeCanonical(files.readiness, value.readiness); fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 }); writeCanonical(files.observation, value.observation); const planArgs = [ @@ -307,6 +457,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => { `--release-set=${files.releaseSet}`, `--catalog-plan=${files.catalogPlan}`, `--catalog-receipt=${files.catalogReceipt}`, + `--deployment-readiness=${files.readiness}`, `--catalog-manifest=${files.manifest}`, `--catalog-manifest-digest=${value.manifestDigest}`, `--output=${files.publicationPlan}`,