diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 89b7bff8..dd2a447c 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -301,6 +301,7 @@ jobs: test/back/ql3ClusterImageReleaseAudit.test.cjs test/back/ql3ReleaseCandidateContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs + test/back/ql3ReleaseCatalogContract.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs test/back/ql3LocalImageAudit.test.cjs test/back/ql3VersionTransition.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 8175800d..8b572f83 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -616,7 +616,7 @@ jobs: needs.release-candidate.result == 'success' && needs.publish.result == 'success' runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 permissions: contents: read packages: write @@ -648,8 +648,12 @@ jobs: set -euo pipefail umask 077 owner="${GITHUB_REPOSITORY_OWNER,,}" + source_repository="${GITHUB_REPOSITORY,,}" candidate="${RUNNER_TEMP}/release-set-candidate.json" - report="${RUNNER_TEMP}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + bundle="${RUNNER_TEMP}/release-catalog-bundle" + mkdir "${bundle}" + report="${bundle}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + plan="${bundle}/qinglong3-release-catalog-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" node scripts/ql3-release-candidate-contract.cjs \ --mode=create \ --version="${RELEASE_VERSION}" \ @@ -677,7 +681,27 @@ jobs: --candidate="${candidate}" \ --records="${RUNNER_TEMP}/release-records" \ --report="${report}" > "${RUNNER_TEMP}/release-set-audit.json" + node scripts/ql3-release-set-contract.cjs \ + --mode=inspect \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${owner}" \ + --report="${report}" > "${RUNNER_TEMP}/release-set-inspection.json" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=plan \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --repository-owner="${owner}" \ + --source-repository="${source_repository}" \ + --release-set="${report}" \ + --output="${plan}" echo "report=${report}" >> "${GITHUB_OUTPUT}" + echo "plan=${plan}" >> "${GITHUB_OUTPUT}" + echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}" - name: Install the exact checksum-pinned OCI copier env: @@ -691,6 +715,8 @@ jobs: chmod 0755 "${REGCTL}" "${REGCTL}" version + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4 with: registry: ghcr.io @@ -747,11 +773,108 @@ jobs: with: subject-path: ${{ steps.release-set.outputs.report }} + - name: Publish and round-trip the durable OCI release catalog + id: catalog + env: + REGCTL: ${{ runner.temp }}/regctl + RELEASE_SET: ${{ steps.release-set.outputs.report }} + PLAN: ${{ steps.release-set.outputs.plan }} + run: | + set -euo pipefail + catalog_repository="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.registryRepository)' "${PLAN}")" + discovery_tag="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.discoveryTag)' "${PLAN}")" + artifact_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.artifactType)' "${PLAN}")" + file_media_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.fileMediaType)' "${PLAN}")" + file_name="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.releaseSet.fileName)' "${PLAN}")" + "${REGCTL}" artifact put \ + --artifact-type "${artifact_type}" \ + --file-media-type "${file_media_type}" \ + --file "${RELEASE_SET}" \ + --file-title \ + --strip-dirs \ + --annotation "dev.qinglong.release.scope=${{ inputs.release_scope }}" \ + --annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY,,}" \ + --annotation "org.opencontainers.image.version=${{ inputs.version }}" \ + "${discovery_tag}" + digest="$("${REGCTL}" image digest "${discovery_tag}")" + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "release catalog did not resolve to an immutable digest" >&2 + exit 1 + fi + immutable_reference="${catalog_repository}@${digest}" + roundtrip="${RUNNER_TEMP}/release-set-roundtrip.json" + manifest="${RUNNER_TEMP}/release-catalog-manifest.json" + "${REGCTL}" artifact get --file "${file_name}" "${immutable_reference}" > "${roundtrip}" + cmp --silent "${RELEASE_SET}" "${roundtrip}" + "${REGCTL}" manifest get "${immutable_reference}" --format raw-body > "${manifest}" + echo "repository=${catalog_repository}" >> "${GITHUB_OUTPUT}" + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "manifest=${manifest}" >> "${GITHUB_OUTPUT}" + + - name: Keylessly sign the immutable release-catalog digest + env: + CATALOG: ${{ steps.catalog.outputs.repository }} + DIGEST: ${{ steps.catalog.outputs.digest }} + run: cosign sign --yes "${CATALOG}@${DIGEST}" + + - name: Attest durable release-catalog provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-name: ${{ steps.catalog.outputs.repository }} + subject-digest: ${{ steps.catalog.outputs.digest }} + push-to-registry: true + + - name: Verify the durable catalog and create its immutable receipt + id: catalog-receipt + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + CATALOG: ${{ steps.catalog.outputs.repository }} + DIGEST: ${{ steps.catalog.outputs.digest }} + MANIFEST: ${{ steps.catalog.outputs.manifest }} + PLAN: ${{ steps.release-set.outputs.plan }} + BUNDLE: ${{ steps.release-set.outputs.bundle }} + run: | + set -euo pipefail + certificate_identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml@${GITHUB_REF}" + cosign verify \ + --certificate-identity "${certificate_identity}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${CATALOG}@${DIGEST}" + gh attestation verify "oci://${CATALOG}@${DIGEST}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \ + --source-digest "${GITHUB_SHA}" \ + --source-ref "${GITHUB_REF}" \ + --deny-self-hosted-runners \ + --bundle-from-oci + receipt="${BUNDLE}/qinglong3-release-catalog-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=receipt \ + --plan="${PLAN}" \ + --manifest="${MANIFEST}" \ + --manifest-digest="${DIGEST}" \ + --output="${receipt}" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=audit \ + --plan="${PLAN}" \ + --manifest="${MANIFEST}" \ + --manifest-digest="${DIGEST}" \ + --receipt="${receipt}" > "${RUNNER_TEMP}/release-catalog-audit.json" + echo "receipt=${receipt}" >> "${GITHUB_OUTPUT}" + + - name: Attest the immutable release-catalog receipt + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: ${{ steps.catalog-receipt.outputs.receipt }} + - name: Publish the deployment digest lock uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }} - path: ${{ steps.release-set.outputs.report }} + path: ${{ steps.release-set.outputs.bundle }} if-no-files-found: error retention-days: 90 compression-level: 0 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index b60faf55..a10822d6 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io//qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v-` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。 - D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。 - D-334/ADR-0426(已接受):根级 canonical `ql3-release.json` 现在是唯一 QingLong 3 release identity authority,精确冻结 3.x SemVer、Node 24.18.0/engine、18-package 边界和 legacy 2.x 排除事实;发布候选、四组容器、Cluster/Worker/Console 部署、Local/Cluster image audit、CloudNativePG、物理 Edge 与外部恢复审计均改为读取同一 authority,candidate contract 额外绑定 identity schema 与 SHA-256。共享 CI 新增 `audit:release-version:ql3`,失败关闭 18 个 workspace、四组 build/runtime manifest+lock、Dockerfile Node/version label 与 242 个部署文本文件中的 32 个 image reference/36 个版本 occurrence。维护者升级版本必须走 closed `audit|plan|apply`:plan 只接受严格递增 exact v3 SemVer并生成 no-replace `0600`、逐文件 path/mode/replacement/before-after bytes+digest 和自身 digest;apply 先全量预检 65 文件/83 处替换,再用同目录确定性临时文件、fsync+rename 逐文件收敛,允许 source/target 混合状态原 plan 幂等恢复并生成 digest-bound report,绝不修改 legacy 根 2.x、自动 commit/tag/push 或宣称跨文件单事务。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、listener、timer、watcher 或任何低配/集群常驻开销。定向回归 177/177,backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;完整回归未发现数据库或部署拓扑漂移。 - D-333/ADR-0425(已接受;公开发布结果待实际 tag):3.0 发布入口不再把所有部署者绑成一个不可分割矩阵。唯一 `.github/workflows/ql3-image-release.yml` 增加 closed `local|cluster|all` deployment-family scope;根级 source-derived release-candidate contract 从 exact `v3` SemVer/tag/40-hex revision、18 个边界审计通过且非 single/shallow 的 workspace、Node 24.18.0 engine、容器 runtime manifest/Dockerfile version、双架构和部署 profile 推导唯一 OS/publish matrix,并以 canonical SHA-256 失败关闭版本或源码漂移。`local` 只发布 AI-excluded Local image、只要求 Edge/Standalone digest rollout,不再等待 Worker management/CloudNativePG 私有 HA evidence;`cluster` 才要求两个 ephemeral private evidence gate,并闭合此前遗漏的 `qinglong3-worker`,与 control/control-ai/admin 一同进入 native amd64/arm64 build-once、Trivy OS scan、CycloneDX、OCI merge、Cosign 与 GitHub attestation 链;`all` 同时保留两族门禁。legacy 根 `2.21.0-14` 被显式标记为不参与 3.0 release identity,而不是伪改旧产品版本。Worker 现在有 27-component(24 external/3 internal)、28-node 的 production SBOM,BSD-3-Clause 纳入受审 allowlist,Worker config 固定 `65532:65532`、`worker` profile、`edge,node` capacity labels 和 3.0 version;control/admin 也补齐同一 version label。candidate contract 作为第四类 digest-bound GitHub predicate 发布并远端回读,Cluster Admin verifier/外部 ceremony/offline audit 同步升级为四类 attestation/八步 transcript。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、listener、timer、watcher 或低配设备常驻资源。定向 105/105、backend 1,246 pass/2 条件 skip/0 fail、18-package clean build/test 均通过;package boundary 确认为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release、OS vulnerability policy、Console/distribution 审计均 compatible,四个 runtime dependency root 的离线缓存审计为 0 vulnerability。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin npm pack 仍为 250 files、271,238-byte tarball、1,690,196-byte unpacked。由于本 Gate 不改变 schema、migration、SQL、role、Pool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得。 diff --git a/docs/adr/ADR-0428-durable-oci-release-catalog.md b/docs/adr/ADR-0428-durable-oci-release-catalog.md new file mode 100644 index 00000000..55acca06 --- /dev/null +++ b/docs/adr/ADR-0428-durable-oci-release-catalog.md @@ -0,0 +1,99 @@ +# ADR-0428:持久化 OCI Release Catalog 与独立部署验真 + +- 状态:Accepted +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-334、D-335、D-336 + +## 上下文 + +ADR-0427 已用 canonical `qinglong/release-set@v1` 闭合一个 deployment family 的全部镜像 digest, +但唯一分发路径仍是保留 90 天的 GitHub Actions artifact。稳定版本可能在该窗口之后才部署或恢复;同时,原 +release-set 完整审计依赖同一次 workflow attempt 的短期 image record,部署者无法在记录过期后独立复验文件的 +结构、身份与自身 digest。 + +低配路由设备不应为了发布验真安装 registry、Cosign、GitHub CLI 或 Cluster 依赖;Cluster 运维者则必须从同一个 +可发现入口得到 control、control-ai、worker、admin 四个角色的完整 digest lock。两类部署需要同一发布事实,但不应 +承担相同的本机工具和运行时成本。 + +## 决策 + +1. 成功生成并完整审计 release set 后,发布 workflow 把原始 canonical JSON 作为单层 OCI artifact 发布到独立的 + `ghcr.io//qinglong3-release-catalog` repository。artifact/file media type 固定为 + `application/vnd.qinglong.release-set.v1+json`。 +2. 发现入口固定为 `v-`,但 discovery tag 的 authority 明确为 `none`。部署与恢复只接受解析并验证 + 后的 `ghcr.io//qinglong3-release-catalog@sha256:`;不能把 tag 直接写入生产 rollout。 +3. publication plan 必须绑定 source repository、release identity、release-set self digest、内容 SHA-256、字节数、 + 确定性 basename、OCI media type、四个精确 annotation 与恢复策略。publisher 必须同时使用 `--file-title` 和 + `--strip-dirs`,禁止 runner 的绝对临时路径进入 layer title,从而保证跨 runner manifest digest 确定性。 +4. 发布后必须从 immutable catalog reference 按确定性 filename 取回文件并逐字节比较,再读取 raw manifest。契约只 + 接受一个 exact layer、empty OCI config、预期 title/annotation、内容 digest/size 和 raw manifest SHA-256。 +5. catalog immutable digest 必须获得 exact workflow identity 的 keyless Cosign signature 与绑定 source tag、source + revision 的 GitHub OCI provenance,并在生成 receipt 前完成远端验证。canonical + `qinglong/release-catalog-receipt@v1` 冻结 plan、manifest digest、immutable reference 和验证结论;receipt 本身再获得 + GitHub file provenance。 +6. release-set 新增独立 `inspect` 模式。它无需已过期的 candidate/image-record 文件,仍会 exact-shape 校验 release、 + scope、owner、镜像集合、双架构、全部 digest reference、Local/Cluster family,并重算 self digest。结果必须显式 + 声明 `sourceRecordsReplayed=false`,不能冒充发布阶段的完整 source-record replay。 +7. 90 天 workflow artifact 继续作为便利 bundle,包含 release set、catalog plan 和 receipt;它不再承担长期唯一归档 + 职责。OCI catalog 也不被宣称为 WORM:组织仍须保留 GHCR package/repository 的读取权限和保留策略,发布 workflow + 不获得删除 catalog 的 authority。 + +## 部署与资源影响 + +- Edge/Standalone 的推荐路径是在可信维护工作站解析 discovery tag、验证 immutable catalog 的 Cosign/GitHub + provenance、取回并独立 inspect `local` release set,然后只把 canonical JSON 和其中的 Local image digest 交给 + 设备。设备无需安装 Node、regctl、Cosign、GitHub CLI、Kubernetes 或 PostgreSQL 依赖,运行时 artifact、模块数和常驻 + 内存保持不变。 +- Cluster 运维者用相同 ceremony 验证 `cluster` release set,并要求 control、control-ai、worker、admin 四个角色精确 + 闭合。catalog 只存在于外部 registry 与短生命周期发布 CI,不增加 Pod、controller、listener、timer、watcher、Pool、 + schema、migration 或 SQL。 +- `all` 仍只是同时发布两族的维护者入口;设备按所选 deployment family 消费,不因 catalog 耦合 Local 与 Cluster + 运行时。 + +## 恢复模型 + +同一 source/version/scope 重跑时允许向 discovery tag 重发相同内容,但每次都必须重新解析 tag、按 immutable digest +取回、逐字节比较并完成签名与 provenance 验证。若 tag 被外部改写,旧 receipt 中的 immutable reference 仍是历史部署 +事实;新部署不得信任 tag 的当前值,必须重新执行完整验证并取得新的 receipt。registry 删除、repository visibility 或 +组织 retention 变更属于外部治理事件,不能由 release contract 隐藏。 + +## 被拒绝的替代方案 + +### 仅延长 Actions artifact retention + +拒绝。它仍把长期部署入口绑定到 workflow run 生命周期,且不能提供 registry-native 的 immutable discovery 与 +signature/provenance 验证。 + +### 让 discovery tag 成为部署 authority + +拒绝。tag 可被改写,不能替代 manifest digest;它只能帮助发现候选 immutable reference。 + +### 在路由器上执行完整供应链工具链 + +拒绝。验证可以在可信工作站完成。把 registry、签名与 GitHub 客户端带入低配设备会扩大镜像、内存、网络和密钥面, +但不会增强设备实际消费的 digest lock。 + +### 增加常驻 release-catalog 服务 + +拒绝。OCI registry 已提供所需的存储、digest addressing 和 referrer 能力;常驻协调服务会引入新的可用性与运维故障域。 + +## 验证 + +- release-catalog contract 覆盖 Local/Cluster/All plan、receipt、owner/release drift、OCI media/blob/title/annotation/raw digest + drift、closed CLI、canonical no-replace、rename/symlink/open-mode 拒绝;release-set 同时覆盖独立 inspect 与 drift 拒绝; +- workflow 静态门要求 standalone inspect、catalog plan、checksum-pinned regctl、`--strip-dirs`、immutable get、byte-exact + compare、raw manifest、catalog Cosign/GitHub provenance、先验证后 receipt、receipt provenance 和 90 天 bundle 的精确顺序; +- 使用官方 regctl v0.11.5 在本机 `ocidir://` 进行真实实验:相同 canonical release set 从两个不同绝对目录发布时, + `--file-title --strip-dirs` 得到相同 manifest digest + `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,按 basename 从 immutable reference 取回后 + 与源文件逐字节一致;仅使用 `--file-title` 会泄漏绝对路径并破坏该性质; +- 定向发布契约、静态 workflow 与 Console distribution 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、 + 0 fail;18-package clean build/test 退出 0;release version、package boundary、dependency、Edge import、Cluster/Worker + deployment、image release、Local image 与 Console distribution 审计全部 compatible; +- package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;14 档 Local artifact 全部 + compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin + pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked; +- 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 HA 拓扑,复用 D-331/D-333 PostgreSQL 18.6 arm64 + 142/142、timeline `1→2` 基线,不把未重跑的数据库门冒充本阶段新证据; +- 本 ADR 接受源码、契约、静态 workflow 门与本地 OCI 互操作证据。公开 tag 尚未运行,因此不宣称真实 GHCR catalog + push、Cosign 或 GitHub attestation 已成功;它们必须由实际 release run 取得。 diff --git a/docs/adr/README.md b/docs/adr/README.md index db8da279..6d99ac1d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -431,6 +431,7 @@ | [ADR-0425](./ADR-0425-deployment-family-release-candidate-contract.md) | Deployment-family Release Candidate Contract | Accepted | | [ADR-0426](./ADR-0426-source-derived-release-version-transition.md) | Source-derived QingLong 3.0 Release Version Transition | Accepted | | [ADR-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted | +| [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index 50f33484..cd46da80 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -1,9 +1,17 @@ # QingLong 3.0 release-set 部署准入 -生产部署的镜像 authority 是成功 `ql3-image-release.yml` 运行产生的 -`ql3-release-set--` artifact,不是可变 version/source tag。下载后先验证该 JSON 的 GitHub file -provenance,确认 repository、source tag、source revision 与目标发布一致,再从 `images[].reference` 读取完整 -`ghcr.io//@sha256:`。 +生产部署的镜像 authority 是持久 OCI catalog 中经过签名、provenance 与逐字节回读验证的 immutable release-set +reference,不是可变 version/source/catalog tag。Actions 中保留 90 天的同名 bundle 只用于便利下载。 + +发布入口为: + +```text +ghcr.io//qinglong3-release-catalog:v- +``` + +该 discovery tag 只用于发现。先把它解析为 +`ghcr.io//qinglong3-release-catalog@sha256:`,验证这个 immutable reference 后,再从 JSON 的 +`images[].reference` 读取完整 `ghcr.io//@sha256:`。 ## 选择 scope @@ -16,24 +24,99 @@ provenance,确认 repository、source tag、source revision 与目标发布一 Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。 +## 工作站验真 + +以下命令应在可信维护工作站运行;先设置目标发布的显式值: + +```sh +owner='' +repository='/' +version='' +scope='local' # 或 cluster/all +source_ref='refs/tags/v' +source_revision='<40-hex-git-revision>' +catalog="ghcr.io/${owner}/qinglong3-release-catalog" +discovery="${catalog}:v${version}-${scope}" +digest="$(regctl image digest "${discovery}")" +immutable="${catalog}@${digest}" +release_set="$(pwd)/qinglong3-release-set-${version}-${scope}.json" +``` + +要求 `digest` 精确匹配 `sha256:<64 lowercase hex>`,然后按 immutable reference 下载并验证: + +```sh +regctl artifact get \ + --file "qinglong3-release-set-${version}-${scope}.json" \ + "${immutable}" > "${release_set}" + +cosign verify \ + --certificate-identity \ + "https://github.com/${repository}/.github/workflows/ql3-image-release.yml@${source_ref}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${immutable}" + +gh attestation verify "oci://${immutable}" \ + --repo "${repository}" \ + --signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \ + --source-digest "${source_revision}" \ + --source-ref "${source_ref}" \ + --deny-self-hosted-runners \ + --bundle-from-oci + +node scripts/ql3-release-set-contract.cjs \ + --mode=inspect \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --report="${release_set}" +``` + +若使用 90 天 bundle 中的文件,还应验证文件 provenance;它是 OCI catalog 的补充证据,不替代上述 immutable +catalog 验证: + +```sh +gh attestation verify "${release_set}" \ + --repo "${repository}" \ + --signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \ + --source-digest "${source_revision}" \ + --source-ref "${source_ref}" \ + --deny-self-hosted-runners +``` + +`inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image +records;其输出必须保持 `sourceRecordsReplayed:false`。 + ## 准入检查 -1. 只接受来自成功、未重跑替换的同一 release workflow attempt 的 artifact,并验证 release-set 文件 - provenance。 +1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable + reference;discovery tag 无 authority。 2. `schema` 必须为 `qinglong/release-set@v1`;`release.version`、`release.sourceRef`、 `release.sourceRevision`、`release.scope` 必须与变更单一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成 `newTag`。Local compose/rollout 同样固定 `@sha256:`。 -5. rollout 前再次向 registry 解析 version/source tag。它们可以用于发现,但只有解析到 release set 的同一 - digest 才算一致;部署仍以 digest 为准。 +5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于 + 发现;部署始终以 release set 中的镜像 digest 为准。 + +## 低资源设备 + +路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign 或 GitHub CLI。维护者在可信工作站完成上述 ceremony, +再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable image reference 写入 compose/rollout。 +设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。 + +如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与 +导入后 image digest 必须继续匹配 release set,不能退回 tag。 ## 发布失败与恢复 GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`。如果 promotion 中途 失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证 -每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有最终 -release-set artifact 和 provenance 都生成后,才能宣布该 deployment family 可部署。 +每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有 +release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family +可部署。 -workflow artifact 当前保留 90 天,因此长期归档属于 release owner 的外部职责。进入稳定 GA 前,应把经验证的 -release-set 同步到不可变、保留期满足组织策略的发布档案;同步过程不得改写 JSON。 +workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORM,release owner 仍须维护 +package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程都不得改写 canonical JSON,并须保留 +原 catalog manifest digest、receipt 与 provenance 关联。 diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index 59c595f2..68fbdcce 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -254,6 +254,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) { '--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', ], '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 a423a9cb..2e0edae7 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -274,8 +274,8 @@ 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\/ql3ReleaseSetContract\.test\.cjs/, - 'cluster image CI must run SBOM, candidate, release-set and workflow negative tests', + /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\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs/, + 'cluster image CI must run SBOM, candidate, release-set, durable catalog and workflow negative tests', ); requirePattern( source, @@ -517,7 +517,7 @@ function auditReleaseWorkflow(source) { JSON.stringify(releaseSetJob?.needs) !== JSON.stringify(['release-candidate', 'publish']) || releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' || - releaseSetJob?.['timeout-minutes'] !== 15 || + releaseSetJob?.['timeout-minutes'] !== 20 || typeof releaseSetJob?.if !== 'string' || !/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test( releaseSetJob.if, @@ -685,7 +685,7 @@ function auditReleaseWorkflow(source) { const releaseSetSteps = releaseSetJob?.steps; if ( !Array.isArray(releaseSetSteps) || - releaseSetSteps.length !== 9 || + releaseSetSteps.length !== 15 || releaseSetSteps[0]?.uses !== 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || releaseSetSteps[0]?.with?.['persist-credentials'] !== false || @@ -702,29 +702,56 @@ function auditReleaseWorkflow(source) { 'merge-multiple': true, }) || releaseSetSteps[3]?.id !== 'release-set' || - !/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test( + !/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test( releaseSetSteps[3]?.run ?? '', ) || !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test( releaseSetSteps[4]?.run ?? '', ) || releaseSetSteps[5]?.uses !== + 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || + releaseSetSteps[6]?.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[6]?.run ?? '', + releaseSetSteps[7]?.run ?? '', ) || - releaseSetSteps[7]?.uses !== + releaseSetSteps[8]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[7]?.with) !== + JSON.stringify(releaseSetSteps[8]?.with) !== JSON.stringify({ 'subject-path': '${{ steps.release-set.outputs.report }}', }) || - releaseSetSteps[8]?.uses !== + releaseSetSteps[9]?.id !== 'catalog' || + !/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]*image digest "\$\{discovery_tag\}"[\s\S]*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]*GITHUB_OUTPUT/.test( + releaseSetSteps[9]?.run ?? '', + ) || + !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( + releaseSetSteps[10]?.run ?? '', + ) || + releaseSetSteps[11]?.uses !== + 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || + JSON.stringify(releaseSetSteps[11]?.with) !== + JSON.stringify({ + 'subject-name': '${{ steps.catalog.outputs.repository }}', + 'subject-digest': '${{ steps.catalog.outputs.digest }}', + 'push-to-registry': true, + }) || + 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[12]?.run ?? '', + ) || + releaseSetSteps[13]?.uses !== + 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || + JSON.stringify(releaseSetSteps[13]?.with) !== + JSON.stringify({ + 'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}', + }) || + releaseSetSteps[14]?.uses !== 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || - JSON.stringify(releaseSetSteps[8]?.with) !== + JSON.stringify(releaseSetSteps[14]?.with) !== JSON.stringify({ name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', - path: '${{ steps.release-set.outputs.report }}', + path: '${{ steps.release-set.outputs.bundle }}', 'if-no-files-found': 'error', 'retention-days': 90, 'compression-level': 0, @@ -733,7 +760,7 @@ function auditReleaseWorkflow(source) { }) ) { throw new Error( - 'release-set job must download only same-run records, independently attest and publish one no-overwrite deployment lock', + 'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle', ); } if ( @@ -832,6 +859,12 @@ function auditReleaseWorkflow(source) { 6, 'all release jobs must pin the reviewed immutable Node setup action', ); + requireOccurrences( + source, + /uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g, + 2, + 'image and release-catalog publishers must both pin the reviewed Cosign installer', + ); requirePattern( source, /name: Recreate and audit the source-derived release candidate contract[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*--version="\$\{RELEASE_VERSION\}"[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{contract\}"/, @@ -891,14 +924,14 @@ function auditReleaseWorkflow(source) { requireOccurrences( source, /uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g, - 5, - 'release workflow must create four image attestations and one complete release-set provenance attestation', + 7, + 'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance', ); requireOccurrences( source, /push-to-registry: true/g, - 4, - 'all GitHub attestations must be pushed beside the OCI image', + 5, + 'all image and durable-catalog OCI attestations must be pushed beside their subject', ); requirePattern( source, @@ -967,7 +1000,7 @@ function auditReleaseWorkflow(source) { 'GitHub attestation verification must read the published OCI bundle', ], ]) { - requireOccurrences(source, pattern, 4, finding); + requireOccurrences(source, pattern, 5, finding); } requirePattern( source, @@ -986,8 +1019,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 the deployment digest lock/, - 'release tags and the deployment lock 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: 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', ); return { trigger: 'explicit protected v3 tag dispatch', @@ -1046,11 +1079,24 @@ function auditReleaseWorkflow(source) { sourceDerived: true, sameRunRecords: true, exactScopeClosure: true, + standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', fileProvenanceAttested: true, artifactRetentionDays: 90, crossRepositoryAtomicity: false, }, + durableCatalog: { + repository: 'qinglong3-release-catalog', + artifactType: 'application/vnd.qinglong.release-set.v1+json', + basenameOnly: true, + crossRunnerDeterministic: true, + byteExactRoundTrip: true, + keylessSignatureVerified: true, + githubProvenanceVerified: true, + discoveryTagAuthority: 'none', + immutableDigestAuthority: 'verified', + receiptAttested: true, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -1061,6 +1107,7 @@ function auditReleaseWorkflow(source) { 'os-vulnerability', 'release-candidate', 'release-set', + 'durable-catalog', 'release-tags', ], }; diff --git a/scripts/ql3-release-candidate-contract.cjs b/scripts/ql3-release-candidate-contract.cjs index 99ddbd25..707d8cc5 100644 --- a/scripts/ql3-release-candidate-contract.cjs +++ b/scripts/ql3-release-candidate-contract.cjs @@ -253,6 +253,7 @@ function createReleaseCandidateContract(options) { 'production-dependency-audit', 'digest-signature-and-attestations', 'cross-image-release-set', + 'durable-oci-release-catalog', ...(options.releaseScope !== 'cluster' ? ['edge-and-standalone-rollout'] : []), diff --git a/scripts/ql3-release-catalog-contract.cjs b/scripts/ql3-release-catalog-contract.cjs new file mode 100644 index 00000000..37ed2090 --- /dev/null +++ b/scripts/ql3-release-catalog-contract.cjs @@ -0,0 +1,434 @@ +#!/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 CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1'; +const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1'; +const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json'; +const FILE_MEDIA_TYPE = ARTIFACT_TYPE; +const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; +const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; +const OCI_EMPTY_CONFIG_DIGEST = + 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const SOURCE_REPOSITORY_PATTERN = + /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u; +const MAX_FILE_BYTES = 1024 * 1024; + +class QingLong3ReleaseCatalogError extends Error { + constructor(message) { + super(`QingLong 3 release catalog failed: ${message}`); + this.name = 'QingLong3ReleaseCatalogError'; + } +} + +function fail(message) { + throw new QingLong3ReleaseCatalogError(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_FILE_BYTES || + fs.realpathSync(resolved) !== resolved || + fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return Object.freeze({ + path: resolved, + contents: fs.readFileSync(resolved, 'utf8'), + bytes: stat.size, + }); +} + +function parseJson(contents, label, requireCanonical) { + let value; + try { + value = JSON.parse(contents); + } catch { + fail(`${label} must contain valid JSON`); + } + if (requireCanonical && canonicalJson(value) !== contents) { + fail(`${label} must use exact canonical JSON encoding`); + } + return value; +} + +function readCanonicalJson(filePath, label) { + const file = readBoundedFile(filePath, label); + return Object.freeze({ + ...file, + value: parseJson(file.contents, label, true), + }); +} + +function writeNoReplace(filePath, value) { + const resolved = resolveCanonicalAbsolute(filePath, 'output'); + if ( + fs.existsSync(resolved) || + fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) + ) { + fail('output must be unused in one canonical directory'); + } + fs.writeFileSync(resolved, canonicalJson(value), { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); +} + +function normalizeSourceRepository(value, repositoryOwner) { + if ( + typeof value !== 'string' || + !SOURCE_REPOSITORY_PATTERN.test(value) || + !value.startsWith(`${repositoryOwner}/`) + ) { + fail( + 'source repository must be one lowercase repository owned by the publisher', + ); + } + return value; +} + +function expectedFileName(version, releaseScope) { + return `qinglong3-release-set-${version}-${releaseScope}.json`; +} + +function createCatalogPlan(releaseSet, options) { + inspectReleaseSet(releaseSet, options); + const sourceRepository = normalizeSourceRepository( + options.sourceRepository, + releaseSet.repositoryOwner, + ); + const contents = canonicalJson(releaseSet); + const fileName = expectedFileName(options.version, options.releaseScope); + const registryRepository = `ghcr.io/${releaseSet.repositoryOwner}/qinglong3-release-catalog`; + const unsigned = { + schemaVersion: 1, + schema: CATALOG_PLAN_SCHEMA, + release: { ...releaseSet.release }, + sourceRepository, + releaseSet: { + schema: RELEASE_SET_SCHEMA, + releaseSetDigest: releaseSet.releaseSetDigest, + contentDigest: sha256(contents), + bytes: Buffer.byteLength(contents), + fileName, + }, + catalog: { + repository: 'qinglong3-release-catalog', + registryRepository, + discoveryTag: `${registryRepository}:v${options.version}-${options.releaseScope}`, + artifactType: ARTIFACT_TYPE, + fileMediaType: FILE_MEDIA_TYPE, + annotations: { + 'dev.qinglong.release.scope': options.releaseScope, + 'org.opencontainers.image.revision': options.sourceRevision, + 'org.opencontainers.image.source': `https://github.com/${sourceRepository}`, + 'org.opencontainers.image.version': options.version, + }, + }, + publicationPolicy: { + title: 'basename_only', + crossRunnerDeterministic: true, + discoveryTagAuthority: 'none', + immutableDigestAuthority: 'required', + roundTrip: 'byte_exact', + recovery: 'republish_same_content_then_verify_digest', + }, + }; + return Object.freeze({ + ...unsigned, + planDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function auditCatalogPlan(actual, releaseSet, options) { + const expected = createCatalogPlan(releaseSet, options); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail('catalog plan differs from the standalone release set'); + } + return expected; +} + +function validatePlanShape(plan) { + if ( + !exactKeys(plan, [ + 'schemaVersion', + 'schema', + 'release', + 'sourceRepository', + 'releaseSet', + 'catalog', + 'publicationPolicy', + 'planDigest', + ]) || + plan.schemaVersion !== 1 || + plan.schema !== CATALOG_PLAN_SCHEMA || + !DIGEST_PATTERN.test(plan.planDigest || '') + ) { + fail('catalog plan shape is invalid'); + } + const { planDigest, ...unsigned } = plan; + if (planDigest !== sha256(JSON.stringify(unsigned))) { + fail('catalog plan digest is invalid'); + } + return plan; +} + +function validateManifest(plan, manifestContents, manifestDigest) { + if ( + !DIGEST_PATTERN.test(manifestDigest || '') || + sha256(manifestContents) !== manifestDigest + ) { + fail('catalog manifest digest is invalid'); + } + const manifest = parseJson(manifestContents, 'catalog manifest', false); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'mediaType', + 'artifactType', + 'config', + 'layers', + 'annotations', + ]) || + manifest.schemaVersion !== 2 || + manifest.mediaType !== OCI_MANIFEST_MEDIA_TYPE || + manifest.artifactType !== plan.catalog.artifactType || + !exactKeys(manifest.config, ['mediaType', 'digest', 'size']) || + manifest.config.mediaType !== OCI_EMPTY_CONFIG_MEDIA_TYPE || + manifest.config.digest !== OCI_EMPTY_CONFIG_DIGEST || + manifest.config.size !== 2 || + !Array.isArray(manifest.layers) || + manifest.layers.length !== 1 || + !exactKeys(manifest.layers[0], [ + 'mediaType', + 'digest', + 'size', + 'annotations', + ]) || + manifest.layers[0].mediaType !== plan.catalog.fileMediaType || + manifest.layers[0].digest !== plan.releaseSet.contentDigest || + manifest.layers[0].size !== plan.releaseSet.bytes || + JSON.stringify(manifest.layers[0].annotations) !== + JSON.stringify({ + 'org.opencontainers.image.title': plan.releaseSet.fileName, + }) || + JSON.stringify(manifest.annotations) !== + JSON.stringify(plan.catalog.annotations) + ) { + fail('catalog manifest differs from the exact publication plan'); + } + return manifest; +} + +function createCatalogReceipt(plan, manifestContents, manifestDigest) { + validatePlanShape(plan); + validateManifest(plan, manifestContents, manifestDigest); + const unsigned = { + schemaVersion: 1, + schema: CATALOG_RECEIPT_SCHEMA, + release: { ...plan.release }, + planDigest: plan.planDigest, + releaseSet: { ...plan.releaseSet }, + catalog: { + repository: plan.catalog.repository, + discoveryTag: plan.catalog.discoveryTag, + manifestDigest, + immutableReference: `${plan.catalog.registryRepository}@${manifestDigest}`, + artifactType: plan.catalog.artifactType, + }, + verification: { + remoteManifestStructure: 'exact', + releaseSetRoundTrip: 'byte_exact', + keylessSignature: 'exact_workflow_identity', + githubProvenance: 'source_tag_and_revision_bound', + discoveryTagAuthority: 'none', + immutableDigestAuthority: 'verified', + }, + }; + return Object.freeze({ + ...unsigned, + receiptDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function auditCatalogReceipt(actual, plan, manifestContents, manifestDigest) { + const expected = createCatalogReceipt(plan, manifestContents, manifestDigest); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail('catalog receipt differs from the verified OCI manifest'); + } + return Object.freeze({ + compatible: true, + releaseSetDigest: actual.releaseSet.releaseSetDigest, + releaseScope: actual.release.scope, + catalogManifestDigest: actual.catalog.manifestDigest, + immutableReference: actual.catalog.immutableReference, + discoveryTagAuthority: actual.verification.discoveryTagAuthority, + }); +} + +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 = [ + 'mode', + 'release-scope', + 'repository-owner', + 'source-ref', + 'source-repository', + 'source-revision', + 'version', + ]; + const expected = + values.mode === 'plan' + ? [...identity, 'output', 'release-set'] + : values.mode === 'receipt' + ? ['manifest', 'manifest-digest', 'mode', 'output', 'plan'] + : values.mode === 'audit' + ? ['manifest', 'manifest-digest', 'mode', '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, + ...(values.version ? { version: values.version } : {}), + ...(values['source-revision'] + ? { sourceRevision: values['source-revision'] } + : {}), + ...(values['source-ref'] ? { sourceRef: values['source-ref'] } : {}), + ...(values['release-scope'] + ? { releaseScope: values['release-scope'] } + : {}), + ...(values['repository-owner'] + ? { repositoryOwner: values['repository-owner'] } + : {}), + ...(values['source-repository'] + ? { sourceRepository: values['source-repository'] } + : {}), + ...(values['release-set'] ? { releaseSet: values['release-set'] } : {}), + ...(values.plan ? { plan: values.plan } : {}), + ...(values.manifest ? { manifest: values.manifest } : {}), + ...(values['manifest-digest'] + ? { manifestDigest: values['manifest-digest'] } + : {}), + ...(values.receipt ? { receipt: values.receipt } : {}), + ...(values.output ? { output: values.output } : {}), + }); +} + +function runCli(argv, output = process.stdout) { + const options = parseArguments(argv); + if (options.mode === 'plan') { + const releaseSetFile = readCanonicalJson(options.releaseSet, 'release set'); + if ( + path.basename(releaseSetFile.path) !== + expectedFileName(options.version, options.releaseScope) + ) { + fail('release set filename must be deterministic'); + } + const plan = createCatalogPlan(releaseSetFile.value, options); + writeNoReplace(options.output, plan); + output.write(canonicalJson(plan)); + return plan; + } + const plan = readCanonicalJson(options.plan, 'catalog plan').value; + const manifest = readBoundedFile(options.manifest, 'catalog manifest'); + if (options.mode === 'receipt') { + const receipt = createCatalogReceipt( + plan, + manifest.contents, + options.manifestDigest, + ); + writeNoReplace(options.output, receipt); + output.write(canonicalJson(receipt)); + return receipt; + } + const receipt = readCanonicalJson(options.receipt, 'catalog receipt').value; + const audit = auditCatalogReceipt( + receipt, + plan, + manifest.contents, + options.manifestDigest, + ); + 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 catalog failed'}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + ARTIFACT_TYPE, + CATALOG_PLAN_SCHEMA, + CATALOG_RECEIPT_SCHEMA, + FILE_MEDIA_TYPE, + OCI_EMPTY_CONFIG_DIGEST, + OCI_EMPTY_CONFIG_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE, + QingLong3ReleaseCatalogError, + auditCatalogPlan, + auditCatalogReceipt, + createCatalogPlan, + createCatalogReceipt, + parseArguments, + runCli, +}); diff --git a/scripts/ql3-release-set-contract.cjs b/scripts/ql3-release-set-contract.cjs index f154146b..7a6293d4 100644 --- a/scripts/ql3-release-set-contract.cjs +++ b/scripts/ql3-release-set-contract.cjs @@ -6,8 +6,13 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { + CLUSTER_IMAGES, + LOCAL_IMAGES, + RELEASE_SCOPES, + SCHEMA: RELEASE_CANDIDATE_SCHEMA, auditReleaseCandidateContract, } = require('./ql3-release-candidate-contract.cjs'); +const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; @@ -312,6 +317,156 @@ function auditReleaseSet(actual, options) { }); } +function inspectReleaseSet(actual, options) { + const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner); + if ( + !RELEASE_SCOPES.includes(options.releaseScope) || + typeof options.version !== 'string' || + !VERSION_PATTERN.test(options.version) || + !/^[a-f0-9]{40}$/u.test(options.sourceRevision || '') || + options.sourceRef !== `refs/tags/v${options.version}` + ) { + fail('expected release identity is invalid'); + } + if ( + !exactKeys(actual, [ + 'schemaVersion', + 'schema', + 'release', + 'candidate', + 'repositoryOwner', + 'platforms', + 'deploymentFamilies', + 'images', + 'promotion', + 'requiredVerification', + 'releaseSetDigest', + ]) || + actual.schemaVersion !== 1 || + actual.schema !== RELEASE_SET_SCHEMA || + !exactKeys(actual.release, [ + 'version', + 'sourceRevision', + 'sourceRef', + 'scope', + ]) || + actual.release.version !== options.version || + actual.release.sourceRevision !== options.sourceRevision || + actual.release.sourceRef !== options.sourceRef || + actual.release.scope !== options.releaseScope || + !exactKeys(actual.candidate, ['schema', 'contractDigest']) || + actual.candidate.schema !== RELEASE_CANDIDATE_SCHEMA || + !DIGEST_PATTERN.test(actual.candidate.contractDigest || '') || + actual.repositoryOwner !== repositoryOwner || + JSON.stringify(actual.platforms) !== + JSON.stringify(['linux/amd64', 'linux/arm64']) || + !exactKeys(actual.deploymentFamilies, ['local', 'cluster']) || + !Array.isArray(actual.images) || + !exactKeys(actual.promotion, [ + 'authority', + 'versionTags', + 'sourceTags', + 'crossRepositoryAtomicity', + 'recovery', + ]) || + JSON.stringify(actual.promotion) !== + JSON.stringify({ + authority: 'complete_verified_release_set', + versionTags: 'promote_after_complete_set_audit', + sourceTags: 'promote_after_complete_set_audit', + crossRepositoryAtomicity: false, + recovery: 'verify_exact_digest_then_continue', + }) || + !exactKeys(actual.requiredVerification, [ + 'imageKeylessSignature', + 'imageAttestations', + 'releaseSetBuildProvenance', + ]) || + JSON.stringify(actual.requiredVerification) !== + JSON.stringify({ + imageKeylessSignature: true, + imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS], + releaseSetBuildProvenance: true, + }) + ) { + fail('standalone release set shape or identity is invalid'); + } + + const selected = + options.releaseScope === 'local' + ? [...LOCAL_IMAGES] + : options.releaseScope === 'cluster' + ? [...CLUSTER_IMAGES] + : [...CLUSTER_IMAGES, ...LOCAL_IMAGES]; + const expectedNames = selected.map((entry) => entry.image); + if (actual.images.length !== selected.length) { + fail('standalone release set image count is invalid'); + } + for (let index = 0; index < selected.length; index += 1) { + const image = actual.images[index]; + const expected = selected[index]; + const repository = `ghcr.io/${repositoryOwner}/${expected.repository}`; + if ( + !exactKeys(image, [ + 'name', + 'repository', + 'digest', + 'reference', + 'versionTag', + 'sourceTag', + 'platforms', + 'imageRecordDigest', + ]) || + image.name !== expected.image || + image.repository !== expected.repository || + !DIGEST_PATTERN.test(image.digest || '') || + image.reference !== `${repository}@${image.digest}` || + image.versionTag !== `${repository}:${options.version}` || + image.sourceTag !== `${repository}:sha-${options.sourceRevision}` || + JSON.stringify(image.platforms) !== JSON.stringify(actual.platforms) || + !DIGEST_PATTERN.test(image.imageRecordDigest || '') + ) { + fail(`standalone release set image is invalid: ${expected.image}`); + } + } + + const expectedFamilies = { + local: { + selected: ['local', 'all'].includes(options.releaseScope), + profiles: ['edge', 'standalone'], + images: expectedNames.filter((name) => name === 'local'), + }, + cluster: { + selected: ['cluster', 'all'].includes(options.releaseScope), + profiles: ['cluster', 'worker-edge', 'worker-node'], + images: expectedNames.filter((name) => name !== 'local'), + }, + }; + if ( + JSON.stringify(actual.deploymentFamilies) !== + JSON.stringify(expectedFamilies) + ) { + fail('standalone release set deployment families are invalid'); + } + const { releaseSetDigest, ...unsigned } = actual; + if ( + !DIGEST_PATTERN.test(releaseSetDigest || '') || + releaseSetDigest !== sha256(JSON.stringify(unsigned)) + ) { + fail('standalone release set digest is invalid'); + } + return Object.freeze({ + compatible: true, + releaseSetDigest, + releaseScope: actual.release.scope, + imageCount: actual.images.length, + images: Object.freeze([...expectedNames]), + references: Object.freeze(actual.images.map((entry) => entry.reference)), + verification: 'standalone_structure_identity_and_self_digest', + sourceRecordsReplayed: false, + }); +} + function readRecordDirectory(directoryPath, candidate) { const resolved = resolveCanonicalAbsolute(directoryPath, 'records'); const stat = fs.lstatSync(resolved); @@ -345,8 +500,7 @@ function parseArguments(argv) { fail('arguments are invalid'); values[match[1]] = match[2]; } - const common = [ - 'candidate', + const identity = [ 'mode', 'release-scope', 'repository-owner', @@ -354,6 +508,7 @@ function parseArguments(argv) { 'source-revision', 'version', ]; + const common = ['candidate', ...identity]; const expected = values.mode === 'record-image' ? [...common, 'digest', 'image', 'output'] @@ -361,6 +516,8 @@ function parseArguments(argv) { ? [...common, 'output', 'records'] : values.mode === 'audit' ? [...common, 'records', 'report'] + : values.mode === 'inspect' + ? [...identity, 'report'] : []; if ( expected.length === 0 || @@ -371,7 +528,7 @@ function parseArguments(argv) { } return Object.freeze({ mode: values.mode, - candidate: values.candidate, + ...(values.candidate ? { candidate: values.candidate } : {}), version: values.version, sourceRevision: values['source-revision'], sourceRef: values['source-ref'], @@ -387,6 +544,12 @@ function parseArguments(argv) { function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) { const options = parseArguments(argv); + if (options.mode === 'inspect') { + const report = readCanonicalJson(options.report, 'release set'); + const inspection = inspectReleaseSet(report, options); + output.write(canonicalJson(inspection)); + return inspection; + } const candidate = readCanonicalJson(options.candidate, 'release candidate'); if (options.mode === 'record-image') { const record = createVerifiedImageRecord({ ...options, candidate, root }); @@ -435,6 +598,7 @@ module.exports = Object.freeze({ auditReleaseSet, createReleaseSet, createVerifiedImageRecord, + inspectReleaseSet, parseArguments, runCli, }); diff --git a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs index f92093e1..9a6439e0 100644 --- a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs @@ -89,6 +89,25 @@ test('rejects verifier, embedded artifact and release workflow drift', () => { } }); +test('rejects an Admin release without the durable OCI catalog', () => { + const result = auditClusterCopilotConsoleDistribution({ + root: ROOT, + readFile: intercept('.github/workflows/ql3-image-release.yml', (source) => + source.replace( + 'Publish and round-trip the durable OCI release catalog', + 'Durable catalog removed', + ), + ), + }); + assert.equal(result.compatible, false); + assert.equal( + result.findings.some( + (entry) => entry.code === 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', + ), + true, + ); +}); + test('rejects external workstation ceremony and offline audit widening', () => { const fixtures = [ [ diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 6e121883..ccd3e0a4 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -96,11 +96,24 @@ test('accepts the reviewed native CI and digest release contracts', () => { sourceDerived: true, sameRunRecords: true, exactScopeClosure: true, + standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', fileProvenanceAttested: true, artifactRetentionDays: 90, crossRepositoryAtomicity: false, }, + durableCatalog: { + repository: 'qinglong3-release-catalog', + artifactType: 'application/vnd.qinglong.release-set.v1+json', + basenameOnly: true, + crossRunnerDeterministic: true, + byteExactRoundTrip: true, + keylessSignatureVerified: true, + githubProvenanceVerified: true, + discoveryTagAuthority: 'none', + immutableDigestAuthority: 'verified', + receiptAttested: true, + }, localRolloutPreflight: true, localRolloutApply: true, postPublishVerification: [ @@ -111,6 +124,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'os-vulnerability', 'release-candidate', 'release-set', + 'durable-catalog', 'release-tags', ], }, @@ -128,6 +142,17 @@ test('rejects removal of the source-derived release version audit', () => { ); }); +test('rejects removal of the durable release-catalog contract tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3ReleaseCatalogContract.test.cjs', + 'test/back/catalog-tests-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /durable catalog and workflow negative tests/, + ); +}); + test('rejects removal of the native arm64 image gate', () => { const mutated = ciSource.replace( 'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n image: control', @@ -714,6 +739,78 @@ test('rejects release-set aggregation without independent audit', () => { ); }); +test('rejects a deployment lock without standalone inspection', () => { + const releaseSetOffset = releaseSource.indexOf('\n release-set:'); + assert.notEqual(releaseSetOffset, -1); + const releaseSetSource = releaseSource.slice(releaseSetOffset); + const mutated = `${releaseSource.slice( + 0, + releaseSetOffset, + )}${releaseSetSource.replace( + ' --mode=inspect \\', + ' --mode=audit \\', + )}`; + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects a catalog title that leaks the runner temporary path', () => { + const mutated = releaseSource.replace( + ' --strip-dirs \\', + ' --index \\', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects a catalog publication without byte-exact round trip', () => { + const mutated = releaseSource.replace( + ' cmp --silent "${RELEASE_SET}" "${roundtrip}"', + ' echo roundtrip-not-checked', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects using the mutable catalog discovery tag as deployment authority', () => { + const mutated = releaseSource.replace( + 'artifact get --file "${file_name}" "${immutable_reference}"', + 'artifact get --file "${file_name}" "${discovery_tag}"', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects durable catalog provenance detached from its manifest digest', () => { + const mutated = releaseSource.replace( + ' subject-digest: ${{ steps.catalog.outputs.digest }}', + ' subject-digest: ${{ github.sha }}', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects a release-catalog receipt without file provenance', () => { + const mutated = releaseSource.replace( + ' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}', + ' subject-path: ${{ steps.release-set.outputs.report }}', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + test('rejects a short-lived deployment digest lock', () => { const marker = ' retention-days: 90'; assert.equal(releaseSource.includes(marker), true); diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs index fed9a1fb..8572ff25 100644 --- a/test/back/ql3ReleaseCandidateContract.test.cjs +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -105,6 +105,10 @@ test('combines local and cluster families without weakening either gate', () => contract.requiredGates.includes('cross-image-release-set'), true, ); + assert.equal( + contract.requiredGates.includes('durable-oci-release-catalog'), + true, + ); assert.equal( contract.requiredGates.includes('worker-management-production-evidence'), true, diff --git a/test/back/ql3ReleaseCatalogContract.test.cjs b/test/back/ql3ReleaseCatalogContract.test.cjs new file mode 100644 index 00000000..2ea7d89f --- /dev/null +++ b/test/back/ql3ReleaseCatalogContract.test.cjs @@ -0,0 +1,293 @@ +'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 { + ARTIFACT_TYPE, + OCI_EMPTY_CONFIG_DIGEST, + OCI_EMPTY_CONFIG_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE, + auditCatalogPlan, + auditCatalogReceipt, + createCatalogPlan, + createCatalogReceipt, + parseArguments, + runCli, +} = 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 root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const identity = Object.freeze({ + version, + sourceRevision: 'd'.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, + ...identity, + releaseScope: scope, + }); +} + +function manifestFor(plan, mutate = () => {}) { + const manifest = { + 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 }, + }; + mutate(manifest); + return JSON.stringify(manifest); +} + +function temporaryDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-catalog-')), + ); + 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 deterministic Local, Cluster and All OCI catalog entries', () => { + for (const scope of ['local', 'cluster', 'all']) { + const set = releaseSet(scope); + const plan = createCatalogPlan(set, { ...identity, releaseScope: scope }); + assert.equal( + plan.catalog.discoveryTag, + `ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`, + ); + assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE); + assert.equal( + plan.releaseSet.bytes, + Buffer.byteLength(`${JSON.stringify(set)}\n`), + ); + assert.equal( + auditCatalogPlan(plan, set, { ...identity, releaseScope: scope }) + .planDigest, + plan.planDigest, + ); + } +}); + +test('creates one digest-addressed receipt from the exact OCI manifest', () => { + const set = releaseSet('cluster'); + const plan = createCatalogPlan(set, { + ...identity, + releaseScope: 'cluster', + }); + const manifest = manifestFor(plan); + const manifestDigest = sha256(manifest); + const receipt = createCatalogReceipt(plan, manifest, manifestDigest); + assert.equal( + receipt.catalog.immutableReference, + `ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`, + ); + assert.equal(receipt.verification.discoveryTagAuthority, 'none'); + assert.equal( + auditCatalogReceipt(receipt, plan, manifest, manifestDigest).compatible, + true, + ); +}); + +test('rejects source ownership, mutable identity and release-set drift', () => { + const set = releaseSet('local'); + assert.throws( + () => + createCatalogPlan(set, { + ...identity, + sourceRepository: 'other/qinglong', + releaseScope: 'local', + }), + /owned by the publisher/, + ); + assert.throws( + () => + createCatalogPlan(set, { + ...identity, + version: '3.0.0', + sourceRef: 'refs/tags/v3.0.0', + releaseScope: 'local', + }), + /standalone release set/, + ); + const drifted = JSON.parse(JSON.stringify(set)); + drifted.images[0].reference = 'ghcr.io/other/image@sha256:' + '1'.repeat(64); + assert.throws( + () => createCatalogPlan(drifted, { ...identity, releaseScope: 'local' }), + /standalone release set/, + ); +}); + +test('rejects OCI media, blob, title, annotation and raw digest drift', () => { + const set = releaseSet('local'); + const plan = createCatalogPlan(set, { ...identity, releaseScope: 'local' }); + for (const mutate of [ + (value) => { + value.mediaType = 'application/json'; + }, + (value) => { + value.layers[0].digest = `sha256:${'0'.repeat(64)}`; + }, + (value) => { + value.layers[0].annotations['org.opencontainers.image.title'] = + '/tmp/release-set.json'; + }, + (value) => { + value.annotations['dev.qinglong.release.scope'] = 'all'; + }, + ]) { + const manifest = manifestFor(plan, mutate); + assert.throws( + () => createCatalogReceipt(plan, manifest, sha256(manifest)), + /manifest differs/, + ); + } + const manifest = manifestFor(plan); + assert.throws( + () => createCatalogReceipt(plan, manifest, `sha256:${'0'.repeat(64)}`), + /manifest digest is invalid/, + ); +}); + +test('CLI plans, receipts and audits canonical no-replace evidence', (t) => { + const directory = temporaryDirectory(t); + const set = releaseSet('local'); + const setPath = path.join( + directory, + `qinglong3-release-set-${version}-local.json`, + ); + writeCanonical(setPath, set); + const planPath = path.join(directory, 'plan.json'); + const planArgs = [ + '--mode=plan', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=local', + `--repository-owner=${identity.repositoryOwner}`, + `--source-repository=${identity.sourceRepository}`, + `--release-set=${setPath}`, + `--output=${planPath}`, + ]; + const output = { write() {} }; + const plan = runCli(planArgs, output); + assert.equal(fs.statSync(planPath).mode & 0o777, 0o600); + const manifest = manifestFor(plan); + const manifestPath = path.join(directory, 'manifest.json'); + fs.writeFileSync(manifestPath, manifest, { mode: 0o600 }); + const manifestDigest = sha256(manifest); + const receiptPath = path.join(directory, 'receipt.json'); + runCli( + [ + '--mode=receipt', + `--plan=${planPath}`, + `--manifest=${manifestPath}`, + `--manifest-digest=${manifestDigest}`, + `--output=${receiptPath}`, + ], + output, + ); + assert.equal( + runCli( + [ + '--mode=audit', + `--plan=${planPath}`, + `--manifest=${manifestPath}`, + `--manifest-digest=${manifestDigest}`, + `--receipt=${receiptPath}`, + ], + output, + ).compatible, + true, + ); + assert.throws(() => runCli(planArgs, output), /output must be unused/); +}); + +test('CLI rejects renamed inputs, symlinks and open modes', (t) => { + const directory = temporaryDirectory(t); + const renamed = path.join(directory, 'set.json'); + writeCanonical(renamed, releaseSet('local')); + const args = [ + '--mode=plan', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=local', + `--repository-owner=${identity.repositoryOwner}`, + `--source-repository=${identity.sourceRepository}`, + `--release-set=${renamed}`, + `--output=${path.join(directory, 'plan.json')}`, + ]; + assert.throws( + () => runCli(args, { write() {} }), + /filename must be deterministic/, + ); + const target = path.join(directory, 'target.json'); + fs.renameSync(renamed, target); + fs.symlinkSync('target.json', renamed); + assert.throws(() => runCli(args, { write() {} }), /canonical regular file/); + assert.throws( + () => parseArguments(['--mode=publish', '--extra=true']), + /arguments are invalid/, + ); +}); diff --git a/test/back/ql3ReleaseSetContract.test.cjs b/test/back/ql3ReleaseSetContract.test.cjs index 8b57c94e..002267f4 100644 --- a/test/back/ql3ReleaseSetContract.test.cjs +++ b/test/back/ql3ReleaseSetContract.test.cjs @@ -9,6 +9,7 @@ const { auditReleaseSet, createReleaseSet, createVerifiedImageRecord, + inspectReleaseSet, parseArguments, runCli, } = require('../../scripts/ql3-release-set-contract.cjs'); @@ -216,6 +217,69 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () => ); }); +test('inspects a release set without short-lived candidate or image records', () => { + for (const scope of ['local', 'cluster', 'all']) { + const releaseCandidate = candidate(scope); + const releaseSet = createReleaseSet({ + root, + candidate: releaseCandidate, + records: recordsFor(releaseCandidate), + ...identity, + releaseScope: scope, + }); + const inspection = inspectReleaseSet(releaseSet, { + ...identity, + releaseScope: scope, + }); + assert.equal(inspection.compatible, true); + assert.equal(inspection.imageCount, releaseCandidate.images.length); + assert.equal(inspection.sourceRecordsReplayed, false); + } +}); + +test('standalone inspection rejects image, family and self-digest drift', () => { + const releaseCandidate = candidate('cluster'); + const releaseSet = createReleaseSet({ + root, + candidate: releaseCandidate, + records: recordsFor(releaseCandidate), + ...identity, + releaseScope: 'cluster', + }); + for (const mutate of [ + (value) => { + value.images[0].repository = 'other'; + }, + (value) => { + value.deploymentFamilies.cluster.images.pop(); + }, + (value) => { + value.releaseSetDigest = `sha256:${'0'.repeat(64)}`; + }, + ]) { + const drifted = JSON.parse(JSON.stringify(releaseSet)); + mutate(drifted); + assert.throws( + () => + inspectReleaseSet(drifted, { + ...identity, + releaseScope: 'cluster', + }), + /standalone release set/, + ); + } + assert.throws( + () => + inspectReleaseSet(releaseSet, { + ...identity, + version: '3.0.0+unreviewed', + sourceRef: 'refs/tags/v3.0.0+unreviewed', + releaseScope: 'cluster', + }), + /expected release identity is invalid/, + ); +}); + test('CLI records, aggregates and audits exact no-replace files', (t) => { const directory = temporaryDirectory(t); const recordsDirectory = path.join(directory, 'records'); @@ -269,6 +333,22 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { ).compatible, true, ); + assert.equal( + runCli( + [ + '--mode=inspect', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=local', + `--repository-owner=${identity.repositoryOwner}`, + `--report=${setPath}`, + ], + root, + output, + ).verification, + 'standalone_structure_identity_and_self_digest', + ); assert.throws( () => runCli(