mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): close release tags after catalog verification
This commit is contained in:
@@ -303,6 +303,7 @@ jobs:
|
||||
test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs
|
||||
test/back/ql3ReleaseSetContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogContract.test.cjs
|
||||
test/back/ql3ReleasePublicationClosureContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
|
||||
test/back/ql3DeploymentLockContract.test.cjs
|
||||
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
||||
|
||||
@@ -810,51 +810,6 @@ jobs:
|
||||
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:
|
||||
@@ -1007,6 +962,168 @@ jobs:
|
||||
with:
|
||||
subject-path: ${{ steps.catalog-receipt.outputs.receipt }}
|
||||
|
||||
- name: Materialize the catalog-authorized final tag publication plan
|
||||
id: final-publication
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||||
RELEASE_SET: ${{ steps.release-set.outputs.report }}
|
||||
CATALOG_PLAN: ${{ steps.release-set.outputs.plan }}
|
||||
CATALOG_MANIFEST: ${{ steps.catalog.outputs.manifest }}
|
||||
CATALOG_MANIFEST_DIGEST: ${{ steps.catalog.outputs.digest }}
|
||||
CATALOG_RECEIPT: ${{ steps.catalog-receipt.outputs.receipt }}
|
||||
BUNDLE: ${{ steps.release-set.outputs.bundle }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
source_repository="${GITHUB_REPOSITORY,,}"
|
||||
plan="${BUNDLE}/qinglong3-release-publication-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
|
||||
observations="${BUNDLE}/qinglong3-release-publication-tag-observation-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
|
||||
receipt="${BUNDLE}/qinglong3-release-publication-closure-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
|
||||
node scripts/ql3-release-publication-closure-contract.cjs \
|
||||
--mode=plan \
|
||||
--version="${RELEASE_VERSION}" \
|
||||
--source-revision="${GITHUB_SHA}" \
|
||||
--source-ref="${GITHUB_REF}" \
|
||||
--release-scope="${RELEASE_SCOPE}" \
|
||||
--repository-owner="${owner}" \
|
||||
--source-repository="${source_repository}" \
|
||||
--release-set="${RELEASE_SET}" \
|
||||
--catalog-plan="${CATALOG_PLAN}" \
|
||||
--catalog-manifest="${CATALOG_MANIFEST}" \
|
||||
--catalog-manifest-digest="${CATALOG_MANIFEST_DIGEST}" \
|
||||
--catalog-receipt="${CATALOG_RECEIPT}" \
|
||||
--output="${plan}" > /dev/null
|
||||
echo "plan=${plan}" >> "${GITHUB_OUTPUT}"
|
||||
echo "observations=${observations}" >> "${GITHUB_OUTPUT}"
|
||||
echo "receipt=${receipt}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Promote final tags only after the catalog receipt is attested
|
||||
env:
|
||||
REGCTL: ${{ runner.temp }}/regctl
|
||||
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
|
||||
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
createPublicationTagObservation,
|
||||
} = require('./scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8'));
|
||||
const regctl = process.env.REGCTL;
|
||||
const maxInventoryBytes = 1024 * 1024;
|
||||
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
|
||||
const run = (args) => {
|
||||
const result = spawnSync(regctl, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: maxInventoryBytes,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`regctl ${args.join(' ')} failed`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
const states = [];
|
||||
for (const image of plan.images) {
|
||||
const source = run(['image', 'digest', image.immutableReference]).trim();
|
||||
if (source !== image.digest) {
|
||||
throw new Error('source digest drifted before promotion');
|
||||
}
|
||||
const inventoryContents = run([
|
||||
'tag',
|
||||
'ls',
|
||||
image.registryRepository,
|
||||
'--format',
|
||||
'{{ range .Tags }}{{ println . }}{{ end }}',
|
||||
]);
|
||||
if (
|
||||
Buffer.byteLength(inventoryContents) > maxInventoryBytes ||
|
||||
(inventoryContents.length > 0 && !inventoryContents.endsWith('\n'))
|
||||
) {
|
||||
throw new Error('release tag inventory is invalid or unbounded');
|
||||
}
|
||||
const inventory = inventoryContents.length === 0
|
||||
? []
|
||||
: inventoryContents.slice(0, -1).split('\n');
|
||||
if (
|
||||
inventory.some((tag) => !tagPattern.test(tag)) ||
|
||||
new Set(inventory).size !== inventory.length
|
||||
) {
|
||||
throw new Error('release tag inventory is malformed');
|
||||
}
|
||||
const inventorySet = new Set(inventory);
|
||||
for (const tag of image.tags) {
|
||||
const tagName = tag.reference.slice(image.registryRepository.length + 1);
|
||||
const present = inventorySet.has(tagName);
|
||||
if (present) {
|
||||
const current = run(['image', 'digest', tag.reference]).trim();
|
||||
if (current !== image.digest) {
|
||||
throw new Error('release tag already points at another digest');
|
||||
}
|
||||
}
|
||||
states.push({ image, tag, present });
|
||||
}
|
||||
}
|
||||
for (const state of states) {
|
||||
if (!state.present) {
|
||||
run([
|
||||
'image',
|
||||
'copy',
|
||||
state.image.immutableReference,
|
||||
state.tag.reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
const observedTags = [];
|
||||
for (const state of states) {
|
||||
const promoted = run(['image', 'digest', state.tag.reference]).trim();
|
||||
if (promoted !== state.image.digest) {
|
||||
throw new Error('promoted tag does not resolve to the release-set digest');
|
||||
}
|
||||
observedTags.push({
|
||||
image: state.image.name,
|
||||
kind: state.tag.kind,
|
||||
reference: state.tag.reference,
|
||||
digest: promoted,
|
||||
});
|
||||
}
|
||||
const observation = createPublicationTagObservation(plan, observedTags);
|
||||
const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Close and audit the final public tag set
|
||||
env:
|
||||
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
|
||||
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
|
||||
CLOSURE_RECEIPT: ${{ steps.final-publication.outputs.receipt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/ql3-release-publication-closure-contract.cjs \
|
||||
--mode=close \
|
||||
--plan="${PUBLICATION_PLAN}" \
|
||||
--observations="${TAG_OBSERVATIONS}" \
|
||||
--output="${CLOSURE_RECEIPT}" > /dev/null
|
||||
node scripts/ql3-release-publication-closure-contract.cjs \
|
||||
--mode=audit \
|
||||
--plan="${PUBLICATION_PLAN}" \
|
||||
--observations="${TAG_OBSERVATIONS}" \
|
||||
--receipt="${CLOSURE_RECEIPT}" > "${RUNNER_TEMP}/release-publication-closure-audit.json"
|
||||
|
||||
- name: Attest the immutable release publication closure receipt
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
|
||||
with:
|
||||
subject-path: ${{ steps.final-publication.outputs.receipt }}
|
||||
|
||||
- name: Publish the deployment digest lock
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
|
||||
最新增量证据(2026-08-18):
|
||||
|
||||
- D-350/ADR-0442(已接受;首份真实 GHCR 部分 promotion/replay 证据待实际 release tag):正式 image tag 不再在完整
|
||||
release-set 审计后、durable catalog 建立前提前公开。`versionTag/sourceTag` mutation 现位于 release-set provenance、catalog
|
||||
immutable round-trip、catalog signature/provenance 验证、catalog receipt 审计及其 attestation 全部成功之后。新的
|
||||
`qinglong/release-publication-plan@v1` 联合复验 exact release-set、catalog plan/raw manifest/manifest digest/receipt,并只列出每个
|
||||
image 的 immutable source 与两个目标 tag。publisher 对每个已存在 image repository 取得最大 1 MiB 的 canonical tag inventory,
|
||||
严格检查 OCI tag 字符集与无重复;auth/network/registry error 不再被任意 `image digest` 非零退出降级成 absent。所有 immutable source
|
||||
和既有目标 tag 必须在任何 mutation 前完成全量预检,冲突保持零 tag 写;随后只 copy absent tag,exact tag 用于 response-loss 重放。
|
||||
最终逐 tag 回读形成 `qinglong/release-publication-tag-observation@v1`,再与上游闭包组成自摘要
|
||||
`qinglong/release-publication-closure-receipt@v1`,本地复审并独立 attested 后才上传 deployment bundle。receipt 诚实保留
|
||||
`crossRepositoryAtomicity=false` 与 `registryTagCas=false`;immutable catalog digest 仍是唯一部署 authority。实现不新增 package、
|
||||
生产依赖、数据库、migration、Kubernetes object、设备工具或常驻资源;所有 inventory 与收据只存在于短生命周期 release runner,
|
||||
Edge/Standalone/路由设备和 Cluster 节点稳态成本均为零。阶段门已重跑:专项 closure/workflow 96/96、完整 backend 1382 pass +
|
||||
2 条条件 skip、18-package clean build/test 全通过、14/14 静态审计与 14/14 artifact 档位通过;artifact 实际字节依次为基础
|
||||
Edge/Standalone `2589890/2589968`、adopted `2809185/2809308`、application `3632769/3632889`、application-api
|
||||
`3800322/3800466`、AI `3069143/3069233`、application+AI `4493043/4493175`、MCP `7315930/7316038`,均低于各自硬上限。
|
||||
PostgreSQL HA Docker 门以 PostgreSQL 18.6/arm64 完成 142/142、timeline `1→2`,私有报告 SHA-256 为
|
||||
`c8c5cad7a7feb6db066b14efcc241f33ed574c3d80b909014bc894d8f9cb7cbf`,证据复审通过且临时容器/卷/网络残留均为零。Barman
|
||||
live object-store 恢复与 cert-manager 在线轮换仍诚实保持外部 release blocker。真实 GHCR 部分 promotion/response-loss 仍只能由
|
||||
受保护 `v3` tag 或受控 release repository 演练证明。
|
||||
|
||||
- D-349/ADR-0441(已接受;首份真实 GHCR conflict/reuse 证据待实际 release tag):关闭 durable catalog discovery tag 的覆盖窗口。
|
||||
之前 workflow 虽声明 `v<version>-<scope>` 无部署 authority,却直接对该 tag 执行 `artifact put`;已有不同 digest 会先被覆盖,
|
||||
response-loss 重跑也没有“相同复用、冲突拒绝”的可执行分支。catalog plan/receipt 现升级为 v2,publisher 必须先在 runner 私有
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# ADR-0442:Catalog-ready 的终态 Release Tag 发布与闭合收据
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-18
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-349、D-350
|
||||
- 关联 ADR:ADR-0427、ADR-0428、ADR-0439、ADR-0441
|
||||
- Supersedes:ADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序
|
||||
|
||||
## 上下文
|
||||
|
||||
QingLong 3 的 image publisher 先写入无 tag 的 immutable digest,完成逐镜像签名、SBOM、漏洞与 provenance 验证,再由
|
||||
release-set job 聚合所有镜像。ADR-0427 因此允许在完整 release-set 审计后 promotion `versionTag/sourceTag`。
|
||||
|
||||
后续 ADR 又增加 release-set file provenance、durable OCI catalog、catalog signature/provenance、manifest round-trip 和 catalog receipt。
|
||||
原有 promotion 步骤却仍位于这些 Gate 之前。如果 catalog 发布、签名、attestation 或 receipt 失败,公开 image tag 已经可见,但可部署的
|
||||
immutable catalog authority 尚未闭合。旧 promotion 还把任意 `regctl image digest <tag>` 非零退出都当作 tag absent;网络、认证或 registry
|
||||
错误可能因此被错误降级成“可以写入”。
|
||||
|
||||
## 决策
|
||||
|
||||
1. `versionTag/sourceTag` mutation 移到以下事实全部成功之后:完整 release-set 已审计并 attested;catalog 已按 immutable digest
|
||||
round-trip;catalog signature 与 GitHub provenance 已验证;catalog receipt 已生成、审计并 attested。
|
||||
2. 新的 `qinglong/release-publication-plan@v1` 必须重新读取并联合审计 exact release-set、catalog plan、raw manifest、manifest
|
||||
digest 与 catalog receipt。计划只含 release identity、上游摘要、每个 image 的 immutable reference/digest、两个目标 tag 和固定策略;
|
||||
catalog receipt 之前不能生成该计划。
|
||||
3. promotion 在任何 tag mutation 前,对每个已存在的 image repository 执行一次完整 tag inventory:最大 1 MiB、canonical line、OCI
|
||||
tag 字符集、无重复。inventory 读取失败、超限或畸形全部失败关闭,不能再把任意 digest lookup 错误解释为 absent。
|
||||
4. 预检首先验证所有 immutable source digest。inventory 中已存在的所有目标 tag 必须逐个解析为计划 digest;任一冲突时尚未发生任何
|
||||
tag mutation。只有全量预检成功后才依固定顺序 copy absent tag;exact tag 不重写,用于 response-loss 恢复。
|
||||
5. copy 阶段完成后,必须重新读取所有 `2 × imageCount` 个 tag,并用
|
||||
`qinglong/release-publication-tag-observation@v1` 固化 exact ordered mapping。缺失、额外、重排、重复或 digest 漂移均不能闭合。
|
||||
6. `qinglong/release-publication-closure-receipt@v1` 同时绑定 publication plan、release-set、catalog plan/receipt/manifest、最终 tag
|
||||
observation 和固定策略,并具有自身 digest。plan、observation、receipt 必须一起进入 90 天 deployment bundle;receipt 必须再次本地
|
||||
审计并单独 attested,使下载者可以离线重放 closure audit。
|
||||
7. receipt 诚实保留 `crossRepositoryAtomicity=false` 与 `registryTagCas=false`。它证明 workflow 观察到 catalog-ready 后的完整终态,
|
||||
不声称 GHCR 提供跨 repository 事务或 tag CAS。发布中途失败时不删除正确 tag;同 protected source tag 重跑只能复用 exact digest,
|
||||
任何不同 digest 都失败。
|
||||
8. deployment consumer 仍只信任已签名/attested 的 immutable catalog digest。最终 image tag 和 closure receipt 是发布可见性与运维证据,
|
||||
不是部署 authority 的替代品。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- catalog receipt attestation 前失败:没有正式 image tag mutation;修复后从同一 protected source tag 重跑。
|
||||
- repository inventory 读取不确定:立即停止,不能把 auth/network/registry error 当作 absent。
|
||||
- 任一既有 tag 指向其他 digest:全量预检阶段停止,不写任何本轮目标 tag。
|
||||
- promotion 中途 response loss:可能已有部分 exact tag;重跑重新取得全部 inventory,复用 exact tag,只补 absent tag,最后重建相同
|
||||
observation 与 closure receipt bytes。
|
||||
- promotion 后竞争:最终逐 tag digest 回读会阻止 closure;稍后外部改写也无法改变已 attested receipt 绑定的 immutable digest,consumer
|
||||
仍不会信任 mutable tag。
|
||||
|
||||
## 部署与资源影响
|
||||
|
||||
- Edge/Standalone/路由设备不执行该协议,不安装 Node、regctl、Cosign 或 GitHub CLI,不增加 RSS、磁盘写、timer、listener、watcher、
|
||||
updater 或常驻进程。
|
||||
- Cluster 节点、Kubernetes object、CloudNativePG、数据库、migration、SQL、Pool、Worker 与运行时镜像均无变化。
|
||||
- 新工作只在短生命周期 GitHub-hosted release runner:每个 image repository 一个最大 1 MiB inventory、一个小型计划/观察/收据文件和
|
||||
最终 tag 回读。不新增 workspace package、生产依赖或部署服务。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 继续在 release-set 审计后立即 promotion
|
||||
|
||||
拒绝。完整镜像集合不等于 durable catalog 已经可验证;后续 catalog 失败会留下过早公开的 tag。
|
||||
|
||||
### 继续把 `image digest` 任意失败解释为 absent
|
||||
|
||||
拒绝。不存在、无权限、网络中断和 registry 故障不能共享同一 mutation 决策。
|
||||
|
||||
### 先写 tag,再用 closure receipt 记录结果
|
||||
|
||||
拒绝。收据只能证明终态,不能修复错误的发布前置顺序;catalog-ready 必须是 mutation 的真实前置条件。
|
||||
|
||||
### 声称 closure receipt 提供跨 repository 原子性
|
||||
|
||||
拒绝。OCI registry 没有该事务语义。精确重放和终态闭合能收敛部分成功,但不能伪造原子 commit。
|
||||
|
||||
## 验证
|
||||
|
||||
- publication closure contract 覆盖 Local/Cluster/All 计划、确定性 receipt、缺失/重排/额外/digest 漂移 tag、上游 catalog 脱离、
|
||||
self-digest tamper、closed CLI 与 no-replace 输出;
|
||||
- workflow 静态门固定 `catalog receipt attestation → publication plan → bounded inventory/preflight → tag mutation → exact observation →
|
||||
closure audit/attestation → bundle upload` 顺序,并拒绝缺失 inventory、closure 或独立 attestation;
|
||||
- 发布链与完整仓库验证结果记录于 QL-RFC-0001 D-350;首份真实 GHCR 部分 promotion/response-loss 重放仍须由受保护 `v3` release tag
|
||||
或受控 release repository 演练产生。
|
||||
@@ -445,6 +445,7 @@
|
||||
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag) |
|
||||
| [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag) |
|
||||
| [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag) |
|
||||
| [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Accepted(首份真实 GHCR 部分 promotion/replay 证据待实际 release tag) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -437,10 +437,16 @@ immutable image reference 写入 compose/rollout。
|
||||
|
||||
## 发布失败与恢复
|
||||
|
||||
GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`。如果 promotion 中途
|
||||
失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证
|
||||
每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有
|
||||
release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family
|
||||
GHCR 不提供跨 repository tag 事务,release set 与最终 closure receipt 都明确记录 `crossRepositoryAtomicity=false`。正式
|
||||
`versionTag/sourceTag` 只能在 release-set file provenance、catalog immutable digest、catalog signature/provenance、catalog receipt
|
||||
及其 attestation 全部成功后开始。publisher 随后必须为每个 image repository 取得不超过 1 MiB 的完整 tag inventory;读取失败、非
|
||||
canonical line、非法 OCI tag 或重复项均失败关闭,不能把任意 `image digest` 错误当作 tag absent。它会在任何 mutation 前验证全部
|
||||
immutable source 与全部既有目标 tag,任一不同 digest 都使本轮零 tag 写入。
|
||||
|
||||
如果 promotion 中途失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会复用 exact
|
||||
tag,只补 absent tag;全部 tag 最终回读后生成 `qinglong/release-publication-tag-observation@v1` 与
|
||||
`qinglong/release-publication-closure-receipt@v1`。closure receipt 绑定 release-set、catalog plan/manifest/receipt 和每个最终 tag,
|
||||
与 plan、observation 一起进入 90 天 bundle;receipt 再次审计、attest 后,下载者可离线重放 closure audit。只有这条闭合链全部生成并验证后,才能宣布该 deployment family
|
||||
可部署。对于 `cluster|all`,还必须等待只读 catalog consumer 与 catalog-bound K3s deployment/retirement Gate 成功;publisher 成功而
|
||||
consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention
|
||||
或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。
|
||||
|
||||
@@ -252,10 +252,14 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Attest the complete release-set file provenance',
|
||||
'Publish and round-trip the durable OCI release catalog',
|
||||
'Attest durable release-catalog provenance',
|
||||
'Verify the durable catalog and create its immutable receipt',
|
||||
'Attest the immutable release-catalog receipt',
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Close and audit the final public tag set',
|
||||
'Attest the immutable release publication closure receipt',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
);
|
||||
|
||||
@@ -274,7 +274,7 @@ function auditClusterImageCiWorkflow(
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.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\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
|
||||
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
|
||||
);
|
||||
requirePattern(
|
||||
@@ -712,7 +712,7 @@ function auditReleaseWorkflow(source) {
|
||||
const releaseSetSteps = releaseSetJob?.steps;
|
||||
if (
|
||||
!Array.isArray(releaseSetSteps) ||
|
||||
releaseSetSteps.length !== 16 ||
|
||||
releaseSetSteps.length !== 19 ||
|
||||
releaseSetSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
@@ -750,55 +750,68 @@ function auditReleaseWorkflow(source) {
|
||||
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
|
||||
releaseSetSteps[7]?.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[8]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[9]?.uses !==
|
||||
releaseSetSteps[8]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[9]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||
}) ||
|
||||
releaseSetSteps[10]?.id !== 'catalog' ||
|
||||
releaseSetSteps[9]?.id !== 'catalog' ||
|
||||
!/local_tag="ocidir:\/\/\$\{local_layout\}:candidate"[\s\S]*artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*"\$\{local_tag\}"[\s\S]*expected_digest=.*image digest "\$\{local_tag\}"[\s\S]*local_immutable="ocidir:\/\/\$\{local_layout\}@\$\{expected_digest\}"/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*staging_tag="\$\{catalog_repository\}:staging-\$\{plan_digest#sha256:\}"[\s\S]*image copy "\$\{local_immutable\}" "\$\{staging_tag\}"[\s\S]*tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*--mode=tag-inventory[\s\S]*--plan="\$\{PLAN\}"[\s\S]*--tag-inventory="\$\{tags\}"[\s\S]*--output="\$\{inventory_decision\}"[\s\S]*tag_state=.*p\.observation/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/--mode=publication-decision[\s\S]*--manifest="\$\{local_manifest\}"[\s\S]*--manifest-digest="\$\{expected_digest\}"[\s\S]*--observed-discovery-digest="\$\{observed_digest\}"[\s\S]*action=.*p\.action[\s\S]*publish_if_absent[\s\S]*image copy "\$\{local_immutable\}" "\$\{discovery_tag\}"[\s\S]*reuse_exact_digest[\s\S]*digest=.*image digest "\$\{discovery_tag\}"[\s\S]*"\$\{digest\}" != "\$\{expected_digest\}"/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*cmp --silent "\$\{local_manifest\}" "\$\{manifest\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
/artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
||||
releaseSetSteps[11]?.run ?? '',
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[12]?.uses !==
|
||||
releaseSetSteps[11]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[12]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[11]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-name': '${{ steps.catalog.outputs.repository }}',
|
||||
'subject-digest': '${{ steps.catalog.outputs.digest }}',
|
||||
'push-to-registry': true,
|
||||
}) ||
|
||||
releaseSetSteps[13]?.id !== 'catalog-receipt' ||
|
||||
releaseSetSteps[12]?.id !== 'catalog-receipt' ||
|
||||
!/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[13]?.run ?? '',
|
||||
releaseSetSteps[12]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[14]?.uses !==
|
||||
releaseSetSteps[13]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[14]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[13]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
}) ||
|
||||
releaseSetSteps[15]?.uses !==
|
||||
releaseSetSteps[14]?.id !== 'final-publication' ||
|
||||
!/plan="\$\{BUNDLE\}\/qinglong3-release-publication-plan-[^\n]+[\s\S]*observations="\$\{BUNDLE\}\/qinglong3-release-publication-tag-observation-[^\n]+[\s\S]*receipt="\$\{BUNDLE\}\/qinglong3-release-publication-closure-receipt-[^\n]+[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{RELEASE_SET\}"[\s\S]*--catalog-plan="\$\{CATALOG_PLAN\}"[\s\S]*--catalog-manifest="\$\{CATALOG_MANIFEST\}"[\s\S]*--catalog-manifest-digest="\$\{CATALOG_MANIFEST_DIGEST\}"[\s\S]*--catalog-receipt="\$\{CATALOG_RECEIPT\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[14]?.run ?? '',
|
||||
) ||
|
||||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/.test(
|
||||
releaseSetSteps[15]?.run ?? '',
|
||||
) ||
|
||||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/.test(
|
||||
releaseSetSteps[16]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[17]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[17]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.final-publication.outputs.receipt }}',
|
||||
}) ||
|
||||
releaseSetSteps[18]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(releaseSetSteps[15]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[18]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
|
||||
path: '${{ steps.release-set.outputs.bundle }}',
|
||||
@@ -1253,8 +1266,8 @@ function auditReleaseWorkflow(source) {
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Publish the deployment digest lock/,
|
||||
'release tags, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete',
|
||||
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Materialize the catalog-authorized final tag publication plan[\s\S]*name: Promote final tags only after the catalog receipt is attested[\s\S]*name: Close and audit the final public tag set[\s\S]*name: Attest the immutable release publication closure receipt[\s\S]*name: Publish the deployment digest lock/,
|
||||
'release tags and the final closure receipt must be published only after the durable catalog is verified and its receipt is attested',
|
||||
);
|
||||
return {
|
||||
trigger: 'explicit protected v3 tag dispatch',
|
||||
@@ -1310,6 +1323,10 @@ function auditReleaseWorkflow(source) {
|
||||
rebuildAfterScan: false,
|
||||
tagAfterVerification: true,
|
||||
tagAfterCompleteReleaseSet: true,
|
||||
tagAfterVerifiedCatalog: true,
|
||||
boundedRepositoryTagInventory: true,
|
||||
allTagConflictsCheckedBeforeMutation: true,
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
},
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
@@ -1319,7 +1336,7 @@ function auditReleaseWorkflow(source) {
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
tagPromotionAuthority: 'verified_immutable_catalog',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
@@ -1346,6 +1363,19 @@ function auditReleaseWorkflow(source) {
|
||||
immutableDigestAuthority: 'verified',
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
planSchema: 'qinglong/release-publication-plan@v1',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v1',
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
tagsPerImage: 2,
|
||||
conflictPolicy: 'fail_closed_before_any_tag_mutation',
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
receiptAttested: true,
|
||||
},
|
||||
catalogDeploymentGate: {
|
||||
scopes: ['cluster', 'all'],
|
||||
catalogAuthority: 'immutable_digest_after_public_consumption',
|
||||
@@ -1379,6 +1409,7 @@ function auditReleaseWorkflow(source) {
|
||||
'catalog-bound-local-compose-deployment',
|
||||
'catalog-bound-k3s-deployment',
|
||||
'release-tags',
|
||||
'release-publication-closure',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
RELEASE_SET_SCHEMA,
|
||||
inspectReleaseSet,
|
||||
} = require('./ql3-release-set-contract.cjs');
|
||||
const {
|
||||
auditCatalogPlan,
|
||||
auditCatalogReceipt,
|
||||
} = require('./ql3-release-catalog-contract.cjs');
|
||||
|
||||
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v1';
|
||||
const TAG_OBSERVATION_SCHEMA =
|
||||
'qinglong/release-publication-tag-observation@v1';
|
||||
const CLOSURE_RECEIPT_SCHEMA =
|
||||
'qinglong/release-publication-closure-receipt@v1';
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const REGISTRY_REPOSITORY_PATTERN =
|
||||
/^ghcr\.io\/[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
||||
const REQUIRED_PREREQUISITES = Object.freeze({
|
||||
releaseSetProvenance: 'attested_before_catalog_publication',
|
||||
catalogSignature: 'verified_exact_workflow_identity',
|
||||
catalogProvenance: 'verified_source_tag_and_revision',
|
||||
catalogReceipt: 'attested_before_tag_promotion',
|
||||
});
|
||||
const PROMOTION_POLICY = Object.freeze({
|
||||
authority: 'verified_immutable_catalog',
|
||||
inventory: 'bounded_exact_repository_tags',
|
||||
conflict: 'fail_closed_before_any_tag_mutation',
|
||||
recovery: 'reuse_exact_digest_only',
|
||||
finalVerification: 'all_tags_exact_digest',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
});
|
||||
const CLOSURE_VERIFICATION = Object.freeze({
|
||||
authority: 'verified_immutable_catalog',
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
inventory: 'bounded_exact_repository_tags',
|
||||
conflict: 'fail_closed_before_any_tag_mutation',
|
||||
recovery: 'reuse_exact_digest_only',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
});
|
||||
|
||||
class QingLong3ReleasePublicationClosureError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 release publication closure failed: ${message}`);
|
||||
this.name = 'QingLong3ReleasePublicationClosureError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3ReleasePublicationClosureError(message);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCanonicalAbsolute(input, label) {
|
||||
if (typeof input !== 'string' || !path.isAbsolute(input)) {
|
||||
fail(`${label} path must be absolute`);
|
||||
}
|
||||
const resolved = path.resolve(input);
|
||||
if (resolved !== input) fail(`${label} path must be normalized`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readBoundedFile(filePath, label) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, label);
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_JSON_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail(`${label} must be one bounded canonical regular file`);
|
||||
}
|
||||
return { contents: fs.readFileSync(resolved, 'utf8'), resolved };
|
||||
}
|
||||
|
||||
function parseJson(contents, label, requireCanonical = true) {
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
if (requireCanonical && canonicalJson(value) !== contents) {
|
||||
fail(`${label} must use canonical JSON encoding`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCanonicalJson(filePath, label) {
|
||||
return parseJson(readBoundedFile(filePath, label).contents, label);
|
||||
}
|
||||
|
||||
function writeNoReplace(filePath, value) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, 'output');
|
||||
const parent = path.dirname(resolved);
|
||||
const parentStat = fs.lstatSync(parent);
|
||||
if (
|
||||
!parentStat.isDirectory() ||
|
||||
parentStat.isSymbolicLink() ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
) {
|
||||
fail('output parent must be one canonical directory');
|
||||
}
|
||||
const descriptor = fs.openSync(resolved, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, canonicalJson(value));
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validateIdentityOptions(options) {
|
||||
for (const key of [
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'sourceRef',
|
||||
'releaseScope',
|
||||
'repositoryOwner',
|
||||
'sourceRepository',
|
||||
]) {
|
||||
if (typeof options[key] !== 'string' || options[key].length === 0) {
|
||||
fail('release identity options are incomplete');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPublicationPlan(
|
||||
releaseSet,
|
||||
catalogPlan,
|
||||
catalogReceipt,
|
||||
catalogManifestContents,
|
||||
catalogManifestDigest,
|
||||
options,
|
||||
) {
|
||||
validateIdentityOptions(options);
|
||||
inspectReleaseSet(releaseSet, options);
|
||||
auditCatalogPlan(catalogPlan, releaseSet, options);
|
||||
auditCatalogReceipt(
|
||||
catalogReceipt,
|
||||
catalogPlan,
|
||||
catalogManifestContents,
|
||||
catalogManifestDigest,
|
||||
);
|
||||
const images = releaseSet.images.map((image) => {
|
||||
const registryRepository = image.reference.slice(
|
||||
0,
|
||||
image.reference.lastIndexOf('@'),
|
||||
);
|
||||
return {
|
||||
name: image.name,
|
||||
registryRepository,
|
||||
immutableReference: image.reference,
|
||||
digest: image.digest,
|
||||
tags: [
|
||||
{ kind: 'version', reference: image.versionTag },
|
||||
{ kind: 'source', reference: image.sourceTag },
|
||||
],
|
||||
};
|
||||
});
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: PUBLICATION_PLAN_SCHEMA,
|
||||
release: { ...releaseSet.release },
|
||||
releaseSet: {
|
||||
schema: RELEASE_SET_SCHEMA,
|
||||
releaseSetDigest: releaseSet.releaseSetDigest,
|
||||
contentDigest: sha256(canonicalJson(releaseSet)),
|
||||
},
|
||||
catalog: {
|
||||
planDigest: catalogPlan.planDigest,
|
||||
receiptDigest: catalogReceipt.receiptDigest,
|
||||
manifestDigest: catalogManifestDigest,
|
||||
immutableReference: catalogReceipt.catalog.immutableReference,
|
||||
},
|
||||
requiredPrerequisites: { ...REQUIRED_PREREQUISITES },
|
||||
promotionPolicy: { ...PROMOTION_POLICY },
|
||||
images,
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function validatePublicationPlan(plan) {
|
||||
if (
|
||||
!exactKeys(plan, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'release',
|
||||
'releaseSet',
|
||||
'catalog',
|
||||
'requiredPrerequisites',
|
||||
'promotionPolicy',
|
||||
'images',
|
||||
'planDigest',
|
||||
]) ||
|
||||
plan.schemaVersion !== 1 ||
|
||||
plan.schema !== PUBLICATION_PLAN_SCHEMA ||
|
||||
!DIGEST_PATTERN.test(plan.planDigest || '') ||
|
||||
!exactKeys(plan.release, [
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'sourceRef',
|
||||
'scope',
|
||||
]) ||
|
||||
typeof plan.release.version !== 'string' ||
|
||||
plan.release.version.length < 1 ||
|
||||
plan.release.version.length > 64 ||
|
||||
!SOURCE_REVISION_PATTERN.test(plan.release.sourceRevision || '') ||
|
||||
plan.release.sourceRef !== `refs/tags/v${plan.release.version}` ||
|
||||
!['local', 'cluster', 'all'].includes(plan.release.scope) ||
|
||||
!exactKeys(plan.releaseSet, [
|
||||
'schema',
|
||||
'releaseSetDigest',
|
||||
'contentDigest',
|
||||
]) ||
|
||||
plan.releaseSet.schema !== RELEASE_SET_SCHEMA ||
|
||||
!DIGEST_PATTERN.test(plan.releaseSet.releaseSetDigest || '') ||
|
||||
!DIGEST_PATTERN.test(plan.releaseSet.contentDigest || '') ||
|
||||
!exactKeys(plan.catalog, [
|
||||
'planDigest',
|
||||
'receiptDigest',
|
||||
'manifestDigest',
|
||||
'immutableReference',
|
||||
]) ||
|
||||
!DIGEST_PATTERN.test(plan.catalog.planDigest || '') ||
|
||||
!DIGEST_PATTERN.test(plan.catalog.receiptDigest || '') ||
|
||||
!DIGEST_PATTERN.test(plan.catalog.manifestDigest || '') ||
|
||||
typeof plan.catalog.immutableReference !== 'string' ||
|
||||
!plan.catalog.immutableReference.endsWith(
|
||||
`@${plan.catalog.manifestDigest}`,
|
||||
) ||
|
||||
JSON.stringify(plan.requiredPrerequisites) !==
|
||||
JSON.stringify(REQUIRED_PREREQUISITES) ||
|
||||
JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) ||
|
||||
!Array.isArray(plan.images) ||
|
||||
plan.images.length !==
|
||||
(plan.release.scope === 'local'
|
||||
? 1
|
||||
: plan.release.scope === 'cluster'
|
||||
? 4
|
||||
: 5)
|
||||
) {
|
||||
fail('publication plan shape is invalid');
|
||||
}
|
||||
const { planDigest, ...unsigned } = plan;
|
||||
if (planDigest !== sha256(JSON.stringify(unsigned))) {
|
||||
fail('publication plan digest is invalid');
|
||||
}
|
||||
const references = [];
|
||||
const names = [];
|
||||
const repositories = [];
|
||||
for (const image of plan.images) {
|
||||
if (
|
||||
!exactKeys(image, [
|
||||
'name',
|
||||
'registryRepository',
|
||||
'immutableReference',
|
||||
'digest',
|
||||
'tags',
|
||||
]) ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(image.name || '') ||
|
||||
!REGISTRY_REPOSITORY_PATTERN.test(image.registryRepository || '') ||
|
||||
image.immutableReference !==
|
||||
`${image.registryRepository}@${image.digest}` ||
|
||||
!DIGEST_PATTERN.test(image.digest || '') ||
|
||||
!Array.isArray(image.tags) ||
|
||||
image.tags.length !== 2
|
||||
) {
|
||||
fail('publication plan image is invalid');
|
||||
}
|
||||
names.push(image.name);
|
||||
repositories.push(image.registryRepository);
|
||||
for (let index = 0; index < image.tags.length; index += 1) {
|
||||
const tag = image.tags[index];
|
||||
const kind = index === 0 ? 'version' : 'source';
|
||||
const expectedReference =
|
||||
kind === 'version'
|
||||
? `${image.registryRepository}:${plan.release.version}`
|
||||
: `${image.registryRepository}:sha-${plan.release.sourceRevision}`;
|
||||
if (
|
||||
!exactKeys(tag, ['kind', 'reference']) ||
|
||||
tag.kind !== kind ||
|
||||
tag.reference !== expectedReference
|
||||
) {
|
||||
fail('publication plan tag is invalid');
|
||||
}
|
||||
references.push(tag.reference);
|
||||
}
|
||||
}
|
||||
if (
|
||||
new Set(references).size !== references.length ||
|
||||
new Set(names).size !== names.length ||
|
||||
new Set(repositories).size !== repositories.length
|
||||
) {
|
||||
fail('publication plan contains duplicate images or tags');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function expectedObservedTags(plan) {
|
||||
return plan.images.flatMap((image) =>
|
||||
image.tags.map((tag) => ({
|
||||
image: image.name,
|
||||
kind: tag.kind,
|
||||
reference: tag.reference,
|
||||
digest: image.digest,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function createPublicationTagObservation(plan, observedTags) {
|
||||
validatePublicationPlan(plan);
|
||||
if (
|
||||
!Array.isArray(observedTags) ||
|
||||
JSON.stringify(observedTags) !== JSON.stringify(expectedObservedTags(plan))
|
||||
) {
|
||||
fail('publication tag observations differ from the exact plan');
|
||||
}
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: TAG_OBSERVATION_SCHEMA,
|
||||
planDigest: plan.planDigest,
|
||||
tags: observedTags.map((entry) => ({ ...entry })),
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
observationDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function validateTagObservation(plan, observation) {
|
||||
if (
|
||||
!exactKeys(observation, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'planDigest',
|
||||
'tags',
|
||||
'observationDigest',
|
||||
]) ||
|
||||
observation.schemaVersion !== 1 ||
|
||||
observation.schema !== TAG_OBSERVATION_SCHEMA ||
|
||||
observation.planDigest !== plan.planDigest ||
|
||||
!DIGEST_PATTERN.test(observation.observationDigest || '')
|
||||
) {
|
||||
fail('publication tag observation shape is invalid');
|
||||
}
|
||||
const expected = createPublicationTagObservation(plan, observation.tags);
|
||||
if (JSON.stringify(observation) !== JSON.stringify(expected)) {
|
||||
fail('publication tag observation digest is invalid');
|
||||
}
|
||||
return observation;
|
||||
}
|
||||
|
||||
function createClosureReceipt(plan, observation) {
|
||||
validatePublicationPlan(plan);
|
||||
validateTagObservation(plan, observation);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: CLOSURE_RECEIPT_SCHEMA,
|
||||
release: { ...plan.release },
|
||||
planDigest: plan.planDigest,
|
||||
releaseSet: { ...plan.releaseSet },
|
||||
catalog: { ...plan.catalog },
|
||||
tagObservationDigest: observation.observationDigest,
|
||||
publishedTags: observation.tags.map((entry) => ({ ...entry })),
|
||||
verification: { ...CLOSURE_VERIFICATION },
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
receiptDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function auditClosureReceipt(actual, plan, observation) {
|
||||
const expected = createClosureReceipt(plan, observation);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
fail('publication closure receipt differs from the verified final tags');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
releaseSetDigest: actual.releaseSet.releaseSetDigest,
|
||||
releaseScope: actual.release.scope,
|
||||
catalogManifestDigest: actual.catalog.manifestDigest,
|
||||
publishedTagCount: actual.publishedTags.length,
|
||||
allTagsExactDigest: actual.verification.allTagsExactDigest,
|
||||
registryTagCas: actual.verification.registryTagCas,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
||||
if (!match || Object.hasOwn(values, match[1])) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
const identity = [
|
||||
'release-scope',
|
||||
'repository-owner',
|
||||
'source-ref',
|
||||
'source-repository',
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const expected =
|
||||
values.mode === 'plan'
|
||||
? [
|
||||
'catalog-manifest',
|
||||
'catalog-manifest-digest',
|
||||
'catalog-plan',
|
||||
'catalog-receipt',
|
||||
'mode',
|
||||
'output',
|
||||
'release-set',
|
||||
...identity,
|
||||
]
|
||||
: values.mode === 'close'
|
||||
? ['mode', 'observations', 'output', 'plan']
|
||||
: values.mode === 'audit'
|
||||
? ['mode', 'observations', 'plan', 'receipt']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
output: values.output,
|
||||
plan: values.plan,
|
||||
observations: values.observations,
|
||||
receipt: values.receipt,
|
||||
releaseSet: values['release-set'],
|
||||
catalogPlan: values['catalog-plan'],
|
||||
catalogReceipt: values['catalog-receipt'],
|
||||
catalogManifest: values['catalog-manifest'],
|
||||
catalogManifestDigest: values['catalog-manifest-digest'],
|
||||
version: values.version,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
releaseScope: values['release-scope'],
|
||||
repositoryOwner: values['repository-owner'],
|
||||
sourceRepository: values['source-repository'],
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'plan') {
|
||||
const releaseSet = readCanonicalJson(options.releaseSet, 'release set');
|
||||
const catalogPlan = readCanonicalJson(options.catalogPlan, 'catalog plan');
|
||||
const catalogReceipt = readCanonicalJson(
|
||||
options.catalogReceipt,
|
||||
'catalog receipt',
|
||||
);
|
||||
const manifest = readBoundedFile(
|
||||
options.catalogManifest,
|
||||
'catalog manifest',
|
||||
).contents;
|
||||
const plan = createPublicationPlan(
|
||||
releaseSet,
|
||||
catalogPlan,
|
||||
catalogReceipt,
|
||||
manifest,
|
||||
options.catalogManifestDigest,
|
||||
options,
|
||||
);
|
||||
writeNoReplace(options.output, plan);
|
||||
output.write(canonicalJson(plan));
|
||||
return plan;
|
||||
}
|
||||
const plan = readCanonicalJson(options.plan, 'publication plan');
|
||||
const observation = readCanonicalJson(
|
||||
options.observations,
|
||||
'tag observations',
|
||||
);
|
||||
if (options.mode === 'close') {
|
||||
const receipt = createClosureReceipt(plan, observation);
|
||||
writeNoReplace(options.output, receipt);
|
||||
output.write(canonicalJson(receipt));
|
||||
return receipt;
|
||||
}
|
||||
const receipt = readCanonicalJson(options.receipt, 'closure receipt');
|
||||
const audit = auditClosureReceipt(receipt, plan, observation);
|
||||
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 publication closure failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
CLOSURE_RECEIPT_SCHEMA,
|
||||
PUBLICATION_PLAN_SCHEMA,
|
||||
TAG_OBSERVATION_SCHEMA,
|
||||
QingLong3ReleasePublicationClosureError,
|
||||
auditClosureReceipt,
|
||||
createClosureReceipt,
|
||||
createPublicationPlan,
|
||||
createPublicationTagObservation,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote mutable release tags',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
|
||||
@@ -94,6 +94,10 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
rebuildAfterScan: false,
|
||||
tagAfterVerification: true,
|
||||
tagAfterCompleteReleaseSet: true,
|
||||
tagAfterVerifiedCatalog: true,
|
||||
boundedRepositoryTagInventory: true,
|
||||
allTagConflictsCheckedBeforeMutation: true,
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
},
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
@@ -103,7 +107,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
tagPromotionAuthority: 'verified_immutable_catalog',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
@@ -130,6 +134,19 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
immutableDigestAuthority: 'verified',
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
planSchema: 'qinglong/release-publication-plan@v1',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v1',
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
tagsPerImage: 2,
|
||||
conflictPolicy: 'fail_closed_before_any_tag_mutation',
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
receiptAttested: true,
|
||||
},
|
||||
catalogDeploymentGate: {
|
||||
scopes: ['cluster', 'all'],
|
||||
catalogAuthority: 'immutable_digest_after_public_consumption',
|
||||
@@ -163,6 +180,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
'catalog-bound-local-compose-deployment',
|
||||
'catalog-bound-k3s-deployment',
|
||||
'release-tags',
|
||||
'release-publication-closure',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -190,6 +208,17 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the final publication closure contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3ReleasePublicationClosureContract.test.cjs',
|
||||
'test/back/publication-closure-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the private release evidence receipt contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs',
|
||||
@@ -1069,6 +1098,61 @@ test('rejects a release-catalog receipt without file provenance', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects final tag publication before catalog receipt attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote tags after release-set audit only',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release tags and the final closure receipt|release-set job must download/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without bounded repository inventory', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" 'tag',\n 'ls',\n image.registryRepository,",
|
||||
" 'image',\n 'digest',\n image.registryRepository,",
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects omission of the final publication closure audit', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' --mode=close \\',
|
||||
' --mode=audit \\',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects omitting tag observations from the durable closure bundle', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'observations="${BUNDLE}/qinglong3-release-publication-tag-observation-',
|
||||
'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a final closure receipt without its own attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' subject-path: ${{ steps.final-publication.outputs.receipt }}',
|
||||
' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a short-lived deployment digest lock', () => {
|
||||
const marker = ' retention-days: 90';
|
||||
assert.equal(releaseSource.includes(marker), true);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
CLOSURE_RECEIPT_SCHEMA,
|
||||
PUBLICATION_PLAN_SCHEMA,
|
||||
TAG_OBSERVATION_SCHEMA,
|
||||
auditClosureReceipt,
|
||||
createClosureReceipt,
|
||||
createPublicationPlan,
|
||||
createPublicationTagObservation,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const {
|
||||
ARTIFACT_TYPE,
|
||||
OCI_EMPTY_CONFIG_DIGEST,
|
||||
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||
OCI_MANIFEST_MEDIA_TYPE,
|
||||
createCatalogPlan,
|
||||
createCatalogReceipt,
|
||||
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
||||
const {
|
||||
createReleaseSet,
|
||||
createVerifiedImageRecord,
|
||||
} = require('../../scripts/ql3-release-set-contract.cjs');
|
||||
const {
|
||||
createReleaseCandidateContract,
|
||||
} = require('../../scripts/ql3-release-candidate-contract.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
const identity = Object.freeze({
|
||||
version,
|
||||
sourceRevision: 'e'.repeat(40),
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
repositoryOwner: 'qinglong-release',
|
||||
sourceRepository: 'qinglong-release/qinglong',
|
||||
});
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function releaseSet(scope) {
|
||||
const candidate = createReleaseCandidateContract({
|
||||
root,
|
||||
version,
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
releaseScope: scope,
|
||||
});
|
||||
const records = candidate.images.map((entry, index) =>
|
||||
createVerifiedImageRecord({
|
||||
root,
|
||||
candidate,
|
||||
...identity,
|
||||
releaseScope: scope,
|
||||
image: entry.image,
|
||||
digest: `sha256:${String(index + 1).repeat(64)}`,
|
||||
}),
|
||||
);
|
||||
return createReleaseSet({
|
||||
root,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
|
||||
releaseScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
function manifestFor(plan) {
|
||||
return JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
||||
artifactType: ARTIFACT_TYPE,
|
||||
config: {
|
||||
mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||
digest: OCI_EMPTY_CONFIG_DIGEST,
|
||||
size: 2,
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: ARTIFACT_TYPE,
|
||||
digest: plan.releaseSet.contentDigest,
|
||||
size: plan.releaseSet.bytes,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': plan.releaseSet.fileName,
|
||||
},
|
||||
},
|
||||
],
|
||||
annotations: { ...plan.catalog.annotations },
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(scope = 'all') {
|
||||
const set = releaseSet(scope);
|
||||
const options = { ...identity, releaseScope: scope };
|
||||
const catalogPlan = createCatalogPlan(set, options);
|
||||
const manifest = manifestFor(catalogPlan);
|
||||
const manifestDigest = sha256(manifest);
|
||||
const catalogReceipt = createCatalogReceipt(
|
||||
catalogPlan,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
);
|
||||
const publicationPlan = createPublicationPlan(
|
||||
set,
|
||||
catalogPlan,
|
||||
catalogReceipt,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
options,
|
||||
);
|
||||
const tags = publicationPlan.images.flatMap((image) =>
|
||||
image.tags.map((tag) => ({
|
||||
image: image.name,
|
||||
kind: tag.kind,
|
||||
reference: tag.reference,
|
||||
digest: image.digest,
|
||||
})),
|
||||
);
|
||||
const observation = createPublicationTagObservation(publicationPlan, tags);
|
||||
return {
|
||||
set,
|
||||
options,
|
||||
catalogPlan,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
catalogReceipt,
|
||||
publicationPlan,
|
||||
observation,
|
||||
};
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-closure-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeCanonical(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
test('plans tag publication only from a verified immutable catalog', () => {
|
||||
for (const scope of ['local', 'cluster', 'all']) {
|
||||
const { publicationPlan, set } = fixture(scope);
|
||||
assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA);
|
||||
assert.equal(
|
||||
publicationPlan.promotionPolicy.authority,
|
||||
'verified_immutable_catalog',
|
||||
);
|
||||
assert.equal(
|
||||
publicationPlan.requiredPrerequisites.catalogReceipt,
|
||||
'attested_before_tag_promotion',
|
||||
);
|
||||
assert.equal(publicationPlan.promotionPolicy.registryTagCas, false);
|
||||
assert.equal(publicationPlan.images.length, set.images.length);
|
||||
assert.equal(
|
||||
publicationPlan.images.every((image) => image.tags.length === 2),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates a deterministic final closure receipt for every exact tag', () => {
|
||||
const { publicationPlan, observation } = fixture();
|
||||
const first = createClosureReceipt(publicationPlan, observation);
|
||||
const replay = createClosureReceipt(publicationPlan, observation);
|
||||
assert.equal(first.schema, CLOSURE_RECEIPT_SCHEMA);
|
||||
assert.equal(observation.schema, TAG_OBSERVATION_SCHEMA);
|
||||
assert.deepEqual(first, replay);
|
||||
assert.equal(first.publishedTags.length, publicationPlan.images.length * 2);
|
||||
assert.equal(first.verification.catalogReadyBeforeTagMutation, true);
|
||||
assert.equal(first.verification.allTagsExactDigest, true);
|
||||
assert.equal(first.verification.registryTagCas, false);
|
||||
assert.equal(
|
||||
auditClosureReceipt(first, publicationPlan, observation).compatible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing, reordered, extra or digest-drifted tag observations', () => {
|
||||
const { publicationPlan, observation } = fixture('cluster');
|
||||
const variants = [
|
||||
observation.tags.slice(1),
|
||||
[...observation.tags].reverse(),
|
||||
[...observation.tags, observation.tags[0]],
|
||||
observation.tags.map((entry, index) =>
|
||||
index === 0 ? { ...entry, digest: `sha256:${'f'.repeat(64)}` } : entry,
|
||||
),
|
||||
];
|
||||
for (const tags of variants) {
|
||||
assert.throws(
|
||||
() => createPublicationTagObservation(publicationPlan, tags),
|
||||
/observations differ from the exact plan/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a publication plan detached from release-set or catalog evidence', () => {
|
||||
const current = fixture('local');
|
||||
const other = fixture('cluster');
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationPlan(
|
||||
current.set,
|
||||
other.catalogPlan,
|
||||
other.catalogReceipt,
|
||||
other.manifest,
|
||||
other.manifestDigest,
|
||||
current.options,
|
||||
),
|
||||
/catalog plan differs from the standalone release set/,
|
||||
);
|
||||
const weakenedReceipt = structuredClone(current.catalogReceipt);
|
||||
weakenedReceipt.verification.keylessSignature = 'not_verified';
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationPlan(
|
||||
current.set,
|
||||
current.catalogPlan,
|
||||
weakenedReceipt,
|
||||
current.manifest,
|
||||
current.manifestDigest,
|
||||
current.options,
|
||||
),
|
||||
/catalog receipt differs from the verified OCI manifest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects tampered plan, observation and closure self-digests', () => {
|
||||
const { publicationPlan, observation } = fixture('local');
|
||||
const badPlan = structuredClone(publicationPlan);
|
||||
badPlan.promotionPolicy.registryTagCas = true;
|
||||
assert.throws(
|
||||
() => createPublicationTagObservation(badPlan, observation.tags),
|
||||
/publication plan (?:shape|digest) is invalid/,
|
||||
);
|
||||
const recomputedWeakenedPlan = structuredClone(publicationPlan);
|
||||
recomputedWeakenedPlan.promotionPolicy.registryTagCas = true;
|
||||
delete recomputedWeakenedPlan.planDigest;
|
||||
recomputedWeakenedPlan.planDigest = sha256(
|
||||
JSON.stringify(recomputedWeakenedPlan),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationTagObservation(recomputedWeakenedPlan, observation.tags),
|
||||
/publication plan shape is invalid/,
|
||||
);
|
||||
const badObservation = structuredClone(observation);
|
||||
badObservation.observationDigest = `sha256:${'a'.repeat(64)}`;
|
||||
assert.throws(
|
||||
() => createClosureReceipt(publicationPlan, badObservation),
|
||||
/observation digest is invalid/,
|
||||
);
|
||||
const receipt = createClosureReceipt(publicationPlan, observation);
|
||||
const badReceipt = structuredClone(receipt);
|
||||
badReceipt.verification.registryTagCas = true;
|
||||
assert.throws(
|
||||
() => auditClosureReceipt(badReceipt, publicationPlan, observation),
|
||||
/closure receipt differs/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
|
||||
const value = fixture('cluster');
|
||||
const directory = temporaryDirectory(t);
|
||||
const files = {
|
||||
releaseSet: path.join(directory, 'release-set.json'),
|
||||
catalogPlan: path.join(directory, 'catalog-plan.json'),
|
||||
catalogReceipt: path.join(directory, 'catalog-receipt.json'),
|
||||
manifest: path.join(directory, 'manifest.json'),
|
||||
publicationPlan: path.join(directory, 'publication-plan.json'),
|
||||
observation: path.join(directory, 'observation.json'),
|
||||
receipt: path.join(directory, 'receipt.json'),
|
||||
};
|
||||
writeCanonical(files.releaseSet, value.set);
|
||||
writeCanonical(files.catalogPlan, value.catalogPlan);
|
||||
writeCanonical(files.catalogReceipt, value.catalogReceipt);
|
||||
fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 });
|
||||
writeCanonical(files.observation, value.observation);
|
||||
const planArgs = [
|
||||
'--mode=plan',
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
`--source-ref=${identity.sourceRef}`,
|
||||
'--release-scope=cluster',
|
||||
`--repository-owner=${identity.repositoryOwner}`,
|
||||
`--source-repository=${identity.sourceRepository}`,
|
||||
`--release-set=${files.releaseSet}`,
|
||||
`--catalog-plan=${files.catalogPlan}`,
|
||||
`--catalog-receipt=${files.catalogReceipt}`,
|
||||
`--catalog-manifest=${files.manifest}`,
|
||||
`--catalog-manifest-digest=${value.manifestDigest}`,
|
||||
`--output=${files.publicationPlan}`,
|
||||
];
|
||||
const output = { write() {} };
|
||||
assert.equal(runCli(planArgs, output).schema, PUBLICATION_PLAN_SCHEMA);
|
||||
assert.throws(() => runCli(planArgs, output), /EEXIST/);
|
||||
assert.equal(
|
||||
runCli(
|
||||
[
|
||||
'--mode=close',
|
||||
`--plan=${files.publicationPlan}`,
|
||||
`--observations=${files.observation}`,
|
||||
`--output=${files.receipt}`,
|
||||
],
|
||||
output,
|
||||
).schema,
|
||||
CLOSURE_RECEIPT_SCHEMA,
|
||||
);
|
||||
assert.equal(
|
||||
runCli(
|
||||
[
|
||||
'--mode=audit',
|
||||
`--plan=${files.publicationPlan}`,
|
||||
`--observations=${files.observation}`,
|
||||
`--receipt=${files.receipt}`,
|
||||
],
|
||||
output,
|
||||
).compatible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects ambiguous CLI modes and extra arguments', () => {
|
||||
assert.throws(
|
||||
() => parseArguments(['--mode=close']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseArguments([
|
||||
'--mode=audit',
|
||||
'--plan=/tmp/plan',
|
||||
'--observations=/tmp/observations',
|
||||
'--receipt=/tmp/receipt',
|
||||
'--extra=true',
|
||||
]),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user