feat(ql3): scope deployment-family release candidates

This commit is contained in:
whyour
2026-08-16 08:54:51 +08:00
parent d991deb7ee
commit 7130d77a76
27 changed files with 1332 additions and 303 deletions
+37 -6
View File
@@ -462,38 +462,66 @@ jobs:
node_arch: x64
image_arch: amd64
image: control
repository: qinglong3-cluster-control
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
image: control
repository: qinglong3-cluster-control
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
image: control-ai
repository: qinglong3-cluster-control-ai
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
image: control-ai
repository: qinglong3-cluster-control-ai
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
image: admin
repository: qinglong3-cluster-admin
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
image: admin
repository: qinglong3-cluster-admin
runtime_user: 10001:10001
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
image: worker
repository: qinglong3-worker
runtime_user: 65532:65532
dockerfile: deploy/containers/ql3-worker/Dockerfile
target: runtime
- runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
image: worker
repository: qinglong3-worker
runtime_user: 65532:65532
dockerfile: deploy/containers/ql3-worker/Dockerfile
target: runtime
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
@@ -509,7 +537,7 @@ jobs:
pnpm audit:image-release:ql3
- name: Build the native production image
env:
IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}
IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
run: >-
docker build
--file ${{ matrix.dockerfile }}
@@ -526,7 +554,7 @@ jobs:
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: 'v0.70.0'
image-ref: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}
image-ref: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
scanners: 'vuln'
vuln-type: 'os'
severity: 'HIGH,CRITICAL'
@@ -539,8 +567,8 @@ jobs:
trivyignores: ${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml
- name: Verify architecture and non-root runtime identity
env:
IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}
EXPECTED: ${{ matrix.image_arch }} 10001:10001
IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
EXPECTED: ${{ matrix.image_arch }} ${{ matrix.runtime_user }}
run: |
set -euo pipefail
actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}}' "${IMAGE}")"
@@ -561,11 +589,11 @@ jobs:
--output=${{ runner.temp }}/ql3-cluster-${{ matrix.image }}.cdx.json
- name: Reconcile SBOM with the actual read-only image inventory
env:
IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}
IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
run: >-
docker run --rm --read-only
--security-opt no-new-privileges
--user 10001:10001
--user ${{ matrix.runtime_user }}
--volume "${{ github.workspace }}:/audit:ro"
--workdir /audit
--entrypoint node
@@ -593,6 +621,9 @@ jobs:
- image: local
dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime
- image: worker
dockerfile: deploy/containers/ql3-worker/Dockerfile
target: runtime
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
+123 -62
View File
@@ -7,6 +7,15 @@ on:
description: Exact QingLong 3 SemVer tag without the v prefix
required: true
type: string
release_scope:
description: Deployment family to publish
required: true
default: all
type: choice
options:
- local
- cluster
- all
permissions:
contents: read
@@ -16,8 +25,56 @@ concurrency:
cancel-in-progress: false
jobs:
release-candidate:
name: Freeze the source-derived release candidate contract
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
cluster-evidence-required: ${{ steps.contract.outputs.cluster-evidence-required }}
os-matrix: ${{ steps.contract.outputs.os-matrix }}
publish-matrix: ${{ steps.contract.outputs.publish-matrix }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Derive the exact deployment-family release plan
id: contract
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
run: |
set -euo pipefail
umask 077
report="${RUNNER_TEMP}/ql3-release-candidate-contract.json"
node scripts/ql3-release-candidate-contract.cjs \
--mode=create \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--output="${report}"
REPORT="${report}" node <<'NODE'
const fs = require('node:fs');
const report = JSON.parse(fs.readFileSync(process.env.REPORT, 'utf8'));
const output = [
`cluster-evidence-required=${report.releasePlan.clusterEvidenceRequired}`,
`os-matrix=${JSON.stringify(report.releasePlan.osMatrix)}`,
`publish-matrix=${JSON.stringify(report.releasePlan.publishMatrix)}`,
];
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output.join('\n')}\n`);
NODE
worker-management-release-evidence:
name: Audit private Worker management release evidence
needs: release-candidate
if: needs.release-candidate.outputs.cluster-evidence-required == 'true'
runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral]
environment: ql3-production-release-evidence
timeout-minutes: 10
@@ -62,6 +119,8 @@ jobs:
cluster-dr-release-evidence:
name: Audit private CloudNativePG disaster-recovery evidence
needs: release-candidate
if: needs.release-candidate.outputs.cluster-evidence-required == 'true'
runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral]
environment: ql3-production-release-evidence
timeout-minutes: 10
@@ -106,6 +165,7 @@ jobs:
os-vulnerability:
name: Scan ${{ matrix.image }} OS packages on ${{ matrix.image_arch }}
needs: release-candidate
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
@@ -113,55 +173,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- image: control
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- image: control
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- image: control-ai
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- image: control-ai
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- image: admin
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- image: admin
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- image: local
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime
- image: local
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime
include: ${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
@@ -244,9 +256,21 @@ jobs:
publish:
name: Publish immutable multi-architecture ${{ matrix.image }} image
needs:
- release-candidate
- worker-management-release-evidence
- cluster-dr-release-evidence
- os-vulnerability
if: >-
always() &&
needs.release-candidate.result == 'success' &&
needs.os-vulnerability.result == 'success' &&
(
needs.release-candidate.outputs.cluster-evidence-required != 'true' ||
(
needs.worker-management-release-evidence.result == 'success' &&
needs.cluster-dr-release-evidence.result == 'success'
)
)
runs-on: ubuntu-24.04
permissions:
contents: read
@@ -257,19 +281,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- image: control
repository: qinglong3-cluster-control
runtime_root: deploy/containers/ql3-cluster-control/runtime-dependencies
- image: control-ai
repository: qinglong3-cluster-control-ai
runtime_root: deploy/containers/ql3-cluster-control/runtime-dependencies
- image: admin
repository: qinglong3-cluster-admin
runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies
- image: local
repository: qinglong3-local-application
runtime_root: deploy/containers/ql3-local-application/runtime-dependencies
include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
@@ -279,6 +291,30 @@ jobs:
with:
node-version: '24.18.0'
- name: Recreate and audit the source-derived release candidate contract
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
run: |
set -euo pipefail
umask 077
contract="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json"
node scripts/ql3-release-candidate-contract.cjs \
--mode=create \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--output="${contract}"
audit="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-audit.json"
node scripts/ql3-release-candidate-contract.cjs \
--mode=audit \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--report="${contract}" > "${audit}"
- name: Resolve and validate release identity
id: identity
env:
@@ -416,6 +452,15 @@ jobs:
predicate-path: ${{ runner.temp }}/${{ matrix.repository }}-os-vulnerability.json
push-to-registry: true
- name: Attest the source-derived release candidate contract
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ steps.identity.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1
predicate-path: ${{ runner.temp }}/${{ matrix.repository }}-release-candidate-contract.json
push-to-registry: true
- name: Verify the published manifest and attestation bindings
env:
IMAGE: ${{ steps.identity.outputs.image }}
@@ -512,6 +557,22 @@ jobs:
--deny-self-hosted-runners \
--bundle-from-oci
- name: Verify the release candidate contract from the OCI registry
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \
--source-digest "${GITHUB_SHA}" \
--source-ref "${GITHUB_REF}" \
--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1" \
--deny-self-hosted-runners \
--bundle-from-oci
- name: Promote only the verified digest to immutable release tags
env:
IMAGE: ${{ steps.identity.outputs.image }}
+3 -2
View File
@@ -37,7 +37,7 @@ deploy/console/ql3-cluster-copilot/verify-release.sh \
For a release acceptance ceremony, prefer the source-tag workstation runner.
Create a current-owner `0700` report directory and a canonical `0600` file
containing only a short-lived GitHub token. Pass canonical absolute executable
paths (resolve Homebrew symlinks first). The token is sent only to the three
paths (resolve Homebrew symlinks first). The token is sent only to the four
`gh attestation verify` children, never in argv, the report or the `cosign` and
`docker` environments.
@@ -54,7 +54,8 @@ node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs \
--output=/absolute/private/release/admin-ceremony.json
```
In addition to the signature, provenance, CycloneDX and OS-vulnerability
In addition to the signature, provenance, CycloneDX, OS-vulnerability and
source-derived release-candidate contract
checks, the runner pulls the same immutable digest, confirms its local
`RepoDigests` binding, and runs the image-carried `evidence-verify` command on
a fixed non-sensitive vector with network disabled, a read-only root, no
@@ -54,5 +54,6 @@ verify_attestation() {
verify_attestation ''
verify_attestation https://cyclonedx.org/bom
verify_attestation https://qinglong.dev/attestations/image-os-vulnerability/v1
verify_attestation https://qinglong.dev/attestations/release-candidate-contract/v1
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"compatible":true}'
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"releaseCandidateContract":true,"compatible":true}'
@@ -96,7 +96,8 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \
org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded Copilot surfaces" \
org.opencontainers.image.source="https://github.com/whyour/qinglong" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.licenses="Apache-2.0"
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.version="3.0.0-alpha.0"
ENV NODE_ENV=production
@@ -97,7 +97,8 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Control" \
org.opencontainers.image.description="QingLong 3.0 PostgreSQL-backed cluster control plane" \
org.opencontainers.image.source="https://github.com/whyour/qinglong" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.licenses="Apache-2.0"
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.version="3.0.0-alpha.0"
ENV NODE_ENV=production
+2 -1
View File
@@ -11,7 +11,8 @@
最新增量证据(2026-08-16):
- D-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io/<owner>/qinglong3-cluster-admin@sha256:<digest>`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATHtoken 只注入 3 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX 与 OS-vulnerability evidence,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identityoffline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed``reportAttestation=none``actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swappingbackend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow packagenpm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`ADR-0424 必须保持 Proposedstub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted
- 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=[]`dependencyEdge importCluster/Worker deploymentimage releaseOS 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 不改变 schemamigrationSQLrolePool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得
- D-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io/<owner>/qinglong3-cluster-admin@sha256:<digest>`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATHtoken 只注入 4 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX、OS-vulnerability evidence 与 D-333 source-derived release-candidate contract,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identityoffline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed``reportAttestation=none``actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swappingbackend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow packagenpm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`ADR-0424 必须保持 Proposedstub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted。
- D-331/ADR-0423(已接受):`@qinglong/cluster-admin` 在既有 `copilot-console/` 职责目录增加独立 TypeScript evidence verifier,并以第 11 个静态产品命令 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 交付。它只通过 no-follow/stable descriptor 读取一个最大 512 KiB 的 canonical absolute UTF-8 JSON,拒绝 BOM、CRLF、minified、duplicate-key、symlink、relative path 与读取中漂移;独立固定检查 exact bundle/request shape、13 operations、16-entry/8 MiB/64-item/depth/key ceiling、安全字段白名单和顺序 typed alias,再重算不含 `contentDigest` 的 canonical SHA-256。结果明确只证明 `bundleDigest=verified`;没有原始 fact 时逐条 digest 为 `not_recomputed_without_raw_facts`server signature/attestation/durable audit 均未验证且 action authority 为 none。实现不读 stdin/environment/context,不联网、不写文件、不新增 package、依赖、route、listener、数据库、Kubernetes workload 或 Edge/Standalone closure。定向门 18/18Cluster Admin 387 pass/3 条件 skip18-package clean build/test 退出 0backend 1,225 pass/2 条件 skip/0 fail。真实 arm64 Admin image `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 11 个命令、有效 bundle、tamper rejection 与零 verifier file write。npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;结构/依赖/部署/发布/Console 审计零 findingworkspace 保持 18 package、无 single/shallow packageCluster Admin 122 个源码中 121 个位于领域目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes。因本门没有 schema/migration/SQL/role/Pool/连接拓扑变化,不重复冒充执行 HA,复用紧邻 D-330 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一门应完成公开 release digest 的外部工作站 ceremony,不得给 verifier 增加上传、签名或行动能力。
- D-330/ADR-0422(已接受):同一 loopback-only Cluster field ledger 现可由用户显式导出纯浏览器本地的脱敏 evidence bundle。导出只消费本页已逐次读取的最近 16 条、最多 8 MiB canonical fact,不调用 upstream/BFF、补读详情/分页、轮询、上传或持久化;固定 sanitizer 只保留 operation、非权威本机观察时间、安全枚举/boolean/有界 number、结构计数/分页事实、per-bundle typed alias 与原始 fact canonical byte count/SHA-256,自由文本、名称、路径/URL/command/input/output/environment、reason/error/message、credential/token/session/authorization、未知字段及 Copilot model text 一律省略。bundle 固定为 UTF-8 `qinglong/cluster-console-redacted-evidence-bundle@v1` JSON、最大 512 KiB,顶层 self-digest 明确不是 server signature/audit/action authority;生成器作为第 4 个 digest-bound asset 留在既有 `@qinglong/cluster-admin`,不增加 package、依赖、Cluster/BFF route、数据库、对象存储、Kubernetes workload 或 Edge/Standalone closure。定向门 24/24Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSONupstream 计数仍为 3390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`1,199 个源码中 1,181 个位于职责目录。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 bytes。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。
- D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operationbrowser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpackedpackage/dependency/Cluster deployment/image release/Console/distribution 审计零 findingworkspace 为 18 package 且无 single-source/shallow package。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 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。
@@ -7,6 +7,7 @@
D-184、D-185、D-186
- 关联 ADRADR-0042、ADR-0090、ADR-0128、ADR-0185、ADR-0194、
ADR-0195
- 后续修订:ADR-0425 已取代本 ADR 的固定三镜像矩阵;本 ADR 的 Local image identity、双架构、SBOM、签名与 digest rollout 约束继续有效。
## 背景
@@ -4,6 +4,7 @@
- 日期:2026-08-01
- 关联 RFCQL-RFC-0001 D-235、D-236
- 关联 ADRADR-0128、ADR-0196、ADR-0252
- 后续修订:ADR-0425 已把本 ADR 的私有 Worker evidence gate 收窄到 `cluster|all` 发布族;`local` 发布不得等待该证据,Cluster 的 source/freshness/private-runner 约束继续有效。
## 背景
@@ -28,12 +28,13 @@ attestation service、transparency log、本地 Docker daemon 和短期 GitHub t
`cosign``gh``docker`、短期 token file 和新 report pathmutable tag、branch
ref、owner 漂移、symlink、group/other-writable executable 或已存在输出均失败关闭。
2. 三个外部工具按绝对路径直接执行,不经 shell 或 ambient `PATH`。GitHub token
必须来自 current-owner `0600` bounded file,只注入`gh attestation verify`
必须来自 current-owner `0600` bounded file,只注入`gh attestation verify`
子进程;不得进入 argv、`cosign`/`docker` 环境、报告或失败输出。工具在执行前后
复验 device/inode/size/SHA-256,降低 ceremony 中途替换风险。
3. ceremony 精确执行一次 keyless signature 验证,以及绑定 release workflow、
source digest、source tag、非 self-hosted runner 和 OCI bundle 的 provenance、
CycloneDX、OS-vulnerability 三类 GitHub attestation 验证。随后拉取同一 digest,
CycloneDX、OS-vulnerability、source-derived release-candidate contract 四类 GitHub
attestation 验证。随后拉取同一 digest,
要求本地 image inspection 的 Linux `amd64|arm64` `RepoDigests` 包含精确输入。
4. ceremony 使用固定、非敏感、单条 `run_read` redacted evidence vector 检验最终
release image 内的第 11 个 `evidence-verify` 产品命令。该容器使用 non-root
@@ -41,12 +42,12 @@ attestation service、transparency log、本地 Docker daemon 和短期 GitHub t
128 MiB、0.25 CPU 与 32 PIDs,只读挂载 vector;输出必须与独立 verifier 的
exact no-authority result 一致,vector inode/size/mtime/digest 前后不变。
5. 成功只新建一个 current-owner `0600`、two-space canonical JSON 报告。报告保留
public release identity、工具 SHA-256/size、步 argv/stdout/stderr digest 与字节数、
public release identity、工具 SHA-256/size、步 argv/stdout/stderr digest 与字节数、
verification/isolation 结果和自身 canonical SHA-256,不保留原始工具输出、token、
executable path 或 workstation identity。它明确声明
`reportAttestation=none``actionAuthority=none`
6. 独立 offline audit 使用 no-follow stable read 校验报告 canonical encoding、exact
shape、expected release identity、工具与步 transcript digest、一致的 isolation/
shape、expected release identity、工具与步 transcript digest、一致的 isolation/
limitation 以及顶层 digest。其结果固定为 `externalResults=not_replayed`;离线审计
不能证明外部命令确实运行,也不能重放某一历史时点的 registry、GitHub 或
transparency-log 状态。
@@ -0,0 +1,120 @@
# ADR-0425:按部署族冻结 3.0 Release Candidate,并闭合 Worker 发布集合
- 状态:Accepted(实现与静态/变异门已完成;公开 tag、GHCR digest 和远端证明结果待实际发布)
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-01、D-03、D-05、D-14、D-42、D-61、D-186、D-257、D-333
- 关联 ADRADR-0088、ADR-0128、ADR-0196、ADR-0253、ADR-0254、ADR-0255、ADR-0281、ADR-0420、ADR-0424
## 背景
原唯一 image release workflow 有两项结构问题。第一,version 只由 dispatch input 与 tag 字符串相互校验,
没有把 18 个 QL3 workspace manifest、容器 runtime manifest、Node ABI、Dockerfile version、部署 Profile、
镜像集合与双架构矩阵冻结为同一份可证明契约。第二,Local、Control、Control AI 与 Admin 四个 image 被一个
固定矩阵发布,且无条件等待 Worker management 和 CloudNativePG 两个私有集群证据。这让只使用
Edge/Standalone 的路由器/NAS 发布也依赖集群 HA 基础设施;反过来,真实 Cluster 部署需要的
`qinglong3-worker` 已有 Dockerfile、锁文件、Kubernetes manifests 和 live rollout,却完全不在发布矩阵中。
这不是测试数量问题,而是产品集合和发布 authority 不一致:轻量用户被过度阻塞,集群用户又拿不到完整制品。
## 决策
### 1. 唯一 workflow 支持三个封闭部署族
`.github/workflows/ql3-image-release.yml` 继续是唯一 image publication authority,并只接受显式
`workflow_dispatch` 到 exact protected `v3` tag。新增必选 `release_scope`
- `local`:仅 `qinglong3-local-application`,服务 Edge/Standalone
- `cluster``control`、可选 AI control、Admin、Worker
- `all`:同时发布两族,但不得弱化任一族的门禁。
scope 不接受自由文本、额外 repository 或运行时拼接。matrix 只能来自下一节的 source-derived contract
workflow 内不再维护第二份 image 清单。
### 2. Source-derived release-candidate contract 是矩阵唯一来源
根级 `ql3-release-candidate-contract.cjs` 接受 exact QingLong 3 SemVer、40-hex commit、匹配的完整 tag ref 和
closed scope,随后从受审源码推导 no-replace canonical JSON
- 18 个 workspace 必须全部通过 package-boundary audithard cap 仍为 18,且无 single/shallow package
- 每个 workspace version 必须等于 tag versionNode engine 必须为 `>=24.18.0 <25`
- 每个所选 image 的 production manifest、Dockerfile Node 24.18.0 与 OCI version label 必须相同;
- 平台固定 `linux/amd64``linux/arm64`
- Local profile 固定 `edge|standalone` 且不要求 Cluster private evidence
- Cluster profile 固定 `cluster|worker-edge|worker-node`,必须要求 Worker management 与 CloudNativePG evidence
- legacy 根 package 的 2.x version 只作为兼容事实记录,并明确排除出 3.0 release identity。
报告携带对自身 unsigned exact JSON 的 SHA-256。publisher 从同一 checkout 重新生成并独立 exact-audit,不能直接
信任 job output 中的任意 repository/pathjob output 只传递 contract 派生的有界 matrix 和 cluster evidence bit。
### 3. Local 不再被 Cluster HA 证据阻塞
Local scope 的两个私有 evidence job 必须为 skippedpublisher 仍无条件依赖 release-candidate 与 native OS scan
并继续对 pushed Local digest 执行 Edge/Standalone 两个真实 compose rollout。Cluster/all scope 才能把 private
evidence bit 置为 truepublisher 使用显式 `always()` 条件,只在 candidate/OS 成功,且 cluster scope 的两个
私有 job 均成功时取得写权限。skipped 不能被当作 Cluster successfailed/cancelled 也不能通过条件表达式旁路。
### 4. Cluster 发布集合必须包含 Worker
Worker 加入与其他 image 相同的 native amd64/arm64 build-once、Trivy 0.70.0 OS-only HIGH/CRITICAL、扫描证据、
OCI merge、production dependency audit、CycloneDX、Cosign keyless、GitHub attestation、远端 manifest 回读和
验证后 tag promotion。Worker production SBOM 当前为 27 components24 external、3 internal)和 28 dependency
nodes;唯一新增 license allowlist 项是锁中 `asn1js` 的 BSD-3-Clause。OCI config 固定 non-root `65532:65532`
唯一 Worker process entrypoint、`io.qinglong.profile=worker``edge,node` capacity profiles 与 exact 3.0 version。
Control/Admin Dockerfile 同步补 exact 3.0 version label,使 tag、workspace、runtime manifest 与所有 OCI config
首次共享同一 release identity。
### 5. Candidate contract 必须成为 digest-bound 第四类证明
每个发布 digest 除 SLSA、CycloneDX、OS-vulnerability 外,再以
`https://qinglong.dev/attestations/release-candidate-contract/v1` 附加 candidate predicate,并以 repository、
workflow、source digest、source ref、非 self-hosted builder 和 OCI bundle 远端回读。Admin image 内的
`verify-release.sh`、外部 workstation ceremony 与 offline report auditor 同步从三类/七步升级为四类/八步;
否则“生成了 contract”不能算发布者或部署者实际验证过。
## 资源与权限边界
- 不新增 workspace package、npm production dependency、数据库、migration、SQL、role、Pool、connection
- contract 与 audit 只在显式 release job 短生命周期运行,不进入 Local/Worker/Control/Admin runtime filesystem
- 不增加 Edge/Standalone timer、watcher、listener、queue、cache 或常驻进程;
- Local scope 不接触 self-hosted private evidence runnerCluster scope 不得把 skipped 私有证据解释为成功;
- release tag 仍只在所有 digest verification 完成后 promotiontag 本身不成为部署 authority。
## 失败与恢复
- tag/version/workspace/container version 任一漂移:修正源码并重新创建 tag,不手改报告;
- package boundary 不兼容或出现第 19 个 package:先独立评审边界,不扩大 candidate hard cap
- Worker SBOM/license/config 漂移:更新锁与供应链 ADR 后重跑,不能从 Cluster scope 静默删除 Worker
- Local scope 意外等待 Cluster evidence:视为发布拓扑回归;
- Cluster scope 的 private job skipped/failedpublisher 不启动;
- candidate attestation 缺失或远端 source binding 不匹配:不得 promotion version/source tag
- 公开发布尚不存在:只报告 implementation-ready,不用 fixture、stub 或本机 tag 冒充 GHCR 成功。
## 被拒绝的替代方案
### 为 Local 和 Cluster 复制两套 workflow
拒绝。它会复制 OIDC identity、action pins、scanner、copier、签名和 tag promotion 逻辑,形成安全策略漂移。
### 继续发布固定 all matrix
拒绝。低配用户会被无关 HA 证据阻塞,同时无法表达独立修补 Local image 的发布意图。
### Cluster 不发布 Worker,让运维现场自行 build
拒绝。部署 manifest 已把 Worker 作为产品制品;现场 build 绕过统一 SBOM、OS scan、签名与 provenance。
### 只校验 tag,不持久化 candidate predicate
拒绝。tag 不能证明 workspace、容器、Profile、平台和 gate 集合,也不能让部署端在 digest 上独立回读。
## 验证
- release candidate create/audit、scope/version/source/report mutation7 项;
- Worker SBOM/OCI、OS policy、共享 release workflow、Admin verifier/ceremony/distribution 定向总计 105/105
- backend 1,246 pass/2 条件 skip/0 fail18-package clean build/test 退出 0
- package boundary 返回 18 packages、hard cap 18、single/shallow 均为空;dependency、Edge import、Cluster/Worker deployment、image release、OS policy、Console/distribution 均 compatible
- 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes
- Cluster Admin npm pack 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;四个 runtime dependency root 的离线缓存审计为 0 vulnerability
- 本 Gate 无 schema、migration、SQL、role、Pool 或连接/HA 拓扑变化,因此不重复 PostgreSQL 门,复用 D-331 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线;
- 公开 tag 后再记录 GHCR 五镜像 digest、四类 attestation 与外部 Admin ceremony,不提前宣称完成。
@@ -284,6 +284,7 @@ function auditReport(value, expected) {
provenance: true,
cyclonedxSbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
imagePulled: true,
localRepoDigestBound: true,
embeddedEvidenceVerifier: true,
@@ -333,6 +334,7 @@ function auditReport(value, expected) {
['provenance_attestation', 'gh'],
['cyclonedx_sbom_attestation', 'gh'],
['os_vulnerability_attestation', 'gh'],
['release_candidate_attestation', 'gh'],
['immutable_image_pull', 'docker'],
['local_digest_inspection', 'docker'],
['embedded_evidence_verifier', 'docker'],
@@ -383,7 +385,7 @@ function auditReport(value, expected) {
fail('step evidence is invalid');
}
});
const verifierStep = value.steps[6];
const verifierStep = value.steps[7];
if (
verifierStep.stdoutBytes < 2 ||
verifierStep.stderrBytes !== 0 ||
@@ -481,6 +481,10 @@ async function runCeremony(options) {
'os_vulnerability_attestation',
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
);
attestation(
'release_candidate_attestation',
'https://qinglong.dev/attestations/release-candidate-contract/v1',
);
discard(
runStep(
tools.docker,
@@ -583,6 +587,7 @@ async function runCeremony(options) {
provenance: true,
cyclonedxSbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
imagePulled: true,
localRepoDigestBound: true,
embeddedEvidenceVerifier: true,
@@ -13,6 +13,7 @@ const FILES = Object.freeze({
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
workflow: '.github/workflows/ql3-image-release.yml',
candidate: 'scripts/ql3-release-candidate-contract.cjs',
cli: 'packages/ql3-cluster-admin/src/copilot-console/cli.ts',
server: 'packages/ql3-cluster-admin/src/copilot-console/server.ts',
});
@@ -105,6 +106,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'--bundle-from-oci',
'https://cyclonedx.org/bom',
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
'https://qinglong.dev/attestations/release-candidate-contract/v1',
],
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
);
@@ -134,6 +136,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
"'--bundle-from-oci'",
"'https://cyclonedx.org/bom'",
"'https://qinglong.dev/attestations/image-os-vulnerability/v1'",
"'https://qinglong.dev/attestations/release-candidate-contract/v1'",
"'immutable_image_pull'",
"'local_digest_inspection'",
"'embedded_evidence_verifier'",
@@ -223,16 +226,30 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
],
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
);
requireFragments(
'candidate',
[
"image: 'admin'",
"repository: 'qinglong3-cluster-admin'",
"image: 'worker'",
"repository: 'qinglong3-worker'",
"profiles: ['edge', 'standalone']",
'requiresClusterPrivateEvidence: false',
'requiresClusterPrivateEvidence: true',
],
'QL3_CLUSTER_ADMIN_RELEASE_CANDIDATE_DRIFT',
);
requireFragments(
'workflow',
[
'image: admin',
'image_arch: amd64',
'image_arch: arm64',
'fromJSON(needs.release-candidate.outputs.publish-matrix)',
'fromJSON(needs.release-candidate.outputs.os-matrix)',
'cosign sign --yes "${IMAGE}@${DIGEST}"',
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
'predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1',
'gh attestation verify "oci://${IMAGE}@${DIGEST}"',
'--predicate-type "https://cyclonedx.org/bom"',
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
'--deny-self-hosted-runners',
'--bundle-from-oci',
'Promote only the verified digest to immutable release tags',
+136 -132
View File
@@ -57,6 +57,8 @@ function auditClusterImageCiWorkflow(
node_arch: 'x64',
image_arch: 'amd64',
image: 'control',
repository: 'qinglong3-cluster-control',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime',
},
@@ -65,6 +67,8 @@ function auditClusterImageCiWorkflow(
node_arch: 'arm64',
image_arch: 'arm64',
image: 'control',
repository: 'qinglong3-cluster-control',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime',
},
@@ -73,6 +77,8 @@ function auditClusterImageCiWorkflow(
node_arch: 'x64',
image_arch: 'amd64',
image: 'control-ai',
repository: 'qinglong3-cluster-control-ai',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime-ai',
},
@@ -81,6 +87,8 @@ function auditClusterImageCiWorkflow(
node_arch: 'arm64',
image_arch: 'arm64',
image: 'control-ai',
repository: 'qinglong3-cluster-control-ai',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime-ai',
},
@@ -89,6 +97,8 @@ function auditClusterImageCiWorkflow(
node_arch: 'x64',
image_arch: 'amd64',
image: 'admin',
repository: 'qinglong3-cluster-admin',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
target: 'runtime',
},
@@ -97,9 +107,31 @@ function auditClusterImageCiWorkflow(
node_arch: 'arm64',
image_arch: 'arm64',
image: 'admin',
repository: 'qinglong3-cluster-admin',
runtime_user: '10001:10001',
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
target: 'runtime',
},
{
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
image: 'worker',
repository: 'qinglong3-worker',
runtime_user: '65532:65532',
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
target: 'runtime',
},
{
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
image: 'worker',
repository: 'qinglong3-worker',
runtime_user: '65532:65532',
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
target: 'runtime',
},
];
const expectedLocalNativeMatrix = [
{
@@ -134,6 +166,11 @@ function auditClusterImageCiWorkflow(
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
target: 'runtime',
},
{
image: 'worker',
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
target: 'runtime',
},
];
if (
JSON.stringify(
@@ -146,7 +183,7 @@ function auditClusterImageCiWorkflow(
JSON.stringify(expectedOciMatrix)
) {
throw new Error(
'image CI matrices must contain only exact control/control-ai/admin/local amd64/arm64 evidence targets',
'image CI matrices must contain only exact control/control-ai/admin/local/worker amd64/arm64 evidence targets',
);
}
const expectedTrivyInputs = {
@@ -170,7 +207,7 @@ function auditClusterImageCiWorkflow(
],
[
clusterImageJob,
'qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}',
'${{ matrix.repository }}:ci-${{ matrix.image_arch }}',
'${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml',
'cluster',
],
@@ -257,6 +294,8 @@ function auditClusterImageCiWorkflow(
['control-ai', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
['admin', 'ubuntu-24\\.04', 'x64', 'amd64'],
['admin', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
['worker', 'ubuntu-24\\.04', 'x64', 'amd64'],
['worker', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
]) {
requirePattern(
source,
@@ -274,7 +313,7 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/--read-only[\s\S]*--user 10001:10001[\s\S]*--inventory-root=\/opt\/qinglong\/node_modules/,
/--read-only[\s\S]*--user \$\{\{ matrix\.runtime_user \}\}[\s\S]*--inventory-root=\/opt\/qinglong\/node_modules/,
'cluster image CI must reconcile the SBOM with a read-only non-root image inventory',
);
requirePattern(
@@ -299,8 +338,8 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime/,
'OCI evidence CI must build independent control, control-ai, admin and local images',
/- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime\s+- image: worker\s+dockerfile: deploy\/containers\/ql3-worker\/Dockerfile\s+target: runtime/,
'OCI evidence CI must build independent control, control-ai, admin, local and worker images',
);
requirePattern(
source,
@@ -329,7 +368,7 @@ function auditClusterImageCiWorkflow(
'local image CI must generate and inventory-check the exact local SBOM profile',
);
return {
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'local', 'worker'],
nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true,
clusterAdminProductFacade: true,
@@ -348,112 +387,23 @@ function auditClusterImageCiWorkflow(
function auditReleaseWorkflow(source) {
const workflow = yaml.load(source);
const candidateJob = workflow?.jobs?.['release-candidate'];
const evidenceJob = workflow?.jobs?.['worker-management-release-evidence'];
const drEvidenceJob = workflow?.jobs?.['cluster-dr-release-evidence'];
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
const publishJob = workflow?.jobs?.publish;
const expectedReleaseMatrix = [
{
image: 'control',
repository: 'qinglong3-cluster-control',
runtime_root:
'deploy/containers/ql3-cluster-control/runtime-dependencies',
},
{
image: 'control-ai',
repository: 'qinglong3-cluster-control-ai',
runtime_root:
'deploy/containers/ql3-cluster-control/runtime-dependencies',
},
{
image: 'admin',
repository: 'qinglong3-cluster-admin',
runtime_root: 'deploy/containers/ql3-cluster-admin/runtime-dependencies',
},
{
image: 'local',
repository: 'qinglong3-local-application',
runtime_root:
'deploy/containers/ql3-local-application/runtime-dependencies',
},
];
const expectedOsVulnerabilityMatrix = [
{
image: 'control',
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime',
},
{
image: 'control',
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime',
},
{
image: 'control-ai',
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime-ai',
},
{
image: 'control-ai',
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime-ai',
},
{
image: 'admin',
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
target: 'runtime',
},
{
image: 'admin',
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
target: 'runtime',
},
{
image: 'local',
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
target: 'runtime',
},
{
image: 'local',
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
target: 'runtime',
},
];
if (
JSON.stringify(publishJob?.strategy?.matrix?.include) !==
JSON.stringify(expectedReleaseMatrix)
publishJob?.strategy?.matrix?.include !==
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
) {
throw new Error(
'release workflow matrix must contain only exact control, control-ai, admin and local image authorities',
'publisher matrix must come only from the source-derived release candidate contract',
);
}
if (
JSON.stringify(osVulnerabilityJob?.strategy?.matrix?.include) !==
JSON.stringify(expectedOsVulnerabilityMatrix) ||
osVulnerabilityJob?.strategy?.matrix?.include !==
'${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}' ||
osVulnerabilityJob?.needs !== 'release-candidate' ||
osVulnerabilityJob?.strategy?.['fail-fast'] !== false ||
osVulnerabilityJob?.['runs-on'] !== '${{ matrix.runner }}' ||
osVulnerabilityJob?.['timeout-minutes'] !== 45 ||
@@ -461,7 +411,7 @@ function auditReleaseWorkflow(source) {
JSON.stringify({ contents: 'read' })
) {
throw new Error(
'release OS vulnerability matrix must scan exact control, control-ai, admin and local amd64/arm64 candidates with read-only authority',
'release OS vulnerability matrix must come from the source-derived deployment-family contract with read-only authority',
);
}
requirePattern(
@@ -469,6 +419,11 @@ function auditReleaseWorkflow(source) {
/workflow_dispatch:\s*\n\s+inputs:\s*\n\s+version:/,
'release workflow must support an explicit version input',
);
requirePattern(
source,
/release_scope:\s+description: Deployment family to publish\s+required: true\s+default: all\s+type: choice\s+options:\s+- local\s+- cluster\s+- all/,
'release workflow must select one closed local, cluster or all deployment family',
);
if (/^ (?:push|pull_request|schedule):/m.test(source)) {
throw new Error(
'release workflow must require an explicit protected-tag dispatch',
@@ -477,6 +432,8 @@ function auditReleaseWorkflow(source) {
if (
JSON.stringify(workflow?.permissions) !==
JSON.stringify({ contents: 'read' }) ||
JSON.stringify(candidateJob?.permissions) !==
JSON.stringify({ contents: 'read' }) ||
JSON.stringify(evidenceJob?.permissions) !==
JSON.stringify({ contents: 'read' }) ||
JSON.stringify(drEvidenceJob?.permissions) !==
@@ -495,6 +452,15 @@ function auditReleaseWorkflow(source) {
);
}
if (
candidateJob?.['runs-on'] !== 'ubuntu-24.04' ||
candidateJob?.['timeout-minutes'] !== 5 ||
JSON.stringify(candidateJob?.outputs) !==
JSON.stringify({
'cluster-evidence-required':
'${{ steps.contract.outputs.cluster-evidence-required }}',
'os-matrix': '${{ steps.contract.outputs.os-matrix }}',
'publish-matrix': '${{ steps.contract.outputs.publish-matrix }}',
}) ||
JSON.stringify(evidenceJob?.['runs-on']) !==
JSON.stringify([
'self-hosted',
@@ -511,16 +477,45 @@ function auditReleaseWorkflow(source) {
]) ||
drEvidenceJob?.environment !== 'ql3-production-release-evidence' ||
drEvidenceJob?.['timeout-minutes'] !== 10 ||
evidenceJob?.needs !== 'release-candidate' ||
drEvidenceJob?.needs !== 'release-candidate' ||
evidenceJob?.if !==
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
drEvidenceJob?.if !==
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
JSON.stringify(publishJob?.needs) !==
JSON.stringify([
'release-candidate',
'worker-management-release-evidence',
'cluster-dr-release-evidence',
'os-vulnerability',
]) ||
publishJob?.if !== undefined
typeof publishJob?.if !== 'string' ||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*os-vulnerability\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test(
publishJob.if,
)
) {
throw new Error(
'release publisher must depend on both protected ephemeral private evidence jobs',
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family',
);
}
const candidateSteps = candidateJob?.steps;
if (
!Array.isArray(candidateSteps) ||
candidateSteps.length !== 3 ||
candidateSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
candidateSteps[0]?.with?.['persist-credentials'] !== false ||
candidateSteps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
candidateSteps[1]?.with?.['node-version'] !== '24.18.0' ||
candidateSteps[2]?.id !== 'contract' ||
!/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]*GITHUB_OUTPUT/.test(
candidateSteps[2]?.run ?? '',
)
) {
throw new Error(
'release candidate job must derive bounded matrices from the exact tag, revision, version and deployment family',
);
}
const osSteps = osVulnerabilityJob?.steps;
@@ -721,34 +716,19 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
4,
5,
'all release jobs must pin the reviewed immutable checkout action',
);
requireOccurrences(
source,
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
4,
5,
'all release jobs must pin the reviewed immutable Node setup action',
);
requirePattern(
source,
/- image: control\s+repository: qinglong3-cluster-control\s+runtime_root: deploy\/containers\/ql3-cluster-control\/runtime-dependencies/,
'release workflow must publish the exact control image authority',
);
requirePattern(
source,
/- image: control-ai\s+repository: qinglong3-cluster-control-ai\s+runtime_root: deploy\/containers\/ql3-cluster-control\/runtime-dependencies/,
'release workflow must publish the exact optional control-ai image authority',
);
requirePattern(
source,
/- image: admin\s+repository: qinglong3-cluster-admin\s+runtime_root: deploy\/containers\/ql3-cluster-admin\/runtime-dependencies/,
'release workflow must publish the exact admin image authority',
);
requirePattern(
source,
/- image: local\s+repository: qinglong3-local-application\s+runtime_root: deploy\/containers\/ql3-local-application\/runtime-dependencies/,
'release workflow must publish the exact local image authority',
/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\}"/,
'publisher must recreate and independently audit its exact source-derived candidate contract',
);
requirePattern(
source,
@@ -798,13 +778,13 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
3,
'release workflow must create provenance, SBOM and OS vulnerability attestations',
4,
'release workflow must create provenance, SBOM, OS vulnerability and release candidate attestations',
);
requireOccurrences(
source,
/push-to-registry: true/g,
3,
4,
'all GitHub attestations must be pushed beside the OCI image',
);
requirePattern(
@@ -817,6 +797,11 @@ function auditReleaseWorkflow(source) {
/predicate-type: https:\/\/qinglong\.dev\/attestations\/image-os-vulnerability\/v1\s+predicate-path: \$\{\{ runner\.temp \}\}\/\$\{\{ matrix\.repository \}\}-os-vulnerability\.json/,
'release workflow must attest the digest-bound OS vulnerability evidence',
);
requirePattern(
source,
/predicate-type: https:\/\/qinglong\.dev\/attestations\/release-candidate-contract\/v1\s+predicate-path: \$\{\{ runner\.temp \}\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json/,
'release workflow must attest the source-derived release candidate contract',
);
requirePattern(
source,
/subject-digest: \$\{\{ steps\.push\.outputs\.digest \}\}/,
@@ -840,8 +825,8 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/gh attestation verify "oci:\/\/\$\{IMAGE\}@\$\{DIGEST\}"/g,
3,
'release workflow must independently verify provenance, CycloneDX and OS vulnerability attestations',
4,
'release workflow must independently verify provenance, CycloneDX, OS vulnerability and candidate attestations',
);
for (const [pattern, finding] of [
[
@@ -869,7 +854,7 @@ function auditReleaseWorkflow(source) {
'GitHub attestation verification must read the published OCI bundle',
],
]) {
requireOccurrences(source, pattern, 3, finding);
requireOccurrences(source, pattern, 4, finding);
}
requirePattern(
source,
@@ -881,6 +866,11 @@ function auditReleaseWorkflow(source) {
/--predicate-type "https:\/\/qinglong\.dev\/attestations\/image-os-vulnerability\/v1"/,
'release workflow must verify the OS vulnerability predicate type explicitly',
);
requirePattern(
source,
/--predicate-type "https:\/\/qinglong\.dev\/attestations\/release-candidate-contract\/v1"/,
'release workflow must verify the release candidate predicate type explicitly',
);
requirePattern(
source,
/name: Promote only the verified digest to immutable release tags[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"[\s\S]*image digest "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image digest "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"/,
@@ -888,6 +878,14 @@ function auditReleaseWorkflow(source) {
);
return {
trigger: 'explicit protected v3 tag dispatch',
releaseCandidateContract: {
scopes: ['local', 'cluster', 'all'],
workspacePackages: 18,
sourceDerived: true,
digestAttested: true,
localClusterEvidenceRequired: false,
clusterPrivateEvidenceRequired: true,
},
workerManagementEvidence: {
sourceAware: true,
privateEphemeralRunner: true,
@@ -913,11 +911,16 @@ function auditReleaseWorkflow(source) {
immutableArtifactRetentionDays: 1,
attestedToPublishedDigest: true,
},
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'worker', 'local'],
platforms: ['linux/amd64', 'linux/arm64'],
keylessSignature: true,
buildkitAttestations: ['sbom', 'provenance'],
githubAttestations: ['provenance', 'sbom', 'os-vulnerability'],
githubAttestations: [
'provenance',
'sbom',
'os-vulnerability',
'release-candidate',
],
publication: {
copier: 'regctl@0.11.5',
copierSha256:
@@ -933,6 +936,7 @@ function auditReleaseWorkflow(source) {
'provenance',
'cyclonedx',
'os-vulnerability',
'release-candidate',
'release-tags',
],
};
+51 -36
View File
@@ -9,10 +9,8 @@ const DEFAULT_ROOT = path.resolve(__dirname, '..');
const IMAGE_PROFILES = Object.freeze({
control: Object.freeze({
id: 'control',
buildManifestPath:
'deploy/containers/ql3-cluster-control/package.json',
buildLockPath:
'deploy/containers/ql3-cluster-control/package-lock.json',
buildManifestPath: 'deploy/containers/ql3-cluster-control/package.json',
buildLockPath: 'deploy/containers/ql3-cluster-control/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-cluster-control/runtime-dependencies/package.json',
imageLockPath:
@@ -26,10 +24,8 @@ const IMAGE_PROFILES = Object.freeze({
}),
'control-ai': Object.freeze({
id: 'control-ai',
buildManifestPath:
'deploy/containers/ql3-cluster-control/package.json',
buildLockPath:
'deploy/containers/ql3-cluster-control/package-lock.json',
buildManifestPath: 'deploy/containers/ql3-cluster-control/package.json',
buildLockPath: 'deploy/containers/ql3-cluster-control/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-cluster-control/runtime-dependencies/package.json',
imageLockPath:
@@ -44,10 +40,8 @@ const IMAGE_PROFILES = Object.freeze({
}),
admin: Object.freeze({
id: 'admin',
buildManifestPath:
'deploy/containers/ql3-cluster-admin/package.json',
buildLockPath:
'deploy/containers/ql3-cluster-admin/package-lock.json',
buildManifestPath: 'deploy/containers/ql3-cluster-admin/package.json',
buildLockPath: 'deploy/containers/ql3-cluster-admin/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json',
imageLockPath:
@@ -62,10 +56,8 @@ const IMAGE_PROFILES = Object.freeze({
}),
local: Object.freeze({
id: 'local',
buildManifestPath:
'deploy/containers/ql3-local-application/package.json',
buildLockPath:
'deploy/containers/ql3-local-application/package-lock.json',
buildManifestPath: 'deploy/containers/ql3-local-application/package.json',
buildLockPath: 'deploy/containers/ql3-local-application/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-local-application/runtime-dependencies/package.json',
imageLockPath:
@@ -84,12 +76,28 @@ const IMAGE_PROFILES = Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
}),
}),
worker: Object.freeze({
id: 'worker',
buildManifestPath: 'deploy/containers/ql3-worker/package.json',
buildLockPath: 'deploy/containers/ql3-worker/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-worker/runtime-dependencies/package.json',
imageLockPath:
'deploy/containers/ql3-worker/runtime-dependencies/package-lock.json',
internalManifestPaths: Object.freeze([
'packages/ql3-runtime-core/package.json',
'packages/ql3-local-process/package.json',
'packages/ql3-worker-runtime/package.json',
]),
buildOnlyDependencies: Object.freeze({}),
}),
});
const INTERNAL_MANIFEST_PATHS = IMAGE_PROFILES.control.internalManifestPaths;
const ALLOWED_LICENSE_IDS = Object.freeze([
'0BSD',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'ISC',
'MIT',
'Python-2.0',
@@ -102,7 +110,7 @@ function resolveImageProfile(value = 'control') {
const profile = IMAGE_PROFILES[value];
if (!profile) {
throw new Error(
'image profile must be exactly control, control-ai, admin or local',
'image profile must be exactly control, control-ai, admin, local or worker',
);
}
return profile;
@@ -167,7 +175,9 @@ function lockPackageName(location, lockPackage) {
const marker = 'node_modules/';
const index = location.lastIndexOf(marker);
if (index === -1) {
throw new Error(`cannot derive package name from lock location: ${location}`);
throw new Error(
`cannot derive package name from lock location: ${location}`,
);
}
const tail = location.slice(index + marker.length);
const parts = tail.split('/');
@@ -300,12 +310,8 @@ function exactDependencyRef(
function createClusterImageSbom(options = {}) {
const root = path.resolve(options.root || DEFAULT_ROOT);
const profile = resolveImageProfile(options.image);
const imageManifest = readJson(
path.join(root, profile.imageManifestPath),
);
const buildManifest = readJson(
path.join(root, profile.buildManifestPath),
);
const imageManifest = readJson(path.join(root, profile.imageManifestPath));
const buildManifest = readJson(path.join(root, profile.buildManifestPath));
const buildLock = readJson(path.join(root, profile.buildLockPath));
const lock = readJson(path.join(root, profile.imageLockPath));
for (const field of ['name', 'version']) {
@@ -319,9 +325,7 @@ function createClusterImageSbom(options = {}) {
}
}
if (
JSON.stringify(
Object.entries(buildManifest.dependencies || {}).sort(),
) !==
JSON.stringify(Object.entries(buildManifest.dependencies || {}).sort()) !==
JSON.stringify(
Object.entries({
...(imageManifest.dependencies || {}),
@@ -334,7 +338,9 @@ function createClusterImageSbom(options = {}) {
);
}
if (imageManifest.devDependencies !== undefined) {
throw new Error('production image manifest must not declare devDependencies');
throw new Error(
'production image manifest must not declare devDependencies',
);
}
const lockRoot = lock.packages?.[''];
if (
@@ -458,14 +464,14 @@ function createClusterImageSbom(options = {}) {
const components = [
...externalComponents,
...internalManifests.map((manifest) =>
componentFromManifest(manifest),
),
...internalManifests.map((manifest) => componentFromManifest(manifest)),
].sort((left, right) =>
left['bom-ref'].localeCompare(right['bom-ref'], 'en'),
);
const componentRefs = new Set(components.map((component) => component['bom-ref']));
const componentRefs = new Set(
components.map((component) => component['bom-ref']),
);
if (componentRefs.size !== components.length) {
throw new Error('runtime component name and version pairs must be unique');
}
@@ -524,7 +530,9 @@ function componentMap(document) {
throw new Error('SBOM component references must be unique strings');
}
if (component.name === 'typescript') {
throw new Error(`development component leaked into SBOM: ${component.name}`);
throw new Error(
`development component leaked into SBOM: ${component.name}`,
);
}
const licenseIds = (component.licenses || []).map(
(entry) => entry?.license?.id,
@@ -599,9 +607,13 @@ function collectRuntimeInventory(nodeModulesRoot) {
visitNodeModules(root);
const refs = inventory.map((entry) => entry.ref).sort();
if (new Set(refs).size !== refs.length) {
throw new Error('runtime inventory contains duplicate name/version packages');
throw new Error(
'runtime inventory contains duplicate name/version packages',
);
}
return inventory.sort((left, right) => left.ref.localeCompare(right.ref, 'en'));
return inventory.sort((left, right) =>
left.ref.localeCompare(right.ref, 'en'),
);
}
function auditClusterImageSbom(document, options = {}) {
@@ -652,7 +664,10 @@ function auditClusterImageSbom(document, options = {}) {
'SBOM component set',
);
for (const [ref, expectedComponent] of expectedComponents) {
if (JSON.stringify(actualComponents.get(ref)) !== JSON.stringify(expectedComponent)) {
if (
JSON.stringify(actualComponents.get(ref)) !==
JSON.stringify(expectedComponent)
) {
throw new Error(`SBOM component metadata differs for ${ref}`);
}
}
+33
View File
@@ -169,6 +169,38 @@ function createBlobReader(layoutRoot) {
}
function expectedImageConfig(architecture, revision, image) {
if (image === 'worker') {
return {
architecture,
os: 'linux',
config: {
User: '65532:65532',
Env: [
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
'NODE_VERSION=24.18.0',
'YARN_VERSION=1.22.22',
'NODE_ENV=production',
],
Entrypoint: [
'node',
'/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js',
],
WorkingDir: '/opt/qinglong',
Labels: {
'io.qinglong.profile': 'worker',
'io.qinglong.worker.capacity-profiles': 'edge,node',
'org.opencontainers.image.description':
'QingLong 3.0 headless Remote Worker runtime',
'org.opencontainers.image.licenses': 'Apache-2.0',
'org.opencontainers.image.revision': revision,
'org.opencontainers.image.source':
'https://github.com/whyour/qinglong',
'org.opencontainers.image.title': 'QingLong 3.0 Worker',
'org.opencontainers.image.version': '3.0.0-alpha.0',
},
},
};
}
if (image === 'local') {
return {
architecture,
@@ -249,6 +281,7 @@ function expectedImageConfig(architecture, revision, image) {
? 'QingLong 3.0 Cluster Control AI'
: 'QingLong 3.0 Cluster Control'
: 'QingLong 3.0 Cluster Admin',
'org.opencontainers.image.version': '3.0.0-alpha.0',
},
},
};
+38 -9
View File
@@ -7,7 +7,13 @@ const path = require('node:path');
const { TextDecoder } = require('node:util');
const FIXTURE = 'qinglong/image-os-vulnerability-exceptions@v1';
const IMAGES = Object.freeze(['admin', 'control', 'control-ai', 'local']);
const IMAGES = Object.freeze([
'admin',
'control',
'control-ai',
'local',
'worker',
]);
const MAX_POLICY_BYTES = 256 * 1024;
const MAX_EXCEPTIONS = 128;
const MAX_EXCEPTION_DAYS = 30;
@@ -15,7 +21,8 @@ const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const CVE_PATTERN = /^CVE-[0-9]{4}-[0-9]{4,}$/;
const OWNER_PATTERN = /^[a-z0-9][a-z0-9._/-]{1,127}$/;
const TICKET_PATTERN = /^[A-Z][A-Z0-9]{1,15}-[1-9][0-9]{0,9}$/;
const PURL_PATTERN = /^pkg:(?:apk|deb|rpm)\/[A-Za-z0-9._~%+-]+\/[A-Za-z0-9._~%+-]+@[A-Za-z0-9._~%+:-]+$/;
const PURL_PATTERN =
/^pkg:(?:apk|deb|rpm)\/[A-Za-z0-9._~%+-]+\/[A-Za-z0-9._~%+-]+@[A-Za-z0-9._~%+:-]+$/;
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const POLICY_PATH = 'deploy/containers/ql3-os-vulnerability-exceptions.json';
@@ -69,8 +76,14 @@ function utcDay(value) {
: null;
}
function auditImageOsVulnerabilityPolicy(policy, dependencies = { now: Date.now }) {
if (!exactKeys(dependencies, ['now']) || typeof dependencies.now !== 'function') {
function auditImageOsVulnerabilityPolicy(
policy,
dependencies = { now: Date.now },
) {
if (
!exactKeys(dependencies, ['now']) ||
typeof dependencies.now !== 'function'
) {
fail('clock is invalid');
}
const nowMs = dependencies.now();
@@ -95,11 +108,18 @@ function auditImageOsVulnerabilityPolicy(policy, dependencies = { now: Date.now
control: 0,
'control-ai': 0,
local: 0,
worker: 0,
}),
});
}
const counts = { admin: 0, control: 0, 'control-ai': 0, local: 0 };
const counts = {
admin: 0,
control: 0,
'control-ai': 0,
local: 0,
worker: 0,
};
const seen = new Set();
let previousId = '';
for (const exception of policy.exceptions) {
@@ -192,7 +212,9 @@ function renderTrivyIgnore(policy, image, dependencies = { now: Date.now }) {
if (!IMAGES.includes(image)) fail('image is invalid');
const audit = auditImageOsVulnerabilityPolicy(policy, dependencies);
if (!audit.compatible) fail('policy is incompatible');
const selected = policy.exceptions.filter((entry) => entry.images.includes(image));
const selected = policy.exceptions.filter((entry) =>
entry.images.includes(image),
);
const lines = ['vulnerabilities:'];
if (selected.length === 0) lines.push(' []');
for (const exception of selected) {
@@ -203,7 +225,9 @@ function renderTrivyIgnore(policy, image, dependencies = { now: Date.now }) {
}
lines.push(` expired_at: ${exception.expiresOn}`);
lines.push(
` statement: ${JSON.stringify(`owner=${exception.owner}; ticket=${exception.ticket}; rationale=${exception.rationale}`)}`,
` statement: ${JSON.stringify(
`owner=${exception.owner}; ticket=${exception.ticket}; rationale=${exception.rationale}`,
)}`,
);
}
return `${lines.join('\n')}\n`;
@@ -241,7 +265,8 @@ function parseArguments(argv) {
for (const argument of argv) {
if (argument === '--') continue;
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
if (!match || Object.hasOwn(values, match[1])) fail('arguments are invalid');
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (
@@ -250,7 +275,11 @@ function parseArguments(argv) {
) {
fail('arguments are invalid');
}
return Object.freeze({ mode: 'render', image: values.image, output: values.output });
return Object.freeze({
mode: 'render',
image: values.image,
output: values.output,
});
}
function runCli(argv, root = DEFAULT_ROOT, dependencies = { now: Date.now }) {
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { auditPackageBoundaries } = require('./ql3-package-boundary-audit.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/release-candidate-contract@v1';
const PREDICATE_TYPE =
'https://qinglong.dev/attestations/release-candidate-contract/v1';
const MAX_REPORT_BYTES = 1024 * 1024;
const RELEASE_SCOPES = Object.freeze(['all', 'cluster', 'local']);
const NODE_ENGINE = '>=24.18.0 <25';
const NODE_VERSION = '24.18.0';
const LOCAL_IMAGES = Object.freeze([
Object.freeze({
image: 'local',
repository: 'qinglong3-local-application',
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
target: 'runtime',
runtime_root:
'deploy/containers/ql3-local-application/runtime-dependencies',
}),
]);
const CLUSTER_IMAGES = Object.freeze([
Object.freeze({
image: 'control',
repository: 'qinglong3-cluster-control',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime',
runtime_root: 'deploy/containers/ql3-cluster-control/runtime-dependencies',
}),
Object.freeze({
image: 'control-ai',
repository: 'qinglong3-cluster-control-ai',
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
target: 'runtime-ai',
runtime_root: 'deploy/containers/ql3-cluster-control/runtime-dependencies',
}),
Object.freeze({
image: 'admin',
repository: 'qinglong3-cluster-admin',
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
target: 'runtime',
runtime_root: 'deploy/containers/ql3-cluster-admin/runtime-dependencies',
}),
Object.freeze({
image: 'worker',
repository: 'qinglong3-worker',
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
target: 'runtime',
runtime_root: 'deploy/containers/ql3-worker/runtime-dependencies',
}),
]);
class ReleaseCandidateContractError extends Error {
constructor(message) {
super(`QingLong release candidate contract failed: ${message}`);
this.name = 'ReleaseCandidateContractError';
}
}
function fail(message) {
throw new ReleaseCandidateContractError(message);
}
function readJson(filePath, maximumBytes = MAX_REPORT_BYTES) {
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > maximumBytes
) {
fail(`invalid bounded JSON file: ${filePath}`);
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function selectedImages(scope) {
if (scope === 'local') return [...LOCAL_IMAGES];
if (scope === 'cluster') return [...CLUSTER_IMAGES];
return [...CLUSTER_IMAGES, ...LOCAL_IMAGES];
}
function validateIdentity(options) {
if (
typeof options.version !== 'string' ||
!/^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test(
options.version,
)
) {
fail('version must be an exact QingLong 3 SemVer');
}
if (!/^[a-f0-9]{40}$/u.test(options.sourceRevision || '')) {
fail('source revision must be an exact Git SHA-1 commit');
}
if (options.sourceRef !== `refs/tags/v${options.version}`) {
fail('source ref must be the exact version tag');
}
if (!RELEASE_SCOPES.includes(options.releaseScope)) {
fail('release scope must be all, cluster or local');
}
}
function createReleaseCandidateContract(options) {
const root = path.resolve(options.root || DEFAULT_ROOT);
validateIdentity(options);
const boundaries = auditPackageBoundaries(root);
if (
!boundaries.compatible ||
boundaries.workspacePackageCount !== 18 ||
boundaries.workspacePackageHardCap !== 18 ||
boundaries.singleSourcePackages.length !== 0 ||
boundaries.shallowSourcePackages.length !== 0
) {
fail('workspace package boundary is incompatible');
}
const workspacePackages = boundaries.packages
.map((entry) => {
const manifest = readJson(path.join(root, entry.path, 'package.json'));
if (
manifest.name !== entry.name ||
manifest.version !== options.version ||
manifest.engines?.node !== NODE_ENGINE
) {
fail(`workspace release identity differs: ${entry.path}`);
}
return Object.freeze({
name: manifest.name,
path: entry.path,
version: manifest.version,
});
})
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
const images = selectedImages(options.releaseScope);
const imageManifests = images.map((image) => {
const manifest = readJson(
path.join(root, image.runtime_root, 'package.json'),
);
if (
manifest.version !== options.version ||
manifest.engines?.node !== NODE_ENGINE
) {
fail(`image release identity differs: ${image.runtime_root}`);
}
const dockerfile = fs.readFileSync(
path.join(root, image.dockerfile),
'utf8',
);
if (
!dockerfile.includes(`node:${NODE_VERSION}-bookworm-slim@sha256:`) ||
!dockerfile.includes(
`org.opencontainers.image.version=\"${options.version}\"`,
)
) {
fail(`image Dockerfile release identity differs: ${image.dockerfile}`);
}
return Object.freeze({
image: image.image,
repository: image.repository,
dockerfile: image.dockerfile,
target: image.target,
runtimeRoot: image.runtime_root,
manifestName: manifest.name,
version: manifest.version,
});
});
const publishMatrix = images.map(({ dockerfile, target, ...image }) => image);
const osMatrix = images.flatMap((image) => [
{
image: image.image,
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: image.dockerfile,
target: image.target,
},
{
image: image.image,
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: image.dockerfile,
target: image.target,
},
]);
const unsigned = {
schemaVersion: 1,
schema: SCHEMA,
release: {
version: options.version,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
scope: options.releaseScope,
},
compatibility: {
legacyRootPackageVersion: readJson(path.join(root, 'package.json'))
.version,
legacyRootExcludedFromReleaseIdentity: true,
nodeVersion: NODE_VERSION,
nodeEngine: NODE_ENGINE,
platforms: ['linux/amd64', 'linux/arm64'],
},
workspace: {
packageCount: workspacePackages.length,
packageHardCap: boundaries.workspacePackageHardCap,
packages: workspacePackages,
},
deploymentFamilies: {
local: {
selected: options.releaseScope !== 'cluster',
profiles: ['edge', 'standalone'],
requiresClusterPrivateEvidence: false,
},
cluster: {
selected: options.releaseScope !== 'local',
profiles: ['cluster', 'worker-edge', 'worker-node'],
requiresClusterPrivateEvidence: true,
},
},
images: imageManifests,
releasePlan: {
clusterEvidenceRequired: options.releaseScope !== 'local',
osMatrix,
publishMatrix,
},
requiredGates: [
'package-boundary',
'source-tag-version-identity',
'native-os-vulnerability',
'multiarch-oci-layout',
'production-dependency-audit',
'digest-signature-and-attestations',
...(options.releaseScope !== 'cluster'
? ['edge-and-standalone-rollout']
: []),
...(options.releaseScope !== 'local'
? [
'worker-management-production-evidence',
'cloudnativepg-disaster-recovery-evidence',
]
: []),
],
};
return Object.freeze({
...unsigned,
contractDigest: sha256(Buffer.from(JSON.stringify(unsigned))),
});
}
function auditReleaseCandidateContract(actual, options) {
const expected = createReleaseCandidateContract(options);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('report differs from the source-derived contract');
}
return Object.freeze({
compatible: true,
contractDigest: actual.contractDigest,
releaseScope: actual.release.scope,
workspacePackageCount: actual.workspace.packageCount,
images: Object.freeze(actual.images.map((entry) => entry.image)),
clusterEvidenceRequired: actual.releasePlan.clusterEvidenceRequired,
});
}
function writeNoReplace(filePath, value) {
const resolved = path.resolve(filePath || '');
if (
!path.isAbsolute(filePath || '') ||
fs.existsSync(resolved) ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail('output must be unused in one canonical directory');
}
fs.writeFileSync(resolved, `${JSON.stringify(value)}\n`, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
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 common = [
'mode',
'release-scope',
'source-ref',
'source-revision',
'version',
];
const expected =
values.mode === 'create'
? [...common, 'output']
: values.mode === 'audit'
? [...common, 'report']
: [];
if (
expected.length === 0 ||
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(expected.sort())
) {
fail('arguments are invalid');
}
return Object.freeze({
mode: values.mode,
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
releaseScope: values['release-scope'],
...(values.output ? { output: values.output } : {}),
...(values.report ? { report: values.report } : {}),
});
}
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
const options = parseArguments(argv);
if (options.mode === 'create') {
const report = createReleaseCandidateContract({ ...options, root });
writeNoReplace(options.output, report);
output.write(`${JSON.stringify(report)}\n`);
return report;
}
const report = readJson(path.resolve(options.report));
const audit = auditReleaseCandidateContract(report, { ...options, root });
output.write(`${JSON.stringify(audit)}\n`);
return audit;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${
error instanceof Error
? error.message
: 'release candidate contract failed'
}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
CLUSTER_IMAGES,
LOCAL_IMAGES,
PREDICATE_TYPE,
RELEASE_SCOPES,
SCHEMA,
ReleaseCandidateContractError,
auditReleaseCandidateContract,
createReleaseCandidateContract,
parseArguments,
runCli,
});
@@ -10,7 +10,9 @@ const verifier = path.join(
ROOT,
'deploy/console/ql3-cluster-copilot/verify-release.sh',
);
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(64)}`;
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(
64,
)}`;
const revision = 'c'.repeat(40);
function fixture(t) {
@@ -46,7 +48,7 @@ function invoke(args, env) {
});
}
test('verifies one signature and three digest-bound GitHub attestations', (t) => {
test('verifies one signature and four digest-bound GitHub attestations', (t) => {
assert.equal(fs.statSync(verifier).mode & 0o777, 0o755);
const value = fixture(t);
const result = invoke(
@@ -61,11 +63,12 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) =>
provenance: true,
sbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
compatible: true,
});
const calls = fs.readFileSync(value.capture, 'utf8');
assert.equal((calls.match(/^cosign$/gmu) ?? []).length, 1);
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 3);
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 4);
for (const required of [
'arg=--certificate-identity',
'arg=https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.1',
@@ -83,17 +86,26 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) =>
'arg=refs/tags/v3.0.0-alpha.1',
'arg=https://cyclonedx.org/bom',
'arg=https://qinglong.dev/attestations/image-os-vulnerability/v1',
'arg=https://qinglong.dev/attestations/release-candidate-contract/v1',
'arg=--deny-self-hosted-runners',
'arg=--bundle-from-oci',
]) {
assert.match(calls, new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'));
assert.match(
calls,
new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'),
);
}
});
test('rejects mutable or source-unbound inputs before invoking trust tools', (t) => {
const value = fixture(t);
for (const args of [
['ghcr.io/example/qinglong3-cluster-admin:latest', 'example/qinglong', revision, 'refs/tags/v3.0.0'],
[
'ghcr.io/example/qinglong3-cluster-admin:latest',
'example/qinglong',
revision,
'refs/tags/v3.0.0',
],
[image, 'other/qinglong', revision, 'refs/tags/v3.0.0'],
[image, 'example/qinglong', 'short', 'refs/tags/v3.0.0'],
[image, 'example/qinglong', revision, 'refs/heads/next'],
@@ -118,7 +118,8 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
assert.equal(report.release.sourceRevision, revision);
assert.equal(report.verification.embeddedEvidenceVerifier, true);
assert.equal(report.evidenceVector.classification, 'synthetic_non_sensitive');
assert.equal(report.steps.length, 7);
assert.equal(report.steps.length, 8);
assert.equal(report.verification.releaseCandidateContract, true);
assert.equal(report.claims.reportAttestation, 'none');
assert.equal(reportText.includes(token), false);
assert.equal(fs.statSync(value.output).mode & 0o777, 0o600);
@@ -136,11 +137,11 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
.map((line) => JSON.parse(line));
assert.deepEqual(
calls.map(({ name }) => name),
['cosign', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'],
['cosign', 'gh', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'],
);
assert.deepEqual(
calls.map(({ tokenPresent }) => tokenPresent),
[false, true, true, true, false, false, false],
[false, true, true, true, true, false, false, false],
);
assert.deepEqual(calls[0].args, [
'verify',
@@ -150,7 +151,7 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
'https://token.actions.githubusercontent.com',
image,
]);
for (const call of calls.slice(1, 4)) {
for (const call of calls.slice(1, 5)) {
for (const required of [
'attestation',
'verify',
@@ -176,7 +177,12 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
),
);
const dockerRun = calls[6].args;
assert.ok(
calls[4].args.includes(
'https://qinglong.dev/attestations/release-candidate-contract/v1',
),
);
const dockerRun = calls[7].args;
for (const required of [
'--read-only',
'none',
@@ -64,6 +64,7 @@ function validReport() {
['provenance_attestation', 'gh'],
['cyclonedx_sbom_attestation', 'gh'],
['os_vulnerability_attestation', 'gh'],
['release_candidate_attestation', 'gh'],
['immutable_image_pull', 'docker'],
['local_digest_inspection', 'docker'],
['embedded_evidence_verifier', 'docker'],
@@ -89,6 +90,7 @@ function validReport() {
provenance: true,
cyclonedxSbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
imagePulled: true,
localRepoDigestBound: true,
embeddedEvidenceVerifier: true,
@@ -116,9 +118,9 @@ function validReport() {
name,
tool,
executableSha256: toolDigest[tool],
argvSha256: `sha256:${'56789ab'[index].repeat(64)}`,
stdoutBytes: index === 6 ? 400 : 0,
stdoutSha256: index === 6 ? `sha256:${'d'.repeat(64)}` : emptyDigest,
argvSha256: `sha256:${'56789abc'[index].repeat(64)}`,
stdoutBytes: index === 7 ? 400 : 0,
stdoutSha256: index === 7 ? `sha256:${'d'.repeat(64)}` : emptyDigest,
stderrBytes: 0,
stderrSha256: emptyDigest,
exitCode: 0,
@@ -166,7 +168,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', (
compatible: true,
reportContentDigest: report.contentDigest,
releaseImage: image,
verificationSteps: 7,
verificationSteps: 8,
externalResults: 'not_replayed',
actionAuthority: 'none',
});
@@ -187,7 +189,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', (
compatible: true,
reportContentDigest: report.contentDigest,
releaseImage: image,
verificationSteps: 7,
verificationSteps: 8,
externalResults: 'not_replayed',
actionAuthority: 'none',
});
@@ -205,7 +207,7 @@ test('rejects structural claim widening even after the report is re-digested', (
report.isolation.network = 'default';
},
(report) => {
report.steps[6].stderrBytes = 1;
report.steps[7].stderrBytes = 1;
},
(report) => {
report.secret = 'must-not-be-accepted';
+77 -19
View File
@@ -23,7 +23,7 @@ const releaseSource = fs.readFileSync(
test('accepts the reviewed native CI and digest release contracts', () => {
assert.deepEqual(auditClusterImageRelease(root), {
ci: {
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'local', 'worker'],
nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true,
clusterAdminProductFacade: true,
@@ -40,6 +40,14 @@ test('accepts the reviewed native CI and digest release contracts', () => {
},
release: {
trigger: 'explicit protected v3 tag dispatch',
releaseCandidateContract: {
scopes: ['local', 'cluster', 'all'],
workspacePackages: 18,
sourceDerived: true,
digestAttested: true,
localClusterEvidenceRequired: false,
clusterPrivateEvidenceRequired: true,
},
workerManagementEvidence: {
sourceAware: true,
privateEphemeralRunner: true,
@@ -65,11 +73,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
immutableArtifactRetentionDays: 1,
attestedToPublishedDigest: true,
},
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'worker', 'local'],
platforms: ['linux/amd64', 'linux/arm64'],
keylessSignature: true,
buildkitAttestations: ['sbom', 'provenance'],
githubAttestations: ['provenance', 'sbom', 'os-vulnerability'],
githubAttestations: [
'provenance',
'sbom',
'os-vulnerability',
'release-candidate',
],
publication: {
copier: 'regctl@0.11.5',
copierSha256:
@@ -85,6 +98,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'provenance',
'cyclonedx',
'os-vulnerability',
'release-candidate',
'release-tags',
],
},
@@ -175,6 +189,17 @@ test('rejects removal of the native cluster-admin image gate', () => {
);
});
test('rejects removal of the native Worker image gate', () => {
const mutated = ciSource.replace(
' image: worker\n repository: qinglong3-worker\n runtime_user: 65532:65532',
' image: worker-disabled\n repository: qinglong3-worker\n runtime_user: 65532:65532',
);
assert.throws(
() => auditClusterImageCiWorkflow(mutated),
/matrices must contain only exact/,
);
});
test('rejects an additional unreviewed CI image authority', () => {
const mutated = ciSource.replace(
' target: runtime\n steps:',
@@ -245,7 +270,7 @@ test('rejects release publication without the private evidence dependency', () =
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -256,7 +281,7 @@ test('rejects release publication without the OS vulnerability dependency', () =
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -267,7 +292,7 @@ test('rejects release publication without current disaster-recovery evidence', (
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -289,12 +314,12 @@ test('rejects disaster-recovery evidence detached from the release source', () =
test('rejects an incomplete native OS vulnerability architecture matrix', () => {
const mutated = releaseSource.replace(
' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n dockerfile: deploy/containers/ql3-local-application/Dockerfile',
' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: disabled\n dockerfile: deploy/containers/ql3-local-application/Dockerfile',
'include: ${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}',
'include: []',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/OS vulnerability matrix must scan exact/,
/OS vulnerability matrix must come from/,
);
});
@@ -349,7 +374,7 @@ test('rejects a reusable private evidence runner', () => {
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/protected ephemeral private evidence job/,
/always require candidate and OS gates/,
);
});
@@ -404,34 +429,34 @@ test('rejects a release missing application SBOM attestation', () => {
test('rejects a release missing the independent admin image', () => {
const mutated = releaseSource.replace(
'- image: admin\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies',
'- image: admin-disabled\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies',
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include: []',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/matrix must come only from/,
);
});
test('rejects a release missing the AI-excluded local image', () => {
const mutated = releaseSource.replace(
'- image: local\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies',
'- image: local-disabled\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies',
' - local\n - cluster\n - all',
' - cluster\n - all',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/closed local, cluster or all/,
);
});
test('rejects an additional repository in the privileged release matrix', () => {
const mutated = releaseSource.replace(
' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n steps:',
' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n - image: unreviewed\n repository: unreviewed\n dockerfile: Dockerfile\n runtime_root: unreviewed\n steps:',
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include:\n - image: unreviewed\n repository: unreviewed\n runtime_root: unreviewed',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/matrix must come only from/,
);
});
@@ -639,3 +664,36 @@ test('rejects removal of the digest-bound OS vulnerability attestation', () => {
/digest-bound OS vulnerability evidence/,
);
});
test('rejects removal of the digest-bound release candidate attestation', () => {
const mutated = releaseSource.replace(
'predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1',
'predicate-type: https://example.invalid/not-a-candidate-contract',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/source-derived release candidate contract/,
);
});
test('rejects treating skipped private evidence as cluster success', () => {
const mutated = releaseSource.replace(
"needs.release-candidate.outputs.cluster-evidence-required != 'true' ||",
"needs.release-candidate.outputs.cluster-evidence-required == 'true' ||",
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/requiring private HA evidence only for a cluster family/,
);
});
test('rejects a publisher matrix detached from the source-derived contract', () => {
const mutated = releaseSource.replace(
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include: ${{ fromJSON(inputs.publish_matrix) }}',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must come only from/,
);
});
+28
View File
@@ -124,6 +124,34 @@ test('generates the AI-excluded local application image closure', () => {
);
});
test('generates the headless Worker image runtime closure', () => {
const document = createClusterImageSbom({ root, image: 'worker' });
const report = auditClusterImageSbom(document, {
root,
image: 'worker',
});
assert.deepEqual(report, {
image: 'worker',
root: 'pkg:npm/%40qinglong/worker-image-dependencies@3.0.0-alpha.0',
components: 27,
externalComponents: 24,
internalComponents: 3,
dependencyNodes: 28,
inventoryVerified: false,
});
assert.equal(
document.components.some(
(component) => component.name === '@qinglong/worker-runtime',
),
true,
);
assert.equal(
document.components.some((component) => component.name === '@qinglong/ai'),
false,
);
});
test('rejects a control SBOM presented as cluster-admin evidence', () => {
const document = createClusterImageSbom({ root });
assert.throws(
+31 -2
View File
@@ -25,6 +25,7 @@ function createFixture(t, options = {}) {
const isControl = image === 'control' || image === 'control-ai';
const isControlAi = image === 'control-ai';
const isLocal = image === 'local';
const isWorker = image === 'worker';
const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-'));
t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true }));
const blobDirectory = path.join(layoutRoot, 'blobs', 'sha256');
@@ -96,6 +97,8 @@ function createFixture(t, options = {}) {
User:
options.rootArm64 && architecture === 'arm64'
? '0:0'
: isWorker
? '65532:65532'
: isLocal
? '65532:65532'
: '10001:10001',
@@ -108,7 +111,9 @@ function createFixture(t, options = {}) {
],
Entrypoint: [
'node',
isLocal
isWorker
? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js'
: isLocal
? '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js'
: isControl
? isControlAi
@@ -118,6 +123,12 @@ function createFixture(t, options = {}) {
],
WorkingDir: '/opt/qinglong',
Labels: {
...(isWorker
? {
'io.qinglong.profile': 'worker',
'io.qinglong.worker.capacity-profiles': 'edge,node',
}
: {}),
...(isLocal
? {
'io.qinglong.ai': 'excluded',
@@ -131,6 +142,8 @@ function createFixture(t, options = {}) {
: {}),
'org.opencontainers.image.description': isLocal
? 'QingLong 3.0 AI-excluded Edge and Standalone runtime'
: isWorker
? 'QingLong 3.0 headless Remote Worker runtime'
: isControl
? isControlAi
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
@@ -142,12 +155,14 @@ function createFixture(t, options = {}) {
'https://github.com/whyour/qinglong',
'org.opencontainers.image.title': isLocal
? 'QingLong 3.0 Local Application'
: isWorker
? 'QingLong 3.0 Worker'
: isControl
? isControlAi
? 'QingLong 3.0 Cluster Control AI'
: 'QingLong 3.0 Cluster Control'
: 'QingLong 3.0 Cluster Admin',
...(isLocal
...(isLocal || isWorker || isControl || image === 'admin'
? {
'org.opencontainers.image.version': '3.0.0-alpha.0',
}
@@ -349,6 +364,20 @@ test('accepts the AI-excluded local image and attestation closure', (t) => {
);
});
test('accepts the headless Worker image and attestation closure', (t) => {
const report = auditClusterOciLayout({
root,
layoutRoot: createFixture(t, { image: 'worker' }),
expectedRevision: revision,
image: 'worker',
});
assert.equal(report.image, 'worker');
assert.deepEqual(
report.platforms.map((platform) => platform.platform),
['linux/amd64', 'linux/arm64'],
);
});
test('rejects cluster-control config presented as cluster-admin evidence', (t) => {
assert.throws(
() =>
@@ -25,7 +25,8 @@ function exception(overrides = {}) {
owner: 'security/platform',
ticket: 'QLSEC-123',
expiresOn: '2026-08-15',
rationale: 'Temporary exposure accepted while the fixed base image is qualified.',
rationale:
'Temporary exposure accepted while the fixed base image is qualified.',
...overrides,
};
}
@@ -55,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => {
control: 0,
'control-ai': 0,
local: 0,
worker: 0,
},
});
assert.equal(
@@ -132,10 +134,7 @@ test('rejects unscoped images and non-OS package purls', () => {
test('rejects duplicate, unsorted and extensible exception identities', () => {
for (const exceptions of [
[exception(), exception()],
[
exception({ id: 'CVE-2026-99999' }),
exception({ id: 'CVE-2026-12345' }),
],
[exception({ id: 'CVE-2026-99999' }), exception({ id: 'CVE-2026-12345' })],
[{ ...exception(), extra: true }],
]) {
const audit = auditImageOsVulnerabilityPolicy(policy(exceptions), {
@@ -144,8 +143,7 @@ test('rejects duplicate, unsorted and extensible exception identities', () => {
assert.equal(audit.compatible, false);
assert.equal(
audit.findings.some(
(finding) =>
finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID',
(finding) => finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID',
),
true,
);
@@ -0,0 +1,202 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
auditReleaseCandidateContract,
createReleaseCandidateContract,
parseArguments,
runCli,
} = require('../../scripts/ql3-release-candidate-contract.cjs');
const root = path.resolve(__dirname, '../..');
const identity = Object.freeze({
version: '3.0.0-alpha.0',
sourceRevision: 'a'.repeat(40),
sourceRef: 'refs/tags/v3.0.0-alpha.0',
});
test('freezes an independent low-resource local release family', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'local',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['local'],
);
assert.equal(contract.releasePlan.clusterEvidenceRequired, false);
assert.deepEqual(contract.deploymentFamilies.local.profiles, [
'edge',
'standalone',
]);
assert.equal(contract.workspace.packageCount, 18);
assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u);
assert.deepEqual(
auditReleaseCandidateContract(contract, {
root,
...identity,
releaseScope: 'local',
}),
{
compatible: true,
contractDigest: contract.contractDigest,
releaseScope: 'local',
workspacePackageCount: 18,
images: ['local'],
clusterEvidenceRequired: false,
},
);
});
test('closes the cluster release family with the Worker image', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'cluster',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['control', 'control-ai', 'admin', 'worker'],
);
assert.equal(contract.releasePlan.clusterEvidenceRequired, true);
assert.equal(contract.releasePlan.osMatrix.length, 8);
assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'),
false,
);
assert.equal(
contract.requiredGates.includes('cloudnativepg-disaster-recovery-evidence'),
true,
);
});
test('combines local and cluster families without weakening either gate', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'all',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['control', 'control-ai', 'admin', 'worker', 'local'],
);
assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'),
true,
);
assert.equal(
contract.requiredGates.includes('worker-management-production-evidence'),
true,
);
});
test('rejects tag, version and source identity drift', () => {
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
sourceRef: 'refs/heads/next',
releaseScope: 'local',
}),
/exact version tag/,
);
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
version: '2.21.0',
releaseScope: 'local',
}),
/QingLong 3 SemVer/,
);
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
sourceRevision: 'movable',
releaseScope: 'local',
}),
/Git SHA-1/,
);
});
test('rejects a source-derived report mutated after creation', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'local',
});
contract.releasePlan.publishMatrix[0].repository = 'unreviewed';
assert.throws(
() =>
auditReleaseCandidateContract(contract, {
root,
...identity,
releaseScope: 'local',
}),
/differs from the source-derived contract/,
);
});
test('writes once and independently audits the exact report through the CLI', (t) => {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-candidate-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const report = path.join(directory, 'contract.json');
const common = [
'--version=3.0.0-alpha.0',
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
'--release-scope=local',
];
const output = { write() {} };
runCli(['--mode=create', ...common, `--output=${report}`], root, output);
assert.equal(fs.statSync(report).mode & 0o777, 0o600);
assert.equal(
runCli(['--mode=audit', ...common, `--report=${report}`], root, output)
.compatible,
true,
);
assert.throws(
() =>
runCli(
['--mode=create', ...common, `--output=${report}`],
root,
output,
),
/output must be unused/,
);
});
test('parses only exact closed create and audit modes', () => {
const common = [
'--version=3.0.0-alpha.0',
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
'--release-scope=local',
];
assert.equal(
parseArguments(['--mode=create', ...common, '--output=/tmp/report.json'])
.mode,
'create',
);
assert.throws(
() =>
parseArguments([
'--mode=create',
...common,
'--output=/tmp/report.json',
'--extra=true',
]),
/arguments are invalid/,
);
});