diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 8b572f83..d45fa4ad 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -880,3 +880,159 @@ jobs: compression-level: 0 overwrite: false include-hidden-files: false + + release-catalog-deployment-live: + name: Consume the public catalog and prove the cluster release on K3s + needs: release-set + if: always() && needs.release-set.result == 'success' && inputs.release_scope != 'local' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: read + attestations: read + 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: 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: Install verified kubectl v1.34.3 + shell: bash + run: | + set -euo pipefail + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/kubectl" \ + https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256 + test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \ + "$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)" + chmod 0755 "${RUNNER_TEMP}/kubectl" + + - name: Consume and independently audit the published OCI catalog + id: catalog-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 + shell: bash + run: | + set -euo pipefail + umask 077 + owner="${REPOSITORY_OWNER,,}" + source_repository="${SOURCE_REPOSITORY,,}" + private_root="${RUNNER_TEMP}/ql3-release-catalog-consumer" + bundle="${private_root}/bundle" + token="${private_root}/github-token" + install -d -m 0700 "${private_root}" + 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}" + 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}" + rm -f "${token}" + trap - EXIT + echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}" + echo "repository-owner=${owner}" >> "${GITHUB_OUTPUT}" + echo "source-repository=${source_repository}" >> "${GITHUB_OUTPUT}" + + - name: Preload the digest-reviewed K3s distribution + shell: bash + run: | + set -euo pipefail + docker pull \ + rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c + docker tag \ + rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c \ + rancher/k3s:v1.34.3-k3s1 + + - name: Prove catalog-bound install and fenced retirement on three K3s nodes + env: + QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl + QL3_DEPLOYMENT_LIVE_REPORT: ${{ runner.temp }}/ql3-release-catalog-deployment/report.json + QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: ${{ steps.catalog-consumption.outputs.bundle }} + QL3_RELEASE_SOURCE_REVISION: ${{ github.sha }} + QL3_RELEASE_SOURCE_REF: ${{ github.ref }} + QL3_RELEASE_SCOPE: ${{ inputs.release_scope }} + QL3_RELEASE_REPOSITORY_OWNER: ${{ steps.catalog-consumption.outputs.repository-owner }} + QL3_RELEASE_SOURCE_REPOSITORY: ${{ steps.catalog-consumption.outputs.source-repository }} + shell: bash + run: | + set -euo pipefail + umask 077 + install -d -m 0700 "$(dirname "${QL3_DEPLOYMENT_LIVE_REPORT}")" + node scripts/ql3-kubernetes-deployment-live-contract.cjs > \ + "${QL3_DEPLOYMENT_LIVE_REPORT}" + node -e \ + "const fs=require('node:fs');const r=JSON.parse(fs.readFileSync(process.env.QL3_DEPLOYMENT_LIVE_REPORT,'utf8'));const d=/^sha256:[a-f0-9]{64}$/;if(r.cleanupComplete!==true||r.receiptAuditCompatible!==true||r.retirement?.receiptAuditCompatible!==true||r.retirement?.targetAbsent!==true||r.retirement?.uidResourceVersionDeletePreconditions!==true||r.releaseAuthority?.mode!=='verified_release_catalog'||r.releaseAuthority?.sourceRevision!==process.env.GITHUB_SHA||r.releaseAuthority?.sourceRef!==process.env.GITHUB_REF||r.releaseAuthority?.scope!==process.env.QL3_RELEASE_SCOPE||!d.test(r.releaseAuthority?.releaseSetDigest||'')||!d.test(r.releaseAuthority?.catalogManifestDigest||'')||!d.test(r.releaseAuthority?.catalogConsumptionDigest||''))process.exit(1)" + + - name: Verify isolated release-gate cleanup + if: always() + shell: bash + run: | + set -euo pipefail + test -z "$(docker ps -aq --filter name=ql3-deploy-live-)" + test -z "$(docker network ls -q --filter name=ql3-deploy-live-)" + + - name: Upload content-free catalog deployment evidence + if: always() + 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 + if-no-files-found: warn + retention-days: 90 + compression-level: 9 + 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 4b9fc996..9c6ac3f4 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,35 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-17 +- 最后更新:2026-08-18 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-17): +最新增量证据(2026-08-18): + +- D-344/ADR-0436(已接受;首份真实公开 catalog evidence 待实际 release tag):镜像发布流水线不再停在 durable catalog 与 + receipt。`release-set` 成功后新增独立只读 `release-catalog-deployment-live`,仅在 `cluster|all` scope 运行;它不继承 publisher + 文件或登录态,而是从公开 discovery ref 重新解析稳定 digest,以 exact Cosign workflow identity 和 GitHub source tag/revision + provenance 验证 immutable catalog,下载并离线重审 exact-three-file consumption bundle,再把同一次 audit 得到的 release-set 对象 + 交给 deployment-lock materializer。生成的 Kubernetes v2 lock 绑定真实 release-set、catalog manifest 与 consumption report digest, + 随后在 digest-reviewed 三节点 K3s 上执行 7-resource、四角色零副本 install、Head commit、UID/resourceVersion 围栏 ConfigMap 退役与两个 + receipt audit。consumer 只有 `contents|packages|attestations:read`,无 Docker login、package/tag/attestation write 或签名 authority; + regctl/Cosign 使用公开读取,package visibility 错误会阻断发布。配置必须一次给齐 bundle/source revision/ref/scope/owner/repository 六项, + 缺一项或 Local scope 都在 K3s 启动前失败关闭;普通 PR live 保留显式 `synthetic_live_fixture`,不混淆为公开发布证据。新增工作只存在于 + Cluster release runner,没有新增 package、生产依赖、controller、CRD、RBAC、Pod、数据库、migration、listener、timer 或常驻进程; + Local/Edge/Standalone 发布、制品与稳态资源保持不变。定向发布/部署契约 109/109;完整 backend 1,344 项为 1,342 pass/ + 2 条件 skip/0 fail,18-package clean build/test 退出 0。14 项有效架构/发布/部署审计 compatible,package boundary 仍为 18 packages、 + `singleSourcePackages=[]`、`shallowSourcePackages=[]`;14 档 Local artifact 全部 compatible 且字节数与 D-343 完全一致。catalog-bound + 三节点 K3s live 在 linux/arm64 上得到 3 个 Ready node,以一份真实执行六步 ceremony、可完整离线重审的 fixture bundle 物化同一 + release-set;Head generation `1→2`、inventory `7→6`,install/retirement receipt audit、UID/resourceVersion DELETE preconditions、 + Unix socket proxy、server-side dry-run/apply、目标 absent 与清理全部通过。release-set/catalog manifest/consumption report digest 分别为 + `sha256:e407cddb6348f96a8fef0822589c73415912be7397205669f2072d70c5bda035`、 + `sha256:74abea141364570ac803bdc9dd6da1dcbe10ea694e6b7e0f3f7a56a2f84ba55a`、 + `sha256:2a9b93dcf19984c90a628b8987a04620f31ec0acb62c672bfe912c19bc458b2b`,最终 retirement receipt digest 为 + `sha256:e20532f2f2cd3fce3e7985e4bb3b4b75dc87ca3b59d7f9530ad9d8e02694d1f3`,Docker container/network 残留为 0。 + PostgreSQL 18.6/arm64 HA 为 142/142 gates、timeline `1→2`,独立 evidence audit compatible;mode-0600 报告 SHA-256 为 + `796e4b85a9f45c3b63537010057c1c91c6011ecfab2f2f289763b4915037d9dd`,container/network/volume 残留为 0。仓库 fixture 证明代码路径与 + 真实 Kubernetes mutation,但不能冒充 GHCR 在线验签;第一份公开 GHCR/Cosign/GitHub 成功记录仍必须由实际受保护 `v3` release tag + dispatch 产生。 - D-343/ADR-0435(已接受;真实公开 catalog 运行仍待 release Gate):在 D-342 的 resource-inventory-closed Head 上增加独立 `cluster.deployment.retirement.preflight|apply|receipt.audit`。退役目标只能来自 current locked manifest 与 committed active inventory, diff --git a/docs/adr/ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md b/docs/adr/ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md new file mode 100644 index 00000000..dabc837b --- /dev/null +++ b/docs/adr/ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md @@ -0,0 +1,100 @@ +# ADR-0436:发布后 Catalog-bound Kubernetes Release Gate + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-336、D-338、D-339、D-341、D-343、D-344 +- 关联 ADR:ADR-0428、ADR-0430、ADR-0431、ADR-0433、ADR-0434、ADR-0435 + +## 上下文 + +ADR-0428 至 ADR-0435 已分别建立持久 OCI catalog、可信工作站消费、catalog-bound deployment lock、目标侧 Kubernetes +install/upgrade/rollback、完整 inventory Head 与 UID/resourceVersion 围栏退役。但发布流水线仍在 durable catalog 与 receipt 上传后结束; +独立 K3s live 门使用合成 lock,只能证明目标侧状态机,不能证明新发布的公开 catalog 能被一个没有发布权限的下游消费者重新发现、验签、 +下载、重建 deployment lock 并送入同一目标 ceremony。 + +把发布者 job 内已经读取过的文件直接交给 K3s 会掩盖 package visibility、discovery、Cosign workflow identity、GitHub source +tag/revision provenance、OCI raw manifest round-trip 或跨 job 权限错误。反过来,给低配 Local/Edge 设备安装 regctl、Cosign、GitHub CLI、 +Node 或 K3s 又会破坏分层部署边界。因此缺口应由发布时的一次性 Cluster release gate 闭合,而不是进入产品运行时。 + +## 决策 + +1. `ql3-image-release.yml` 在 `release-set` 成功后增加独立 `release-catalog-deployment-live` job。它仅为 `cluster|all` scope 运行; + `local` scope 明确跳过。job 使用 GitHub-hosted runner、30 分钟硬超时和只读 `contents|packages|attestations` 权限,不拥有 package write、 + OIDC signing、attestation write、Docker login、tag promotion 或 catalog mutation authority。 +2. 下游 job 不下载发布 job 的 release-set artifact 作为部署 authority。它从 + `ghcr.io//qinglong3-release-catalog:v-` 重新发现 digest,前后两次解析必须稳定;随后只使用 immutable catalog + reference,按 ADR-0430 验证 exact Cosign workflow identity 与 GitHub source tag/revision provenance,并发布 exact-three-file、 + owner-private consumption bundle。regctl 与 Cosign 不接收 registry credential;公开读取失败即阻断 Cluster release。 +3. 工具链固定为 checksum-pinned `regctl@0.11.5`、immutable-action 安装的 Cosign、canonical GitHub CLI、checksum-verified kubectl + v1.34.3 和 digest-reviewed K3s v1.34.3+k3s1。短期 GitHub token 只通过 mode-0600 文件进入 GitHub provenance verifier,ceremony + 后立即删除;bundle 与最终 evidence 不包含 credential。 +4. Kubernetes live contract 增加严格 all-or-none 的 release identity 环境输入。配置完整时先离线重审 consumption bundle,再把其中同一 + release-set 对象交给既有 `createKubernetesLock`,生成绑定 release-set/catalog manifest/consumption report digest 的 v2 lock;缺少 + 任一 identity 字段、scope 为 local、角色镜像不唯一或 bundle 漂移都在启动 K3s 前失败关闭。普通 PR/live workflow 不提供这些字段, + 继续使用明确标注为 `synthetic_live_fixture` 的状态机 fixture。 +5. release gate 使用一个有界 7-resource manifest:Namespace、四个零副本角色 Deployment、Plugin Package admission ConfigMap 和一个 + 可退役 ConfigMap。真实 catalog 中 `control|control-ai|admin|worker` 的 immutable image reference 被写入 API Server,但零副本门不拉取 + 或启动业务镜像;镜像图、签名和 provenance 由上游发布/消费门负责,目标门负责证明 catalog authority 到 server-side apply、Head、 + inventory 与 retirement receipt 的连续性。 +6. 隔离的三节点 K3s 执行 install preflight/apply/receipt audit,再对目标 ConfigMap 执行 UID/resourceVersion-preconditioned retirement + preflight/apply/receipt audit。成功证据必须声明 `verified_release_catalog`、三个 catalog/release digest、source revision/ref/scope、 + Head 收敛、目标 absent 与清理完成;不保存 bundle 正文、token、kubeconfig、command 或目标集群长期 credential。 +7. 该 Gate 不新增 workspace package、生产依赖、数据库、migration、controller、CRD、webhook、ServiceAccount、Pod、listener、timer + 或常驻进程。Local/Edge/Standalone 的镜像、导入图和稳态资源不变;集群用户也只在发布流水线承担一次性验证成本。 +8. 第一份真实成功 evidence 只能由实际受保护 `v3` release tag 的 workflow dispatch 产生。仓库内 fixture、离线 bundle 和本机 K3s + 回归可以验证代码与失败关闭语义,但不能冒充尚未发生的公开 GHCR/Cosign/GitHub 在线 ceremony。 + +## 部署与资源影响 + +- 只在 Cluster 发布时新增一次 GitHub-hosted runner job;三节点 K3s、kubectl proxy、私有目录和 Docker network/container 全部短生命周期。 +- 四个业务 Deployment 固定 `replicas=0`,不下载或启动 QingLong 业务镜像;因此该 Gate 的成本由 K3s control-plane 与 API 操作主导, + 不把完整 Cluster 数据面启动时间混入 supply-chain 判定。 +- Local-only 发布无该 job;路由器、NAS、Edge 与 Standalone 用户不安装 catalog 工作站工具,也不运行 Kubernetes。 +- 发布者与消费者权限分离。consumer 不能修复 package visibility 或 tag;它只能让发布失败,并由维护者修正发布配置后对同一 tag 重跑。 + +## 被拒绝的替代方案 + +### 在 `release-set` publisher job 内直接运行 K3s + +拒绝。它复用发布者登录态、文件和写权限,不能证明一个真实下游只读消费者可以独立取得 catalog。 + +### 从 90 天 workflow artifact 生成 deployment lock + +拒绝。短期 artifact 不是长期发现入口,也绕过 immutable OCI manifest、Cosign 与 GitHub OCI provenance 的重新验证。 + +### 只解析 mutable discovery tag 一次 + +拒绝。tag 只有发现权;ceremony 必须固定 immutable digest,并在下载与验证后再次确认 discovery 未变化。 + +### 为 Local/Edge 也运行 K3s 或下发验签工具 + +拒绝。Local image 已有独立 Compose digest rollout gate;Cluster K3s 门不能成为低资源部署的发布或稳态依赖。 + +### 在 release gate 启动四类真实业务 Pod + +拒绝。业务启动还依赖数据库、Secret、Policy 与各自 lifecycle gate,会把 supply-chain/目标 mutation 证明扩展成不稳定的全栈验收。此门只验证 +immutable image authority 被目标 API 接受和 Head/receipt 闭包,不重复镜像构建与各产品 live contract。 + +## 验证 + +- `ql3ClusterImageReleaseAudit` 冻结 job dependency、scope、只读权限、11 个精确步骤、三套 pinned 工具、公开 catalog create+offline audit、 + digest-reviewed K3s、catalog-bound live 输入、隔离清理与 content-free evidence,并包含移除 Gate、错误 Local 条件和 consumer 写权限的负向用例; +- `ql3DeploymentLockContract` 验证完整/部分环境选择、cluster/all scope、同一 audited release-set 到 v2 lock 的物化、四角色引用、7-resource + closure、五个受影响资源的 catalog annotations 与退役目标存在; +- 发布/部署定向契约 109/109;完整 backend 1,344 项为 1,342 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0; +- 14 项有效架构/发布/部署审计与 14 档 Local artifact 全部 compatible;package boundary 为 18,`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`,所有 Local artifact 字节数与 D-343 一致; +- 使用真实执行六步 catalog ceremony 的可离线重审 fixture bundle,在 linux/arm64 三节点 K3s v1.34.3+k3s1 上完成 catalog-bound + lock、7-resource install、Head `1→2`、inventory `7→6` 和 UID/resourceVersion 围栏退役;两个 receipt audit、target absent、Unix + socket proxy 与 cleanup 全部通过,最终 retirement receipt digest 为 + `sha256:e20532f2f2cd3fce3e7985e4bb3b4b75dc87ca3b59d7f9530ad9d8e02694d1f3`; +- PostgreSQL 18.6/arm64 HA 为 142/142、timeline `1→2`,独立 evidence audit compatible,报告 SHA-256 为 + `796e4b85a9f45c3b63537010057c1c91c6011ecfab2f2f289763b4915037d9dd`,所有 HA Docker 资源清理为零; +- 真实公开 registry/Cosign/GitHub ceremony 仍必须由第一份实际受保护 release tag 运行记录闭合,本机 fixture 不计作线上成功证据。 + +## 规范依据 + +- [GitHub artifact attestations verification](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/verifying-the-authenticity-of-artifacts) +- [Sigstore Cosign verify](https://docs.sigstore.dev/cosign/verifying/verify/) +- [OCI image-spec manifest](https://github.com/opencontainers/image-spec/blob/main/manifest.md) +- [Kubernetes server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) diff --git a/docs/adr/README.md b/docs/adr/README.md index 2759120d..6615b6ec 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -439,6 +439,7 @@ | [ADR-0433](./ADR-0433-catalog-bound-kubernetes-target-deployment-ceremony.md) | Catalog-bound Kubernetes 目标部署 Ceremony | Accepted | | [ADR-0434](./ADR-0434-resource-inventory-closed-kubernetes-deployment-head.md) | Resource-inventory-closed Kubernetes Deployment Head | Accepted | | [ADR-0435](./ADR-0435-uid-resource-version-fenced-kubernetes-resource-retirement.md) | UID/resourceVersion 围栏的 Kubernetes 资源退役 Ceremony | Accepted | +| [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index d0d04d16..f9a14b86 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -13,6 +13,19 @@ ghcr.io//qinglong3-release-catalog:v- `ghcr.io//qinglong3-release-catalog@sha256:`,验证这个 immutable reference 后,再从 JSON 的 `images[].reference` 读取完整 `ghcr.io//@sha256:`。 +## 发布流水线内置 Cluster 下游门 + +`ql3-image-release.yml` 在 durable catalog、签名、provenance、receipt 和 90 天便利 bundle 全部发布后,才启动独立 +`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 不能替代它。 + ## 选择 scope | 部署类型 | release scope | 必须出现的镜像 | @@ -393,7 +406,9 @@ GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossReposit 失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证 每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有 release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family -可部署。 +可部署。对于 `cluster|all`,还必须等待只读 catalog consumer 与 catalog-bound K3s deployment/retirement Gate 成功;publisher 成功而 +consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention +或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。 workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORM,release owner 仍须维护 package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程都不得改写 canonical JSON,并须保留 diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index cd408f9d..06072c52 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -410,6 +410,8 @@ function auditReleaseWorkflow(source) { const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability']; const publishJob = workflow?.jobs?.publish; const releaseSetJob = workflow?.jobs?.['release-set']; + const catalogDeploymentJob = + workflow?.jobs?.['release-catalog-deployment-live']; if ( publishJob?.strategy?.matrix?.include !== '${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}' @@ -471,6 +473,12 @@ function auditReleaseWorkflow(source) { 'id-token': 'write', attestations: 'write', 'artifact-metadata': 'write', + }) || + JSON.stringify(catalogDeploymentJob?.permissions) !== + JSON.stringify({ + contents: 'read', + packages: 'read', + attestations: 'read', }) ) { throw new Error( @@ -769,6 +777,85 @@ function auditReleaseWorkflow(source) { 'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle', ); } + const catalogDeploymentSteps = catalogDeploymentJob?.steps; + if ( + catalogDeploymentJob?.needs !== 'release-set' || + catalogDeploymentJob?.if !== + "always() && needs.release-set.result == 'success' && inputs.release_scope != 'local'" || + catalogDeploymentJob?.['runs-on'] !== 'ubuntu-24.04' || + catalogDeploymentJob?.['timeout-minutes'] !== 30 || + !Array.isArray(catalogDeploymentSteps) || + catalogDeploymentSteps.length !== 11 || + catalogDeploymentSteps[0]?.uses !== + 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || + catalogDeploymentSteps[0]?.with?.['persist-credentials'] !== false || + catalogDeploymentSteps[1]?.uses !== + 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' || + catalogDeploymentSteps[1]?.with?.['node-version'] !== '24.18.0' || + !/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts/u.test( + catalogDeploymentSteps[2]?.run ?? '', + ) || + !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test( + catalogDeploymentSteps[3]?.run ?? '', + ) || + catalogDeploymentSteps[4]?.uses !== + 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || + !/v1\.34\.3\/bin\/linux\/amd64\/kubectl[\s\S]*kubectl\.sha256[\s\S]*sha256sum/u.test( + catalogDeploymentSteps[5]?.run ?? '', + ) || + catalogDeploymentSteps[6]?.id !== 'catalog-consumption' || + !/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test( + catalogDeploymentSteps[6]?.run ?? '', + ) || + !/docker pull[\s\S]*rancher\/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c[\s\S]*docker tag[\s\S]*rancher\/k3s:v1\.34\.3-k3s1/u.test( + catalogDeploymentSteps[7]?.run ?? '', + ) || + JSON.stringify(catalogDeploymentSteps[8]?.env) !== + JSON.stringify({ + QL3_KUBECTL_BIN: '${{ runner.temp }}/kubectl', + QL3_DEPLOYMENT_LIVE_REPORT: + '${{ runner.temp }}/ql3-release-catalog-deployment/report.json', + QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: + '${{ steps.catalog-consumption.outputs.bundle }}', + QL3_RELEASE_SOURCE_REVISION: '${{ github.sha }}', + QL3_RELEASE_SOURCE_REF: '${{ github.ref }}', + QL3_RELEASE_SCOPE: '${{ inputs.release_scope }}', + QL3_RELEASE_REPOSITORY_OWNER: + '${{ steps.catalog-consumption.outputs.repository-owner }}', + QL3_RELEASE_SOURCE_REPOSITORY: + '${{ steps.catalog-consumption.outputs.source-repository }}', + }) || + !/ql3-kubernetes-deployment-live-contract\.cjs[\s\S]*verified_release_catalog[\s\S]*releaseSetDigest[\s\S]*catalogManifestDigest[\s\S]*catalogConsumptionDigest/u.test( + catalogDeploymentSteps[8]?.run ?? '', + ) || + catalogDeploymentSteps[9]?.if !== 'always()' || + !/docker ps -aq --filter name=ql3-deploy-live-[\s\S]*docker network ls -q --filter name=ql3-deploy-live-/u.test( + catalogDeploymentSteps[9]?.run ?? '', + ) || + catalogDeploymentSteps[10]?.if !== 'always()' || + catalogDeploymentSteps[10]?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(catalogDeploymentSteps[10]?.with) !== + JSON.stringify({ + name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}', + path: '${{ runner.temp }}/ql3-release-catalog-deployment/report.json', + 'if-no-files-found': 'warn', + 'retention-days': 90, + 'compression-level': 9, + overwrite: false, + 'include-hidden-files': false, + }) || + catalogDeploymentSteps.some( + (step) => + /(?:artifact put|\bimage copy\b|cosign sign)/u.test(step.run ?? '') || + step.uses?.startsWith('actions/attest@') || + step.uses?.startsWith('docker/login-action@'), + ) + ) { + throw new Error( + 'cluster release must read the newly published immutable catalog, reconstruct its deployment lock and prove install plus fenced retirement on isolated K3s without publication authority', + ); + } if ( !Array.isArray(evidenceJob?.steps) || evidenceJob.steps.length !== 3 || @@ -856,20 +943,20 @@ function auditReleaseWorkflow(source) { requireOccurrences( source, /uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g, - 6, + 7, 'all release jobs must pin the reviewed immutable checkout action', ); requireOccurrences( source, /uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g, - 6, + 7, 'all release jobs must pin the reviewed immutable Node setup action', ); requireOccurrences( source, /uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g, - 2, - 'image and release-catalog publishers must both pin the reviewed Cosign installer', + 3, + 'image publisher, catalog publisher and catalog consumer must pin the reviewed Cosign installer', ); requirePattern( source, @@ -909,8 +996,8 @@ function auditReleaseWorkflow(source) { requireExactOccurrences( source, /c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g, - 2, - 'both image and release-set publishers must checksum-pin the exact regctl OCI copier', + 3, + 'image publisher, release-set publisher and catalog consumer must checksum-pin the exact regctl OCI copier and reader', ); requirePattern( source, @@ -1103,6 +1190,15 @@ function auditReleaseWorkflow(source) { immutableDigestAuthority: 'verified', receiptAttested: true, }, + catalogDeploymentGate: { + scopes: ['cluster', 'all'], + catalogAuthority: 'immutable_digest_after_public_consumption', + deploymentLockReconstructed: true, + k3sNodes: 3, + installReceiptAudited: true, + fencedRetirementReceiptAudited: true, + publicationAuthority: false, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -1114,6 +1210,8 @@ function auditReleaseWorkflow(source) { 'release-candidate', 'release-set', 'durable-catalog', + 'catalog-consumption', + 'catalog-bound-k3s-deployment', 'release-tags', ], }; diff --git a/scripts/ql3-kubernetes-deployment-live-contract.cjs b/scripts/ql3-kubernetes-deployment-live-contract.cjs index e6bd709e..b6e15adf 100644 --- a/scripts/ql3-kubernetes-deployment-live-contract.cjs +++ b/scripts/ql3-kubernetes-deployment-live-contract.cjs @@ -15,6 +15,10 @@ const { } = require('./lib/ql3-kubernetes-deployment-ceremony.cjs'); const { K3sDockerLiveFixture } = require('./lib/ql3-k3s-docker-live.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); +const { createKubernetesLock } = require('./ql3-deployment-lock-contract.cjs'); +const { + auditCeremonyBundle, +} = require('./ql3-release-catalog-consumption-ceremony.cjs'); const ROOT = path.resolve(__dirname, '..'); const VERSION = readReleaseIdentity(ROOT).version; @@ -203,6 +207,162 @@ function lockedArtifacts() { }); } +function catalogSourceManifest(imageReferences) { + const annotations = { + 'qinglong.io/live-contract': 'verified-release-catalog', + }; + const deployment = (role) => ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: `ql3-${role}`, + namespace: NAMESPACE, + annotations, + }, + spec: { + replicas: 0, + selector: { matchLabels: { 'app.kubernetes.io/name': `ql3-${role}` } }, + template: { + metadata: { + labels: { 'app.kubernetes.io/name': `ql3-${role}` }, + annotations, + }, + spec: { + containers: [ + { + name: role, + image: imageReferences[role], + command: ['/bin/false'], + }, + ], + }, + }, + }, + }); + return `${[ + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { name: NAMESPACE }, + }, + ...ROLE_ORDER.map(deployment), + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'ql3-plugin-package-secret-action-admission', + namespace: NAMESPACE, + annotations, + }, + data: { image: imageReferences.admin }, + }, + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'ql3-retirement-live-target', + namespace: NAMESPACE, + annotations, + }, + data: { purpose: 'catalog-bound-retirement-live-contract' }, + }, + ] + .map((resource) => JSON.stringify(resource)) + .join('\n---\n')}\n`; +} + +function catalogLockedArtifacts(options, dependencies = {}) { + const audit = dependencies.auditCeremonyBundle ?? auditCeremonyBundle; + const materialize = dependencies.createKubernetesLock ?? createKubernetesLock; + const identity = Object.freeze({ + version: VERSION, + sourceRevision: options?.sourceRevision, + sourceRef: options?.sourceRef, + releaseScope: options?.releaseScope, + repositoryOwner: options?.repositoryOwner, + sourceRepository: options?.sourceRepository, + }); + if (!['cluster', 'all'].includes(identity.releaseScope)) { + fail('release catalog live deployment requires cluster or all scope'); + } + const consumption = audit({ + ...identity, + outputDirectory: options?.consumptionBundle, + }); + const imageReferences = Object.freeze( + Object.fromEntries( + ROLE_ORDER.map((role) => { + const images = consumption.releaseSet?.images?.filter( + (image) => image.name === role, + ); + if (images?.length !== 1) { + fail(`verified release catalog is missing one ${role} image`); + } + return [role, images[0].reference]; + }), + ), + ); + const sourceManifest = catalogSourceManifest(imageReferences); + const created = materialize(consumption.releaseSet, sourceManifest, { + ...identity, + consumption, + requiredImages: ROLE_ORDER.join(','), + }); + return Object.freeze({ + manifest: created.outputManifest, + report: created.report, + imageReferences, + releaseAuthority: Object.freeze({ + mode: 'verified_release_catalog', + version: VERSION, + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + scope: identity.releaseScope, + releaseSetDigest: consumption.releaseSetDigest, + catalogManifestDigest: consumption.catalogManifestDigest, + catalogConsumptionDigest: consumption.contentDigest, + immutableReference: consumption.immutableReference, + }), + }); +} + +function deploymentArtifacts(environment = process.env, dependencies = {}) { + const configuration = { + consumptionBundle: environment.QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE, + sourceRevision: environment.QL3_RELEASE_SOURCE_REVISION, + sourceRef: environment.QL3_RELEASE_SOURCE_REF, + releaseScope: environment.QL3_RELEASE_SCOPE, + repositoryOwner: environment.QL3_RELEASE_REPOSITORY_OWNER, + sourceRepository: environment.QL3_RELEASE_SOURCE_REPOSITORY, + }; + const values = Object.values(configuration); + const configured = values.filter( + (value) => typeof value === 'string' && value.length > 0, + ).length; + if (configured === 0) { + const artifacts = lockedArtifacts(); + return Object.freeze({ + ...artifacts, + releaseAuthority: Object.freeze({ + mode: 'synthetic_live_fixture', + version: artifacts.report.release.version, + sourceRevision: artifacts.report.release.sourceRevision, + sourceRef: artifacts.report.release.sourceRef, + scope: artifacts.report.release.scope, + releaseSetDigest: artifacts.report.releaseSetDigest, + catalogManifestDigest: artifacts.report.catalog.manifestDigest, + catalogConsumptionDigest: + artifacts.report.catalog.consumptionReportDigest, + immutableReference: artifacts.report.catalog.immutableReference, + }), + }); + } + if (configured !== values.length) { + fail('release catalog live deployment configuration is incomplete'); + } + return catalogLockedArtifacts(configuration, dependencies); +} + function writeCommand(directory, name, operation, request) { return privateFile( directory, @@ -216,10 +376,10 @@ function writeCommand(directory, name, operation, request) { ); } -async function main() { +async function main(environment = process.env) { const fixture = new K3sDockerLiveFixture({ prefix: 'ql3-deploy-live', - kubectl: process.env.QL3_KUBECTL_BIN, + kubectl: environment.QL3_KUBECTL_BIN, }); let evidence; let cleanupComplete = false; @@ -241,7 +401,7 @@ async function main() { kind: 'Namespace', metadata: { name: NAMESPACE }, }); - const artifacts = lockedArtifacts(); + const artifacts = deploymentArtifacts(environment); const manifestPath = privateFile( ceremonyDirectory, 'locked.yaml', @@ -503,6 +663,7 @@ async function main() { deploymentDigest: deploymentHead.deployment.deploymentDigest, resourceInventoryCount: deploymentHead.deployment.resources.length, }, + releaseAuthority: artifacts.releaseAuthority, preflightDigest: preflight.preflightDigest, receiptDigest: receipt.receiptDigest, receiptAuditCompatible: audit.compatible, @@ -545,4 +706,9 @@ if (require.main === module) { }); } -module.exports = Object.freeze({ lockedArtifacts, main }); +module.exports = Object.freeze({ + catalogLockedArtifacts, + deploymentArtifacts, + lockedArtifacts, + main, +}); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index b564df92..556cf468 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -115,6 +115,15 @@ test('accepts the reviewed native CI and digest release contracts', () => { immutableDigestAuthority: 'verified', receiptAttested: true, }, + catalogDeploymentGate: { + scopes: ['cluster', 'all'], + catalogAuthority: 'immutable_digest_after_public_consumption', + deploymentLockReconstructed: true, + k3sNodes: 3, + installReceiptAudited: true, + fencedRetirementReceiptAudited: true, + publicationAuthority: false, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -126,6 +135,8 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'release-candidate', 'release-set', 'durable-catalog', + 'catalog-consumption', + 'catalog-bound-k3s-deployment', 'release-tags', ], }, @@ -565,7 +576,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/, + /privileged publisher|immutable checkout action|release-set job|cluster release must read/, ); }); @@ -718,13 +729,53 @@ test('rejects an image tag created before digest verification completes', () => }); test('rejects any publisher step after the deployment lock is published', () => { - const mutated = `${releaseSource}\n - name: Post-promotion mutation\n run: echo unsafe\n`; + const marker = '\n release-catalog-deployment-live:'; + assert.equal(releaseSource.includes(marker), true); + const mutated = releaseSource.replace( + marker, + '\n - name: Post-promotion mutation\n run: echo unsafe\n' + + marker, + ); assert.throws( () => auditReleaseWorkflow(mutated), /release-set job must download only same-run records/, ); }); +test('rejects removal of the downstream catalog deployment release gate', function rejectsMissingCatalogDeploymentGate() { + const marker = '\n release-catalog-deployment-live:'; + const offset = releaseSource.indexOf(marker); + assert.notEqual(offset, -1); + assert.throws( + () => auditReleaseWorkflow(releaseSource.slice(0, offset)), + /keep evidence read-only|cluster release must read the newly published immutable catalog/, + ); +}); + +test('rejects running the cluster deployment gate for Local-only releases', function rejectsLocalCatalogDeploymentGate() { + const mutated = releaseSource.replace( + "inputs.release_scope != 'local'", + "inputs.release_scope == 'local'", + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /cluster release must read the newly published immutable catalog/, + ); +}); + +test('rejects publication authority in the post-publish catalog consumer', function rejectsCatalogConsumerPublicationAuthority() { + const marker = '\n release-catalog-deployment-live:'; + const offset = releaseSource.indexOf(marker); + assert.notEqual(offset, -1); + const mutated = `${releaseSource.slice(0, offset)}${releaseSource + .slice(offset) + .replace('packages: read', 'packages: write')}`; + assert.throws( + () => auditReleaseWorkflow(mutated), + /keep evidence read-only|without publication authority/, + ); +}); + test('rejects release-set closure before every image publisher succeeds', () => { const mutated = releaseSource.replace( " needs.publish.result == 'success'", diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs index abeaca76..60aa03f0 100644 --- a/test/back/ql3DeploymentLockContract.test.cjs +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -38,6 +38,10 @@ const { const { readReleaseIdentity, } = require('../../scripts/lib/ql3-release-identity.cjs'); +const { + catalogLockedArtifacts, + deploymentArtifacts, +} = require('../../scripts/ql3-kubernetes-deployment-live-contract.cjs'); const root = path.resolve(__dirname, '../..'); const version = readReleaseIdentity(root).version; @@ -314,6 +318,112 @@ function kubernetesOptions(set, extra = {}) { }); } +test('materializes the Kubernetes live lock from one audited catalog consumption bundle', function materializesCatalogBoundLiveLock() { + const set = releaseSet('cluster'); + const consumption = Object.freeze({ + ...consumptionAuthority(set), + releaseSet: set, + }); + const consumptionBundle = '/private/ql3-release-catalog-consumption'; + let auditedOptions; + const artifacts = catalogLockedArtifacts( + { + ...identity, + releaseScope: 'cluster', + consumptionBundle, + }, + { + auditCeremonyBundle(options) { + auditedOptions = options; + return consumption; + }, + }, + ); + assert.deepEqual(auditedOptions, { + ...identity, + releaseScope: 'cluster', + outputDirectory: consumptionBundle, + }); + assert.equal(artifacts.report.releaseSetDigest, set.releaseSetDigest); + assert.equal( + artifacts.report.catalog.manifestDigest, + consumption.catalogManifestDigest, + ); + assert.equal(artifacts.releaseAuthority.mode, 'verified_release_catalog'); + assert.equal( + artifacts.releaseAuthority.catalogConsumptionDigest, + consumption.contentDigest, + ); + assert.deepEqual(artifacts.imageReferences, references(set)); + assert.equal(artifacts.report.manifest.resources, 7); + assert.equal(artifacts.report.manifest.changedResources, 5); + assert.equal(artifacts.report.manifest.admissionAuthorityCount, 1); + const resources = []; + yaml.loadAll(artifacts.manifest, (resource) => resources.push(resource)); + assert.equal(resources.length, 7); + assert.equal( + resources.find( + (resource) => resource.metadata?.name === 'ql3-retirement-live-target', + )?.data?.purpose, + 'catalog-bound-retirement-live-contract', + ); + for (const resource of resources.filter( + (entry) => entry.kind === 'Deployment', + )) { + assert.equal( + resource.metadata.annotations['qinglong.io/release-set-digest'], + set.releaseSetDigest, + ); + assert.match( + resource.spec.template.spec.containers[0].image, + /^ghcr\.io\/qinglong-release\/qinglong3-[a-z-]+@sha256:[a-f0-9]{64}$/u, + ); + } +}); + +test('selects catalog-backed live artifacts only for one complete environment', function selectsCompleteCatalogLiveEnvironment() { + assert.equal( + deploymentArtifacts({}).releaseAuthority.mode, + 'synthetic_live_fixture', + ); + assert.throws( + () => + deploymentArtifacts({ + QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: '/private/bundle', + }), + /configuration is incomplete/u, + ); + assert.throws( + () => + catalogLockedArtifacts({ + ...identity, + releaseScope: 'local', + consumptionBundle: '/private/bundle', + }), + /requires cluster or all scope/u, + ); + const set = releaseSet('all'); + const consumption = Object.freeze({ + ...consumptionAuthority(set), + releaseSet: set, + }); + const environment = { + QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: '/private/bundle', + QL3_RELEASE_SOURCE_REVISION: identity.sourceRevision, + QL3_RELEASE_SOURCE_REF: identity.sourceRef, + QL3_RELEASE_SCOPE: 'all', + QL3_RELEASE_REPOSITORY_OWNER: identity.repositoryOwner, + QL3_RELEASE_SOURCE_REPOSITORY: identity.sourceRepository, + }; + const artifacts = deploymentArtifacts(environment, { + auditCeremonyBundle: () => consumption, + }); + assert.equal(artifacts.releaseAuthority.mode, 'verified_release_catalog'); + assert.equal(artifacts.releaseAuthority.scope, 'all'); + assert.equal(artifacts.report.release.scope, 'all'); + assert.equal(artifacts.report.requiredImages.length, 4); +}); + test('selects one immutable Local Compose image without adding device work', () => { for (const scope of ['local', 'all']) { const set = releaseSet(scope);