diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 947c2793..4c0c92ac 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/ql3ReleasePublicationClosureContract.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3DeploymentLockContract.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index aba93b17..ca7a6328 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -810,51 +810,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Promote tags only after the complete set is verified - env: - REGCTL: ${{ runner.temp }}/regctl - RELEASE_SET: ${{ steps.release-set.outputs.report }} - run: | - set -euo pipefail - node <<'NODE' - const fs = require('node:fs'); - const { spawnSync } = require('node:child_process'); - const report = JSON.parse(fs.readFileSync(process.env.RELEASE_SET, 'utf8')); - const regctl = process.env.REGCTL; - const run = (args, allowFailure = false) => { - const result = spawnSync(regctl, args, { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }); - if (result.error) throw result.error; - if (!allowFailure && result.status !== 0) { - throw new Error(`regctl ${args.join(' ')} failed`); - } - return result; - }; - const states = []; - for (const image of report.images) { - const source = run(['image', 'digest', image.reference]).stdout.trim(); - if (source !== image.digest) throw new Error('source digest drifted before promotion'); - for (const tag of [image.versionTag, image.sourceTag]) { - const current = run(['image', 'digest', tag], true); - if (current.status === 0 && current.stdout.trim() !== image.digest) { - throw new Error('release tag already points at another digest'); - } - states.push({ image, tag, current: current.status === 0 }); - } - } - for (const state of states) { - if (!state.current) { - run(['image', 'copy', state.image.reference, state.tag]); - } - const promoted = run(['image', 'digest', state.tag]).stdout.trim(); - if (promoted !== state.image.digest) { - throw new Error('promoted tag does not resolve to the release-set digest'); - } - } - NODE - - name: Attest the complete release-set file provenance uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 with: @@ -1007,6 +962,168 @@ 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 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index c9bfa8ce..cf8fe215 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,26 @@ 最新增量证据(2026-08-18): +- 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 全部成功之后。新的 + `qinglong/release-publication-plan@v1` 联合复验 exact release-set、catalog plan/raw manifest/manifest digest/receipt,并只列出每个 + image 的 immutable source 与两个目标 tag。publisher 对每个已存在 image repository 取得最大 1 MiB 的 canonical tag inventory, + 严格检查 OCI tag 字符集与无重复;auth/network/registry error 不再被任意 `image digest` 非零退出降级成 absent。所有 immutable source + 和既有目标 tag 必须在任何 mutation 前完成全量预检,冲突保持零 tag 写;随后只 copy absent tag,exact tag 用于 response-loss 重放。 + 最终逐 tag 回读形成 `qinglong/release-publication-tag-observation@v1`,再与上游闭包组成自摘要 + `qinglong/release-publication-closure-receipt@v1`,本地复审并独立 attested 后才上传 deployment bundle。receipt 诚实保留 + `crossRepositoryAtomicity=false` 与 `registryTagCas=false`;immutable catalog digest 仍是唯一部署 authority。实现不新增 package、 + 生产依赖、数据库、migration、Kubernetes object、设备工具或常驻资源;所有 inventory 与收据只存在于短生命周期 release runner, + Edge/Standalone/路由设备和 Cluster 节点稳态成本均为零。阶段门已重跑:专项 closure/workflow 96/96、完整 backend 1382 pass + + 2 条条件 skip、18-package clean build/test 全通过、14/14 静态审计与 14/14 artifact 档位通过;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`,均低于各自硬上限。 + PostgreSQL HA Docker 门以 PostgreSQL 18.6/arm64 完成 142/142、timeline `1→2`,私有报告 SHA-256 为 + `c8c5cad7a7feb6db066b14efcc241f33ed574c3d80b909014bc894d8f9cb7cbf`,证据复审通过且临时容器/卷/网络残留均为零。Barman + live object-store 恢复与 cert-manager 在线轮换仍诚实保持外部 release blocker。真实 GHCR 部分 promotion/response-loss 仍只能由 + 受保护 `v3` tag 或受控 release repository 演练证明。 + - D-349/ADR-0441(已接受;首份真实 GHCR conflict/reuse 证据待实际 release tag):关闭 durable catalog discovery tag 的覆盖窗口。 之前 workflow 虽声明 `v-` 无部署 authority,却直接对该 tag 执行 `artifact put`;已有不同 digest 会先被覆盖, response-loss 重跑也没有“相同复用、冲突拒绝”的可执行分支。catalog plan/receipt 现升级为 v2,publisher 必须先在 runner 私有 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 new file mode 100644 index 00000000..4aa77f75 --- /dev/null +++ b/docs/adr/ADR-0442-catalog-ready-terminal-release-tag-publication.md @@ -0,0 +1,84 @@ +# ADR-0442:Catalog-ready 的终态 Release Tag 发布与闭合收据 + +- 状态:Accepted +- 日期: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”的最早发布顺序 + +## 上下文 + +QingLong 3 的 image publisher 先写入无 tag 的 immutable digest,完成逐镜像签名、SBOM、漏洞与 provenance 验证,再由 +release-set job 聚合所有镜像。ADR-0427 因此允许在完整 release-set 审计后 promotion `versionTag/sourceTag`。 + +后续 ADR 又增加 release-set file provenance、durable OCI catalog、catalog signature/provenance、manifest round-trip 和 catalog receipt。 +原有 promotion 步骤却仍位于这些 Gate 之前。如果 catalog 发布、签名、attestation 或 receipt 失败,公开 image tag 已经可见,但可部署的 +immutable catalog authority 尚未闭合。旧 promotion 还把任意 `regctl image digest ` 非零退出都当作 tag absent;网络、认证或 registry +错误可能因此被错误降级成“可以写入”。 + +## 决策 + +1. `versionTag/sourceTag` mutation 移到以下事实全部成功之后:完整 release-set 已审计并 attested;catalog 已按 immutable digest + round-trip;catalog signature 与 GitHub provenance 已验证;catalog receipt 已生成、审计并 attested。 +2. 新的 `qinglong/release-publication-plan@v1` 必须重新读取并联合审计 exact release-set、catalog plan、raw manifest、manifest + digest 与 catalog receipt。计划只含 release identity、上游摘要、每个 image 的 immutable reference/digest、两个目标 tag 和固定策略; + catalog receipt 之前不能生成该计划。 +3. promotion 在任何 tag mutation 前,对每个已存在的 image repository 执行一次完整 tag inventory:最大 1 MiB、canonical line、OCI + tag 字符集、无重复。inventory 读取失败、超限或畸形全部失败关闭,不能再把任意 digest lookup 错误解释为 absent。 +4. 预检首先验证所有 immutable source digest。inventory 中已存在的所有目标 tag 必须逐个解析为计划 digest;任一冲突时尚未发生任何 + tag mutation。只有全量预检成功后才依固定顺序 copy absent tag;exact tag 不重写,用于 response-loss 恢复。 +5. copy 阶段完成后,必须重新读取所有 `2 × imageCount` 个 tag,并用 + `qinglong/release-publication-tag-observation@v1` 固化 exact ordered mapping。缺失、额外、重排、重复或 digest 漂移均不能闭合。 +6. `qinglong/release-publication-closure-receipt@v1` 同时绑定 publication plan、release-set、catalog plan/receipt/manifest、最终 tag + observation 和固定策略,并具有自身 digest。plan、observation、receipt 必须一起进入 90 天 deployment bundle;receipt 必须再次本地 + 审计并单独 attested,使下载者可以离线重放 closure audit。 +7. receipt 诚实保留 `crossRepositoryAtomicity=false` 与 `registryTagCas=false`。它证明 workflow 观察到 catalog-ready 后的完整终态, + 不声称 GHCR 提供跨 repository 事务或 tag CAS。发布中途失败时不删除正确 tag;同 protected source tag 重跑只能复用 exact digest, + 任何不同 digest 都失败。 +8. deployment consumer 仍只信任已签名/attested 的 immutable catalog digest。最终 image tag 和 closure receipt 是发布可见性与运维证据, + 不是部署 authority 的替代品。 + +## 失败与恢复 + +- catalog receipt attestation 前失败:没有正式 image tag mutation;修复后从同一 protected source tag 重跑。 +- repository inventory 读取不确定:立即停止,不能把 auth/network/registry error 当作 absent。 +- 任一既有 tag 指向其他 digest:全量预检阶段停止,不写任何本轮目标 tag。 +- promotion 中途 response loss:可能已有部分 exact tag;重跑重新取得全部 inventory,复用 exact tag,只补 absent tag,最后重建相同 + observation 与 closure receipt bytes。 +- promotion 后竞争:最终逐 tag digest 回读会阻止 closure;稍后外部改写也无法改变已 attested receipt 绑定的 immutable digest,consumer + 仍不会信任 mutable tag。 + +## 部署与资源影响 + +- Edge/Standalone/路由设备不执行该协议,不安装 Node、regctl、Cosign 或 GitHub CLI,不增加 RSS、磁盘写、timer、listener、watcher、 + updater 或常驻进程。 +- Cluster 节点、Kubernetes object、CloudNativePG、数据库、migration、SQL、Pool、Worker 与运行时镜像均无变化。 +- 新工作只在短生命周期 GitHub-hosted release runner:每个 image repository 一个最大 1 MiB inventory、一个小型计划/观察/收据文件和 + 最终 tag 回读。不新增 workspace package、生产依赖或部署服务。 + +## 被拒绝的替代方案 + +### 继续在 release-set 审计后立即 promotion + +拒绝。完整镜像集合不等于 durable catalog 已经可验证;后续 catalog 失败会留下过早公开的 tag。 + +### 继续把 `image digest` 任意失败解释为 absent + +拒绝。不存在、无权限、网络中断和 registry 故障不能共享同一 mutation 决策。 + +### 先写 tag,再用 closure receipt 记录结果 + +拒绝。收据只能证明终态,不能修复错误的发布前置顺序;catalog-ready 必须是 mutation 的真实前置条件。 + +### 声称 closure receipt 提供跨 repository 原子性 + +拒绝。OCI registry 没有该事务语义。精确重放和终态闭合能收敛部分成功,但不能伪造原子 commit。 + +## 验证 + +- publication closure contract 覆盖 Local/Cluster/All 计划、确定性 receipt、缺失/重排/额外/digest 漂移 tag、上游 catalog 脱离、 + self-digest tamper、closed CLI 与 no-replace 输出; +- workflow 静态门固定 `catalog receipt attestation → publication plan → bounded inventory/preflight → tag mutation → exact observation → + closure audit/attestation → bundle upload` 顺序,并拒绝缺失 inventory、closure 或独立 attestation; +- 发布链与完整仓库验证结果记录于 QL-RFC-0001 D-350;首份真实 GHCR 部分 promotion/response-loss 重放仍须由受保护 `v3` release tag + 或受控 release repository 演练产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index cee8cd1a..b28782ed 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -445,6 +445,7 @@ | [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) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index ab6552f7..545ebbd7 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -437,10 +437,16 @@ immutable image reference 写入 compose/rollout。 ## 发布失败与恢复 -GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`。如果 promotion 中途 -失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证 -每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有 -release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family +GHCR 不提供跨 repository tag 事务,release set 与最终 closure receipt 都明确记录 `crossRepositoryAtomicity=false`。正式 +`versionTag/sourceTag` 只能在 release-set file provenance、catalog immutable digest、catalog signature/provenance、catalog receipt +及其 attestation 全部成功后开始。publisher 随后必须为每个 image repository 取得不超过 1 MiB 的完整 tag inventory;读取失败、非 +canonical line、非法 OCI tag 或重复项均失败关闭,不能把任意 `image digest` 错误当作 tag absent。它会在任何 mutation 前验证全部 +immutable source 与全部既有目标 tag,任一不同 digest 都使本轮零 tag 写入。 + +如果 promotion 中途失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会复用 exact +tag,只补 absent tag;全部 tag 最终回读后生成 `qinglong/release-publication-tag-observation@v1` 与 +`qinglong/release-publication-closure-receipt@v1`。closure receipt 绑定 release-set、catalog plan/manifest/receipt 和每个最终 tag, +与 plan、observation 一起进入 90 天 bundle;receipt 再次审计、attest 后,下载者可离线重放 closure audit。只有这条闭合链全部生成并验证后,才能宣布该 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。 diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index 68fbdcce..e39b6e2c 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -252,10 +252,14 @@ function auditClusterCopilotConsoleDistribution(options = {}) { '--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"', '--deny-self-hosted-runners', '--bundle-from-oci', - 'Promote tags only after the complete set is verified', 'Attest the complete release-set file provenance', 'Publish and round-trip the durable OCI release catalog', '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', ], '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 6705cba8..563c3036 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\/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\/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( @@ -712,7 +712,7 @@ function auditReleaseWorkflow(source) { const releaseSetSteps = releaseSetJob?.steps; if ( !Array.isArray(releaseSetSteps) || - releaseSetSteps.length !== 16 || + releaseSetSteps.length !== 19 || releaseSetSteps[0]?.uses !== 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || releaseSetSteps[0]?.with?.['persist-credentials'] !== false || @@ -750,55 +750,68 @@ function auditReleaseWorkflow(source) { 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || releaseSetSteps[7]?.uses !== 'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' || - !/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test( - releaseSetSteps[8]?.run ?? '', - ) || - releaseSetSteps[9]?.uses !== + releaseSetSteps[8]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[9]?.with) !== + JSON.stringify(releaseSetSteps[8]?.with) !== JSON.stringify({ 'subject-path': '${{ steps.release-set.outputs.report }}', }) || - releaseSetSteps[10]?.id !== 'catalog' || + releaseSetSteps[9]?.id !== 'catalog' || !/local_tag="ocidir:\/\/\$\{local_layout\}:candidate"[\s\S]*artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*"\$\{local_tag\}"[\s\S]*expected_digest=.*image digest "\$\{local_tag\}"[\s\S]*local_immutable="ocidir:\/\/\$\{local_layout\}@\$\{expected_digest\}"/.test( - releaseSetSteps[10]?.run ?? '', + releaseSetSteps[9]?.run ?? '', ) || !/tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*staging_tag="\$\{catalog_repository\}:staging-\$\{plan_digest#sha256:\}"[\s\S]*image copy "\$\{local_immutable\}" "\$\{staging_tag\}"[\s\S]*tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*--mode=tag-inventory[\s\S]*--plan="\$\{PLAN\}"[\s\S]*--tag-inventory="\$\{tags\}"[\s\S]*--output="\$\{inventory_decision\}"[\s\S]*tag_state=.*p\.observation/.test( - releaseSetSteps[10]?.run ?? '', + releaseSetSteps[9]?.run ?? '', ) || !/--mode=publication-decision[\s\S]*--manifest="\$\{local_manifest\}"[\s\S]*--manifest-digest="\$\{expected_digest\}"[\s\S]*--observed-discovery-digest="\$\{observed_digest\}"[\s\S]*action=.*p\.action[\s\S]*publish_if_absent[\s\S]*image copy "\$\{local_immutable\}" "\$\{discovery_tag\}"[\s\S]*reuse_exact_digest[\s\S]*digest=.*image digest "\$\{discovery_tag\}"[\s\S]*"\$\{digest\}" != "\$\{expected_digest\}"/.test( - releaseSetSteps[10]?.run ?? '', + releaseSetSteps[9]?.run ?? '', ) || !/artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*cmp --silent "\$\{local_manifest\}" "\$\{manifest\}"[\s\S]*GITHUB_OUTPUT/.test( - releaseSetSteps[10]?.run ?? '', + releaseSetSteps[9]?.run ?? '', ) || /artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test( - releaseSetSteps[10]?.run ?? '', + releaseSetSteps[9]?.run ?? '', ) || !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( - releaseSetSteps[11]?.run ?? '', + releaseSetSteps[10]?.run ?? '', ) || - releaseSetSteps[12]?.uses !== + releaseSetSteps[11]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[12]?.with) !== + JSON.stringify(releaseSetSteps[11]?.with) !== JSON.stringify({ 'subject-name': '${{ steps.catalog.outputs.repository }}', 'subject-digest': '${{ steps.catalog.outputs.digest }}', 'push-to-registry': true, }) || - releaseSetSteps[13]?.id !== 'catalog-receipt' || + releaseSetSteps[12]?.id !== 'catalog-receipt' || !/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test( - releaseSetSteps[13]?.run ?? '', + releaseSetSteps[12]?.run ?? '', ) || - releaseSetSteps[14]?.uses !== + releaseSetSteps[13]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[14]?.with) !== + JSON.stringify(releaseSetSteps[13]?.with) !== JSON.stringify({ 'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}', }) || - releaseSetSteps[15]?.uses !== + 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 !== 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || - JSON.stringify(releaseSetSteps[15]?.with) !== + JSON.stringify(releaseSetSteps[18]?.with) !== JSON.stringify({ name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', path: '${{ steps.release-set.outputs.bundle }}', @@ -1253,8 +1266,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: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\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: Publish the deployment digest lock/, - 'release tags, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete', + /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', ); return { trigger: 'explicit protected v3 tag dispatch', @@ -1310,6 +1323,10 @@ function auditReleaseWorkflow(source) { rebuildAfterScan: false, tagAfterVerification: true, tagAfterCompleteReleaseSet: true, + tagAfterVerifiedCatalog: true, + boundedRepositoryTagInventory: true, + allTagConflictsCheckedBeforeMutation: true, + responseLossRecovery: 'reuse_exact_digest_only', }, releaseSet: { sourceDerived: true, @@ -1319,7 +1336,7 @@ function auditReleaseWorkflow(source) { privateEvidenceFreshnessRevalidatedAtClosure: true, exactScopeClosure: true, standaloneInspection: true, - tagPromotionAuthority: 'complete_verified_release_set', + tagPromotionAuthority: 'verified_immutable_catalog', fileProvenanceAttested: true, artifactRetentionDays: 90, crossRepositoryAtomicity: false, @@ -1346,6 +1363,19 @@ function auditReleaseWorkflow(source) { immutableDigestAuthority: 'verified', receiptAttested: true, }, + finalPublicationClosure: { + planSchema: 'qinglong/release-publication-plan@v1', + tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', + receiptSchema: 'qinglong/release-publication-closure-receipt@v1', + catalogReadyBeforeTagMutation: true, + allTagsExactDigest: true, + tagsPerImage: 2, + conflictPolicy: 'fail_closed_before_any_tag_mutation', + responseLossRecovery: 'reuse_exact_digest_only', + crossRepositoryAtomicity: false, + registryTagCas: false, + receiptAttested: true, + }, catalogDeploymentGate: { scopes: ['cluster', 'all'], catalogAuthority: 'immutable_digest_after_public_consumption', @@ -1379,6 +1409,7 @@ function auditReleaseWorkflow(source) { 'catalog-bound-local-compose-deployment', 'catalog-bound-k3s-deployment', 'release-tags', + 'release-publication-closure', ], }; } diff --git a/scripts/ql3-release-publication-closure-contract.cjs b/scripts/ql3-release-publication-closure-contract.cjs new file mode 100644 index 00000000..7f42d4c6 --- /dev/null +++ b/scripts/ql3-release-publication-closure-contract.cjs @@ -0,0 +1,552 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + RELEASE_SET_SCHEMA, + inspectReleaseSet, +} = require('./ql3-release-set-contract.cjs'); +const { + auditCatalogPlan, + auditCatalogReceipt, +} = require('./ql3-release-catalog-contract.cjs'); + +const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v1'; +const TAG_OBSERVATION_SCHEMA = + 'qinglong/release-publication-tag-observation@v1'; +const CLOSURE_RECEIPT_SCHEMA = + 'qinglong/release-publication-closure-receipt@v1'; +const MAX_JSON_BYTES = 1024 * 1024; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u; +const REGISTRY_REPOSITORY_PATTERN = + /^ghcr\.io\/[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u; +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', +}); +const PROMOTION_POLICY = Object.freeze({ + authority: 'verified_immutable_catalog', + inventory: 'bounded_exact_repository_tags', + conflict: 'fail_closed_before_any_tag_mutation', + recovery: 'reuse_exact_digest_only', + finalVerification: 'all_tags_exact_digest', + crossRepositoryAtomicity: false, + registryTagCas: false, +}); +const CLOSURE_VERIFICATION = Object.freeze({ + authority: 'verified_immutable_catalog', + catalogReadyBeforeTagMutation: true, + allTagsExactDigest: true, + inventory: 'bounded_exact_repository_tags', + conflict: 'fail_closed_before_any_tag_mutation', + recovery: 'reuse_exact_digest_only', + crossRepositoryAtomicity: false, + registryTagCas: false, +}); + +class QingLong3ReleasePublicationClosureError extends Error { + constructor(message) { + super(`QingLong 3 release publication closure failed: ${message}`); + this.name = 'QingLong3ReleasePublicationClosureError'; + } +} + +function fail(message) { + throw new QingLong3ReleasePublicationClosureError(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 resolveCanonicalAbsolute(input, label) { + if (typeof input !== 'string' || !path.isAbsolute(input)) { + fail(`${label} path must be absolute`); + } + const resolved = path.resolve(input); + if (resolved !== input) fail(`${label} path must be normalized`); + return resolved; +} + +function readBoundedFile(filePath, label) { + const resolved = resolveCanonicalAbsolute(filePath, label); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > MAX_JSON_BYTES || + fs.realpathSync(resolved) !== resolved || + fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return { contents: fs.readFileSync(resolved, 'utf8'), resolved }; +} + +function parseJson(contents, label, requireCanonical = true) { + let value; + try { + value = JSON.parse(contents); + } catch { + fail(`${label} must contain valid JSON`); + } + if (requireCanonical && canonicalJson(value) !== contents) { + fail(`${label} must use canonical JSON encoding`); + } + return value; +} + +function readCanonicalJson(filePath, label) { + return parseJson(readBoundedFile(filePath, label).contents, label); +} + +function writeNoReplace(filePath, value) { + const resolved = resolveCanonicalAbsolute(filePath, 'output'); + const parent = path.dirname(resolved); + const parentStat = fs.lstatSync(parent); + if ( + !parentStat.isDirectory() || + parentStat.isSymbolicLink() || + fs.realpathSync(parent) !== parent + ) { + 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 validateIdentityOptions(options) { + for (const key of [ + 'version', + 'sourceRevision', + 'sourceRef', + 'releaseScope', + 'repositoryOwner', + 'sourceRepository', + ]) { + if (typeof options[key] !== 'string' || options[key].length === 0) { + fail('release identity options are incomplete'); + } + } +} + +function createPublicationPlan( + releaseSet, + catalogPlan, + catalogReceipt, + catalogManifestContents, + catalogManifestDigest, + options, +) { + validateIdentityOptions(options); + inspectReleaseSet(releaseSet, options); + auditCatalogPlan(catalogPlan, releaseSet, options); + auditCatalogReceipt( + catalogReceipt, + catalogPlan, + catalogManifestContents, + catalogManifestDigest, + ); + const images = releaseSet.images.map((image) => { + const registryRepository = image.reference.slice( + 0, + image.reference.lastIndexOf('@'), + ); + return { + name: image.name, + registryRepository, + immutableReference: image.reference, + digest: image.digest, + tags: [ + { kind: 'version', reference: image.versionTag }, + { kind: 'source', reference: image.sourceTag }, + ], + }; + }); + const unsigned = { + schemaVersion: 1, + schema: PUBLICATION_PLAN_SCHEMA, + release: { ...releaseSet.release }, + releaseSet: { + schema: RELEASE_SET_SCHEMA, + releaseSetDigest: releaseSet.releaseSetDigest, + contentDigest: sha256(canonicalJson(releaseSet)), + }, + catalog: { + planDigest: catalogPlan.planDigest, + receiptDigest: catalogReceipt.receiptDigest, + manifestDigest: catalogManifestDigest, + immutableReference: catalogReceipt.catalog.immutableReference, + }, + requiredPrerequisites: { ...REQUIRED_PREREQUISITES }, + promotionPolicy: { ...PROMOTION_POLICY }, + images, + }; + return Object.freeze({ + ...unsigned, + planDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function validatePublicationPlan(plan) { + if ( + !exactKeys(plan, [ + 'schemaVersion', + 'schema', + 'release', + 'releaseSet', + 'catalog', + 'requiredPrerequisites', + 'promotionPolicy', + 'images', + 'planDigest', + ]) || + plan.schemaVersion !== 1 || + plan.schema !== PUBLICATION_PLAN_SCHEMA || + !DIGEST_PATTERN.test(plan.planDigest || '') || + !exactKeys(plan.release, [ + 'version', + 'sourceRevision', + 'sourceRef', + 'scope', + ]) || + typeof plan.release.version !== 'string' || + plan.release.version.length < 1 || + plan.release.version.length > 64 || + !SOURCE_REVISION_PATTERN.test(plan.release.sourceRevision || '') || + plan.release.sourceRef !== `refs/tags/v${plan.release.version}` || + !['local', 'cluster', 'all'].includes(plan.release.scope) || + !exactKeys(plan.releaseSet, [ + 'schema', + 'releaseSetDigest', + 'contentDigest', + ]) || + plan.releaseSet.schema !== RELEASE_SET_SCHEMA || + !DIGEST_PATTERN.test(plan.releaseSet.releaseSetDigest || '') || + !DIGEST_PATTERN.test(plan.releaseSet.contentDigest || '') || + !exactKeys(plan.catalog, [ + 'planDigest', + 'receiptDigest', + 'manifestDigest', + 'immutableReference', + ]) || + !DIGEST_PATTERN.test(plan.catalog.planDigest || '') || + !DIGEST_PATTERN.test(plan.catalog.receiptDigest || '') || + !DIGEST_PATTERN.test(plan.catalog.manifestDigest || '') || + typeof plan.catalog.immutableReference !== 'string' || + !plan.catalog.immutableReference.endsWith( + `@${plan.catalog.manifestDigest}`, + ) || + JSON.stringify(plan.requiredPrerequisites) !== + JSON.stringify(REQUIRED_PREREQUISITES) || + JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) || + !Array.isArray(plan.images) || + plan.images.length !== + (plan.release.scope === 'local' + ? 1 + : plan.release.scope === 'cluster' + ? 4 + : 5) + ) { + fail('publication plan shape is invalid'); + } + const { planDigest, ...unsigned } = plan; + if (planDigest !== sha256(JSON.stringify(unsigned))) { + fail('publication plan digest is invalid'); + } + const references = []; + const names = []; + const repositories = []; + for (const image of plan.images) { + if ( + !exactKeys(image, [ + 'name', + 'registryRepository', + 'immutableReference', + 'digest', + 'tags', + ]) || + !/^[a-z][a-z0-9-]{0,31}$/u.test(image.name || '') || + !REGISTRY_REPOSITORY_PATTERN.test(image.registryRepository || '') || + image.immutableReference !== + `${image.registryRepository}@${image.digest}` || + !DIGEST_PATTERN.test(image.digest || '') || + !Array.isArray(image.tags) || + image.tags.length !== 2 + ) { + fail('publication plan image is invalid'); + } + names.push(image.name); + repositories.push(image.registryRepository); + for (let index = 0; index < image.tags.length; index += 1) { + const tag = image.tags[index]; + const kind = index === 0 ? 'version' : 'source'; + const expectedReference = + kind === 'version' + ? `${image.registryRepository}:${plan.release.version}` + : `${image.registryRepository}:sha-${plan.release.sourceRevision}`; + if ( + !exactKeys(tag, ['kind', 'reference']) || + tag.kind !== kind || + tag.reference !== expectedReference + ) { + fail('publication plan tag is invalid'); + } + references.push(tag.reference); + } + } + if ( + new Set(references).size !== references.length || + new Set(names).size !== names.length || + new Set(repositories).size !== repositories.length + ) { + fail('publication plan contains duplicate images or tags'); + } + return plan; +} + +function expectedObservedTags(plan) { + return plan.images.flatMap((image) => + image.tags.map((tag) => ({ + image: image.name, + kind: tag.kind, + reference: tag.reference, + digest: image.digest, + })), + ); +} + +function createPublicationTagObservation(plan, observedTags) { + validatePublicationPlan(plan); + if ( + !Array.isArray(observedTags) || + JSON.stringify(observedTags) !== JSON.stringify(expectedObservedTags(plan)) + ) { + fail('publication tag observations differ from the exact plan'); + } + const unsigned = { + schemaVersion: 1, + schema: TAG_OBSERVATION_SCHEMA, + planDigest: plan.planDigest, + tags: observedTags.map((entry) => ({ ...entry })), + }; + return Object.freeze({ + ...unsigned, + observationDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function validateTagObservation(plan, observation) { + if ( + !exactKeys(observation, [ + 'schemaVersion', + 'schema', + 'planDigest', + 'tags', + 'observationDigest', + ]) || + observation.schemaVersion !== 1 || + observation.schema !== TAG_OBSERVATION_SCHEMA || + observation.planDigest !== plan.planDigest || + !DIGEST_PATTERN.test(observation.observationDigest || '') + ) { + fail('publication tag observation shape is invalid'); + } + const expected = createPublicationTagObservation(plan, observation.tags); + if (JSON.stringify(observation) !== JSON.stringify(expected)) { + fail('publication tag observation digest is invalid'); + } + return observation; +} + +function createClosureReceipt(plan, observation) { + validatePublicationPlan(plan); + validateTagObservation(plan, observation); + const unsigned = { + schemaVersion: 1, + schema: CLOSURE_RECEIPT_SCHEMA, + release: { ...plan.release }, + planDigest: plan.planDigest, + releaseSet: { ...plan.releaseSet }, + catalog: { ...plan.catalog }, + tagObservationDigest: observation.observationDigest, + publishedTags: observation.tags.map((entry) => ({ ...entry })), + verification: { ...CLOSURE_VERIFICATION }, + }; + return Object.freeze({ + ...unsigned, + receiptDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function auditClosureReceipt(actual, plan, observation) { + const expected = createClosureReceipt(plan, observation); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail('publication closure receipt differs from the verified final tags'); + } + return Object.freeze({ + compatible: true, + releaseSetDigest: actual.releaseSet.releaseSetDigest, + releaseScope: actual.release.scope, + catalogManifestDigest: actual.catalog.manifestDigest, + publishedTagCount: actual.publishedTags.length, + allTagsExactDigest: actual.verification.allTagsExactDigest, + registryTagCas: actual.verification.registryTagCas, + }); +} + +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]; + } + const identity = [ + 'release-scope', + 'repository-owner', + 'source-ref', + 'source-repository', + 'source-revision', + 'version', + ]; + const expected = + values.mode === 'plan' + ? [ + 'catalog-manifest', + 'catalog-manifest-digest', + 'catalog-plan', + 'catalog-receipt', + 'mode', + 'output', + 'release-set', + ...identity, + ] + : values.mode === 'close' + ? ['mode', 'observations', 'output', 'plan'] + : values.mode === 'audit' + ? ['mode', 'observations', 'plan', 'receipt'] + : []; + if ( + expected.length === 0 || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail('arguments are invalid'); + } + return Object.freeze({ + mode: values.mode, + output: values.output, + plan: values.plan, + observations: values.observations, + receipt: values.receipt, + releaseSet: values['release-set'], + catalogPlan: values['catalog-plan'], + catalogReceipt: values['catalog-receipt'], + catalogManifest: values['catalog-manifest'], + catalogManifestDigest: values['catalog-manifest-digest'], + version: values.version, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + releaseScope: values['release-scope'], + repositoryOwner: values['repository-owner'], + sourceRepository: values['source-repository'], + }); +} + +function runCli(argv, output = process.stdout) { + const options = parseArguments(argv); + if (options.mode === 'plan') { + const releaseSet = readCanonicalJson(options.releaseSet, 'release set'); + const catalogPlan = readCanonicalJson(options.catalogPlan, 'catalog plan'); + const catalogReceipt = readCanonicalJson( + options.catalogReceipt, + 'catalog receipt', + ); + const manifest = readBoundedFile( + options.catalogManifest, + 'catalog manifest', + ).contents; + const plan = createPublicationPlan( + releaseSet, + catalogPlan, + catalogReceipt, + manifest, + options.catalogManifestDigest, + options, + ); + writeNoReplace(options.output, plan); + output.write(canonicalJson(plan)); + return plan; + } + const plan = readCanonicalJson(options.plan, 'publication plan'); + const observation = readCanonicalJson( + options.observations, + 'tag observations', + ); + if (options.mode === 'close') { + const receipt = createClosureReceipt(plan, observation); + writeNoReplace(options.output, receipt); + output.write(canonicalJson(receipt)); + return receipt; + } + const receipt = readCanonicalJson(options.receipt, 'closure receipt'); + const audit = auditClosureReceipt(receipt, plan, observation); + 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 publication closure failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + CLOSURE_RECEIPT_SCHEMA, + PUBLICATION_PLAN_SCHEMA, + TAG_OBSERVATION_SCHEMA, + QingLong3ReleasePublicationClosureError, + auditClosureReceipt, + createClosureReceipt, + createPublicationPlan, + createPublicationTagObservation, + parseArguments, + runCli, +}); diff --git a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs index 9a6439e0..4810aa49 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 tags only after the complete set is verified', + 'Promote final tags only after the catalog receipt is attested', '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 9710d580..e67a8cc3 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -94,6 +94,10 @@ test('accepts the reviewed native CI and digest release contracts', () => { rebuildAfterScan: false, tagAfterVerification: true, tagAfterCompleteReleaseSet: true, + tagAfterVerifiedCatalog: true, + boundedRepositoryTagInventory: true, + allTagConflictsCheckedBeforeMutation: true, + responseLossRecovery: 'reuse_exact_digest_only', }, releaseSet: { sourceDerived: true, @@ -103,7 +107,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { privateEvidenceFreshnessRevalidatedAtClosure: true, exactScopeClosure: true, standaloneInspection: true, - tagPromotionAuthority: 'complete_verified_release_set', + tagPromotionAuthority: 'verified_immutable_catalog', fileProvenanceAttested: true, artifactRetentionDays: 90, crossRepositoryAtomicity: false, @@ -130,6 +134,19 @@ test('accepts the reviewed native CI and digest release contracts', () => { immutableDigestAuthority: 'verified', receiptAttested: true, }, + finalPublicationClosure: { + planSchema: 'qinglong/release-publication-plan@v1', + tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', + receiptSchema: 'qinglong/release-publication-closure-receipt@v1', + catalogReadyBeforeTagMutation: true, + allTagsExactDigest: true, + tagsPerImage: 2, + conflictPolicy: 'fail_closed_before_any_tag_mutation', + responseLossRecovery: 'reuse_exact_digest_only', + crossRepositoryAtomicity: false, + registryTagCas: false, + receiptAttested: true, + }, catalogDeploymentGate: { scopes: ['cluster', 'all'], catalogAuthority: 'immutable_digest_after_public_consumption', @@ -163,6 +180,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'catalog-bound-local-compose-deployment', 'catalog-bound-k3s-deployment', 'release-tags', + 'release-publication-closure', ], }, }); @@ -190,6 +208,17 @@ test('rejects removal of the durable release-catalog contract tests', () => { ); }); +test('rejects removal of the final publication closure contract tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3ReleasePublicationClosureContract.test.cjs', + 'test/back/publication-closure-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', @@ -1069,6 +1098,61 @@ test('rejects a release-catalog receipt without file provenance', () => { ); }); +test('rejects final tag publication before catalog receipt attestation', () => { + const mutated = releaseSource.replace( + 'Promote final tags only after the catalog receipt is attested', + 'Promote tags after release-set audit only', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /release tags and the final closure receipt|release-set job must download/, + ); +}); + +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/, + ); +}); + +test('rejects omission of the final publication closure audit', () => { + const mutated = releaseSource.replace( + ' --mode=close \\', + ' --mode=audit \\', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /release-set job must download only same-run records/, + ); +}); + +test('rejects omitting tag observations from the durable closure bundle', () => { + const mutated = releaseSource.replace( + 'observations="${BUNDLE}/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/, + ); +}); + +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/, + ); +}); + test('rejects a short-lived deployment digest lock', () => { const marker = ' retention-days: 90'; assert.equal(releaseSource.includes(marker), true); diff --git a/test/back/ql3ReleasePublicationClosureContract.test.cjs b/test/back/ql3ReleasePublicationClosureContract.test.cjs new file mode 100644 index 00000000..1a028260 --- /dev/null +++ b/test/back/ql3ReleasePublicationClosureContract.test.cjs @@ -0,0 +1,359 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + CLOSURE_RECEIPT_SCHEMA, + PUBLICATION_PLAN_SCHEMA, + TAG_OBSERVATION_SCHEMA, + auditClosureReceipt, + createClosureReceipt, + createPublicationPlan, + createPublicationTagObservation, + parseArguments, + runCli, +} = require('../../scripts/ql3-release-publication-closure-contract.cjs'); +const { + ARTIFACT_TYPE, + OCI_EMPTY_CONFIG_DIGEST, + OCI_EMPTY_CONFIG_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE, + createCatalogPlan, + createCatalogReceipt, +} = require('../../scripts/ql3-release-catalog-contract.cjs'); +const { + createReleaseSet, + createVerifiedImageRecord, +} = require('../../scripts/ql3-release-set-contract.cjs'); +const { + createReleaseCandidateContract, +} = require('../../scripts/ql3-release-candidate-contract.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); +const { + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const identity = Object.freeze({ + version, + sourceRevision: 'e'.repeat(40), + sourceRef: `refs/tags/v${version}`, + repositoryOwner: 'qinglong-release', + sourceRepository: 'qinglong-release/qinglong', +}); + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function releaseSet(scope) { + const candidate = createReleaseCandidateContract({ + root, + version, + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + releaseScope: scope, + }); + const records = candidate.images.map((entry, index) => + createVerifiedImageRecord({ + root, + candidate, + ...identity, + releaseScope: scope, + image: entry.image, + digest: `sha256:${String(index + 1).repeat(64)}`, + }), + ); + return createReleaseSet({ + root, + candidate, + records, + evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release), + ...identity, + validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'), + releaseScope: scope, + }); +} + +function manifestFor(plan) { + return JSON.stringify({ + schemaVersion: 2, + mediaType: OCI_MANIFEST_MEDIA_TYPE, + artifactType: ARTIFACT_TYPE, + config: { + mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE, + digest: OCI_EMPTY_CONFIG_DIGEST, + size: 2, + }, + layers: [ + { + mediaType: ARTIFACT_TYPE, + digest: plan.releaseSet.contentDigest, + size: plan.releaseSet.bytes, + annotations: { + 'org.opencontainers.image.title': plan.releaseSet.fileName, + }, + }, + ], + annotations: { ...plan.catalog.annotations }, + }); +} + +function fixture(scope = 'all') { + const set = releaseSet(scope); + const options = { ...identity, releaseScope: scope }; + const catalogPlan = createCatalogPlan(set, options); + const manifest = manifestFor(catalogPlan); + const manifestDigest = sha256(manifest); + const catalogReceipt = createCatalogReceipt( + catalogPlan, + manifest, + manifestDigest, + ); + const publicationPlan = createPublicationPlan( + set, + catalogPlan, + catalogReceipt, + manifest, + manifestDigest, + options, + ); + const tags = publicationPlan.images.flatMap((image) => + image.tags.map((tag) => ({ + image: image.name, + kind: tag.kind, + reference: tag.reference, + digest: image.digest, + })), + ); + const observation = createPublicationTagObservation(publicationPlan, tags); + return { + set, + options, + catalogPlan, + manifest, + manifestDigest, + catalogReceipt, + publicationPlan, + observation, + }; +} + +function temporaryDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-closure-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writeCanonical(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); +} + +test('plans tag publication only from a verified immutable catalog', () => { + for (const scope of ['local', 'cluster', 'all']) { + const { publicationPlan, set } = fixture(scope); + assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA); + assert.equal( + publicationPlan.promotionPolicy.authority, + 'verified_immutable_catalog', + ); + assert.equal( + publicationPlan.requiredPrerequisites.catalogReceipt, + 'attested_before_tag_promotion', + ); + assert.equal(publicationPlan.promotionPolicy.registryTagCas, false); + assert.equal(publicationPlan.images.length, set.images.length); + assert.equal( + publicationPlan.images.every((image) => image.tags.length === 2), + true, + ); + } +}); + +test('creates a deterministic final closure receipt for every exact tag', () => { + const { publicationPlan, observation } = fixture(); + const first = createClosureReceipt(publicationPlan, observation); + const replay = createClosureReceipt(publicationPlan, observation); + assert.equal(first.schema, CLOSURE_RECEIPT_SCHEMA); + assert.equal(observation.schema, TAG_OBSERVATION_SCHEMA); + assert.deepEqual(first, replay); + assert.equal(first.publishedTags.length, publicationPlan.images.length * 2); + assert.equal(first.verification.catalogReadyBeforeTagMutation, true); + assert.equal(first.verification.allTagsExactDigest, true); + assert.equal(first.verification.registryTagCas, false); + assert.equal( + auditClosureReceipt(first, publicationPlan, observation).compatible, + true, + ); +}); + +test('rejects missing, reordered, extra or digest-drifted tag observations', () => { + const { publicationPlan, observation } = fixture('cluster'); + const variants = [ + observation.tags.slice(1), + [...observation.tags].reverse(), + [...observation.tags, observation.tags[0]], + observation.tags.map((entry, index) => + index === 0 ? { ...entry, digest: `sha256:${'f'.repeat(64)}` } : entry, + ), + ]; + for (const tags of variants) { + assert.throws( + () => createPublicationTagObservation(publicationPlan, tags), + /observations differ from the exact plan/, + ); + } +}); + +test('rejects a publication plan detached from release-set or catalog evidence', () => { + const current = fixture('local'); + const other = fixture('cluster'); + assert.throws( + () => + createPublicationPlan( + current.set, + other.catalogPlan, + other.catalogReceipt, + other.manifest, + other.manifestDigest, + current.options, + ), + /catalog plan differs from the standalone release set/, + ); + const weakenedReceipt = structuredClone(current.catalogReceipt); + weakenedReceipt.verification.keylessSignature = 'not_verified'; + assert.throws( + () => + createPublicationPlan( + current.set, + current.catalogPlan, + weakenedReceipt, + current.manifest, + current.manifestDigest, + current.options, + ), + /catalog receipt differs from the verified OCI manifest/, + ); +}); + +test('rejects tampered plan, observation and closure self-digests', () => { + const { publicationPlan, observation } = fixture('local'); + const badPlan = structuredClone(publicationPlan); + badPlan.promotionPolicy.registryTagCas = true; + assert.throws( + () => createPublicationTagObservation(badPlan, observation.tags), + /publication plan (?:shape|digest) is invalid/, + ); + const recomputedWeakenedPlan = structuredClone(publicationPlan); + recomputedWeakenedPlan.promotionPolicy.registryTagCas = true; + delete recomputedWeakenedPlan.planDigest; + recomputedWeakenedPlan.planDigest = sha256( + JSON.stringify(recomputedWeakenedPlan), + ); + assert.throws( + () => + createPublicationTagObservation(recomputedWeakenedPlan, observation.tags), + /publication plan shape is invalid/, + ); + const badObservation = structuredClone(observation); + badObservation.observationDigest = `sha256:${'a'.repeat(64)}`; + assert.throws( + () => createClosureReceipt(publicationPlan, badObservation), + /observation digest is invalid/, + ); + const receipt = createClosureReceipt(publicationPlan, observation); + const badReceipt = structuredClone(receipt); + badReceipt.verification.registryTagCas = true; + assert.throws( + () => auditClosureReceipt(badReceipt, publicationPlan, observation), + /closure receipt differs/, + ); +}); + +test('runs plan, close and audit as canonical no-replace CLI stages', (t) => { + const value = fixture('cluster'); + const directory = temporaryDirectory(t); + const files = { + releaseSet: path.join(directory, 'release-set.json'), + catalogPlan: path.join(directory, 'catalog-plan.json'), + catalogReceipt: path.join(directory, 'catalog-receipt.json'), + manifest: path.join(directory, 'manifest.json'), + publicationPlan: path.join(directory, 'publication-plan.json'), + observation: path.join(directory, 'observation.json'), + receipt: path.join(directory, 'receipt.json'), + }; + writeCanonical(files.releaseSet, value.set); + writeCanonical(files.catalogPlan, value.catalogPlan); + writeCanonical(files.catalogReceipt, value.catalogReceipt); + fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 }); + writeCanonical(files.observation, value.observation); + const planArgs = [ + '--mode=plan', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=cluster', + `--repository-owner=${identity.repositoryOwner}`, + `--source-repository=${identity.sourceRepository}`, + `--release-set=${files.releaseSet}`, + `--catalog-plan=${files.catalogPlan}`, + `--catalog-receipt=${files.catalogReceipt}`, + `--catalog-manifest=${files.manifest}`, + `--catalog-manifest-digest=${value.manifestDigest}`, + `--output=${files.publicationPlan}`, + ]; + const output = { write() {} }; + assert.equal(runCli(planArgs, output).schema, PUBLICATION_PLAN_SCHEMA); + assert.throws(() => runCli(planArgs, output), /EEXIST/); + assert.equal( + runCli( + [ + '--mode=close', + `--plan=${files.publicationPlan}`, + `--observations=${files.observation}`, + `--output=${files.receipt}`, + ], + output, + ).schema, + CLOSURE_RECEIPT_SCHEMA, + ); + assert.equal( + runCli( + [ + '--mode=audit', + `--plan=${files.publicationPlan}`, + `--observations=${files.observation}`, + `--receipt=${files.receipt}`, + ], + output, + ).compatible, + true, + ); +}); + +test('rejects ambiguous CLI modes and extra arguments', () => { + assert.throws( + () => parseArguments(['--mode=close']), + /arguments are invalid/, + ); + assert.throws( + () => + parseArguments([ + '--mode=audit', + '--plan=/tmp/plan', + '--observations=/tmp/observations', + '--receipt=/tmp/receipt', + '--extra=true', + ]), + /arguments are invalid/, + ); +});