feat(ql3): close release tags after catalog verification

This commit is contained in:
whyour
2026-08-18 03:53:25 +08:00
parent dbd57b27d0
commit a99f1508f7
12 changed files with 1336 additions and 77 deletions
+1
View File
@@ -303,6 +303,7 @@ jobs:
test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs
test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs
test/back/ql3ReleaseCatalogContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs
test/back/ql3ReleasePublicationClosureContract.test.cjs
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
test/back/ql3DeploymentLockContract.test.cjs test/back/ql3DeploymentLockContract.test.cjs
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
+162 -45
View File
@@ -810,51 +810,6 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} 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 - name: Attest the complete release-set file provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with: with:
@@ -1007,6 +962,168 @@ jobs:
with: with:
subject-path: ${{ steps.catalog-receipt.outputs.receipt }} 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 - name: Publish the deployment digest lock
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
+20
View File
@@ -11,6 +11,26 @@
最新增量证据(2026-08-18): 最新增量证据(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 tagexact 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 的覆盖窗口。 - D-349/ADR-0441(已接受;首份真实 GHCR conflict/reuse 证据待实际 release tag):关闭 durable catalog discovery tag 的覆盖窗口。
之前 workflow 虽声明 `v<version>-<scope>` 无部署 authority,却直接对该 tag 执行 `artifact put`;已有不同 digest 会先被覆盖, 之前 workflow 虽声明 `v<version>-<scope>` 无部署 authority,却直接对该 tag 执行 `artifact put`;已有不同 digest 会先被覆盖,
response-loss 重跑也没有“相同复用、冲突拒绝”的可执行分支。catalog plan/receipt 现升级为 v2publisher 必须先在 runner 私有 response-loss 重跑也没有“相同复用、冲突拒绝”的可执行分支。catalog plan/receipt 现升级为 v2publisher 必须先在 runner 私有
@@ -0,0 +1,84 @@
# ADR-0442Catalog-ready 的终态 Release Tag 发布与闭合收据
- 状态:Accepted
- 日期:2026-08-18
- 关联 RFCQL-RFC-0001 D-03、D-14、D-336、D-349、D-350
- 关联 ADRADR-0427、ADR-0428、ADR-0439、ADR-0441
- SupersedesADR-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 已审计并 attestedcatalog 已按 immutable digest
round-tripcatalog 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 tagexact 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 bundlereceipt 必须再次本地
审计并单独 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 digestconsumer
仍不会信任 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 演练产生。
+1
View File
@@ -445,6 +445,7 @@
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag | | [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-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-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 |
## 规则 ## 规则
+10 -4
View File
@@ -437,10 +437,16 @@ immutable image reference 写入 compose/rollout。
## 发布失败与恢复 ## 发布失败与恢复
GHCR 不提供跨 repository tag 事务,release set 明确记录 `crossRepositoryAtomicity=false`如果 promotion 中途 GHCR 不提供跨 repository tag 事务,release set 与最终 closure receipt 都明确记录 `crossRepositoryAtomicity=false`正式
失败,不删除已经正确的 tag,也不重新构建镜像。使用原 source tag/revision 重跑 release workflow:它会先验证 `versionTag/sourceTag` 只能在 release-set file provenance、catalog immutable digest、catalog signature/provenance、catalog receipt
每个 source digest 和既有 tag;既有 tag 指向同一 digest 时继续,指向其他 digest 时立即失败。只有 及其 attestation 全部成功后开始。publisher 随后必须为每个 image repository 取得不超过 1 MiB 的完整 tag inventory;读取失败、非
release-set、catalog immutable digest、两类 provenance 与 receipt 全部生成并验证后,才能宣布该 deployment family 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 天 bundlereceipt 再次审计、attest 后,下载者可离线重放 closure audit。只有这条闭合链全部生成并验证后,才能宣布该 deployment family
可部署。对于 `cluster|all`,还必须等待只读 catalog consumer 与 catalog-bound K3s deployment/retirement Gate 成功;publisher 成功而 可部署。对于 `cluster|all`,还必须等待只读 catalog consumer 与 catalog-bound K3s deployment/retirement Gate 成功;publisher 成功而
consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention
或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。 或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。
@@ -252,10 +252,14 @@ 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 tags only after the complete set is verified',
'Attest the complete release-set file provenance', 'Attest the complete release-set file provenance',
'Publish and round-trip the durable OCI release catalog', 'Publish and round-trip the durable OCI release catalog',
'Attest durable release-catalog provenance', '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', 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
); );
+56 -25
View File
@@ -274,7 +274,7 @@ function auditClusterImageCiWorkflow(
); );
requirePattern( requirePattern(
source, source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/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', '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( requirePattern(
@@ -712,7 +712,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps; const releaseSetSteps = releaseSetJob?.steps;
if ( if (
!Array.isArray(releaseSetSteps) || !Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 16 || releaseSetSteps.length !== 19 ||
releaseSetSteps[0]?.uses !== releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false || releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -750,55 +750,68 @@ function auditReleaseWorkflow(source) {
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
releaseSetSteps[7]?.uses !== releaseSetSteps[7]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' || 'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test( releaseSetSteps[8]?.uses !==
releaseSetSteps[8]?.run ?? '',
) ||
releaseSetSteps[9]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[9]?.with) !== JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify({ JSON.stringify({
'subject-path': '${{ steps.release-set.outputs.report }}', 'subject-path': '${{ steps.release-set.outputs.report }}',
}) || }) ||
releaseSetSteps[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( !/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( !/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( !/--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( !/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( /artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test(
releaseSetSteps[10]?.run ?? '', releaseSetSteps[9]?.run ?? '',
) || ) ||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
releaseSetSteps[11]?.run ?? '', releaseSetSteps[10]?.run ?? '',
) || ) ||
releaseSetSteps[12]?.uses !== releaseSetSteps[11]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[12]?.with) !== JSON.stringify(releaseSetSteps[11]?.with) !==
JSON.stringify({ JSON.stringify({
'subject-name': '${{ steps.catalog.outputs.repository }}', 'subject-name': '${{ steps.catalog.outputs.repository }}',
'subject-digest': '${{ steps.catalog.outputs.digest }}', 'subject-digest': '${{ steps.catalog.outputs.digest }}',
'push-to-registry': true, '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( !/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' || 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[14]?.with) !== JSON.stringify(releaseSetSteps[13]?.with) !==
JSON.stringify({ JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}', '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' || 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[15]?.with) !== JSON.stringify(releaseSetSteps[18]?.with) !==
JSON.stringify({ JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.release-set.outputs.bundle }}', path: '${{ steps.release-set.outputs.bundle }}',
@@ -1253,8 +1266,8 @@ function auditReleaseWorkflow(source) {
); );
requirePattern( requirePattern(
source, source,
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish 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-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, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete', 'release tags and the final closure receipt must be published only after the durable catalog is verified and its receipt is attested',
); );
return { return {
trigger: 'explicit protected v3 tag dispatch', trigger: 'explicit protected v3 tag dispatch',
@@ -1310,6 +1323,10 @@ function auditReleaseWorkflow(source) {
rebuildAfterScan: false, rebuildAfterScan: false,
tagAfterVerification: true, tagAfterVerification: true,
tagAfterCompleteReleaseSet: true, tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true,
boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only',
}, },
releaseSet: { releaseSet: {
sourceDerived: true, sourceDerived: true,
@@ -1319,7 +1336,7 @@ function auditReleaseWorkflow(source) {
privateEvidenceFreshnessRevalidatedAtClosure: true, privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true, standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set', tagPromotionAuthority: 'verified_immutable_catalog',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, crossRepositoryAtomicity: false,
@@ -1346,6 +1363,19 @@ function auditReleaseWorkflow(source) {
immutableDigestAuthority: 'verified', immutableDigestAuthority: 'verified',
receiptAttested: true, 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: { catalogDeploymentGate: {
scopes: ['cluster', 'all'], scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption', catalogAuthority: 'immutable_digest_after_public_consumption',
@@ -1379,6 +1409,7 @@ function auditReleaseWorkflow(source) {
'catalog-bound-local-compose-deployment', 'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment', 'catalog-bound-k3s-deployment',
'release-tags', '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', '.github/workflows/ql3-image-release.yml',
(source) => (source) =>
source.replace( 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', 'Promote mutable release tags',
), ),
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
+85 -1
View File
@@ -94,6 +94,10 @@ test('accepts the reviewed native CI and digest release contracts', () => {
rebuildAfterScan: false, rebuildAfterScan: false,
tagAfterVerification: true, tagAfterVerification: true,
tagAfterCompleteReleaseSet: true, tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true,
boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only',
}, },
releaseSet: { releaseSet: {
sourceDerived: true, sourceDerived: true,
@@ -103,7 +107,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
privateEvidenceFreshnessRevalidatedAtClosure: true, privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true, standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set', tagPromotionAuthority: 'verified_immutable_catalog',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, crossRepositoryAtomicity: false,
@@ -130,6 +134,19 @@ test('accepts the reviewed native CI and digest release contracts', () => {
immutableDigestAuthority: 'verified', immutableDigestAuthority: 'verified',
receiptAttested: true, 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: { catalogDeploymentGate: {
scopes: ['cluster', 'all'], scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption', 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-local-compose-deployment',
'catalog-bound-k3s-deployment', 'catalog-bound-k3s-deployment',
'release-tags', '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', () => { test('rejects removal of the private release evidence receipt contract tests', () => {
const mutated = ciSource.replace( const mutated = ciSource.replace(
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs', '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', () => { test('rejects a short-lived deployment digest lock', () => {
const marker = ' retention-days: 90'; const marker = ' retention-days: 90';
assert.equal(releaseSource.includes(marker), true); assert.equal(releaseSource.includes(marker), true);
@@ -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/,
);
});