mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): scope deployment-family release candidates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 PATH,token 只注入 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 identity;offline 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 swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/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 modules,application+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 必须保持 Proposed;stub 或本地 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-component(24 external/3 internal)、28-node 的 production SBOM,BSD-3-Clause 纳入受审 allowlist,Worker config 固定 `65532:65532`、`worker` profile、`edge,node` capacity labels 和 3.0 version;control/admin 也补齐同一 version label。candidate contract 作为第四类 digest-bound GitHub predicate 发布并远端回读,Cluster Admin verifier/外部 ceremony/offline audit 同步升级为四类 attestation/八步 transcript。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、listener、timer、watcher 或低配设备常驻资源。定向 105/105、backend 1,246 pass/2 条件 skip/0 fail、18-package clean build/test 均通过;package boundary 确认为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release、OS vulnerability policy、Console/distribution 审计均 compatible,四个 runtime dependency root 的离线缓存审计为 0 vulnerability。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin npm pack 仍为 250 files、271,238-byte tarball、1,690,196-byte unpacked。由于本 Gate 不改变 schema、migration、SQL、role、Pool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得。
|
||||
- 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 PATH,token 只注入 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 identity;offline 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 swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/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 modules,application+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 必须保持 Proposed;stub 或本地 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/18,Cluster Admin 387 pass/3 条件 skip,18-package clean build/test 退出 0,backend 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 审计零 finding,workspace 保持 18 package、无 single/shallow package,Cluster 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/24,Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSON,upstream 计数仍为 3,390×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 unpacked;package/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 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 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 个显式只读 operation;browser 仍不能提交 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/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 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 unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。本 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
|
||||
- 关联 ADR:ADR-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
|
||||
- 关联 RFC:QL-RFC-0001 D-235、D-236
|
||||
- 关联 ADR:ADR-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 path;mutable 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
|
||||
- 关联 RFC:QL-RFC-0001 D-01、D-03、D-05、D-14、D-42、D-61、D-186、D-257、D-333
|
||||
- 关联 ADR:ADR-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 audit,hard cap 仍为 18,且无 single/shallow package;
|
||||
- 每个 workspace version 必须等于 tag version,Node 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/path;job output 只传递 contract 派生的有界 matrix 和 cluster evidence bit。
|
||||
|
||||
### 3. Local 不再被 Cluster HA 证据阻塞
|
||||
|
||||
Local scope 的两个私有 evidence job 必须为 skipped;publisher 仍无条件依赖 release-candidate 与 native OS scan,
|
||||
并继续对 pushed Local digest 执行 Edge/Standalone 两个真实 compose rollout。Cluster/all scope 才能把 private
|
||||
evidence bit 置为 true;publisher 使用显式 `always()` 条件,只在 candidate/OS 成功,且 cluster scope 的两个
|
||||
私有 job 均成功时取得写权限。skipped 不能被当作 Cluster success,failed/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 components(24 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 runner;Cluster scope 不得把 skipped 私有证据解释为成功;
|
||||
- release tag 仍只在所有 digest verification 完成后 promotion,tag 本身不成为部署 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/failed:publisher 不启动;
|
||||
- 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 mutation:7 项;
|
||||
- Worker SBOM/OCI、OS policy、共享 release workflow、Admin verifier/ceremony/distribution 定向总计 105/105;
|
||||
- backend 1,246 pass/2 条件 skip/0 fail,18-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 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;
|
||||
- Cluster Admin npm pack 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;四个 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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user