feat(ql3): publish durable release catalog

This commit is contained in:
whyour
2026-08-16 10:50:15 +08:00
parent 41549524d4
commit a44c213be1
16 changed files with 1486 additions and 37 deletions
+1
View File
@@ -301,6 +301,7 @@ jobs:
test/back/ql3ClusterImageReleaseAudit.test.cjs test/back/ql3ClusterImageReleaseAudit.test.cjs
test/back/ql3ReleaseCandidateContract.test.cjs test/back/ql3ReleaseCandidateContract.test.cjs
test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs
test/back/ql3ReleaseCatalogContract.test.cjs
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
test/back/ql3LocalImageAudit.test.cjs test/back/ql3LocalImageAudit.test.cjs
test/back/ql3VersionTransition.test.cjs test/back/ql3VersionTransition.test.cjs
+126 -3
View File
@@ -616,7 +616,7 @@ jobs:
needs.release-candidate.result == 'success' && needs.release-candidate.result == 'success' &&
needs.publish.result == 'success' needs.publish.result == 'success'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 15 timeout-minutes: 20
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -648,8 +648,12 @@ jobs:
set -euo pipefail set -euo pipefail
umask 077 umask 077
owner="${GITHUB_REPOSITORY_OWNER,,}" owner="${GITHUB_REPOSITORY_OWNER,,}"
source_repository="${GITHUB_REPOSITORY,,}"
candidate="${RUNNER_TEMP}/release-set-candidate.json" 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 \ node scripts/ql3-release-candidate-contract.cjs \
--mode=create \ --mode=create \
--version="${RELEASE_VERSION}" \ --version="${RELEASE_VERSION}" \
@@ -677,7 +681,27 @@ jobs:
--candidate="${candidate}" \ --candidate="${candidate}" \
--records="${RUNNER_TEMP}/release-records" \ --records="${RUNNER_TEMP}/release-records" \
--report="${report}" > "${RUNNER_TEMP}/release-set-audit.json" --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 "report=${report}" >> "${GITHUB_OUTPUT}"
echo "plan=${plan}" >> "${GITHUB_OUTPUT}"
echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}"
- name: Install the exact checksum-pinned OCI copier - name: Install the exact checksum-pinned OCI copier
env: env:
@@ -691,6 +715,8 @@ jobs:
chmod 0755 "${REGCTL}" chmod 0755 "${REGCTL}"
"${REGCTL}" version "${REGCTL}" version
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4 - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4
with: with:
registry: ghcr.io registry: ghcr.io
@@ -747,11 +773,108 @@ jobs:
with: with:
subject-path: ${{ steps.release-set.outputs.report }} 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 - name: Publish the deployment digest lock
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }} 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 if-no-files-found: error
retention-days: 90 retention-days: 90
compression-level: 0 compression-level: 0
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16): 最新增量证据(2026-08-16):
- D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io/<owner>/qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip`v<version>-<scope>` 仅作发现且 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/93backend 1282 项为 1280 pass、2 条件 skip、0 fail18-package clean build/test 退出 0package 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 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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` setCluster 只消费四角色 set`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77backend 1,264 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package 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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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-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` setCluster 只消费四角色 set`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77backend 1,264 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package 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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 与外部恢复审计均改为读取同一 authoritycandidate 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 和自身 digestapply 先全量预检 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/177backend 1,254 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package 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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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-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 与外部恢复审计均改为读取同一 authoritycandidate 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 和自身 digestapply 先全量预检 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/177backend 1,254 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package 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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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-component24 external/3 internal)、28-node 的 production SBOMBSD-3-Clause 纳入受审 allowlistWorker config 固定 `65532:65532``worker` profile、`edge,node` capacity labels 和 3.0 versioncontrol/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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 重新取得。 - 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-component24 external/3 internal)、28-node 的 production SBOMBSD-3-Clause 纳入受审 allowlistWorker config 固定 `65532:65532``worker` profile、`edge,node` capacity labels 和 3.0 versioncontrol/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 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 重新取得。
@@ -0,0 +1,99 @@
# ADR-0428:持久化 OCI Release Catalog 与独立部署验真
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-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/<owner>/qinglong3-release-catalog` repository。artifact/file media type 固定为
`application/vnd.qinglong.release-set.v1+json`
2. 发现入口固定为 `v<version>-<scope>`,但 discovery tag 的 authority 明确为 `none`。部署与恢复只接受解析并验证
后的 `ghcr.io/<owner>/qinglong3-release-catalog@sha256:<manifest-digest>`;不能把 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/93backend 1282 项为 1280 pass、2 条件 skip、
0 fail18-package clean build/test 退出 0release 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 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 取得。
+1
View File
@@ -431,6 +431,7 @@
| [ADR-0425](./ADR-0425-deployment-family-release-candidate-contract.md) | Deployment-family Release Candidate Contract | Accepted | | [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-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-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 |
## 规则 ## 规则
+95 -12
View File
@@ -1,9 +1,17 @@
# QingLong 3.0 release-set 部署准入 # QingLong 3.0 release-set 部署准入
生产部署的镜像 authority 是成功 `ql3-image-release.yml` 运行产生的 生产部署的镜像 authority 是持久 OCI catalog 中经过签名、provenance 与逐字节回读验证的 immutable release-set
`ql3-release-set-<version>-<scope>` artifact,不是可变 version/source tag。下载后先验证该 JSON 的 GitHub file reference,不是可变 version/source/catalog tag。Actions 中保留 90 天的同名 bundle 只用于便利下载。
provenance,确认 repository、source tag、source revision 与目标发布一致,再从 `images[].reference` 读取完整
`ghcr.io/<owner>/<repository>@sha256:<digest>` 发布入口为:
```text
ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
```
该 discovery tag 只用于发现。先把它解析为
`ghcr.io/<owner>/qinglong3-release-catalog@sha256:<manifest-digest>`,验证这个 immutable reference 后,再从 JSON 的
`images[].reference` 读取完整 `ghcr.io/<owner>/<repository>@sha256:<digest>`
## 选择 scope ## 选择 scope
@@ -16,24 +24,99 @@ provenance,确认 repository、source tag、source revision 与目标发布一
Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local
image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。 image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。
## 工作站验真
以下命令应在可信维护工作站运行;先设置目标发布的显式值:
```sh
owner='<lowercase-owner>'
repository='<owner>/<source-repository>'
version='<source-derived-version>'
scope='local' # 或 cluster/all
source_ref='refs/tags/v<version>'
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 文件 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
provenance referencediscovery tag 无 authority
2. `schema` 必须为 `qinglong/release-set@v1``release.version``release.sourceRef` 2. `schema` 必须为 `qinglong/release-set@v1``release.version``release.sourceRef`
`release.sourceRevision``release.scope` 必须与变更单一致。 `release.sourceRevision``release.scope` 必须与变更单一致。
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成 4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成
`newTag`。Local compose/rollout 同样固定 `@sha256:` `newTag`。Local compose/rollout 同样固定 `@sha256:`
5. rollout 前再次向 registry 解析 version/source tag。它们可以用于发现,但只有解析到 release set 的同一 5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于
digest 才算一致;部署仍以 digest 为准。 发现;部署始终以 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 中途 GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`。如果 promotion 中途
失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证 失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证
每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有最终 每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有
release-set artifact 和 provenance 都生成后,才能宣布该 deployment family 可部署。 release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family
可部署。
workflow artifact 当前保留 90 天,因此长期归档属于 release owner 的外部职责。进入稳定 GA 前,应把经验证的 workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORMrelease owner 仍须维护
release-set 同步到不可变、保留期满足组织策略的发布档案;同步过程不得改写 JSON。 package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程不得改写 canonical JSON,并须保留
原 catalog manifest digest、receipt 与 provenance 关联。
@@ -254,6 +254,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'--bundle-from-oci', '--bundle-from-oci',
'Promote tags only after the complete set is verified', 'Promote tags only after the complete set is verified',
'Attest the complete release-set file provenance', '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', 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
); );
+66 -19
View File
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
); );
requirePattern( requirePattern(
source, 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/, /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 and workflow negative tests', 'cluster image CI must run SBOM, candidate, release-set, durable catalog and workflow negative tests',
); );
requirePattern( requirePattern(
source, source,
@@ -517,7 +517,7 @@ function auditReleaseWorkflow(source) {
JSON.stringify(releaseSetJob?.needs) !== JSON.stringify(releaseSetJob?.needs) !==
JSON.stringify(['release-candidate', 'publish']) || JSON.stringify(['release-candidate', 'publish']) ||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' || releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
releaseSetJob?.['timeout-minutes'] !== 15 || releaseSetJob?.['timeout-minutes'] !== 20 ||
typeof releaseSetJob?.if !== 'string' || typeof releaseSetJob?.if !== 'string' ||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test( !/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
releaseSetJob.if, releaseSetJob.if,
@@ -685,7 +685,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps; const releaseSetSteps = releaseSetJob?.steps;
if ( if (
!Array.isArray(releaseSetSteps) || !Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 9 || releaseSetSteps.length !== 15 ||
releaseSetSteps[0]?.uses !== releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false || releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -702,29 +702,56 @@ function auditReleaseWorkflow(source) {
'merge-multiple': true, 'merge-multiple': true,
}) || }) ||
releaseSetSteps[3]?.id !== 'release-set' || 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 ?? '', releaseSetSteps[3]?.run ?? '',
) || ) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test( !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
releaseSetSteps[4]?.run ?? '', releaseSetSteps[4]?.run ?? '',
) || ) ||
releaseSetSteps[5]?.uses !== releaseSetSteps[5]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
releaseSetSteps[6]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' || '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( !/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' || 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[7]?.with) !== JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify({ JSON.stringify({
'subject-path': '${{ steps.release-set.outputs.report }}', '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' || 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[8]?.with) !== JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify({ JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', 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', 'if-no-files-found': 'error',
'retention-days': 90, 'retention-days': 90,
'compression-level': 0, 'compression-level': 0,
@@ -733,7 +760,7 @@ function auditReleaseWorkflow(source) {
}) })
) { ) {
throw new Error( 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 ( if (
@@ -832,6 +859,12 @@ function auditReleaseWorkflow(source) {
6, 6,
'all release jobs must pin the reviewed immutable Node setup action', '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( requirePattern(
source, 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\}"/, /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( requireOccurrences(
source, source,
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g, /uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
5, 7,
'release workflow must create four image attestations and one complete release-set provenance attestation', 'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance',
); );
requireOccurrences( requireOccurrences(
source, source,
/push-to-registry: true/g, /push-to-registry: true/g,
4, 5,
'all GitHub attestations must be pushed beside the OCI image', 'all image and durable-catalog OCI attestations must be pushed beside their subject',
); );
requirePattern( requirePattern(
source, source,
@@ -967,7 +1000,7 @@ function auditReleaseWorkflow(source) {
'GitHub attestation verification must read the published OCI bundle', 'GitHub attestation verification must read the published OCI bundle',
], ],
]) { ]) {
requireOccurrences(source, pattern, 4, finding); requireOccurrences(source, pattern, 5, finding);
} }
requirePattern( requirePattern(
source, source,
@@ -986,8 +1019,8 @@ function auditReleaseWorkflow(source) {
); );
requirePattern( requirePattern(
source, 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-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 and the deployment lock must be published only after every selected digest record is complete', 'release tags, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete',
); );
return { return {
trigger: 'explicit protected v3 tag dispatch', trigger: 'explicit protected v3 tag dispatch',
@@ -1046,11 +1079,24 @@ function auditReleaseWorkflow(source) {
sourceDerived: true, sourceDerived: true,
sameRunRecords: true, sameRunRecords: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set', tagPromotionAuthority: 'complete_verified_release_set',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, 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, localRolloutPreflight: true,
localRolloutApply: true, localRolloutApply: true,
postPublishVerification: [ postPublishVerification: [
@@ -1061,6 +1107,7 @@ function auditReleaseWorkflow(source) {
'os-vulnerability', 'os-vulnerability',
'release-candidate', 'release-candidate',
'release-set', 'release-set',
'durable-catalog',
'release-tags', 'release-tags',
], ],
}; };
@@ -253,6 +253,7 @@ function createReleaseCandidateContract(options) {
'production-dependency-audit', 'production-dependency-audit',
'digest-signature-and-attestations', 'digest-signature-and-attestations',
'cross-image-release-set', 'cross-image-release-set',
'durable-oci-release-catalog',
...(options.releaseScope !== 'cluster' ...(options.releaseScope !== 'cluster'
? ['edge-and-standalone-rollout'] ? ['edge-and-standalone-rollout']
: []), : []),
+434
View File
@@ -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,
});
+167 -3
View File
@@ -6,8 +6,13 @@ const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { const {
CLUSTER_IMAGES,
LOCAL_IMAGES,
RELEASE_SCOPES,
SCHEMA: RELEASE_CANDIDATE_SCHEMA,
auditReleaseCandidateContract, auditReleaseCandidateContract,
} = require('./ql3-release-candidate-contract.cjs'); } = require('./ql3-release-candidate-contract.cjs');
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; 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) { function readRecordDirectory(directoryPath, candidate) {
const resolved = resolveCanonicalAbsolute(directoryPath, 'records'); const resolved = resolveCanonicalAbsolute(directoryPath, 'records');
const stat = fs.lstatSync(resolved); const stat = fs.lstatSync(resolved);
@@ -345,8 +500,7 @@ function parseArguments(argv) {
fail('arguments are invalid'); fail('arguments are invalid');
values[match[1]] = match[2]; values[match[1]] = match[2];
} }
const common = [ const identity = [
'candidate',
'mode', 'mode',
'release-scope', 'release-scope',
'repository-owner', 'repository-owner',
@@ -354,6 +508,7 @@ function parseArguments(argv) {
'source-revision', 'source-revision',
'version', 'version',
]; ];
const common = ['candidate', ...identity];
const expected = const expected =
values.mode === 'record-image' values.mode === 'record-image'
? [...common, 'digest', 'image', 'output'] ? [...common, 'digest', 'image', 'output']
@@ -361,6 +516,8 @@ function parseArguments(argv) {
? [...common, 'output', 'records'] ? [...common, 'output', 'records']
: values.mode === 'audit' : values.mode === 'audit'
? [...common, 'records', 'report'] ? [...common, 'records', 'report']
: values.mode === 'inspect'
? [...identity, 'report']
: []; : [];
if ( if (
expected.length === 0 || expected.length === 0 ||
@@ -371,7 +528,7 @@ function parseArguments(argv) {
} }
return Object.freeze({ return Object.freeze({
mode: values.mode, mode: values.mode,
candidate: values.candidate, ...(values.candidate ? { candidate: values.candidate } : {}),
version: values.version, version: values.version,
sourceRevision: values['source-revision'], sourceRevision: values['source-revision'],
sourceRef: values['source-ref'], sourceRef: values['source-ref'],
@@ -387,6 +544,12 @@ function parseArguments(argv) {
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) { function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
const options = parseArguments(argv); 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'); const candidate = readCanonicalJson(options.candidate, 'release candidate');
if (options.mode === 'record-image') { if (options.mode === 'record-image') {
const record = createVerifiedImageRecord({ ...options, candidate, root }); const record = createVerifiedImageRecord({ ...options, candidate, root });
@@ -435,6 +598,7 @@ module.exports = Object.freeze({
auditReleaseSet, auditReleaseSet,
createReleaseSet, createReleaseSet,
createVerifiedImageRecord, createVerifiedImageRecord,
inspectReleaseSet,
parseArguments, parseArguments,
runCli, runCli,
}); });
@@ -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', () => { test('rejects external workstation ceremony and offline audit widening', () => {
const fixtures = [ const fixtures = [
[ [
@@ -96,11 +96,24 @@ test('accepts the reviewed native CI and digest release contracts', () => {
sourceDerived: true, sourceDerived: true,
sameRunRecords: true, sameRunRecords: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set', tagPromotionAuthority: 'complete_verified_release_set',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, 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, localRolloutPreflight: true,
localRolloutApply: true, localRolloutApply: true,
postPublishVerification: [ postPublishVerification: [
@@ -111,6 +124,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'os-vulnerability', 'os-vulnerability',
'release-candidate', 'release-candidate',
'release-set', 'release-set',
'durable-catalog',
'release-tags', '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', () => { test('rejects removal of the native arm64 image gate', () => {
const mutated = ciSource.replace( const mutated = ciSource.replace(
'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n image: control', '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', () => { test('rejects a short-lived deployment digest lock', () => {
const marker = ' retention-days: 90'; const marker = ' retention-days: 90';
assert.equal(releaseSource.includes(marker), true); assert.equal(releaseSource.includes(marker), true);
@@ -105,6 +105,10 @@ test('combines local and cluster families without weakening either gate', () =>
contract.requiredGates.includes('cross-image-release-set'), contract.requiredGates.includes('cross-image-release-set'),
true, true,
); );
assert.equal(
contract.requiredGates.includes('durable-oci-release-catalog'),
true,
);
assert.equal( assert.equal(
contract.requiredGates.includes('worker-management-production-evidence'), contract.requiredGates.includes('worker-management-production-evidence'),
true, true,
@@ -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/,
);
});
+80
View File
@@ -9,6 +9,7 @@ const {
auditReleaseSet, auditReleaseSet,
createReleaseSet, createReleaseSet,
createVerifiedImageRecord, createVerifiedImageRecord,
inspectReleaseSet,
parseArguments, parseArguments,
runCli, runCli,
} = require('../../scripts/ql3-release-set-contract.cjs'); } = 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) => { test('CLI records, aggregates and audits exact no-replace files', (t) => {
const directory = temporaryDirectory(t); const directory = temporaryDirectory(t);
const recordsDirectory = path.join(directory, 'records'); const recordsDirectory = path.join(directory, 'records');
@@ -269,6 +333,22 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
).compatible, ).compatible,
true, 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( assert.throws(
() => () =>
runCli( runCli(