mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): publish complete release sets
This commit is contained in:
@@ -299,6 +299,8 @@ jobs:
|
|||||||
test/back/ql3ProfileVulnerabilityAudit.test.cjs
|
test/back/ql3ProfileVulnerabilityAudit.test.cjs
|
||||||
test/back/ql3ClusterImageSbom.test.cjs
|
test/back/ql3ClusterImageSbom.test.cjs
|
||||||
test/back/ql3ClusterImageReleaseAudit.test.cjs
|
test/back/ql3ClusterImageReleaseAudit.test.cjs
|
||||||
|
test/back/ql3ReleaseCandidateContract.test.cjs
|
||||||
|
test/back/ql3ReleaseSetContract.test.cjs
|
||||||
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
||||||
test/back/ql3LocalImageAudit.test.cjs
|
test/back/ql3LocalImageAudit.test.cjs
|
||||||
test/back/ql3VersionTransition.test.cjs
|
test/back/ql3VersionTransition.test.cjs
|
||||||
|
|||||||
@@ -573,21 +573,187 @@ jobs:
|
|||||||
--deny-self-hosted-runners \
|
--deny-self-hosted-runners \
|
||||||
--bundle-from-oci
|
--bundle-from-oci
|
||||||
|
|
||||||
- name: Promote only the verified digest to immutable release tags
|
- name: Record the fully verified image for release-set aggregation
|
||||||
env:
|
env:
|
||||||
IMAGE: ${{ steps.identity.outputs.image }}
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
VERSION: ${{ steps.identity.outputs.version }}
|
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||||||
DIGEST: ${{ steps.push.outputs.digest }}
|
DIGEST: ${{ steps.push.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||||
|
mkdir "${RUNNER_TEMP}/release-record"
|
||||||
|
node scripts/ql3-release-set-contract.cjs \
|
||||||
|
--mode=record-image \
|
||||||
|
--version="${RELEASE_VERSION}" \
|
||||||
|
--source-revision="${GITHUB_SHA}" \
|
||||||
|
--source-ref="${GITHUB_REF}" \
|
||||||
|
--release-scope="${RELEASE_SCOPE}" \
|
||||||
|
--repository-owner="${owner}" \
|
||||||
|
--candidate="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json" \
|
||||||
|
--image="${{ matrix.image }}" \
|
||||||
|
--digest="${DIGEST}" \
|
||||||
|
--output="${RUNNER_TEMP}/release-record/${{ matrix.image }}.json"
|
||||||
|
|
||||||
|
- name: Upload the same-run verified image record
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}
|
||||||
|
path: ${{ runner.temp }}/release-record/${{ matrix.image }}.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
compression-level: 0
|
||||||
|
overwrite: false
|
||||||
|
include-hidden-files: false
|
||||||
|
|
||||||
|
release-set:
|
||||||
|
name: Close and publish the complete deployment release set
|
||||||
|
needs:
|
||||||
|
- release-candidate
|
||||||
|
- publish
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.release-candidate.result == 'success' &&
|
||||||
|
needs.publish.result == 'success'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
artifact-metadata: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||||
|
with:
|
||||||
|
node-version: '24.18.0'
|
||||||
|
|
||||||
|
- name: Download every same-run verified image record
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
pattern: ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||||
|
path: ${{ runner.temp }}/release-records
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Aggregate and independently audit the complete release set
|
||||||
|
id: release-set
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||||
|
candidate="${RUNNER_TEMP}/release-set-candidate.json"
|
||||||
|
report="${RUNNER_TEMP}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.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="${candidate}"
|
||||||
|
node scripts/ql3-release-set-contract.cjs \
|
||||||
|
--mode=aggregate \
|
||||||
|
--version="${RELEASE_VERSION}" \
|
||||||
|
--source-revision="${GITHUB_SHA}" \
|
||||||
|
--source-ref="${GITHUB_REF}" \
|
||||||
|
--release-scope="${RELEASE_SCOPE}" \
|
||||||
|
--repository-owner="${owner}" \
|
||||||
|
--candidate="${candidate}" \
|
||||||
|
--records="${RUNNER_TEMP}/release-records" \
|
||||||
|
--output="${report}"
|
||||||
|
node scripts/ql3-release-set-contract.cjs \
|
||||||
|
--mode=audit \
|
||||||
|
--version="${RELEASE_VERSION}" \
|
||||||
|
--source-revision="${GITHUB_SHA}" \
|
||||||
|
--source-ref="${GITHUB_REF}" \
|
||||||
|
--release-scope="${RELEASE_SCOPE}" \
|
||||||
|
--repository-owner="${owner}" \
|
||||||
|
--candidate="${candidate}" \
|
||||||
|
--records="${RUNNER_TEMP}/release-records" \
|
||||||
|
--report="${report}" > "${RUNNER_TEMP}/release-set-audit.json"
|
||||||
|
echo "report=${report}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Install the exact checksum-pinned OCI copier
|
||||||
|
env:
|
||||||
REGCTL: ${{ runner.temp }}/regctl
|
REGCTL: ${{ runner.temp }}/regctl
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
"${REGCTL}" image copy "${IMAGE}@${DIGEST}" "${IMAGE}:${VERSION}"
|
curl --fail --location --proto '=https' --tlsv1.2 \
|
||||||
"${REGCTL}" image copy "${IMAGE}@${DIGEST}" "${IMAGE}:sha-${GITHUB_SHA}"
|
--output "${REGCTL}" \
|
||||||
if [[ "$("${REGCTL}" image digest "${IMAGE}:${VERSION}")" != "${DIGEST}" ]]; then
|
https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64
|
||||||
echo "release version tag does not resolve to the verified digest" >&2
|
echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${REGCTL}" | sha256sum --check --strict
|
||||||
exit 1
|
chmod 0755 "${REGCTL}"
|
||||||
fi
|
"${REGCTL}" version
|
||||||
if [[ "$("${REGCTL}" image digest "${IMAGE}:sha-${GITHUB_SHA}")" != "${DIGEST}" ]]; then
|
|
||||||
echo "release source tag does not resolve to the verified digest" >&2
|
- uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4
|
||||||
exit 1
|
with:
|
||||||
fi
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Promote tags only after the complete set is verified
|
||||||
|
env:
|
||||||
|
REGCTL: ${{ runner.temp }}/regctl
|
||||||
|
RELEASE_SET: ${{ steps.release-set.outputs.report }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const report = JSON.parse(fs.readFileSync(process.env.RELEASE_SET, 'utf8'));
|
||||||
|
const regctl = process.env.REGCTL;
|
||||||
|
const run = (args, allowFailure = false) => {
|
||||||
|
const result = spawnSync(regctl, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (!allowFailure && result.status !== 0) {
|
||||||
|
throw new Error(`regctl ${args.join(' ')} failed`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const states = [];
|
||||||
|
for (const image of report.images) {
|
||||||
|
const source = run(['image', 'digest', image.reference]).stdout.trim();
|
||||||
|
if (source !== image.digest) throw new Error('source digest drifted before promotion');
|
||||||
|
for (const tag of [image.versionTag, image.sourceTag]) {
|
||||||
|
const current = run(['image', 'digest', tag], true);
|
||||||
|
if (current.status === 0 && current.stdout.trim() !== image.digest) {
|
||||||
|
throw new Error('release tag already points at another digest');
|
||||||
|
}
|
||||||
|
states.push({ image, tag, current: current.status === 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const state of states) {
|
||||||
|
if (!state.current) {
|
||||||
|
run(['image', 'copy', state.image.reference, state.tag]);
|
||||||
|
}
|
||||||
|
const promoted = run(['image', 'digest', state.tag]).stdout.trim();
|
||||||
|
if (promoted !== state.image.digest) {
|
||||||
|
throw new Error('promoted tag does not resolve to the release-set digest');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Attest the complete release-set file provenance
|
||||||
|
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
|
||||||
|
with:
|
||||||
|
subject-path: ${{ steps.release-set.outputs.report }}
|
||||||
|
|
||||||
|
- name: Publish the deployment digest lock
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}
|
||||||
|
path: ${{ steps.release-set.outputs.report }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0
|
||||||
|
overwrite: false
|
||||||
|
include-hidden-files: false
|
||||||
|
|||||||
@@ -161,10 +161,13 @@ pnpm audit:cluster-remote-manifest:ql3 \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The existence of the workflow is not publication evidence. Before production
|
The existence of the workflow is not publication evidence. Before production
|
||||||
rollout, record a successful release run for both image repositories,
|
rollout, obtain the `cluster` or `all` deployment release set from a successful
|
||||||
independently verify each signature and attestation set, and pin both resulting
|
release run, verify its file provenance, and pin the four control, control-ai,
|
||||||
`sha256:` digests. Never treat the version tag or the control image's proof as
|
worker and admin `sha256:` references recorded in that one source-bound set.
|
||||||
authority for the admin Job.
|
Never treat the version tag, a partial matrix run or one image's proof as
|
||||||
|
authority for another role. The exact operator procedure and non-atomic tag
|
||||||
|
recovery boundary are documented in
|
||||||
|
[`docs/operations/ql3-release-set-deployment.md`](../../../docs/operations/ql3-release-set-deployment.md).
|
||||||
|
|
||||||
The committed CloudNativePG control, migration and Plugin Package recovery overlays use
|
The committed CloudNativePG control, migration and Plugin Package recovery overlays use
|
||||||
an all-zero SHA-256 digest as an intentionally unpullable fail-closed
|
an all-zero SHA-256 digest as an intentionally unpullable fail-closed
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ docker build \
|
|||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Production rollout must take the Worker `@sha256:` reference from the same
|
||||||
|
verified `cluster` or `all` release set as control, control-ai and admin. A
|
||||||
|
version/source tag or a successful control image alone is not Worker release
|
||||||
|
authority. See
|
||||||
|
[`docs/operations/ql3-release-set-deployment.md`](../../../docs/operations/ql3-release-set-deployment.md).
|
||||||
|
|
||||||
The builder and runtime dependency roots have separate npm v3 locks generated
|
The builder and runtime dependency roots have separate npm v3 locks generated
|
||||||
from the reviewed workspace `pnpm-lock.yaml`. The runtime image contains only
|
from the reviewed workspace `pnpm-lock.yaml`. The runtime image contains only
|
||||||
`runtime-core`, `local-process`, `worker-runtime` and their 24 external runtime
|
`runtime-core`, `local-process`, `worker-runtime` and their 24 external runtime
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(2026-08-16):
|
||||||
|
|
||||||
|
- D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。
|
||||||
- D-334/ADR-0426(已接受):根级 canonical `ql3-release.json` 现在是唯一 QingLong 3 release identity authority,精确冻结 3.x SemVer、Node 24.18.0/engine、18-package 边界和 legacy 2.x 排除事实;发布候选、四组容器、Cluster/Worker/Console 部署、Local/Cluster image audit、CloudNativePG、物理 Edge 与外部恢复审计均改为读取同一 authority,candidate contract 额外绑定 identity schema 与 SHA-256。共享 CI 新增 `audit:release-version:ql3`,失败关闭 18 个 workspace、四组 build/runtime manifest+lock、Dockerfile Node/version label 与 242 个部署文本文件中的 32 个 image reference/36 个版本 occurrence。维护者升级版本必须走 closed `audit|plan|apply`:plan 只接受严格递增 exact v3 SemVer并生成 no-replace `0600`、逐文件 path/mode/replacement/before-after bytes+digest 和自身 digest;apply 先全量预检 65 文件/83 处替换,再用同目录确定性临时文件、fsync+rename 逐文件收敛,允许 source/target 混合状态原 plan 幂等恢复并生成 digest-bound report,绝不修改 legacy 根 2.x、自动 commit/tag/push 或宣称跨文件单事务。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、listener、timer、watcher 或任何低配/集群常驻开销。定向回归 177/177,backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;完整回归未发现数据库或部署拓扑漂移。
|
- D-334/ADR-0426(已接受):根级 canonical `ql3-release.json` 现在是唯一 QingLong 3 release identity authority,精确冻结 3.x SemVer、Node 24.18.0/engine、18-package 边界和 legacy 2.x 排除事实;发布候选、四组容器、Cluster/Worker/Console 部署、Local/Cluster image audit、CloudNativePG、物理 Edge 与外部恢复审计均改为读取同一 authority,candidate contract 额外绑定 identity schema 与 SHA-256。共享 CI 新增 `audit:release-version:ql3`,失败关闭 18 个 workspace、四组 build/runtime manifest+lock、Dockerfile Node/version label 与 242 个部署文本文件中的 32 个 image reference/36 个版本 occurrence。维护者升级版本必须走 closed `audit|plan|apply`:plan 只接受严格递增 exact v3 SemVer并生成 no-replace `0600`、逐文件 path/mode/replacement/before-after bytes+digest 和自身 digest;apply 先全量预检 65 文件/83 处替换,再用同目录确定性临时文件、fsync+rename 逐文件收敛,允许 source/target 混合状态原 plan 幂等恢复并生成 digest-bound report,绝不修改 legacy 根 2.x、自动 commit/tag/push 或宣称跨文件单事务。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、listener、timer、watcher 或任何低配/集群常驻开销。定向回归 177/177,backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;完整回归未发现数据库或部署拓扑漂移。
|
||||||
- D-333/ADR-0425(已接受;公开发布结果待实际 tag):3.0 发布入口不再把所有部署者绑成一个不可分割矩阵。唯一 `.github/workflows/ql3-image-release.yml` 增加 closed `local|cluster|all` deployment-family scope;根级 source-derived release-candidate contract 从 exact `v3` SemVer/tag/40-hex revision、18 个边界审计通过且非 single/shallow 的 workspace、Node 24.18.0 engine、容器 runtime manifest/Dockerfile version、双架构和部署 profile 推导唯一 OS/publish matrix,并以 canonical SHA-256 失败关闭版本或源码漂移。`local` 只发布 AI-excluded Local image、只要求 Edge/Standalone digest rollout,不再等待 Worker management/CloudNativePG 私有 HA evidence;`cluster` 才要求两个 ephemeral private evidence gate,并闭合此前遗漏的 `qinglong3-worker`,与 control/control-ai/admin 一同进入 native amd64/arm64 build-once、Trivy OS scan、CycloneDX、OCI merge、Cosign 与 GitHub attestation 链;`all` 同时保留两族门禁。legacy 根 `2.21.0-14` 被显式标记为不参与 3.0 release identity,而不是伪改旧产品版本。Worker 现在有 27-component(24 external/3 internal)、28-node 的 production SBOM,BSD-3-Clause 纳入受审 allowlist,Worker config 固定 `65532:65532`、`worker` profile、`edge,node` capacity labels 和 3.0 version;control/admin 也补齐同一 version label。candidate contract 作为第四类 digest-bound GitHub predicate 发布并远端回读,Cluster Admin verifier/外部 ceremony/offline audit 同步升级为四类 attestation/八步 transcript。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、listener、timer、watcher 或低配设备常驻资源。定向 105/105、backend 1,246 pass/2 条件 skip/0 fail、18-package clean build/test 均通过;package boundary 确认为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release、OS vulnerability policy、Console/distribution 审计均 compatible,四个 runtime dependency root 的离线缓存审计为 0 vulnerability。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin npm pack 仍为 250 files、271,238-byte tarball、1,690,196-byte unpacked。由于本 Gate 不改变 schema、migration、SQL、role、Pool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得。
|
- 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-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。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ADR-0427:完整跨镜像发布集与部署 Digest Lock
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-334、D-335
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
D-333 已把发布候选拆成 `local|cluster|all` 三个 deployment-family scope,并让每个镜像在原生
|
||||||
|
amd64/arm64 build、OS 漏洞扫描、OCI 合并、Cosign 与四类 GitHub attestation 后独立回读验证。
|
||||||
|
但矩阵 publisher 过去会在各自验证完成后立即写 version/source tag。五个仓库之间没有事务,后续镜像失败时,
|
||||||
|
部署者可能看到只覆盖部分候选的同版本标签,也没有一个能同时冻结 scope、source revision 和所有镜像 digest 的
|
||||||
|
部署输入。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 每个 publisher 只发布不可变 digest,不再写 version/source tag。完成远端 manifest、Cosign、四类 GitHub
|
||||||
|
attestation 和适用的 Local profile rollout 验证后,生成一个 canonical、no-replace、mode `0600` 的
|
||||||
|
`qinglong/release-set-image-record@v1`。
|
||||||
|
2. image record 必须绑定同一个 source-derived release candidate digest、scope、version、source ref/revision、
|
||||||
|
lowercase repository owner、repository、双架构、immutable reference 和自身 SHA-256。矩阵 job 只保留同一
|
||||||
|
`run_id/run_attempt` 的短期 record artifact。
|
||||||
|
3. 唯一 `release-set` job 必须等待整个 publish matrix 成功,重新从 exact tag source 创建并审计 candidate,下载
|
||||||
|
同一次 workflow attempt 的全部 record,并要求 record 集与 candidate image 集精确闭合、无遗漏、无重复、顺序
|
||||||
|
与 candidate 一致。
|
||||||
|
4. 聚合结果为 `qinglong/release-set@v1`,按 `local` 与 `cluster` deployment family 列出 exact digest reference、
|
||||||
|
version/source tag、platform 和 image-record digest。`local` scope 只包含 Local image;`cluster` scope 包含
|
||||||
|
control、control-ai、worker、admin;`all` 同时闭合两族。
|
||||||
|
5. 只有 release set 独立重算审计成功后才允许统一 promotion。promoter 先回读所有 source digest 和已有 tag:
|
||||||
|
tag 若已指向其他 digest 则失败;缺失 tag 才执行 digest-to-tag copy;每次写入后必须再次解析为 release-set
|
||||||
|
digest。
|
||||||
|
6. 不宣称 GHCR 跨 repository tag write 具有原子性。恢复模型明确为
|
||||||
|
`verify_exact_digest_then_continue`:失败重跑先验证已存在 tag,只有相同 digest 才继续补齐其余 tag。
|
||||||
|
workflow concurrency 只串行化同一 release ref,外部写入仍由 conflict preflight 失败关闭。
|
||||||
|
7. 完整 release-set JSON 使用 GitHub file provenance attestation,并作为 90 天、no-overwrite 的 deployment
|
||||||
|
digest-lock artifact 发布。生产部署必须从该文件取 `@sha256:` reference;version/source tag 只用于发现和
|
||||||
|
人类导航,不能成为 rollout authority。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- Edge/Standalone 路由设备只下载并解析 `local` release set,不需要 Cluster 镜像、Kubernetes、数据库或发布
|
||||||
|
工具;运行时 artifact、模块数和常驻资源预算不变。
|
||||||
|
- Cluster 节点使用 `cluster` release set,同时锁定四个角色镜像;release-set job 只存在于发布 CI,不新增
|
||||||
|
Pod、controller、listener、timer、watcher、Pool、schema、migration 或 SQL。
|
||||||
|
- `all` 是维护者同时发布两族的便利 scope,不把 Local 部署依赖于 Cluster 私有证据,也不要求单个设备拉取
|
||||||
|
五个镜像。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 每个矩阵 job 验证后立即打标签
|
||||||
|
|
||||||
|
拒绝。单镜像证据正确不等于 deployment family 完整;部分成功会暴露同版本的混合状态。
|
||||||
|
|
||||||
|
### 假设多个 GHCR repository 的 tag promotion 原子
|
||||||
|
|
||||||
|
拒绝。registry 没有本 workflow 可用的跨仓库事务。显式、可重入的 digest preflight 比虚构原子性更可靠。
|
||||||
|
|
||||||
|
### 让部署继续只引用 version tag
|
||||||
|
|
||||||
|
拒绝。tag 可变且无法表达同一 source candidate 下的跨镜像闭包。生产 rollout 必须使用 release set 中的
|
||||||
|
immutable digest。
|
||||||
|
|
||||||
|
### 为 release-set 增加常驻发布协调服务
|
||||||
|
|
||||||
|
拒绝。该问题属于低频发布控制面,GitHub Actions 的有界终态 job 足够;常驻服务会给低配用户和集群都增加
|
||||||
|
不必要的新故障域。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- contract 覆盖 Local/Cluster/All 聚合、缺失/重复/跨候选 record、owner/digest/report drift、symlink、额外文件、
|
||||||
|
closed CLI、no-replace 与独立 audit;
|
||||||
|
- workflow 静态门要求 per-image publisher 不再 promotion、record 位于全部验证之后、same-run attempt 下载、
|
||||||
|
publish matrix 全成功、独立 aggregate/audit、checksum-pinned copier、先全量 preflight 后 promotion、最终 file
|
||||||
|
attestation 和 90 天 artifact;
|
||||||
|
- release-set contract/workflow 定向门 73/73,连同 Admin Console distribution 交叉审计为 77/77;backend
|
||||||
|
1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 仍为 18 packages、无
|
||||||
|
single-source/shallow package,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image
|
||||||
|
审计均 compatible;
|
||||||
|
- 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、
|
||||||
|
56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin
|
||||||
|
pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;
|
||||||
|
- 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 HA 拓扑,因此复用 D-331/D-333 PostgreSQL 18.6
|
||||||
|
arm64 142/142、timeline `1→2` 基线,不把未重跑的数据库门冒充本阶段新证据;
|
||||||
|
- 本 ADR 接受的是源码、契约和 workflow 门。公开 tag 尚未运行时,不宣称真实 GHCR promotion、Cosign 或 GitHub
|
||||||
|
attestation 已成功;它们必须由实际 release run 取得。
|
||||||
@@ -427,6 +427,10 @@
|
|||||||
| [ADR-0421](./ADR-0421-explicit-cluster-run-task-workflow-observation-console.md) | 显式 Cluster Run、Task 与 Workflow 观察台 | Accepted |
|
| [ADR-0421](./ADR-0421-explicit-cluster-run-task-workflow-observation-console.md) | 显式 Cluster Run、Task 与 Workflow 观察台 | Accepted |
|
||||||
| [ADR-0422](./ADR-0422-browser-local-redacted-cluster-evidence-bundle.md) | 浏览器本地生成的脱敏 Cluster Evidence Bundle | Accepted |
|
| [ADR-0422](./ADR-0422-browser-local-redacted-cluster-evidence-bundle.md) | 浏览器本地生成的脱敏 Cluster Evidence Bundle | Accepted |
|
||||||
| [ADR-0423](./ADR-0423-independent-offline-cluster-evidence-verifier.md) | 独立、离线且无 Authority 的 Cluster Evidence Verifier | Accepted |
|
| [ADR-0423](./ADR-0423-independent-offline-cluster-evidence-verifier.md) | 独立、离线且无 Authority 的 Cluster Evidence Verifier | Accepted |
|
||||||
|
| [ADR-0424](./ADR-0424-external-cluster-admin-release-workstation-ceremony.md) | Cluster Admin 外部发布工作站 Ceremony | Proposed |
|
||||||
|
| [ADR-0425](./ADR-0425-deployment-family-release-candidate-contract.md) | Deployment-family Release Candidate Contract | Accepted |
|
||||||
|
| [ADR-0426](./ADR-0426-source-derived-release-version-transition.md) | Source-derived QingLong 3.0 Release Version Transition | Accepted |
|
||||||
|
| [ADR-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# QingLong 3.0 release-set 部署准入
|
||||||
|
|
||||||
|
生产部署的镜像 authority 是成功 `ql3-image-release.yml` 运行产生的
|
||||||
|
`ql3-release-set-<version>-<scope>` artifact,不是可变 version/source tag。下载后先验证该 JSON 的 GitHub file
|
||||||
|
provenance,确认 repository、source tag、source revision 与目标发布一致,再从 `images[].reference` 读取完整
|
||||||
|
`ghcr.io/<owner>/<repository>@sha256:<digest>`。
|
||||||
|
|
||||||
|
## 选择 scope
|
||||||
|
|
||||||
|
| 部署类型 | release scope | 必须出现的镜像 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 低配路由器、Edge、Standalone | `local` | `local` |
|
||||||
|
| Kubernetes/Cluster | `cluster` | `control`、`control-ai`、`worker`、`admin` |
|
||||||
|
| 同时发布两族 | `all` | 上述五个镜像 |
|
||||||
|
|
||||||
|
Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local
|
||||||
|
image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。
|
||||||
|
|
||||||
|
## 准入检查
|
||||||
|
|
||||||
|
1. 只接受来自成功、未重跑替换的同一 release workflow attempt 的 artifact,并验证 release-set 文件
|
||||||
|
provenance。
|
||||||
|
2. `schema` 必须为 `qinglong/release-set@v1`;`release.version`、`release.sourceRef`、
|
||||||
|
`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||||
|
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||||
|
4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成
|
||||||
|
`newTag`。Local compose/rollout 同样固定 `@sha256:`。
|
||||||
|
5. rollout 前再次向 registry 解析 version/source tag。它们可以用于发现,但只有解析到 release set 的同一
|
||||||
|
digest 才算一致;部署仍以 digest 为准。
|
||||||
|
|
||||||
|
## 发布失败与恢复
|
||||||
|
|
||||||
|
GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`。如果 promotion 中途
|
||||||
|
失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证
|
||||||
|
每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有最终
|
||||||
|
release-set artifact 和 provenance 都生成后,才能宣布该 deployment family 可部署。
|
||||||
|
|
||||||
|
workflow artifact 当前保留 90 天,因此长期归档属于 release owner 的外部职责。进入稳定 GA 前,应把经验证的
|
||||||
|
release-set 同步到不可变、保留期满足组织策略的发布档案;同步过程不得改写 JSON。
|
||||||
@@ -252,7 +252,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
|||||||
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
|
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
|
||||||
'--deny-self-hosted-runners',
|
'--deny-self-hosted-runners',
|
||||||
'--bundle-from-oci',
|
'--bundle-from-oci',
|
||||||
'Promote only the verified digest to immutable release tags',
|
'Promote tags only after the complete set is verified',
|
||||||
|
'Attest the complete release-set file provenance',
|
||||||
],
|
],
|
||||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
|
|||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/node --test test\/back\/ql3ClusterImageSbom\.test\.cjs test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs/,
|
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs/,
|
||||||
'cluster image CI must run SBOM and release-contract negative tests',
|
'cluster image CI must run SBOM, candidate, release-set and workflow negative tests',
|
||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
@@ -403,6 +403,7 @@ function auditReleaseWorkflow(source) {
|
|||||||
const drEvidenceJob = workflow?.jobs?.['cluster-dr-release-evidence'];
|
const drEvidenceJob = workflow?.jobs?.['cluster-dr-release-evidence'];
|
||||||
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
|
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
|
||||||
const publishJob = workflow?.jobs?.publish;
|
const publishJob = workflow?.jobs?.publish;
|
||||||
|
const releaseSetJob = workflow?.jobs?.['release-set'];
|
||||||
if (
|
if (
|
||||||
publishJob?.strategy?.matrix?.include !==
|
publishJob?.strategy?.matrix?.include !==
|
||||||
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
|
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
|
||||||
@@ -450,6 +451,14 @@ function auditReleaseWorkflow(source) {
|
|||||||
JSON.stringify(drEvidenceJob?.permissions) !==
|
JSON.stringify(drEvidenceJob?.permissions) !==
|
||||||
JSON.stringify({ contents: 'read' }) ||
|
JSON.stringify({ contents: 'read' }) ||
|
||||||
JSON.stringify(publishJob?.permissions) !==
|
JSON.stringify(publishJob?.permissions) !==
|
||||||
|
JSON.stringify({
|
||||||
|
contents: 'read',
|
||||||
|
packages: 'write',
|
||||||
|
'id-token': 'write',
|
||||||
|
attestations: 'write',
|
||||||
|
'artifact-metadata': 'write',
|
||||||
|
}) ||
|
||||||
|
JSON.stringify(releaseSetJob?.permissions) !==
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
contents: 'read',
|
contents: 'read',
|
||||||
packages: 'write',
|
packages: 'write',
|
||||||
@@ -459,7 +468,7 @@ function auditReleaseWorkflow(source) {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'release permissions must keep evidence read-only and grant writes only to the gated publisher',
|
'release permissions must keep evidence read-only and grant writes only to gated image and release-set publishers',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -504,10 +513,18 @@ function auditReleaseWorkflow(source) {
|
|||||||
typeof publishJob?.if !== 'string' ||
|
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(
|
!/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,
|
publishJob.if,
|
||||||
|
) ||
|
||||||
|
JSON.stringify(releaseSetJob?.needs) !==
|
||||||
|
JSON.stringify(['release-candidate', 'publish']) ||
|
||||||
|
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||||
|
releaseSetJob?.['timeout-minutes'] !== 15 ||
|
||||||
|
typeof releaseSetJob?.if !== 'string' ||
|
||||||
|
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
|
||||||
|
releaseSetJob.if,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family',
|
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family; release-set closure must additionally require the complete publish matrix',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const candidateSteps = candidateJob?.steps;
|
const candidateSteps = candidateJob?.steps;
|
||||||
@@ -616,8 +633,14 @@ function auditReleaseWorkflow(source) {
|
|||||||
const importIndex = publishSteps?.findIndex(
|
const importIndex = publishSteps?.findIndex(
|
||||||
(step) => step.id === 'push' && /image import/.test(step.run || ''),
|
(step) => step.id === 'push' && /image import/.test(step.run || ''),
|
||||||
);
|
);
|
||||||
const promotionIndex = publishSteps?.findIndex((step) =>
|
const recordIndex = publishSteps?.findIndex((step) =>
|
||||||
/Promote only the verified digest/.test(step.name || ''),
|
/Record the fully verified image/.test(step.name || ''),
|
||||||
|
);
|
||||||
|
const recordUploadIndex = publishSteps?.findIndex(
|
||||||
|
(step) =>
|
||||||
|
step.uses ===
|
||||||
|
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' &&
|
||||||
|
/same-run verified image record/.test(step.name || ''),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!Array.isArray(publishSteps) ||
|
!Array.isArray(publishSteps) ||
|
||||||
@@ -627,9 +650,9 @@ function auditReleaseWorkflow(source) {
|
|||||||
mergeIndex < 0 ||
|
mergeIndex < 0 ||
|
||||||
loginIndex <= mergeIndex ||
|
loginIndex <= mergeIndex ||
|
||||||
importIndex <= loginIndex ||
|
importIndex <= loginIndex ||
|
||||||
promotionIndex !== publishSteps.length - 1 ||
|
recordIndex !== publishSteps.length - 2 ||
|
||||||
publishSteps.filter((step) => /\bimage copy\b/.test(step.run || ''))
|
recordUploadIndex !== publishSteps.length - 1 ||
|
||||||
.length !== 1 ||
|
publishSteps.some((step) => /\bimage copy\b/.test(step.run || '')) ||
|
||||||
publishSteps.some(
|
publishSteps.some(
|
||||||
(step) =>
|
(step) =>
|
||||||
step.uses?.startsWith('docker/build-push-action@') ||
|
step.uses?.startsWith('docker/build-push-action@') ||
|
||||||
@@ -637,7 +660,80 @@ function auditReleaseWorkflow(source) {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'privileged publisher must re-audit, import and verify the scanned bundle without any rebuild before final tag promotion',
|
'privileged image publisher must re-audit, import and verify the scanned bundle without any rebuild or tag promotion before release-set closure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!/ql3-release-set-contract\.cjs[\s\S]*--mode=record-image[\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]*--repository-owner="\$\{owner\}"[\s\S]*--candidate="\$\{RUNNER_TEMP\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json"[\s\S]*--image="\$\{\{ matrix\.image \}\}"[\s\S]*--digest="\$\{DIGEST\}"/.test(
|
||||||
|
publishSteps[recordIndex]?.run ?? '',
|
||||||
|
) ||
|
||||||
|
JSON.stringify(publishSteps[recordUploadIndex]?.with) !==
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}',
|
||||||
|
path: '${{ runner.temp }}/release-record/${{ matrix.image }}.json',
|
||||||
|
'if-no-files-found': 'error',
|
||||||
|
'retention-days': 1,
|
||||||
|
'compression-level': 0,
|
||||||
|
overwrite: false,
|
||||||
|
'include-hidden-files': false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'each image publisher must upload one exact same-run digest record only after all image verification',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const releaseSetSteps = releaseSetJob?.steps;
|
||||||
|
if (
|
||||||
|
!Array.isArray(releaseSetSteps) ||
|
||||||
|
releaseSetSteps.length !== 9 ||
|
||||||
|
releaseSetSteps[0]?.uses !==
|
||||||
|
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||||
|
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||||
|
releaseSetSteps[1]?.uses !==
|
||||||
|
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||||
|
releaseSetSteps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||||
|
releaseSetSteps[2]?.uses !==
|
||||||
|
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
|
||||||
|
JSON.stringify(releaseSetSteps[2]?.with) !==
|
||||||
|
JSON.stringify({
|
||||||
|
pattern:
|
||||||
|
'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||||
|
path: '${{ runner.temp }}/release-records',
|
||||||
|
'merge-multiple': true,
|
||||||
|
}) ||
|
||||||
|
releaseSetSteps[3]?.id !== 'release-set' ||
|
||||||
|
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||||
|
releaseSetSteps[3]?.run ?? '',
|
||||||
|
) ||
|
||||||
|
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
|
||||||
|
releaseSetSteps[4]?.run ?? '',
|
||||||
|
) ||
|
||||||
|
releaseSetSteps[5]?.uses !==
|
||||||
|
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
|
||||||
|
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test(
|
||||||
|
releaseSetSteps[6]?.run ?? '',
|
||||||
|
) ||
|
||||||
|
releaseSetSteps[7]?.uses !==
|
||||||
|
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||||
|
JSON.stringify(releaseSetSteps[7]?.with) !==
|
||||||
|
JSON.stringify({
|
||||||
|
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||||
|
}) ||
|
||||||
|
releaseSetSteps[8]?.uses !==
|
||||||
|
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||||
|
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
|
||||||
|
path: '${{ steps.release-set.outputs.report }}',
|
||||||
|
'if-no-files-found': 'error',
|
||||||
|
'retention-days': 90,
|
||||||
|
'compression-level': 0,
|
||||||
|
overwrite: false,
|
||||||
|
'include-hidden-files': false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'release-set job must download only same-run records, independently attest and publish one no-overwrite deployment lock',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -727,13 +823,13 @@ function auditReleaseWorkflow(source) {
|
|||||||
requireOccurrences(
|
requireOccurrences(
|
||||||
source,
|
source,
|
||||||
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
|
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
|
||||||
5,
|
6,
|
||||||
'all release jobs must pin the reviewed immutable checkout action',
|
'all release jobs must pin the reviewed immutable checkout action',
|
||||||
);
|
);
|
||||||
requireOccurrences(
|
requireOccurrences(
|
||||||
source,
|
source,
|
||||||
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
|
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
|
||||||
5,
|
6,
|
||||||
'all release jobs must pin the reviewed immutable Node setup action',
|
'all release jobs must pin the reviewed immutable Node setup action',
|
||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
@@ -771,6 +867,12 @@ function auditReleaseWorkflow(source) {
|
|||||||
/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/,
|
/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/,
|
||||||
'publisher must checksum-pin the exact regctl OCI copier',
|
'publisher must checksum-pin the exact regctl OCI copier',
|
||||||
);
|
);
|
||||||
|
requireExactOccurrences(
|
||||||
|
source,
|
||||||
|
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
|
||||||
|
2,
|
||||||
|
'both image and release-set publishers must checksum-pin the exact regctl OCI copier',
|
||||||
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/regctl[\s\S]*image import "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{ARCHIVE\}"[\s\S]*image digest "\$\{IMAGE\}@\$\{DIGEST\}"/,
|
/regctl[\s\S]*image import "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{ARCHIVE\}"[\s\S]*image digest "\$\{IMAGE\}@\$\{DIGEST\}"/,
|
||||||
@@ -789,8 +891,8 @@ function auditReleaseWorkflow(source) {
|
|||||||
requireOccurrences(
|
requireOccurrences(
|
||||||
source,
|
source,
|
||||||
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
|
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
|
||||||
4,
|
5,
|
||||||
'release workflow must create provenance, SBOM, OS vulnerability and release candidate attestations',
|
'release workflow must create four image attestations and one complete release-set provenance attestation',
|
||||||
);
|
);
|
||||||
requireOccurrences(
|
requireOccurrences(
|
||||||
source,
|
source,
|
||||||
@@ -884,8 +986,8 @@ function auditReleaseWorkflow(source) {
|
|||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
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\}"/,
|
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish the deployment digest lock/,
|
||||||
'release tags must be promoted only after all digest verification succeeds',
|
'release tags and the deployment lock must be published only after every selected digest record is complete',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
trigger: 'explicit protected v3 tag dispatch',
|
trigger: 'explicit protected v3 tag dispatch',
|
||||||
@@ -938,6 +1040,16 @@ function auditReleaseWorkflow(source) {
|
|||||||
'c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467',
|
'c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467',
|
||||||
rebuildAfterScan: false,
|
rebuildAfterScan: false,
|
||||||
tagAfterVerification: true,
|
tagAfterVerification: true,
|
||||||
|
tagAfterCompleteReleaseSet: true,
|
||||||
|
},
|
||||||
|
releaseSet: {
|
||||||
|
sourceDerived: true,
|
||||||
|
sameRunRecords: true,
|
||||||
|
exactScopeClosure: true,
|
||||||
|
tagPromotionAuthority: 'complete_verified_release_set',
|
||||||
|
fileProvenanceAttested: true,
|
||||||
|
artifactRetentionDays: 90,
|
||||||
|
crossRepositoryAtomicity: false,
|
||||||
},
|
},
|
||||||
localRolloutPreflight: true,
|
localRolloutPreflight: true,
|
||||||
localRolloutApply: true,
|
localRolloutApply: true,
|
||||||
@@ -948,6 +1060,7 @@ function auditReleaseWorkflow(source) {
|
|||||||
'cyclonedx',
|
'cyclonedx',
|
||||||
'os-vulnerability',
|
'os-vulnerability',
|
||||||
'release-candidate',
|
'release-candidate',
|
||||||
|
'release-set',
|
||||||
'release-tags',
|
'release-tags',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ function createReleaseCandidateContract(options) {
|
|||||||
'multiarch-oci-layout',
|
'multiarch-oci-layout',
|
||||||
'production-dependency-audit',
|
'production-dependency-audit',
|
||||||
'digest-signature-and-attestations',
|
'digest-signature-and-attestations',
|
||||||
|
'cross-image-release-set',
|
||||||
...(options.releaseScope !== 'cluster'
|
...(options.releaseScope !== 'cluster'
|
||||||
? ['edge-and-standalone-rollout']
|
? ['edge-and-standalone-rollout']
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const {
|
||||||
|
auditReleaseCandidateContract,
|
||||||
|
} = require('./ql3-release-candidate-contract.cjs');
|
||||||
|
|
||||||
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||||
|
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v1';
|
||||||
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||||
|
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
||||||
|
const REQUIRED_IMAGE_ATTESTATIONS = Object.freeze([
|
||||||
|
'github-provenance',
|
||||||
|
'cyclonedx-sbom',
|
||||||
|
'os-vulnerability',
|
||||||
|
'release-candidate-contract',
|
||||||
|
]);
|
||||||
|
|
||||||
|
class QingLong3ReleaseSetError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(`QingLong 3 release set failed: ${message}`);
|
||||||
|
this.name = 'QingLong3ReleaseSetError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new QingLong3ReleaseSetError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value, expected) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value) {
|
||||||
|
return `${JSON.stringify(value)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCanonicalAbsolute(input, label) {
|
||||||
|
if (typeof input !== 'string' || !path.isAbsolute(input)) {
|
||||||
|
fail(`${label} path must be absolute`);
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(input);
|
||||||
|
if (resolved !== input) fail(`${label} path must be normalized`);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCanonicalJson(filePath, label) {
|
||||||
|
const resolved = resolveCanonicalAbsolute(filePath, label);
|
||||||
|
const stat = fs.lstatSync(resolved);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.size < 2 ||
|
||||||
|
stat.size > MAX_JSON_BYTES ||
|
||||||
|
fs.realpathSync(resolved) !== resolved ||
|
||||||
|
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||||
|
) {
|
||||||
|
fail(`${label} must be one bounded canonical regular file`);
|
||||||
|
}
|
||||||
|
const contents = fs.readFileSync(resolved, 'utf8');
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
fail(`${label} must contain valid JSON`);
|
||||||
|
}
|
||||||
|
if (canonicalJson(value) !== contents) {
|
||||||
|
fail(`${label} must use exact canonical JSON encoding`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeNoReplace(filePath, value) {
|
||||||
|
const resolved = resolveCanonicalAbsolute(filePath, 'output');
|
||||||
|
if (
|
||||||
|
fs.existsSync(resolved) ||
|
||||||
|
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||||
|
) {
|
||||||
|
fail('output must be unused in one canonical directory');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(resolved, canonicalJson(value), {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepositoryOwner(value) {
|
||||||
|
if (typeof value !== 'string' || !OWNER_PATTERN.test(value)) {
|
||||||
|
fail('repository owner must be one lowercase GitHub owner');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCandidate(candidate, options) {
|
||||||
|
auditReleaseCandidateContract(candidate, {
|
||||||
|
root: options.root || DEFAULT_ROOT,
|
||||||
|
version: options.version,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
sourceRef: options.sourceRef,
|
||||||
|
releaseScope: options.releaseScope,
|
||||||
|
});
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedImage(candidate, imageName) {
|
||||||
|
const matches = candidate.images.filter((entry) => entry.image === imageName);
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
fail('image must be selected exactly once by the release candidate');
|
||||||
|
}
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveVerifiedImageRecord(
|
||||||
|
candidate,
|
||||||
|
repositoryOwner,
|
||||||
|
imageName,
|
||||||
|
digest,
|
||||||
|
) {
|
||||||
|
if (!DIGEST_PATTERN.test(digest || '')) {
|
||||||
|
fail('image digest must be an exact SHA-256 digest');
|
||||||
|
}
|
||||||
|
const selected = selectedImage(candidate, imageName);
|
||||||
|
const repository = `ghcr.io/${repositoryOwner}/${selected.repository}`;
|
||||||
|
const image = {
|
||||||
|
name: selected.image,
|
||||||
|
repository: selected.repository,
|
||||||
|
digest,
|
||||||
|
reference: `${repository}@${digest}`,
|
||||||
|
versionTag: `${repository}:${candidate.release.version}`,
|
||||||
|
sourceTag: `${repository}:sha-${candidate.release.sourceRevision}`,
|
||||||
|
platforms: [...candidate.compatibility.platforms],
|
||||||
|
};
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: IMAGE_RECORD_SCHEMA,
|
||||||
|
release: { ...candidate.release },
|
||||||
|
candidateContractDigest: candidate.contractDigest,
|
||||||
|
repositoryOwner,
|
||||||
|
image,
|
||||||
|
verification: {
|
||||||
|
remoteDigestVerified: true,
|
||||||
|
keylessSignatureVerified: true,
|
||||||
|
githubAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||||
|
localProfileRolloutVerified: selected.image === 'local',
|
||||||
|
tagPromotion: 'deferred_to_complete_release_set',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
...unsigned,
|
||||||
|
recordDigest: sha256(JSON.stringify(unsigned)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVerifiedImageRecord(options) {
|
||||||
|
const candidate = verifyCandidate(options.candidate, options);
|
||||||
|
const owner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||||
|
return deriveVerifiedImageRecord(
|
||||||
|
candidate,
|
||||||
|
owner,
|
||||||
|
options.image,
|
||||||
|
options.digest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImageRecord(record, candidate, repositoryOwner) {
|
||||||
|
if (
|
||||||
|
!exactKeys(record, [
|
||||||
|
'schemaVersion',
|
||||||
|
'schema',
|
||||||
|
'release',
|
||||||
|
'candidateContractDigest',
|
||||||
|
'repositoryOwner',
|
||||||
|
'image',
|
||||||
|
'verification',
|
||||||
|
'recordDigest',
|
||||||
|
]) ||
|
||||||
|
record.schemaVersion !== 1 ||
|
||||||
|
record.schema !== IMAGE_RECORD_SCHEMA ||
|
||||||
|
!exactKeys(record.image, [
|
||||||
|
'name',
|
||||||
|
'repository',
|
||||||
|
'digest',
|
||||||
|
'reference',
|
||||||
|
'versionTag',
|
||||||
|
'sourceTag',
|
||||||
|
'platforms',
|
||||||
|
]) ||
|
||||||
|
!exactKeys(record.verification, [
|
||||||
|
'remoteDigestVerified',
|
||||||
|
'keylessSignatureVerified',
|
||||||
|
'githubAttestations',
|
||||||
|
'localProfileRolloutVerified',
|
||||||
|
'tagPromotion',
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
fail('image record shape is invalid');
|
||||||
|
}
|
||||||
|
const expected = deriveVerifiedImageRecord(
|
||||||
|
candidate,
|
||||||
|
repositoryOwner,
|
||||||
|
record.image.name,
|
||||||
|
record.image.digest,
|
||||||
|
);
|
||||||
|
if (JSON.stringify(record) !== JSON.stringify(expected)) {
|
||||||
|
fail(`image record drifted: ${record.image.name || 'unknown'}`);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentFamily(candidate, family, imageNames) {
|
||||||
|
const source = candidate.deploymentFamilies[family];
|
||||||
|
return Object.freeze({
|
||||||
|
selected: source.selected,
|
||||||
|
profiles: [...source.profiles],
|
||||||
|
images: imageNames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReleaseSet(options) {
|
||||||
|
const candidate = verifyCandidate(options.candidate, options);
|
||||||
|
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||||
|
if (!Array.isArray(options.records)) fail('image records must be an array');
|
||||||
|
if (options.records.length !== candidate.images.length) {
|
||||||
|
fail('image record count differs from the release candidate');
|
||||||
|
}
|
||||||
|
const recordsByName = new Map();
|
||||||
|
for (const record of options.records) {
|
||||||
|
validateImageRecord(record, candidate, repositoryOwner);
|
||||||
|
if (recordsByName.has(record.image.name)) {
|
||||||
|
fail('image records must be unique');
|
||||||
|
}
|
||||||
|
recordsByName.set(record.image.name, record);
|
||||||
|
}
|
||||||
|
const orderedRecords = candidate.images.map((entry) => {
|
||||||
|
const record = recordsByName.get(entry.image);
|
||||||
|
if (!record) fail(`missing image record: ${entry.image}`);
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
const localImages = orderedRecords
|
||||||
|
.filter((record) => record.image.name === 'local')
|
||||||
|
.map((record) => record.image.name);
|
||||||
|
const clusterImages = orderedRecords
|
||||||
|
.filter((record) => record.image.name !== 'local')
|
||||||
|
.map((record) => record.image.name);
|
||||||
|
const images = orderedRecords.map((record) => ({
|
||||||
|
...record.image,
|
||||||
|
imageRecordDigest: record.recordDigest,
|
||||||
|
}));
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: RELEASE_SET_SCHEMA,
|
||||||
|
release: { ...candidate.release },
|
||||||
|
candidate: {
|
||||||
|
schema: candidate.schema,
|
||||||
|
contractDigest: candidate.contractDigest,
|
||||||
|
},
|
||||||
|
repositoryOwner,
|
||||||
|
platforms: [...candidate.compatibility.platforms],
|
||||||
|
deploymentFamilies: {
|
||||||
|
local: deploymentFamily(candidate, 'local', localImages),
|
||||||
|
cluster: deploymentFamily(candidate, 'cluster', clusterImages),
|
||||||
|
},
|
||||||
|
images,
|
||||||
|
promotion: {
|
||||||
|
authority: 'complete_verified_release_set',
|
||||||
|
versionTags: 'promote_after_complete_set_audit',
|
||||||
|
sourceTags: 'promote_after_complete_set_audit',
|
||||||
|
crossRepositoryAtomicity: false,
|
||||||
|
recovery: 'verify_exact_digest_then_continue',
|
||||||
|
},
|
||||||
|
requiredVerification: {
|
||||||
|
imageKeylessSignature: true,
|
||||||
|
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||||
|
releaseSetBuildProvenance: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
...unsigned,
|
||||||
|
releaseSetDigest: sha256(JSON.stringify(unsigned)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditReleaseSet(actual, options) {
|
||||||
|
const expected = createReleaseSet(options);
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
fail('release set differs from the verified image records');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
compatible: true,
|
||||||
|
releaseSetDigest: actual.releaseSetDigest,
|
||||||
|
releaseScope: actual.release.scope,
|
||||||
|
imageCount: actual.images.length,
|
||||||
|
images: Object.freeze(actual.images.map((entry) => entry.name)),
|
||||||
|
references: Object.freeze(actual.images.map((entry) => entry.reference)),
|
||||||
|
tagPromotionAuthority: actual.promotion.authority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecordDirectory(directoryPath, candidate) {
|
||||||
|
const resolved = resolveCanonicalAbsolute(directoryPath, 'records');
|
||||||
|
const stat = fs.lstatSync(resolved);
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
fs.realpathSync(resolved) !== resolved
|
||||||
|
) {
|
||||||
|
fail('records must be one canonical directory');
|
||||||
|
}
|
||||||
|
const expectedNames = candidate.images
|
||||||
|
.map((entry) => `${entry.image}.json`)
|
||||||
|
.sort();
|
||||||
|
const actualNames = fs.readdirSync(resolved).sort();
|
||||||
|
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||||
|
fail('records directory differs from the exact selected image set');
|
||||||
|
}
|
||||||
|
return candidate.images.map((entry) =>
|
||||||
|
readCanonicalJson(
|
||||||
|
path.join(resolved, `${entry.image}.json`),
|
||||||
|
'image record',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
'candidate',
|
||||||
|
'mode',
|
||||||
|
'release-scope',
|
||||||
|
'repository-owner',
|
||||||
|
'source-ref',
|
||||||
|
'source-revision',
|
||||||
|
'version',
|
||||||
|
];
|
||||||
|
const expected =
|
||||||
|
values.mode === 'record-image'
|
||||||
|
? [...common, 'digest', 'image', 'output']
|
||||||
|
: values.mode === 'aggregate'
|
||||||
|
? [...common, 'output', 'records']
|
||||||
|
: values.mode === 'audit'
|
||||||
|
? [...common, 'records', '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,
|
||||||
|
candidate: values.candidate,
|
||||||
|
version: values.version,
|
||||||
|
sourceRevision: values['source-revision'],
|
||||||
|
sourceRef: values['source-ref'],
|
||||||
|
releaseScope: values['release-scope'],
|
||||||
|
repositoryOwner: values['repository-owner'],
|
||||||
|
...(values.image ? { image: values.image } : {}),
|
||||||
|
...(values.digest ? { digest: values.digest } : {}),
|
||||||
|
...(values.records ? { records: values.records } : {}),
|
||||||
|
...(values.output ? { output: values.output } : {}),
|
||||||
|
...(values.report ? { report: values.report } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||||
|
const options = parseArguments(argv);
|
||||||
|
const candidate = readCanonicalJson(options.candidate, 'release candidate');
|
||||||
|
if (options.mode === 'record-image') {
|
||||||
|
const record = createVerifiedImageRecord({ ...options, candidate, root });
|
||||||
|
writeNoReplace(options.output, record);
|
||||||
|
output.write(canonicalJson(record));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
const records = readRecordDirectory(options.records, candidate);
|
||||||
|
if (options.mode === 'aggregate') {
|
||||||
|
const releaseSet = createReleaseSet({
|
||||||
|
...options,
|
||||||
|
candidate,
|
||||||
|
records,
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
writeNoReplace(options.output, releaseSet);
|
||||||
|
output.write(canonicalJson(releaseSet));
|
||||||
|
return releaseSet;
|
||||||
|
}
|
||||||
|
const report = readCanonicalJson(options.report, 'release set');
|
||||||
|
const audit = auditReleaseSet(report, {
|
||||||
|
...options,
|
||||||
|
candidate,
|
||||||
|
records,
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
output.write(canonicalJson(audit));
|
||||||
|
return audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
runCli(process.argv.slice(2));
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(
|
||||||
|
`${error instanceof Error ? error.message : 'release set failed'}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
IMAGE_RECORD_SCHEMA,
|
||||||
|
RELEASE_SET_SCHEMA,
|
||||||
|
QingLong3ReleaseSetError,
|
||||||
|
auditReleaseSet,
|
||||||
|
createReleaseSet,
|
||||||
|
createVerifiedImageRecord,
|
||||||
|
parseArguments,
|
||||||
|
runCli,
|
||||||
|
});
|
||||||
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
|||||||
'.github/workflows/ql3-image-release.yml',
|
'.github/workflows/ql3-image-release.yml',
|
||||||
(source) =>
|
(source) =>
|
||||||
source.replace(
|
source.replace(
|
||||||
'Promote only the verified digest to immutable release tags',
|
'Promote tags only after the complete set is verified',
|
||||||
'Promote mutable release tags',
|
'Promote mutable release tags',
|
||||||
),
|
),
|
||||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
'c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467',
|
'c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467',
|
||||||
rebuildAfterScan: false,
|
rebuildAfterScan: false,
|
||||||
tagAfterVerification: true,
|
tagAfterVerification: true,
|
||||||
|
tagAfterCompleteReleaseSet: true,
|
||||||
|
},
|
||||||
|
releaseSet: {
|
||||||
|
sourceDerived: true,
|
||||||
|
sameRunRecords: true,
|
||||||
|
exactScopeClosure: true,
|
||||||
|
tagPromotionAuthority: 'complete_verified_release_set',
|
||||||
|
fileProvenanceAttested: true,
|
||||||
|
artifactRetentionDays: 90,
|
||||||
|
crossRepositoryAtomicity: false,
|
||||||
},
|
},
|
||||||
localRolloutPreflight: true,
|
localRolloutPreflight: true,
|
||||||
localRolloutApply: true,
|
localRolloutApply: true,
|
||||||
@@ -100,6 +110,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
'cyclonedx',
|
'cyclonedx',
|
||||||
'os-vulnerability',
|
'os-vulnerability',
|
||||||
'release-candidate',
|
'release-candidate',
|
||||||
|
'release-set',
|
||||||
'release-tags',
|
'release-tags',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -506,7 +517,7 @@ test('rejects a movable action tag in the privileged release job', () => {
|
|||||||
)}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`;
|
)}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`;
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => auditReleaseWorkflow(mutated),
|
() => auditReleaseWorkflow(mutated),
|
||||||
/privileged publisher|immutable checkout action/,
|
/privileged publisher|immutable checkout action|release-set job/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -654,18 +665,62 @@ test('rejects an image tag created before digest verification completes', () =>
|
|||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => auditReleaseWorkflow(mutated),
|
() => auditReleaseWorkflow(mutated),
|
||||||
/before final tag promotion/,
|
/without any rebuild or tag promotion before release-set closure/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects any publisher step after immutable tag promotion', () => {
|
test('rejects any publisher step after the deployment lock is published', () => {
|
||||||
const mutated = `${releaseSource}\n - name: Post-promotion mutation\n run: echo unsafe\n`;
|
const mutated = `${releaseSource}\n - name: Post-promotion mutation\n run: echo unsafe\n`;
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => auditReleaseWorkflow(mutated),
|
() => auditReleaseWorkflow(mutated),
|
||||||
/before final tag promotion/,
|
/release-set job must download only same-run records/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects release-set closure before every image publisher succeeds', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
" needs.publish.result == 'success'",
|
||||||
|
" needs.publish.result != 'success'",
|
||||||
|
);
|
||||||
|
assert.throws(() => auditReleaseWorkflow(mutated), /complete publish matrix/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects image records detached from the same workflow attempt', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||||
|
'ql3-release-record-${{ github.run_id }}-*',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/download only same-run records/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects release-set aggregation without independent audit', () => {
|
||||||
|
const releaseSetOffset = releaseSource.indexOf('\n release-set:');
|
||||||
|
assert.notEqual(releaseSetOffset, -1);
|
||||||
|
const releaseSetSource = releaseSource.slice(releaseSetOffset);
|
||||||
|
const mutatedReleaseSet = releaseSetSource.replace(
|
||||||
|
' --mode=audit \\',
|
||||||
|
' --mode=aggregate \\',
|
||||||
|
);
|
||||||
|
const mutated = `${releaseSource.slice(
|
||||||
|
0,
|
||||||
|
releaseSetOffset,
|
||||||
|
)}${mutatedReleaseSet}`;
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/download only same-run records/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a short-lived deployment digest lock', () => {
|
||||||
|
const marker = ' retention-days: 90';
|
||||||
|
assert.equal(releaseSource.includes(marker), true);
|
||||||
|
const mutated = releaseSource.replace(marker, ' retention-days: 1');
|
||||||
|
assert.throws(() => auditReleaseWorkflow(mutated), /deployment lock/);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects removal of the digest-bound OS vulnerability attestation', () => {
|
test('rejects removal of the digest-bound OS vulnerability attestation', () => {
|
||||||
const mutated = releaseSource.replace(
|
const mutated = releaseSource.replace(
|
||||||
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ test('combines local and cluster families without weakening either gate', () =>
|
|||||||
contract.requiredGates.includes('edge-and-standalone-rollout'),
|
contract.requiredGates.includes('edge-and-standalone-rollout'),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
contract.requiredGates.includes('cross-image-release-set'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
contract.requiredGates.includes('worker-management-production-evidence'),
|
contract.requiredGates.includes('worker-management-production-evidence'),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
'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 {
|
||||||
|
auditReleaseSet,
|
||||||
|
createReleaseSet,
|
||||||
|
createVerifiedImageRecord,
|
||||||
|
parseArguments,
|
||||||
|
runCli,
|
||||||
|
} = require('../../scripts/ql3-release-set-contract.cjs');
|
||||||
|
const {
|
||||||
|
createReleaseCandidateContract,
|
||||||
|
} = require('../../scripts/ql3-release-candidate-contract.cjs');
|
||||||
|
const {
|
||||||
|
readReleaseIdentity,
|
||||||
|
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '../..');
|
||||||
|
const version = readReleaseIdentity(root).version;
|
||||||
|
const identity = Object.freeze({
|
||||||
|
version,
|
||||||
|
sourceRevision: 'b'.repeat(40),
|
||||||
|
sourceRef: `refs/tags/v${version}`,
|
||||||
|
repositoryOwner: 'qinglong-release',
|
||||||
|
});
|
||||||
|
|
||||||
|
function candidate(scope) {
|
||||||
|
return createReleaseCandidateContract({
|
||||||
|
root,
|
||||||
|
version,
|
||||||
|
sourceRevision: identity.sourceRevision,
|
||||||
|
sourceRef: identity.sourceRef,
|
||||||
|
releaseScope: scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordsFor(releaseCandidate) {
|
||||||
|
return releaseCandidate.images.map((entry, index) =>
|
||||||
|
createVerifiedImageRecord({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
...identity,
|
||||||
|
releaseScope: releaseCandidate.release.scope,
|
||||||
|
image: entry.image,
|
||||||
|
digest: `sha256:${String(index + 1).repeat(64)}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryDirectory(t) {
|
||||||
|
const directory = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-set-')),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCanonical(filePath, value) {
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('aggregates the independent Local image into one immutable release set', () => {
|
||||||
|
const releaseCandidate = candidate('local');
|
||||||
|
const records = recordsFor(releaseCandidate);
|
||||||
|
const releaseSet = createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records,
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
releaseSet.images.map((entry) => entry.name),
|
||||||
|
['local'],
|
||||||
|
);
|
||||||
|
assert.equal(releaseSet.deploymentFamilies.local.selected, true);
|
||||||
|
assert.equal(releaseSet.deploymentFamilies.cluster.selected, false);
|
||||||
|
assert.match(releaseSet.images[0].reference, /@sha256:1{64}$/u);
|
||||||
|
assert.equal(releaseSet.promotion.authority, 'complete_verified_release_set');
|
||||||
|
assert.match(releaseSet.releaseSetDigest, /^sha256:[a-f0-9]{64}$/u);
|
||||||
|
assert.equal(
|
||||||
|
auditReleaseSet(releaseSet, {
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records,
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
}).compatible,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes cluster and all scopes over the exact candidate image order', () => {
|
||||||
|
const clusterCandidate = candidate('cluster');
|
||||||
|
const clusterSet = createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: clusterCandidate,
|
||||||
|
records: recordsFor(clusterCandidate).reverse(),
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
clusterSet.images.map((entry) => entry.name),
|
||||||
|
['control', 'control-ai', 'admin', 'worker'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(clusterSet.deploymentFamilies.cluster.images, [
|
||||||
|
'control',
|
||||||
|
'control-ai',
|
||||||
|
'admin',
|
||||||
|
'worker',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allCandidate = candidate('all');
|
||||||
|
const allSet = createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: allCandidate,
|
||||||
|
records: recordsFor(allCandidate),
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'all',
|
||||||
|
});
|
||||||
|
assert.equal(allSet.images.length, 5);
|
||||||
|
assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||||
|
const releaseCandidate = candidate('cluster');
|
||||||
|
const records = recordsFor(releaseCandidate);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records: records.slice(1),
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
}),
|
||||||
|
/record count differs/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records: [records[0], records[0], records[2], records[3]],
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
}),
|
||||||
|
/records must be unique/,
|
||||||
|
);
|
||||||
|
const mutated = JSON.parse(JSON.stringify(records[0]));
|
||||||
|
mutated.release.sourceRevision = 'c'.repeat(40);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records: [mutated, ...records.slice(1)],
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
}),
|
||||||
|
/image record drifted/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects mutable identity, malformed owner and post-aggregate drift', () => {
|
||||||
|
const releaseCandidate = candidate('local');
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createVerifiedImageRecord({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
...identity,
|
||||||
|
repositoryOwner: 'UPPERCASE',
|
||||||
|
releaseScope: 'local',
|
||||||
|
image: 'local',
|
||||||
|
digest: `sha256:${'1'.repeat(64)}`,
|
||||||
|
}),
|
||||||
|
/lowercase GitHub owner/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createVerifiedImageRecord({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
image: 'local',
|
||||||
|
digest: 'latest',
|
||||||
|
}),
|
||||||
|
/exact SHA-256 digest/,
|
||||||
|
);
|
||||||
|
const records = recordsFor(releaseCandidate);
|
||||||
|
const releaseSet = createReleaseSet({
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records,
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
});
|
||||||
|
const mutated = JSON.parse(JSON.stringify(releaseSet));
|
||||||
|
mutated.images[0].versionTag = 'ghcr.io/qinglong-release/other:latest';
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
auditReleaseSet(mutated, {
|
||||||
|
root,
|
||||||
|
candidate: releaseCandidate,
|
||||||
|
records,
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
}),
|
||||||
|
/differs from the verified image records/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||||
|
const directory = temporaryDirectory(t);
|
||||||
|
const recordsDirectory = path.join(directory, 'records');
|
||||||
|
fs.mkdirSync(recordsDirectory);
|
||||||
|
const releaseCandidate = candidate('local');
|
||||||
|
const candidatePath = path.join(directory, 'candidate.json');
|
||||||
|
writeCanonical(candidatePath, releaseCandidate);
|
||||||
|
const recordPath = path.join(recordsDirectory, 'local.json');
|
||||||
|
const common = [
|
||||||
|
`--version=${version}`,
|
||||||
|
`--source-revision=${identity.sourceRevision}`,
|
||||||
|
`--source-ref=${identity.sourceRef}`,
|
||||||
|
'--release-scope=local',
|
||||||
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
|
`--candidate=${candidatePath}`,
|
||||||
|
];
|
||||||
|
const output = { write() {} };
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=record-image',
|
||||||
|
...common,
|
||||||
|
'--image=local',
|
||||||
|
`--digest=sha256:${'1'.repeat(64)}`,
|
||||||
|
`--output=${recordPath}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
assert.equal(fs.statSync(recordPath).mode & 0o777, 0o600);
|
||||||
|
const setPath = path.join(directory, 'release-set.json');
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=aggregate',
|
||||||
|
...common,
|
||||||
|
`--records=${recordsDirectory}`,
|
||||||
|
`--output=${setPath}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=audit',
|
||||||
|
...common,
|
||||||
|
`--records=${recordsDirectory}`,
|
||||||
|
`--report=${setPath}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
output,
|
||||||
|
).compatible,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=aggregate',
|
||||||
|
...common,
|
||||||
|
`--records=${recordsDirectory}`,
|
||||||
|
`--output=${setPath}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
output,
|
||||||
|
),
|
||||||
|
/output must be unused/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||||
|
const directory = temporaryDirectory(t);
|
||||||
|
const recordsDirectory = path.join(directory, 'records');
|
||||||
|
fs.mkdirSync(recordsDirectory);
|
||||||
|
const releaseCandidate = candidate('local');
|
||||||
|
const candidatePath = path.join(directory, 'candidate.json');
|
||||||
|
writeCanonical(candidatePath, releaseCandidate);
|
||||||
|
writeCanonical(
|
||||||
|
path.join(recordsDirectory, 'local.json'),
|
||||||
|
recordsFor(releaseCandidate)[0],
|
||||||
|
);
|
||||||
|
writeCanonical(path.join(recordsDirectory, 'extra.json'), {});
|
||||||
|
const common = [
|
||||||
|
`--version=${version}`,
|
||||||
|
`--source-revision=${identity.sourceRevision}`,
|
||||||
|
`--source-ref=${identity.sourceRef}`,
|
||||||
|
'--release-scope=local',
|
||||||
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
|
`--candidate=${candidatePath}`,
|
||||||
|
];
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=aggregate',
|
||||||
|
...common,
|
||||||
|
`--records=${recordsDirectory}`,
|
||||||
|
`--output=${path.join(directory, 'set.json')}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
{ write() {} },
|
||||||
|
),
|
||||||
|
/exact selected image set/,
|
||||||
|
);
|
||||||
|
fs.unlinkSync(path.join(recordsDirectory, 'extra.json'));
|
||||||
|
fs.renameSync(candidatePath, path.join(directory, 'candidate-target.json'));
|
||||||
|
fs.symlinkSync('candidate-target.json', candidatePath);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=aggregate',
|
||||||
|
...common,
|
||||||
|
`--records=${recordsDirectory}`,
|
||||||
|
`--output=${path.join(directory, 'set.json')}`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
{ write() {} },
|
||||||
|
),
|
||||||
|
/canonical regular file/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseArguments(['--mode=audit', '--extra=true']),
|
||||||
|
/arguments are invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user