From fd433c41cd539a7ffa997d777dcf0a055f612539 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 18 Aug 2026 00:56:12 +0800 Subject: [PATCH] feat(ql3): gate local releases through published catalogs --- .github/workflows/ql3-image-release.yml | 189 ++++++++++++++++++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 20 ++ ...cation-catalog-bound-local-release-gate.md | 88 ++++++++ docs/adr/README.md | 1 + docs/operations/ql3-release-set-deployment.md | 12 ++ scripts/ql3-cluster-image-release-audit.cjs | 92 ++++++++- ...l3-local-compose-rollout-live-contract.cjs | 149 ++++++++++++-- .../back/ql3ClusterImageReleaseAudit.test.cjs | 82 +++++++- ...calComposeCatalogReleaseSelection.test.cjs | 180 +++++++++++++++++ 9 files changed, 794 insertions(+), 19 deletions(-) create mode 100644 docs/adr/ADR-0437-post-publication-catalog-bound-local-release-gate.md create mode 100644 test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index d45fa4ad..0542ef36 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -881,6 +881,195 @@ jobs: overwrite: false include-hidden-files: false + release-catalog-local-deployment-live: + name: Consume the public catalog and prove both Local profiles + needs: release-set + if: always() && needs.release-set.result == 'success' && inputs.release_scope != 'cluster' + 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 + pnpm --filter @qinglong/local-owner-cli check + + - 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: Consume and independently audit the published OCI catalog + id: local-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-local-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: Materialize and audit the catalog-bound Local selection + id: local-selection + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + CONSUMPTION_BUNDLE: ${{ steps.local-catalog-consumption.outputs.bundle }} + REPOSITORY_OWNER: ${{ steps.local-catalog-consumption.outputs.repository-owner }} + SOURCE_REPOSITORY: ${{ steps.local-catalog-consumption.outputs.source-repository }} + shell: bash + run: | + set -euo pipefail + umask 077 + private_root="${RUNNER_TEMP}/ql3-release-catalog-local-selection" + selection="${private_root}/selection.json" + install -d -m 0700 "${private_root}" + node scripts/ql3-deployment-lock-contract.cjs \ + --mode=local-create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${REPOSITORY_OWNER}" \ + --source-repository="${SOURCE_REPOSITORY}" \ + --consumption-bundle="${CONSUMPTION_BUNDLE}" \ + --allow-root-service=false \ + --output="${selection}" > /dev/null + node scripts/ql3-deployment-lock-contract.cjs \ + --mode=local-audit \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${REPOSITORY_OWNER}" \ + --source-repository="${SOURCE_REPOSITORY}" \ + --consumption-bundle="${CONSUMPTION_BUNDLE}" \ + --allow-root-service=false \ + --selection="${selection}" > /dev/null + SELECTION="${selection}" node - <<'NODE' >> "${GITHUB_OUTPUT}" + const fs = require('node:fs'); + const selection = JSON.parse(fs.readFileSync(process.env.SELECTION, 'utf8')); + if (!/^sha256:[a-f0-9]{64}$/.test(selection.selectionDigest || '') || + !/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-application@sha256:[a-f0-9]{64}$/.test(selection.service?.image || '')) process.exit(1); + process.stdout.write(`selection=${process.env.SELECTION}\nselection-digest=${selection.selectionDigest}\nimage=${selection.service.image}\n`); + NODE + + - name: Prove catalog-bound Edge and Standalone rollout + env: + IMAGE: ${{ steps.local-selection.outputs.image }} + RELEASE_SELECTION: ${{ steps.local-selection.outputs.selection }} + SELECTION_DIGEST: ${{ steps.local-selection.outputs.selection-digest }} + EVIDENCE_ROOT: ${{ runner.temp }}/ql3-release-catalog-local-deployment + RELEASE_SCOPE: ${{ inputs.release_scope }} + shell: bash + run: | + set -euo pipefail + umask 077 + install -d -m 0700 "${EVIDENCE_ROOT}" + docker pull "${IMAGE}" + docker_executable="$(realpath "$(command -v docker)")" + docker_socket="$(realpath /var/run/docker.sock)" + for profile in edge standalone; do + node scripts/ql3-local-compose-rollout-live-contract.cjs \ + --image="${IMAGE}" \ + --docker-executable="${docker_executable}" \ + --docker-socket="${docker_socket}" \ + --profile="${profile}" \ + --release-selection="${RELEASE_SELECTION}" \ + --expected-selection-digest="${SELECTION_DIGEST}" > \ + "${EVIDENCE_ROOT}/${profile}.json" + done + node -e \ + "const fs=require('node:fs');const p=process.env.EVIDENCE_ROOT;const d=/^sha256:[a-f0-9]{64}$/;for(const n of ['edge','standalone']){const r=JSON.parse(fs.readFileSync(p+'/'+n+'.json','utf8'));if(r.profile!==n||r.compatible!==true||r.rolloutActive!==true||r.gracefulCleanup!==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.RELEASE_SCOPE||r.releaseAuthority?.selectionDigest!==process.env.SELECTION_DIGEST||!d.test(r.releaseAuthority?.releaseSetDigest||'')||!d.test(r.releaseAuthority?.catalogManifestDigest||'')||!d.test(r.releaseAuthority?.catalogConsumptionDigest||''))process.exit(1)}" + + - name: Verify isolated Local release-gate cleanup + if: always() + env: + IMAGE: ${{ steps.local-selection.outputs.image }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${IMAGE}" ]]; then + test -z "$(docker ps -aq --filter ancestor="${IMAGE}")" + fi + + - name: Upload content-free catalog Local deployment evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-release-catalog-local-deployment + if-no-files-found: warn + retention-days: 90 + compression-level: 9 + 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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 9c6ac3f4..2db19210 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-345/ADR-0437(已接受;首份真实公开 catalog evidence 待实际 release tag):Local 发布不再只依赖 image publisher 在完整 + release-set/catalog 形成前直接掌握 digest 的 Compose rollout。`release-set` 成功后新增只读 + `release-catalog-local-deployment-live`,仅在 `local|all` scope 运行;它从公开 discovery ref 独立消费 immutable catalog,重建并审计 + exact-three-file bundle,再以 `local-create|local-audit` 物化唯一 catalog-bound v2 selection。Edge 与 Standalone 必须顺序消费同一 selection, + 进入正式 deployment prepare、Compose revision/apply、SQLite backup/restore、evidence collection 与 graceful stop 链;真实模式在创建部署目录前 + 重验 selection canonical path/owner/`0600`/单链接/大小、自摘要、source tag/revision/scope、catalog 三摘要、image 与 root policy。普通 PR + rollout 保留显式 `synthetic_live_fixture`,不能混同公开发布。consumer 仅有 `contents|packages|attestations:read`,无 Docker login、package/ + tag/attestation write、签名或 catalog mutation;设备侧不安装 regctl、Cosign、GitHub CLI、Node workspace 或发布 credential。新增工作只在 + Local/All release runner 短生命周期运行,不新增 package、生产依赖、数据库、migration、listener、timer、watcher、updater 或稳态资源。 + 定向发布/selection/部署锁契约 116/116;完整 backend 1,353 项为 1,351 pass/2 条件 skip/0 fail,18-package clean build/test + 退出 0。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Cluster dependency、Edge import、 + image release、deployment-lock surface 与 release-version 审计全部 compatible。14 档 Local artifact 全部 compatible 且与 D-344 字节 + 基线完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application 为 3,632,769/3,632,889 bytes, + application-api 为 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 + 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes。阶段完整性 PostgreSQL HA 在 18.6/arm64 上重跑 + 142/142 gates、timeline `1→2`,独立 evidence audit 通过,mode-0600 报告 SHA-256 为 + `555b31757194379f1a44bdfbb1ef78549ed6b6787781e5fffeada852d26dcd9d`,container/network/volume 残留为 0。本机没有可供 + 独立消费者拉取的公开 QingLong 3 Local immutable GHCR image,因此不把 fixture、selection 单测或 workflow 静态审计写成真实公开 + catalog-bound Compose 成功;第一份端到端线上成功仍只能由实际受保护 `v3` release tag dispatch 产生。 + - 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 diff --git a/docs/adr/ADR-0437-post-publication-catalog-bound-local-release-gate.md b/docs/adr/ADR-0437-post-publication-catalog-bound-local-release-gate.md new file mode 100644 index 00000000..cdb5c775 --- /dev/null +++ b/docs/adr/ADR-0437-post-publication-catalog-bound-local-release-gate.md @@ -0,0 +1,88 @@ +# ADR-0437:发布后 Catalog-bound Local Release Gate + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-01、D-03、D-05、D-14、D-338、D-339、D-345 +- 关联 ADR:ADR-0196、ADR-0198、ADR-0199、ADR-0200、ADR-0201、ADR-0202、ADR-0425、ADR-0430、ADR-0431、ADR-0432、ADR-0436 + +## 上下文 + +Local image publisher 已在每个刚推送的 digest 上执行 Edge 与 Standalone Compose rollout,但该检查发生在完整 release-set、版本标签和 +durable OCI catalog 形成之前。ADR-0430 至 ADR-0432 已定义公开 catalog 消费、Local selection 和目标侧 Compose revision,发布流水线却 +没有证明一个不继承 publisher 登录态的下游消费者能够从公开 catalog 重新发现、验签、下载并重建 selection,再让实际 Local 产品入口消费 +同一份 selection。 + +因此 Local 发布仍有一段未被连续证明的链:publisher 内直接掌握的 image digest 成功,不等于路由器、NAS 或单机用户最终拿到的 catalog +selection 能成功进入 Edge/Standalone。该缺口不能通过为设备安装 Cosign、regctl、GitHub CLI 或常驻 updater 来关闭;这些工具应只存在于 +发布者外部的短生命周期 release gate。 + +## 决策 + +1. `ql3-image-release.yml` 在 `release-set` 完成后增加独立 `release-catalog-local-deployment-live`。它仅在 `local|all` scope 运行; + `cluster` scope 明确跳过。现有 Kubernetes gate 继续只处理 `cluster|all`,两族发布门互不替代。 +2. Local gate 使用 GitHub-hosted runner、30 分钟硬上限和只读 `contents|packages|attestations` 权限。它没有 package write、OIDC signing、 + attestation write、Docker login、tag promotion 或 catalog mutation authority。 +3. consumer 不下载 release-set workflow artifact 作为 authority。它使用 checksum-pinned `regctl@0.11.5`、pinned Cosign installer 与 + canonical GitHub CLI,从公开 discovery tag 两次稳定解析 immutable catalog,以 exact workflow identity、source tag/revision 和公开 OCI + provenance 完成 ADR-0430 three-file consumption bundle。短期 GitHub token 只通过当前 UID 的 `0600` 文件进入 verifier,完成即删除。 +4. 同一 job 使用 `ql3-deployment-lock-contract.cjs local-create|local-audit` 从 bundle 物化唯一 + `qinglong/local-compose-release-image@v2`。selection 必须绑定 release-set、catalog manifest、consumption report、source identity、唯一 + Local image digest 和显式 `allowRootService=false`;输出为当前 UID、单链接、canonical `0600` no-replace 文件。 +5. `ql3-local-compose-rollout-live-contract.cjs` 保留原 synthetic PR 模式,并增加 all-or-none 的 + `--release-selection + --expected-selection-digest` 模式。真实模式在任何部署准备前重验 canonical path/parent、owner、mode、link count、大小、 + canonical JSON、自摘要、source tag/revision/scope、catalog 三个 digest、Local image 和 root policy;任一漂移失败关闭。 +6. Edge 与 Standalone 都必须把同一 selection 交给正式 `prepareLocalDeployment`、Compose revision switch/apply、SQLite rollout backup/restore、 + evidence collection 与 graceful stop 路径。成功报告只投影 content-free `releaseAuthority` digest/identity,不上传 selection、bundle、token、 + Docker socket、deployment root、command 或数据库。 +7. Local gate 先公开拉取 selection 中的 immutable image,再依次运行两种 Profile;完成后要求没有以该 image 为 ancestor 的容器残留。失败也只 + 上传已有的 content-free report,runner 结束即销毁私有 bundle/selection。 +8. 不新增 workspace package、production dependency、数据库、migration、listener、timer、watcher、updater 或设备侧工具。Local/Edge/ + Standalone 的镜像字节、模块闭包和稳态资源必须保持不变。 +9. 第一份真实成功 evidence 只能来自实际受保护 `v3` release tag。仓库 fixture 与本机 Docker 可以证明 selection handoff、产品 rollout 和 + 失败关闭语义,但不能冒充公开 GHCR/Cosign/GitHub 在线 ceremony。 + +## 失败与恢复 + +- 公开 catalog 不可读、discovery digest 漂移或签名/provenance 不匹配:发布失败;不能退回 publisher artifact 或登录态。 +- selection create/audit 不一致:修正 catalog/release-set 或源码后重跑;不能手工复制 image digest。 +- Edge 或 Standalone 任一失败:Local release 失败;一种 Profile 的成功不能覆盖另一种。 +- selection 文件被替换、扩权、硬链接、改写或 image 不匹配:在创建 deployment root 前拒绝。 +- rollout 失败:既有 Compose/SQLite recovery contract 保持 authority;release gate 不增加自动清库、删卷或覆盖 receipt 的能力。 + +## 部署与资源影响 + +- 额外成本只发生在 Local/All 正式发布 runner:一次 catalog 消费、一次 selection 物化、一次 image pull 和 Edge/Standalone 各一轮 rollout。 +- 设备只消费最终 selection 和 immutable image;不安装 Node workspace、regctl、Cosign、GitHub CLI 或发布凭据。 +- Cluster-only 发布不运行本门;Local-only 发布不运行 K3s,也不等待 Worker/CloudNativePG 私有证据。 +- 两次 rollout 顺序执行,复用一个小型 selection;没有 per-device queue、daemon 或后台连接。 + +## 被拒绝的替代方案 + +### 继续依赖 publisher 内的 pushed-digest rollout + +拒绝。它早于完整 release-set/catalog,且继承 publisher 的文件和登录态,无法证明公开消费者得到的 selection 可用。 + +### 把 release-set artifact 直接交给 Compose + +拒绝。90 天 artifact 不是长期 discovery authority,也绕过 durable catalog 的 immutable manifest、签名、provenance 和 byte round-trip。 + +### 只检查 selection JSON,不运行容器 + +拒绝。结构审计不能覆盖 Profile compose merge、只读根、SQLite backup/restore、健康收敛和 graceful stop 的实际产品链。 + +### 在路由设备上执行公开 catalog 验签 + +拒绝。它会给低配用户增加发布工具、网络、凭据和维护负担;设备目标侧只需消费已由可信工作站生成并审核的有界 selection。 + +## 验证 + +- catalog selection 参数、权限、摘要、image/source/catalog 漂移与 synthetic compatibility:5/5; +- release workflow、deployment lock、catalog consumption 与负向 mutation 定向契约:116/116; +- 完整 backend 为 1,351 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages 且 + `singleSourcePackages=[]`、`shallowSourcePackages=[]`,相关依赖、Edge import、发布、部署锁与版本审计全部 compatible; +- 14 档 Local artifact 全部 compatible 且与 D-344 字节基线完全一致;发布工具与工作区依赖没有进入 Edge/Standalone 制品; +- 本切片无数据库、migration、SQL、role、Pool 或 Cluster runtime 变化,不以 PostgreSQL HA 替代 Local 门;阶段完整性仍重跑 + PostgreSQL 18.6/arm64 物理 HA 142/142、timeline `1→2`,独立 evidence audit 通过且 Docker 残留为 0; +- 当前没有可供独立消费者拉取的公开 QingLong 3 Local immutable GHCR image,因此仓库内 selection/静态 workflow 证据不记为真实公开 + catalog-bound Docker Compose 成功; +- 公开成功记录继续等待实际受保护 release tag,不能用 fixture 提前改为完成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 6615b6ec..0c0e85f0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -440,6 +440,7 @@ | [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) | +| [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local 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 f9a14b86..06f3aed0 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -13,6 +13,18 @@ ghcr.io//qinglong3-release-catalog:v- `ghcr.io//qinglong3-release-catalog@sha256:`,验证这个 immutable reference 后,再从 JSON 的 `images[].reference` 读取完整 `ghcr.io//@sha256:`。 +## 发布流水线内置 Local 与 Cluster 下游门 + +`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog +重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON +检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence +collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest; +任一 Profile 失败都会阻断 Local release。 + +该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。regctl、Cosign、GitHub CLI、 +Node workspace、token 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻 updater、 +listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。 + ## 发布流水线内置 Cluster 下游门 `ql3-image-release.yml` 在 durable catalog、签名、provenance、receipt 和 90 天便利 bundle 全部发布后,才启动独立 diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 06072c52..66aeadcd 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 localCatalogDeploymentJob = + workflow?.jobs?.['release-catalog-local-deployment-live']; const catalogDeploymentJob = workflow?.jobs?.['release-catalog-deployment-live']; if ( @@ -474,6 +476,12 @@ function auditReleaseWorkflow(source) { attestations: 'write', 'artifact-metadata': 'write', }) || + JSON.stringify(localCatalogDeploymentJob?.permissions) !== + JSON.stringify({ + contents: 'read', + packages: 'read', + attestations: 'read', + }) || JSON.stringify(catalogDeploymentJob?.permissions) !== JSON.stringify({ contents: 'read', @@ -777,6 +785,68 @@ 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 localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps; + if ( + localCatalogDeploymentJob?.needs !== 'release-set' || + localCatalogDeploymentJob?.if !== + "always() && needs.release-set.result == 'success' && inputs.release_scope != 'cluster'" || + localCatalogDeploymentJob?.['runs-on'] !== 'ubuntu-24.04' || + localCatalogDeploymentJob?.['timeout-minutes'] !== 30 || + !Array.isArray(localCatalogDeploymentSteps) || + localCatalogDeploymentSteps.length !== 10 || + localCatalogDeploymentSteps[0]?.uses !== + 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || + localCatalogDeploymentSteps[0]?.with?.['persist-credentials'] !== false || + localCatalogDeploymentSteps[1]?.uses !== + 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' || + localCatalogDeploymentSteps[1]?.with?.['node-version'] !== '24.18.0' || + !/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts[\s\S]*pnpm --filter @qinglong\/local-owner-cli check/u.test( + localCatalogDeploymentSteps[2]?.run ?? '', + ) || + !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test( + localCatalogDeploymentSteps[3]?.run ?? '', + ) || + localCatalogDeploymentSteps[4]?.uses !== + 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || + localCatalogDeploymentSteps[5]?.id !== 'local-catalog-consumption' || + !/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test( + localCatalogDeploymentSteps[5]?.run ?? '', + ) || + localCatalogDeploymentSteps[6]?.id !== 'local-selection' || + !/ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-create[\s\S]*--consumption-bundle="\$\{CONSUMPTION_BUNDLE\}"[\s\S]*--allow-root-service=false[\s\S]*--output="\$\{selection\}"[\s\S]*ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-audit[\s\S]*--selection="\$\{selection\}"[\s\S]*selection-digest=\$\{selection\.selectionDigest\}[\s\S]*image=\$\{selection\.service\.image\}/u.test( + localCatalogDeploymentSteps[6]?.run ?? '', + ) || + !/docker pull "\$\{IMAGE\}"[\s\S]*for profile in edge standalone[\s\S]*ql3-local-compose-rollout-live-contract\.cjs[\s\S]*--image="\$\{IMAGE\}"[\s\S]*--profile="\$\{profile\}"[\s\S]*--release-selection="\$\{RELEASE_SELECTION\}"[\s\S]*--expected-selection-digest="\$\{SELECTION_DIGEST\}"[\s\S]*verified_release_catalog[\s\S]*catalogConsumptionDigest/u.test( + localCatalogDeploymentSteps[7]?.run ?? '', + ) || + localCatalogDeploymentSteps[8]?.if !== 'always()' || + !/docker ps -aq --filter ancestor="\$\{IMAGE\}"/u.test( + localCatalogDeploymentSteps[8]?.run ?? '', + ) || + localCatalogDeploymentSteps[9]?.if !== 'always()' || + localCatalogDeploymentSteps[9]?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(localCatalogDeploymentSteps[9]?.with) !== + JSON.stringify({ + name: 'ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}', + path: '${{ runner.temp }}/ql3-release-catalog-local-deployment', + 'if-no-files-found': 'warn', + 'retention-days': 90, + 'compression-level': 9, + overwrite: false, + 'include-hidden-files': false, + }) || + localCatalogDeploymentSteps.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( + 'Local release must independently consume the published catalog, reconstruct its exact selection and prove both Compose profiles without publication authority', + ); + } const catalogDeploymentSteps = catalogDeploymentJob?.steps; if ( catalogDeploymentJob?.needs !== 'release-set' || @@ -943,20 +1013,20 @@ function auditReleaseWorkflow(source) { requireOccurrences( source, /uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g, - 7, + 8, 'all release jobs must pin the reviewed immutable checkout action', ); requireOccurrences( source, /uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g, - 7, + 8, 'all release jobs must pin the reviewed immutable Node setup action', ); requireOccurrences( source, /uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g, - 3, - 'image publisher, catalog publisher and catalog consumer must pin the reviewed Cosign installer', + 4, + 'image publisher, catalog publisher and both catalog consumers must pin the reviewed Cosign installer', ); requirePattern( source, @@ -996,8 +1066,8 @@ function auditReleaseWorkflow(source) { requireExactOccurrences( source, /c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g, - 3, - 'image publisher, release-set publisher and catalog consumer must checksum-pin the exact regctl OCI copier and reader', + 4, + 'image publisher, release-set publisher and both catalog consumers must checksum-pin the exact regctl OCI copier and reader', ); requirePattern( source, @@ -1199,6 +1269,15 @@ function auditReleaseWorkflow(source) { fencedRetirementReceiptAudited: true, publicationAuthority: false, }, + localCatalogDeploymentGate: { + scopes: ['local', 'all'], + catalogAuthority: 'immutable_digest_after_public_consumption', + selectionReconstructed: true, + profiles: ['edge', 'standalone'], + rolloutReceiptAudited: true, + gracefulCleanup: true, + publicationAuthority: false, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -1211,6 +1290,7 @@ function auditReleaseWorkflow(source) { 'release-set', 'durable-catalog', 'catalog-consumption', + 'catalog-bound-local-compose-deployment', 'catalog-bound-k3s-deployment', 'release-tags', ], diff --git a/scripts/ql3-local-compose-rollout-live-contract.cjs b/scripts/ql3-local-compose-rollout-live-contract.cjs index a55246b5..679cb5d0 100644 --- a/scripts/ql3-local-compose-rollout-live-contract.cjs +++ b/scripts/ql3-local-compose-rollout-live-contract.cjs @@ -2,6 +2,7 @@ 'use strict'; +const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -13,6 +14,8 @@ const { const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const MAX_RELEASE_SELECTION_BYTES = 1024 * 1024; function fail(message) { throw new Error(`QingLong local Compose rollout failed: ${message}`); @@ -28,15 +31,30 @@ function argumentsValue(argv) { return [argument.slice(2, separator), argument.slice(separator + 1)]; }), ); + const keys = Object.keys(values).sort(); + const baseKeys = [ + 'docker-executable', + 'docker-socket', + 'image', + 'profile', + ].sort(); + const catalogKeys = [ + ...baseKeys, + 'expected-selection-digest', + 'release-selection', + ].sort(); + const syntheticInput = JSON.stringify(keys) === JSON.stringify(baseKeys); + const catalogInput = JSON.stringify(keys) === JSON.stringify(catalogKeys); if ( Object.keys(values).length !== argv.length || + (!syntheticInput && !catalogInput) || !IMAGE_PATTERN.test(values.image ?? '') || (values.profile !== 'edge' && values.profile !== 'standalone') || typeof values['docker-executable'] !== 'string' || typeof values['docker-socket'] !== 'string' ) { fail( - 'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone', + 'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone [--release-selection=/canonical/selection.json --expected-selection-digest=sha256:digest]', ); } const dockerExecutable = fs.realpathSync(values['docker-executable']); @@ -47,11 +65,114 @@ function argumentsValue(argv) { ) { fail('Docker executable and socket must be canonical paths'); } + let releaseSelection; + if (catalogInput) { + if ( + !path.isAbsolute(values['release-selection']) || + !DIGEST_PATTERN.test(values['expected-selection-digest']) + ) { + fail('catalog selection path and digest are invalid'); + } + const selectionPath = fs.realpathSync(values['release-selection']); + if (selectionPath !== values['release-selection']) { + fail('catalog selection path must be canonical'); + } + releaseSelection = Object.freeze({ + path: selectionPath, + expectedSelectionDigest: values['expected-selection-digest'], + }); + } return Object.freeze({ image: values.image, profile: values.profile, dockerExecutable, dockerSocket, + ...(releaseSelection ? { releaseSelection } : {}), + }); +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function resolveReleaseSelection(input, temporaryRoot, uid) { + if (!input.releaseSelection) { + const releaseSelection = writeSyntheticLocalReleaseSelection({ + directory: temporaryRoot, + image: input.image, + allowRootService: uid === 0, + sourceRevision: process.env.GITHUB_SHA, + }); + return Object.freeze({ + releaseSelection, + authority: Object.freeze({ mode: 'synthetic_live_fixture' }), + }); + } + const selectionPath = input.releaseSelection.path; + const stat = fs.lstatSync(selectionPath); + const contents = fs.readFileSync(selectionPath, 'utf8'); + let selection; + try { + selection = JSON.parse(contents); + } catch { + fail('catalog selection must be canonical JSON'); + } + const unsigned = { ...selection }; + delete unsigned.selectionDigest; + const computedDigest = sha256(JSON.stringify(unsigned)); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.uid !== uid || + (stat.mode & 0o777) !== 0o600 || + stat.size < 2 || + stat.size > MAX_RELEASE_SELECTION_BYTES || + fs.realpathSync(selectionPath) !== selectionPath || + fs.realpathSync(path.dirname(selectionPath)) !== + path.dirname(selectionPath) || + contents !== `${JSON.stringify(selection)}\n` || + computedDigest !== input.releaseSelection.expectedSelectionDigest || + selection.selectionDigest !== computedDigest || + selection.schemaVersion !== 1 || + selection.schema !== 'qinglong/local-compose-release-image@v2' || + !/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test( + selection.release?.version ?? '', + ) || + !/^[a-f0-9]{40}$/u.test(selection.release?.sourceRevision ?? '') || + selection.release?.sourceRef !== + `refs/tags/v${selection.release?.version}` || + !['local', 'all'].includes(selection.release?.scope) || + !DIGEST_PATTERN.test(selection.releaseSetDigest ?? '') || + selection.deploymentFamily !== 'local' || + selection.service?.kind !== 'compose' || + selection.service?.image !== input.image || + selection.service?.allowRootService !== (uid === 0) || + selection.catalog?.schema !== + 'qinglong/release-catalog-consumption-ceremony@v1' || + !DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') || + !DIGEST_PATTERN.test(selection.catalog?.consumptionReportDigest ?? '') || + selection.catalog?.releaseSetDigest !== selection.releaseSetDigest || + selection.catalog?.discoveryTagAuthority !== 'none' || + selection.verification?.catalogConsumption !== 'offline_reconstructed' || + selection.verification?.externalToolResultsReplayed !== false || + selection.verification?.networkAccess !== false || + selection.verification?.deploymentMutation !== false + ) { + fail('catalog-bound release selection is incompatible'); + } + return Object.freeze({ + releaseSelection: input.releaseSelection, + authority: Object.freeze({ + mode: 'verified_release_catalog', + sourceRevision: selection.release.sourceRevision, + sourceRef: selection.release.sourceRef, + scope: selection.release.scope, + releaseSetDigest: selection.releaseSetDigest, + catalogManifestDigest: selection.catalog.manifestDigest, + catalogConsumptionDigest: selection.catalog.consumptionReportDigest, + selectionDigest: selection.selectionDigest, + }), }); } @@ -181,12 +302,8 @@ async function main() { const deploymentRoot = path.join(temporaryRoot, 'deployment'); const commandPath = path.join(temporaryRoot, 'rollout.json'); const uid = process.getuid(); - const releaseSelection = writeSyntheticLocalReleaseSelection({ - directory: temporaryRoot, - image: input.image, - allowRootService: uid === 0, - sourceRevision: process.env.GITHUB_SHA, - }); + const selection = resolveReleaseSelection(input, temporaryRoot, uid); + const releaseSelection = selection.releaseSelection; let composeResourcesPresent = false; try { @@ -852,6 +969,7 @@ async function main() { sqliteEvidenceCollected: true, sqliteCollectedRolloutReplayUnchanged: true, gracefulCleanup: true, + releaseAuthority: selection.authority, compatible: true, })}\n`, ); @@ -867,9 +985,16 @@ async function main() { } } -main().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} + +module.exports = Object.freeze({ + argumentsValue, + resolveReleaseSelection, }); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 556cf468..9cc7651c 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -124,6 +124,15 @@ test('accepts the reviewed native CI and digest release contracts', () => { fencedRetirementReceiptAudited: true, publicationAuthority: false, }, + localCatalogDeploymentGate: { + scopes: ['local', 'all'], + catalogAuthority: 'immutable_digest_after_public_consumption', + selectionReconstructed: true, + profiles: ['edge', 'standalone'], + rolloutReceiptAudited: true, + gracefulCleanup: true, + publicationAuthority: false, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -136,6 +145,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'release-set', 'durable-catalog', 'catalog-consumption', + 'catalog-bound-local-compose-deployment', 'catalog-bound-k3s-deployment', 'release-tags', ], @@ -729,7 +739,7 @@ test('rejects an image tag created before digest verification completes', () => }); test('rejects any publisher step after the deployment lock is published', () => { - const marker = '\n release-catalog-deployment-live:'; + const marker = '\n release-catalog-local-deployment-live:'; assert.equal(releaseSource.includes(marker), true); const mutated = releaseSource.replace( marker, @@ -742,6 +752,76 @@ test('rejects any publisher step after the deployment lock is published', () => ); }); +test('rejects removal of the downstream Local catalog deployment gate', function rejectsMissingLocalCatalogDeploymentGate() { + const localMarker = '\n release-catalog-local-deployment-live:'; + const clusterMarker = '\n release-catalog-deployment-live:'; + const localOffset = releaseSource.indexOf(localMarker); + const clusterOffset = releaseSource.indexOf(clusterMarker); + assert.notEqual(localOffset, -1); + assert.equal(clusterOffset > localOffset, true); + const mutated = `${releaseSource.slice(0, localOffset)}${releaseSource.slice( + clusterOffset, + )}`; + assert.throws( + () => auditReleaseWorkflow(mutated), + /keep evidence read-only|Local release must independently consume/, + ); +}); + +test('rejects running the Local catalog gate for Cluster-only releases', function rejectsClusterLocalCatalogDeploymentGate() { + const mutated = releaseSource.replace( + "inputs.release_scope != 'cluster'", + "inputs.release_scope == 'cluster'", + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /Local release must independently consume/, + ); +}); + +test('rejects a Local rollout detached from the audited catalog selection', function rejectsDetachedLocalCatalogSelection() { + const localMarker = '\n release-catalog-local-deployment-live:'; + const clusterMarker = '\n release-catalog-deployment-live:'; + const localOffset = releaseSource.indexOf(localMarker); + const clusterOffset = releaseSource.indexOf(clusterMarker); + const mutatedLocal = releaseSource + .slice(localOffset, clusterOffset) + .replace( + '--release-selection="${RELEASE_SELECTION}"', + '--release-selection="/tmp/unreviewed.json"', + ); + assert.throws( + () => + auditReleaseWorkflow( + `${releaseSource.slice( + 0, + localOffset, + )}${mutatedLocal}${releaseSource.slice(clusterOffset)}`, + ), + /Local release must independently consume/, + ); +}); + +test('rejects publication authority in the post-publish Local catalog consumer', function rejectsLocalCatalogConsumerPublicationAuthority() { + const localMarker = '\n release-catalog-local-deployment-live:'; + const clusterMarker = '\n release-catalog-deployment-live:'; + const localOffset = releaseSource.indexOf(localMarker); + const clusterOffset = releaseSource.indexOf(clusterMarker); + const mutatedLocal = releaseSource + .slice(localOffset, clusterOffset) + .replace('packages: read', 'packages: write'); + assert.throws( + () => + auditReleaseWorkflow( + `${releaseSource.slice( + 0, + localOffset, + )}${mutatedLocal}${releaseSource.slice(clusterOffset)}`, + ), + /keep evidence read-only|without publication authority/, + ); +}); + test('rejects removal of the downstream catalog deployment release gate', function rejectsMissingCatalogDeploymentGate() { const marker = '\n release-catalog-deployment-live:'; const offset = releaseSource.indexOf(marker); diff --git a/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs b/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs new file mode 100644 index 00000000..5c26a36e --- /dev/null +++ b/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs @@ -0,0 +1,180 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + argumentsValue, + resolveReleaseSelection, +} = require('../../scripts/ql3-local-compose-rollout-live-contract.cjs'); +const { + writeSyntheticLocalReleaseSelection, +} = require('../../scripts/lib/ql3-local-release-selection-test-fixture.cjs'); + +const IMAGE = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat( + 64, +)}`; + +function privateDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-catalog-selection-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function catalogSelection(t) { + const directory = privateDirectory(t); + return { + directory, + releaseSelection: writeSyntheticLocalReleaseSelection({ + directory, + image: IMAGE, + allowRootService: process.getuid() === 0, + sourceRevision: 'b'.repeat(40), + }), + }; +} + +test('preserves the existing synthetic rollout arguments', (t) => { + const directory = privateDirectory(t); + const socket = path.join(directory, 'docker.sock'); + fs.writeFileSync(socket, 'fixture', { mode: 0o600 }); + assert.deepEqual( + argumentsValue([ + `--image=${IMAGE}`, + `--docker-executable=${fs.realpathSync(process.execPath)}`, + `--docker-socket=${fs.realpathSync(socket)}`, + '--profile=edge', + ]), + { + image: IMAGE, + profile: 'edge', + dockerExecutable: fs.realpathSync(process.execPath), + dockerSocket: fs.realpathSync(socket), + }, + ); +}); + +test('accepts only a complete catalog selection handoff', (t) => { + const { directory, releaseSelection } = catalogSelection(t); + const socket = path.join(directory, 'docker.sock'); + fs.writeFileSync(socket, 'fixture', { mode: 0o600 }); + const common = [ + `--image=${IMAGE}`, + `--docker-executable=${fs.realpathSync(process.execPath)}`, + `--docker-socket=${fs.realpathSync(socket)}`, + '--profile=standalone', + ]; + assert.deepEqual( + argumentsValue([ + ...common, + `--release-selection=${releaseSelection.path}`, + `--expected-selection-digest=${releaseSelection.expectedSelectionDigest}`, + ]).releaseSelection, + releaseSelection, + ); + assert.throws( + () => + argumentsValue([ + ...common, + `--release-selection=${releaseSelection.path}`, + ]), + /usage/, + ); + assert.throws( + () => + argumentsValue([ + ...common, + `--expected-selection-digest=${releaseSelection.expectedSelectionDigest}`, + ]), + /usage/, + ); +}); + +test('projects a content-free verified catalog authority from the exact selection', (t) => { + const { directory, releaseSelection } = catalogSelection(t); + const result = resolveReleaseSelection( + { image: IMAGE, releaseSelection }, + directory, + process.getuid(), + ); + assert.equal(result.releaseSelection, releaseSelection); + assert.deepEqual(result.authority, { + mode: 'verified_release_catalog', + sourceRevision: 'b'.repeat(40), + sourceRef: 'refs/tags/v3.0.0-alpha.0', + scope: 'local', + releaseSetDigest: JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8')) + .releaseSetDigest, + catalogManifestDigest: JSON.parse( + fs.readFileSync(releaseSelection.path, 'utf8'), + ).catalog.manifestDigest, + catalogConsumptionDigest: JSON.parse( + fs.readFileSync(releaseSelection.path, 'utf8'), + ).catalog.consumptionReportDigest, + selectionDigest: releaseSelection.expectedSelectionDigest, + }); +}); + +test('rejects selection drift, image substitution and wider permissions', (t) => { + const first = catalogSelection(t); + assert.throws( + () => + resolveReleaseSelection( + { + image: IMAGE.replace('qinglong3-local-application', 'other'), + releaseSelection: first.releaseSelection, + }, + first.directory, + process.getuid(), + ), + /incompatible/, + ); + + const second = catalogSelection(t); + fs.chmodSync(second.releaseSelection.path, 0o640); + assert.throws( + () => + resolveReleaseSelection( + { image: IMAGE, releaseSelection: second.releaseSelection }, + second.directory, + process.getuid(), + ), + /incompatible/, + ); + + const third = catalogSelection(t); + const selection = JSON.parse( + fs.readFileSync(third.releaseSelection.path, 'utf8'), + ); + selection.release.sourceRevision = 'c'.repeat(40); + fs.writeFileSync( + third.releaseSelection.path, + `${JSON.stringify(selection)}\n`, + { mode: 0o600 }, + ); + assert.throws( + () => + resolveReleaseSelection( + { image: IMAGE, releaseSelection: third.releaseSelection }, + third.directory, + process.getuid(), + ), + /incompatible/, + ); +}); + +test('marks an ordinary PR rollout as a synthetic fixture', (t) => { + const directory = privateDirectory(t); + const result = resolveReleaseSelection( + { image: IMAGE }, + directory, + process.getuid(), + ); + assert.deepEqual(result.authority, { mode: 'synthetic_live_fixture' }); + assert.equal(fs.existsSync(result.releaseSelection.path), true); +});