mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prevent release catalog tag overwrite
This commit is contained in:
@@ -873,6 +873,10 @@ jobs:
|
|||||||
artifact_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.artifactType)' "${PLAN}")"
|
artifact_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.artifactType)' "${PLAN}")"
|
||||||
file_media_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.fileMediaType)' "${PLAN}")"
|
file_media_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.fileMediaType)' "${PLAN}")"
|
||||||
file_name="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.releaseSet.fileName)' "${PLAN}")"
|
file_name="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.releaseSet.fileName)' "${PLAN}")"
|
||||||
|
plan_digest="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.planDigest)' "${PLAN}")"
|
||||||
|
local_layout="${RUNNER_TEMP}/release-catalog-layout"
|
||||||
|
local_tag="ocidir://${local_layout}:candidate"
|
||||||
|
mkdir "${local_layout}"
|
||||||
"${REGCTL}" artifact put \
|
"${REGCTL}" artifact put \
|
||||||
--artifact-type "${artifact_type}" \
|
--artifact-type "${artifact_type}" \
|
||||||
--file-media-type "${file_media_type}" \
|
--file-media-type "${file_media_type}" \
|
||||||
@@ -883,20 +887,66 @@ jobs:
|
|||||||
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||||
--annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY,,}" \
|
--annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY,,}" \
|
||||||
--annotation "org.opencontainers.image.version=${{ inputs.version }}" \
|
--annotation "org.opencontainers.image.version=${{ inputs.version }}" \
|
||||||
"${discovery_tag}"
|
"${local_tag}"
|
||||||
digest="$("${REGCTL}" image digest "${discovery_tag}")"
|
expected_digest="$("${REGCTL}" image digest "${local_tag}")"
|
||||||
if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
if [[ ! "${expected_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||||
echo "release catalog did not resolve to an immutable digest" >&2
|
echo "local release catalog did not resolve to an immutable digest" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
immutable_reference="${catalog_repository}@${digest}"
|
local_immutable="ocidir://${local_layout}@${expected_digest}"
|
||||||
|
local_manifest="${RUNNER_TEMP}/release-catalog-local-manifest.json"
|
||||||
|
"${REGCTL}" manifest get "${local_immutable}" --format raw-body > "${local_manifest}"
|
||||||
|
tags="${RUNNER_TEMP}/release-catalog-tags"
|
||||||
|
if ! "${REGCTL}" tag ls "${catalog_repository}" --format '{{ range .Tags }}{{ println . }}{{ end }}' > "${tags}"; then
|
||||||
|
staging_tag="${catalog_repository}:staging-${plan_digest#sha256:}"
|
||||||
|
"${REGCTL}" image copy "${local_immutable}" "${staging_tag}"
|
||||||
|
staged_digest="$("${REGCTL}" image digest "${staging_tag}")"
|
||||||
|
if [[ "${staged_digest}" != "${expected_digest}" ]]; then
|
||||||
|
echo "release catalog staging tag did not preserve the manifest digest" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"${REGCTL}" tag ls "${catalog_repository}" --format '{{ range .Tags }}{{ println . }}{{ end }}' > "${tags}"
|
||||||
|
fi
|
||||||
|
inventory_decision="${RUNNER_TEMP}/release-catalog-tag-inventory-decision.json"
|
||||||
|
node scripts/ql3-release-catalog-contract.cjs \
|
||||||
|
--mode=tag-inventory \
|
||||||
|
--plan="${PLAN}" \
|
||||||
|
--tag-inventory="${tags}" \
|
||||||
|
--output="${inventory_decision}" > /dev/null
|
||||||
|
tag_state="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.observation)' "${inventory_decision}")"
|
||||||
|
observed_digest="absent"
|
||||||
|
if [[ "${tag_state}" == "present" ]]; then
|
||||||
|
observed_digest="$("${REGCTL}" image digest "${discovery_tag}")"
|
||||||
|
fi
|
||||||
|
decision="${RUNNER_TEMP}/release-catalog-publication-decision.json"
|
||||||
|
node scripts/ql3-release-catalog-contract.cjs \
|
||||||
|
--mode=publication-decision \
|
||||||
|
--plan="${PLAN}" \
|
||||||
|
--manifest="${local_manifest}" \
|
||||||
|
--manifest-digest="${expected_digest}" \
|
||||||
|
--observed-discovery-digest="${observed_digest}" \
|
||||||
|
--output="${decision}" > /dev/null
|
||||||
|
action="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.action)' "${decision}")"
|
||||||
|
if [[ "${action}" == "publish_if_absent" ]]; then
|
||||||
|
"${REGCTL}" image copy "${local_immutable}" "${discovery_tag}"
|
||||||
|
elif [[ "${action}" != "reuse_exact_digest" ]]; then
|
||||||
|
echo "release catalog publication action is invalid" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
digest="$("${REGCTL}" image digest "${discovery_tag}")"
|
||||||
|
if [[ "${digest}" != "${expected_digest}" ]]; then
|
||||||
|
echo "release catalog discovery tag does not resolve to the planned digest" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
immutable_reference="${catalog_repository}@${expected_digest}"
|
||||||
roundtrip="${RUNNER_TEMP}/release-set-roundtrip.json"
|
roundtrip="${RUNNER_TEMP}/release-set-roundtrip.json"
|
||||||
manifest="${RUNNER_TEMP}/release-catalog-manifest.json"
|
manifest="${RUNNER_TEMP}/release-catalog-manifest.json"
|
||||||
"${REGCTL}" artifact get --file "${file_name}" "${immutable_reference}" > "${roundtrip}"
|
"${REGCTL}" artifact get --file "${file_name}" "${immutable_reference}" > "${roundtrip}"
|
||||||
cmp --silent "${RELEASE_SET}" "${roundtrip}"
|
cmp --silent "${RELEASE_SET}" "${roundtrip}"
|
||||||
"${REGCTL}" manifest get "${immutable_reference}" --format raw-body > "${manifest}"
|
"${REGCTL}" manifest get "${immutable_reference}" --format raw-body > "${manifest}"
|
||||||
|
cmp --silent "${local_manifest}" "${manifest}"
|
||||||
echo "repository=${catalog_repository}" >> "${GITHUB_OUTPUT}"
|
echo "repository=${catalog_repository}" >> "${GITHUB_OUTPUT}"
|
||||||
echo "digest=${digest}" >> "${GITHUB_OUTPUT}"
|
echo "digest=${expected_digest}" >> "${GITHUB_OUTPUT}"
|
||||||
echo "manifest=${manifest}" >> "${GITHUB_OUTPUT}"
|
echo "manifest=${manifest}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Keylessly sign the immutable release-catalog digest
|
- name: Keylessly sign the immutable release-catalog digest
|
||||||
|
|||||||
@@ -11,6 +11,27 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-18):
|
最新增量证据(2026-08-18):
|
||||||
|
|
||||||
|
- 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 私有
|
||||||
|
`ocidir://` 生成 exact manifest;远端 repository 不存在时仅以 `staging-<plan-digest>` 非权威 tag 建立可读 inventory。
|
||||||
|
`qinglong/release-catalog-tag-inventory-decision@v1` 先执行 1 MiB、canonical line、OCI tag 字符集和无重复约束,随后
|
||||||
|
`qinglong/release-catalog-publication-decision@v1` 将 absent 映射为 `publish_if_absent`、exact digest 映射为
|
||||||
|
`reuse_exact_digest`,其他 digest 或无界/畸形 inventory 均在 discovery mutation 前失败。两条成功路径都按 immutable reference
|
||||||
|
回读 release-set 和 raw manifest;receipt 明确绑定 `fail_closed_before_mutation` 与
|
||||||
|
`reuse_exact_manifest_digest_only`。OCI registry 仍无 tag CAS,因此诚实保留 `registryTagCas=false`:同 ref concurrency 与组织
|
||||||
|
package 权限限制外部 writer,最终回读检测发布期间竞争,consumer 永远只信任签名/attested immutable digest。本 Gate 不修改
|
||||||
|
release-set v3/OCI media、package、生产依赖、数据库、Kubernetes object 或设备运行时。定向 receipt/set/catalog/consumption/
|
||||||
|
deployment-lock/image-release 回归 143/143;完整 backend 1,371 项为 1,369 pass/2 条件 skip/0 fail,18-package clean
|
||||||
|
build/test 退出 0。12 项 package boundary、Cluster dependency、Edge import、Cluster/Worker/CloudNativePG 部署、backup、
|
||||||
|
Barman/cert-manager selection、image release、deployment-lock surface 与 release-version 审计全部 compatible;14 档 Local
|
||||||
|
artifact 全部 compatible 且保持既有字节基线:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,adopted 为
|
||||||
|
2,809,185/2,809,308 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为
|
||||||
|
3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes,
|
||||||
|
MCP 为 7,315,930/7,316,038 bytes。真实 PostgreSQL 18.6 arm64 physical HA 再次通过 142/142、timeline `1→2`,报告
|
||||||
|
SHA-256 为 `13e2f3793d7f418f0c1cc3b05206b393c4f09a45e1b9a5783219c12fb930b3dd`,离线审计通过且无 `ql3-ha-*`
|
||||||
|
Docker 容器、卷或网络残留。首份真实 GHCR conflict/reuse 结果仍只能由实际受保护 `v3` release tag 取得。
|
||||||
|
|
||||||
- D-348/ADR-0440(已接受;首份真实线上闭合待实际 release tag):补齐 D-347 确定性收据与实际 tag promotion 之间的 freshness 窗口。此前私有
|
- D-348/ADR-0440(已接受;首份真实线上闭合待实际 release tag):补齐 D-347 确定性收据与实际 tag promotion 之间的 freshness 窗口。此前私有
|
||||||
job 在收据创建时验证 24 小时 freshness,但 release-set aggregate 只检查 `observedAt` 可解析与 receipt digest;镜像构建、扫描和 attestation
|
job 在收据创建时验证 24 小时 freshness,但 release-set aggregate 只检查 `observedAt` 可解析与 receipt digest;镜像构建、扫描和 attestation
|
||||||
延迟后,创建时有效的收据可能在闭合时已经过期。现在 `cluster|all` aggregate 与紧随其后的 source-record audit 必须各自取得 runner-owned 当前
|
延迟后,创建时有效的收据可能在闭合时已经过期。现在 `cluster|all` aggregate 与紧随其后的 source-record audit 必须各自取得 runner-owned 当前
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ADR-0441:Release Catalog Discovery Tag 无覆盖发布
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-18
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-347、D-349
|
||||||
|
- 关联 ADR:ADR-0428、ADR-0430、ADR-0439
|
||||||
|
- Supersedes:ADR-0428 中允许直接向 discovery tag 重发内容的恢复语义,以及 catalog plan/receipt v1
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0428 把 `v<version>-<scope>` 定义为无部署 authority 的发现入口,并要求部署只信任验证后的 immutable manifest digest。
|
||||||
|
ADR-0439 又要求相同 source evidence 的合法 workflow 重跑生成相同 release-set 与 catalog identity。
|
||||||
|
|
||||||
|
但发布 workflow 仍直接对远端 discovery tag 执行 `regctl artifact put`。OCI Distribution tag 没有跨 registry CAS;如果该 tag 已被外部
|
||||||
|
写成另一个 digest,直接 put 会先覆盖冲突事实,再在新 digest 上完成验证。response-loss 重跑也无法区分“远端已经是同一确定内容”和“远端
|
||||||
|
曾指向不同内容”。workflow 的同 ref concurrency 只能串行本仓库 Actions 运行,不能证明 registry 没有其他 package writer。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. catalog plan 升为 `qinglong/release-catalog-plan@v2`,publication policy 固定声明:
|
||||||
|
- staging/discovery tag authority 均为 `none`;
|
||||||
|
- discovery 冲突必须在 mutation 前失败;
|
||||||
|
- response-loss 只能复用 exact manifest digest;
|
||||||
|
- immutable digest 仍是唯一部署 authority。
|
||||||
|
2. publisher 必须先把 canonical release-set 发布到 runner 私有的 `ocidir://` layout,并从该 layout 取得 manifest digest 和 raw
|
||||||
|
manifest。绝不再直接对 discovery tag 执行 `artifact put`。
|
||||||
|
3. publisher 随后读取远端 catalog tag inventory。首次发布时 repository 可能尚不存在;只有在 inventory 读取失败后,才允许把本地
|
||||||
|
immutable manifest copy 到 `staging-<plan-digest>`。该 deterministic staging tag 无 authority,写后必须解析为同一 manifest
|
||||||
|
digest,再重新读取 inventory。第二次读取失败即失败关闭。
|
||||||
|
4. `qinglong/release-catalog-tag-inventory-decision@v1` 对 tag inventory 执行最大 1 MiB、canonical line、OCI tag 字符集和
|
||||||
|
无重复校验。若 discovery tag 存在,必须读取其 digest;若缺失,则以显式 `absent` 状态进入 publication decision。
|
||||||
|
5. 新的 `qinglong/release-catalog-publication-decision@v1` 只接受已通过 exact manifest contract 的本地 manifest:
|
||||||
|
- `absent` 产生 `publish_if_absent`;
|
||||||
|
- exact digest 产生 `reuse_exact_digest`,用于 response-loss 恢复;
|
||||||
|
- 任意不同、缺失格式或非 SHA-256 观察值在 discovery mutation 前失败。
|
||||||
|
6. `publish_if_absent` 只能从本地 immutable reference copy 到 discovery tag;`reuse_exact_digest` 不写 registry。两条路径都必须
|
||||||
|
再读取 discovery digest、按 immutable reference round-trip release-set、逐字节比较本地/远端 raw manifest,然后才允许签名、
|
||||||
|
provenance 和 receipt。
|
||||||
|
7. catalog receipt 升为 `qinglong/release-catalog-receipt@v2`,明确绑定
|
||||||
|
`fail_closed_before_mutation` 与 `reuse_exact_manifest_digest_only`。release-set v3 和 OCI media type 不变,因为被发布的
|
||||||
|
canonical release-set bytes 没有改变。
|
||||||
|
8. OCI registry 仍没有 tag CAS,因此本 Gate 不宣称能阻止仓库外 writer 在 inventory 与 copy 之间竞争。workflow 保持同 ref 串行,
|
||||||
|
package write authority 必须由组织治理;最终回读会发现写入期间的竞争,而任何稍后改写也不能改变 receipt/provenance 中的 immutable
|
||||||
|
digest。consumer 继续禁止把 discovery tag 当作 authority。
|
||||||
|
|
||||||
|
## 失败与恢复
|
||||||
|
|
||||||
|
- discovery 已指向不同 digest:不执行 discovery copy,不覆盖现场;操作者必须调查 tag/source/package authority,不能以重跑“修复”。
|
||||||
|
- discovery 已指向 exact digest:不重新发布,继续 immutable round-trip、签名和 provenance 验证,得到与首次成功相同的 durable
|
||||||
|
plan/receipt bytes。
|
||||||
|
- catalog repository 尚不存在:deterministic staging copy 只用于建立 repository 和确定可读 tag inventory;staging tag 永远不能进入
|
||||||
|
deployment lock。
|
||||||
|
- registry/auth/network 不可用:staging 或第二次 inventory 失败即停止;不能把不确定读取当成 tag 缺失。
|
||||||
|
- discovery copy response 丢失:重跑后 inventory 必须看到 exact digest 并走 reuse;看到其他 digest 则失败。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- Edge/Standalone/路由设备继续只接收 catalog-bound Local selection 和 immutable image digest,不安装 regctl、Node、Cosign 或 GitHub
|
||||||
|
CLI,不增加 RSS、I/O、timer、listener、updater 或后台任务。
|
||||||
|
- Cluster 节点、Kubernetes object、CloudNativePG、数据库、migration、SQL、Pool 与运行时镜像均无变化。
|
||||||
|
- 新工作只在短生命周期发布 runner:一个私有 OCI layout、至多 1 MiB tag inventory、一次 publication decision 和必要时一次 staging
|
||||||
|
copy;不新增 workspace package、生产依赖或常驻服务。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 继续直接 artifact put 后再验 digest
|
||||||
|
|
||||||
|
拒绝。后验一致只能证明覆盖后的结果,不能保留或拒绝原有冲突。
|
||||||
|
|
||||||
|
### 把 workflow concurrency 当作 registry CAS
|
||||||
|
|
||||||
|
拒绝。它只串行同仓库同 ref 的 Actions run,无法约束其他 package token、组织管理员或 registry 外部 writer。
|
||||||
|
|
||||||
|
### 让 discovery tag 成为不可变部署入口
|
||||||
|
|
||||||
|
拒绝。OCI tag 仍可改写;唯一可持久签名、attest 和部署锁定的 authority 是 manifest digest。
|
||||||
|
|
||||||
|
### 为 catalog 新增常驻协调服务
|
||||||
|
|
||||||
|
拒绝。发布路径不值得引入新的服务、数据库和低配设备依赖;确定性 staging、冲突决策与 immutable consumer 已能把剩余竞态限制在非权威 tag。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- contract 覆盖空/存在/重复/畸形/超限 tag inventory、absent publish、exact response-loss reuse、不同 digest 冲突、非法观察值和
|
||||||
|
closed CLI;
|
||||||
|
- workflow 静态门拒绝 direct discovery `artifact put`、缺失 publication decision、缺失 exact reuse、无界/malformed inventory 和未回读
|
||||||
|
immutable digest;
|
||||||
|
- catalog consumption、deployment-lock、Local/Cluster post-publication gate 必须继续只消费 immutable reference;
|
||||||
|
- 定向发布链 143/143,完整 backend 1,371 项为 1,369 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;
|
||||||
|
- 12 项静态审计和 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 至 MCP 的既有字节/RSS 上限未漂移;
|
||||||
|
- PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,证据 SHA-256 为
|
||||||
|
`13e2f3793d7f418f0c1cc3b05206b393c4f09a45e1b9a5783219c12fb930b3dd`,离线审计 compatible 且 Docker 资源零残留;
|
||||||
|
- 完整证据记录于 QL-RFC-0001 D-349;首份真实 GHCR conflict/reuse 证据仍须由受保护 `v3` release tag 产生。
|
||||||
@@ -444,6 +444,7 @@
|
|||||||
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) |
|
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) |
|
||||||
| [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) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -445,6 +445,20 @@ release-set、catalog immutable digest、两类 provenance 与 receipt 全部生
|
|||||||
consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention
|
consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention
|
||||||
或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。
|
或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。
|
||||||
|
|
||||||
|
catalog discovery tag 另有独立的无覆盖协议。publisher 先在 runner 私有 `ocidir://` 中生成 exact manifest;不得直接向
|
||||||
|
`v<version>-<scope>` 执行 `artifact put`。远端 repository 不存在时只允许创建
|
||||||
|
`staging-<catalog-plan-digest>` 非权威 tag,以便重新取得不超过 1 MiB 的完整 tag inventory;inventory 必须先通过
|
||||||
|
`qinglong/release-catalog-tag-inventory-decision@v1` 的 canonical line、OCI tag 字符集和无重复检查。随后:
|
||||||
|
|
||||||
|
- discovery 缺失:publication decision 为 `publish_if_absent`,从本地 immutable reference copy 后立即回读;
|
||||||
|
- discovery 已是 exact manifest digest:decision 为 `reuse_exact_digest`,不写 registry,继续 immutable 验证;
|
||||||
|
- discovery 是其他 digest、inventory 无界/重复/畸形,或 repository 建立后仍无法读取:在覆盖前失败。
|
||||||
|
|
||||||
|
catalog plan/receipt 因此分别为 v2,receipt 固定声明 `fail_closed_before_mutation` 与
|
||||||
|
`reuse_exact_manifest_digest_only`。GHCR tag 没有 CAS,组织仍必须限制 package writer;workflow 的同 ref concurrency 只能串行受保护
|
||||||
|
tag 的本仓库 run。最终 digest/manifest 回读用于发现发布期间竞争,deployment consumer 仍只接受已签名和 attested 的 immutable reference,
|
||||||
|
绝不能因为 discovery 当前“看起来正确”而把 tag 写入 rollout。
|
||||||
|
|
||||||
workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORM,release owner 仍须维护
|
workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORM,release owner 仍须维护
|
||||||
package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程都不得改写 canonical JSON,并须保留
|
package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程都不得改写 canonical JSON,并须保留
|
||||||
原 catalog manifest digest、receipt 与 provenance 关联。
|
原 catalog manifest digest、receipt 与 provenance 关联。
|
||||||
|
|||||||
@@ -760,7 +760,19 @@ function auditReleaseWorkflow(source) {
|
|||||||
'subject-path': '${{ steps.release-set.outputs.report }}',
|
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||||
}) ||
|
}) ||
|
||||||
releaseSetSteps[10]?.id !== 'catalog' ||
|
releaseSetSteps[10]?.id !== 'catalog' ||
|
||||||
!/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]*image digest "\$\{discovery_tag\}"[\s\S]*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]*GITHUB_OUTPUT/.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 ?? '',
|
||||||
|
) ||
|
||||||
|
!/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 ?? '',
|
||||||
|
) ||
|
||||||
|
!/--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 ?? '',
|
||||||
|
) ||
|
||||||
|
!/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 ?? '',
|
||||||
|
) ||
|
||||||
|
/artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test(
|
||||||
releaseSetSteps[10]?.run ?? '',
|
releaseSetSteps[10]?.run ?? '',
|
||||||
) ||
|
) ||
|
||||||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
||||||
@@ -1315,12 +1327,22 @@ function auditReleaseWorkflow(source) {
|
|||||||
durableCatalog: {
|
durableCatalog: {
|
||||||
repository: 'qinglong3-release-catalog',
|
repository: 'qinglong3-release-catalog',
|
||||||
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||||
|
planSchema: 'qinglong/release-catalog-plan@v2',
|
||||||
|
receiptSchema: 'qinglong/release-catalog-receipt@v2',
|
||||||
|
tagInventoryDecisionSchema:
|
||||||
|
'qinglong/release-catalog-tag-inventory-decision@v1',
|
||||||
|
publicationDecisionSchema:
|
||||||
|
'qinglong/release-catalog-publication-decision@v1',
|
||||||
basenameOnly: true,
|
basenameOnly: true,
|
||||||
crossRunnerDeterministic: true,
|
crossRunnerDeterministic: true,
|
||||||
byteExactRoundTrip: true,
|
byteExactRoundTrip: true,
|
||||||
keylessSignatureVerified: true,
|
keylessSignatureVerified: true,
|
||||||
githubProvenanceVerified: true,
|
githubProvenanceVerified: true,
|
||||||
|
deterministicStagingTag: true,
|
||||||
discoveryTagAuthority: 'none',
|
discoveryTagAuthority: 'none',
|
||||||
|
discoveryTagConflictPolicy: 'fail_closed_without_overwrite',
|
||||||
|
responseLossRecovery: 'reuse_exact_manifest_digest_only',
|
||||||
|
registryTagCas: false,
|
||||||
immutableDigestAuthority: 'verified',
|
immutableDigestAuthority: 'verified',
|
||||||
receiptAttested: true,
|
receiptAttested: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ const {
|
|||||||
inspectReleaseSet,
|
inspectReleaseSet,
|
||||||
} = require('./ql3-release-set-contract.cjs');
|
} = require('./ql3-release-set-contract.cjs');
|
||||||
|
|
||||||
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v2';
|
||||||
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
const CATALOG_PUBLICATION_DECISION_SCHEMA =
|
||||||
|
'qinglong/release-catalog-publication-decision@v1';
|
||||||
|
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v2';
|
||||||
|
const CATALOG_TAG_INVENTORY_DECISION_SCHEMA =
|
||||||
|
'qinglong/release-catalog-tag-inventory-decision@v1';
|
||||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json';
|
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json';
|
||||||
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
||||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||||
@@ -19,9 +23,20 @@ const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
|||||||
const OCI_EMPTY_CONFIG_DIGEST =
|
const OCI_EMPTY_CONFIG_DIGEST =
|
||||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||||
|
const OCI_TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
|
||||||
const SOURCE_REPOSITORY_PATTERN =
|
const SOURCE_REPOSITORY_PATTERN =
|
||||||
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
||||||
const MAX_FILE_BYTES = 1024 * 1024;
|
const MAX_FILE_BYTES = 1024 * 1024;
|
||||||
|
const PUBLICATION_POLICY = Object.freeze({
|
||||||
|
title: 'basename_only',
|
||||||
|
crossRunnerDeterministic: true,
|
||||||
|
stagingTagAuthority: 'none',
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
immutableDigestAuthority: 'required',
|
||||||
|
roundTrip: 'byte_exact',
|
||||||
|
conflict: 'fail_closed_before_discovery_tag_mutation',
|
||||||
|
recovery: 'reuse_exact_manifest_digest_only',
|
||||||
|
});
|
||||||
|
|
||||||
class QingLong3ReleaseCatalogError extends Error {
|
class QingLong3ReleaseCatalogError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
@@ -167,14 +182,7 @@ function createCatalogPlan(releaseSet, options) {
|
|||||||
'org.opencontainers.image.version': options.version,
|
'org.opencontainers.image.version': options.version,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
publicationPolicy: {
|
publicationPolicy: { ...PUBLICATION_POLICY },
|
||||||
title: 'basename_only',
|
|
||||||
crossRunnerDeterministic: true,
|
|
||||||
discoveryTagAuthority: 'none',
|
|
||||||
immutableDigestAuthority: 'required',
|
|
||||||
roundTrip: 'byte_exact',
|
|
||||||
recovery: 'republish_deterministic_content_then_verify_digest',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...unsigned,
|
...unsigned,
|
||||||
@@ -204,6 +212,8 @@ function validatePlanShape(plan) {
|
|||||||
]) ||
|
]) ||
|
||||||
plan.schemaVersion !== 1 ||
|
plan.schemaVersion !== 1 ||
|
||||||
plan.schema !== CATALOG_PLAN_SCHEMA ||
|
plan.schema !== CATALOG_PLAN_SCHEMA ||
|
||||||
|
JSON.stringify(plan.publicationPolicy) !==
|
||||||
|
JSON.stringify(PUBLICATION_POLICY) ||
|
||||||
!DIGEST_PATTERN.test(plan.planDigest || '')
|
!DIGEST_PATTERN.test(plan.planDigest || '')
|
||||||
) {
|
) {
|
||||||
fail('catalog plan shape is invalid');
|
fail('catalog plan shape is invalid');
|
||||||
@@ -262,6 +272,93 @@ function validateManifest(plan, manifestContents, manifestDigest) {
|
|||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifestContents,
|
||||||
|
manifestDigest,
|
||||||
|
observedDiscoveryDigest,
|
||||||
|
) {
|
||||||
|
validatePlanShape(plan);
|
||||||
|
validateManifest(plan, manifestContents, manifestDigest);
|
||||||
|
if (
|
||||||
|
observedDiscoveryDigest !== 'absent' &&
|
||||||
|
!DIGEST_PATTERN.test(observedDiscoveryDigest || '')
|
||||||
|
) {
|
||||||
|
fail('observed discovery tag digest is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
observedDiscoveryDigest !== 'absent' &&
|
||||||
|
observedDiscoveryDigest !== manifestDigest
|
||||||
|
) {
|
||||||
|
fail('discovery tag already points at another catalog manifest');
|
||||||
|
}
|
||||||
|
const action =
|
||||||
|
observedDiscoveryDigest === 'absent'
|
||||||
|
? 'publish_if_absent'
|
||||||
|
: 'reuse_exact_digest';
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: CATALOG_PUBLICATION_DECISION_SCHEMA,
|
||||||
|
planDigest: plan.planDigest,
|
||||||
|
catalog: {
|
||||||
|
discoveryTag: plan.catalog.discoveryTag,
|
||||||
|
manifestDigest,
|
||||||
|
immutableReference: `${plan.catalog.registryRepository}@${manifestDigest}`,
|
||||||
|
},
|
||||||
|
observation:
|
||||||
|
observedDiscoveryDigest === 'absent' ? 'absent' : 'exact_manifest_digest',
|
||||||
|
action,
|
||||||
|
guards: {
|
||||||
|
stagingTagAuthority: 'none',
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
overwriteConflicts: false,
|
||||||
|
verifyAfterPublication: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
...unsigned,
|
||||||
|
decisionDigest: sha256(JSON.stringify(unsigned)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCatalogTagInventoryDecision(plan, inventoryContents) {
|
||||||
|
validatePlanShape(plan);
|
||||||
|
if (
|
||||||
|
typeof inventoryContents !== 'string' ||
|
||||||
|
Buffer.byteLength(inventoryContents) > MAX_FILE_BYTES ||
|
||||||
|
(inventoryContents.length > 0 && !inventoryContents.endsWith('\n'))
|
||||||
|
) {
|
||||||
|
fail('catalog tag inventory is invalid or unbounded');
|
||||||
|
}
|
||||||
|
const tags =
|
||||||
|
inventoryContents.length === 0
|
||||||
|
? []
|
||||||
|
: inventoryContents.slice(0, -1).split('\n');
|
||||||
|
if (
|
||||||
|
tags.some((value) => !OCI_TAG_PATTERN.test(value)) ||
|
||||||
|
new Set(tags).size !== tags.length
|
||||||
|
) {
|
||||||
|
fail('catalog tag inventory is malformed');
|
||||||
|
}
|
||||||
|
const separator = plan.catalog.discoveryTag.lastIndexOf(':');
|
||||||
|
const discoveryTagName = plan.catalog.discoveryTag.slice(separator + 1);
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: CATALOG_TAG_INVENTORY_DECISION_SCHEMA,
|
||||||
|
planDigest: plan.planDigest,
|
||||||
|
discoveryTag: plan.catalog.discoveryTag,
|
||||||
|
inventory: {
|
||||||
|
count: tags.length,
|
||||||
|
contentDigest: sha256(inventoryContents),
|
||||||
|
},
|
||||||
|
observation: tags.includes(discoveryTagName) ? 'present' : 'absent',
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
...unsigned,
|
||||||
|
decisionDigest: sha256(JSON.stringify(unsigned)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createCatalogReceipt(plan, manifestContents, manifestDigest) {
|
function createCatalogReceipt(plan, manifestContents, manifestDigest) {
|
||||||
validatePlanShape(plan);
|
validatePlanShape(plan);
|
||||||
validateManifest(plan, manifestContents, manifestDigest);
|
validateManifest(plan, manifestContents, manifestDigest);
|
||||||
@@ -285,6 +382,8 @@ function createCatalogReceipt(plan, manifestContents, manifestDigest) {
|
|||||||
githubProvenance: 'source_tag_and_revision_bound',
|
githubProvenance: 'source_tag_and_revision_bound',
|
||||||
discoveryTagAuthority: 'none',
|
discoveryTagAuthority: 'none',
|
||||||
immutableDigestAuthority: 'verified',
|
immutableDigestAuthority: 'verified',
|
||||||
|
discoveryTagConflictPolicy: 'fail_closed_before_mutation',
|
||||||
|
responseLossRecovery: 'reuse_exact_manifest_digest_only',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -305,6 +404,7 @@ function auditCatalogReceipt(actual, plan, manifestContents, manifestDigest) {
|
|||||||
catalogManifestDigest: actual.catalog.manifestDigest,
|
catalogManifestDigest: actual.catalog.manifestDigest,
|
||||||
immutableReference: actual.catalog.immutableReference,
|
immutableReference: actual.catalog.immutableReference,
|
||||||
discoveryTagAuthority: actual.verification.discoveryTagAuthority,
|
discoveryTagAuthority: actual.verification.discoveryTagAuthority,
|
||||||
|
discoveryTagConflictPolicy: actual.verification.discoveryTagConflictPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +428,17 @@ function parseArguments(argv) {
|
|||||||
const expected =
|
const expected =
|
||||||
values.mode === 'plan'
|
values.mode === 'plan'
|
||||||
? [...identity, 'output', 'release-set']
|
? [...identity, 'output', 'release-set']
|
||||||
|
: values.mode === 'tag-inventory'
|
||||||
|
? ['mode', 'output', 'plan', 'tag-inventory']
|
||||||
|
: values.mode === 'publication-decision'
|
||||||
|
? [
|
||||||
|
'manifest',
|
||||||
|
'manifest-digest',
|
||||||
|
'mode',
|
||||||
|
'observed-discovery-digest',
|
||||||
|
'output',
|
||||||
|
'plan',
|
||||||
|
]
|
||||||
: values.mode === 'receipt'
|
: values.mode === 'receipt'
|
||||||
? ['manifest', 'manifest-digest', 'mode', 'output', 'plan']
|
? ['manifest', 'manifest-digest', 'mode', 'output', 'plan']
|
||||||
: values.mode === 'audit'
|
: values.mode === 'audit'
|
||||||
@@ -362,7 +473,13 @@ function parseArguments(argv) {
|
|||||||
...(values['manifest-digest']
|
...(values['manifest-digest']
|
||||||
? { manifestDigest: values['manifest-digest'] }
|
? { manifestDigest: values['manifest-digest'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(values['observed-discovery-digest']
|
||||||
|
? { observedDiscoveryDigest: values['observed-discovery-digest'] }
|
||||||
|
: {}),
|
||||||
...(values.receipt ? { receipt: values.receipt } : {}),
|
...(values.receipt ? { receipt: values.receipt } : {}),
|
||||||
|
...(values['tag-inventory']
|
||||||
|
? { tagInventory: values['tag-inventory'] }
|
||||||
|
: {}),
|
||||||
...(values.output ? { output: values.output } : {}),
|
...(values.output ? { output: values.output } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -383,7 +500,42 @@ function runCli(argv, output = process.stdout) {
|
|||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
const plan = readCanonicalJson(options.plan, 'catalog plan').value;
|
const plan = readCanonicalJson(options.plan, 'catalog plan').value;
|
||||||
|
if (options.mode === 'tag-inventory') {
|
||||||
|
const inventoryPath = resolveCanonicalAbsolute(
|
||||||
|
options.tagInventory,
|
||||||
|
'catalog tag inventory',
|
||||||
|
);
|
||||||
|
const stat = fs.lstatSync(inventoryPath);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.size > MAX_FILE_BYTES ||
|
||||||
|
fs.realpathSync(inventoryPath) !== inventoryPath ||
|
||||||
|
fs.realpathSync(path.dirname(inventoryPath)) !==
|
||||||
|
path.dirname(inventoryPath)
|
||||||
|
) {
|
||||||
|
fail('catalog tag inventory must be one bounded canonical regular file');
|
||||||
|
}
|
||||||
|
const decision = createCatalogTagInventoryDecision(
|
||||||
|
plan,
|
||||||
|
fs.readFileSync(inventoryPath, 'utf8'),
|
||||||
|
);
|
||||||
|
writeNoReplace(options.output, decision);
|
||||||
|
output.write(canonicalJson(decision));
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
const manifest = readBoundedFile(options.manifest, 'catalog manifest');
|
const manifest = readBoundedFile(options.manifest, 'catalog manifest');
|
||||||
|
if (options.mode === 'publication-decision') {
|
||||||
|
const decision = createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifest.contents,
|
||||||
|
options.manifestDigest,
|
||||||
|
options.observedDiscoveryDigest,
|
||||||
|
);
|
||||||
|
writeNoReplace(options.output, decision);
|
||||||
|
output.write(canonicalJson(decision));
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
if (options.mode === 'receipt') {
|
if (options.mode === 'receipt') {
|
||||||
const receipt = createCatalogReceipt(
|
const receipt = createCatalogReceipt(
|
||||||
plan,
|
plan,
|
||||||
@@ -419,7 +571,9 @@ if (require.main === module) {
|
|||||||
module.exports = Object.freeze({
|
module.exports = Object.freeze({
|
||||||
ARTIFACT_TYPE,
|
ARTIFACT_TYPE,
|
||||||
CATALOG_PLAN_SCHEMA,
|
CATALOG_PLAN_SCHEMA,
|
||||||
|
CATALOG_PUBLICATION_DECISION_SCHEMA,
|
||||||
CATALOG_RECEIPT_SCHEMA,
|
CATALOG_RECEIPT_SCHEMA,
|
||||||
|
CATALOG_TAG_INVENTORY_DECISION_SCHEMA,
|
||||||
FILE_MEDIA_TYPE,
|
FILE_MEDIA_TYPE,
|
||||||
OCI_EMPTY_CONFIG_DIGEST,
|
OCI_EMPTY_CONFIG_DIGEST,
|
||||||
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||||
@@ -428,7 +582,9 @@ module.exports = Object.freeze({
|
|||||||
auditCatalogPlan,
|
auditCatalogPlan,
|
||||||
auditCatalogReceipt,
|
auditCatalogReceipt,
|
||||||
createCatalogPlan,
|
createCatalogPlan,
|
||||||
|
createCatalogPublicationDecision,
|
||||||
createCatalogReceipt,
|
createCatalogReceipt,
|
||||||
|
createCatalogTagInventoryDecision,
|
||||||
parseArguments,
|
parseArguments,
|
||||||
runCli,
|
runCli,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,12 +111,22 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
durableCatalog: {
|
durableCatalog: {
|
||||||
repository: 'qinglong3-release-catalog',
|
repository: 'qinglong3-release-catalog',
|
||||||
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||||
|
planSchema: 'qinglong/release-catalog-plan@v2',
|
||||||
|
receiptSchema: 'qinglong/release-catalog-receipt@v2',
|
||||||
|
tagInventoryDecisionSchema:
|
||||||
|
'qinglong/release-catalog-tag-inventory-decision@v1',
|
||||||
|
publicationDecisionSchema:
|
||||||
|
'qinglong/release-catalog-publication-decision@v1',
|
||||||
basenameOnly: true,
|
basenameOnly: true,
|
||||||
crossRunnerDeterministic: true,
|
crossRunnerDeterministic: true,
|
||||||
byteExactRoundTrip: true,
|
byteExactRoundTrip: true,
|
||||||
keylessSignatureVerified: true,
|
keylessSignatureVerified: true,
|
||||||
githubProvenanceVerified: true,
|
githubProvenanceVerified: true,
|
||||||
|
deterministicStagingTag: true,
|
||||||
discoveryTagAuthority: 'none',
|
discoveryTagAuthority: 'none',
|
||||||
|
discoveryTagConflictPolicy: 'fail_closed_without_overwrite',
|
||||||
|
responseLossRecovery: 'reuse_exact_manifest_digest_only',
|
||||||
|
registryTagCas: false,
|
||||||
immutableDigestAuthority: 'verified',
|
immutableDigestAuthority: 'verified',
|
||||||
receiptAttested: true,
|
receiptAttested: true,
|
||||||
},
|
},
|
||||||
@@ -982,6 +992,50 @@ test('rejects a catalog publication without byte-exact round trip', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects direct overwrite publication to the catalog discovery tag', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
' "${local_tag}"',
|
||||||
|
' "${discovery_tag}"',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/independently inspect, durably publish/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects catalog publication without a conflict-aware decision', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
'--mode=publication-decision',
|
||||||
|
'--mode=receipt',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/independently inspect, durably publish/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects catalog publication without bounded tag inventory classification', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
'--mode=tag-inventory',
|
||||||
|
'--mode=publication-decision',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/independently inspect, durably publish/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects response-loss recovery that cannot reuse an exact catalog digest', () => {
|
||||||
|
const mutated = releaseSource.replace(
|
||||||
|
' elif [[ "${action}" != "reuse_exact_digest" ]]; then',
|
||||||
|
' elif [[ "${action}" != "publish_if_absent" ]]; then',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditReleaseWorkflow(mutated),
|
||||||
|
/independently inspect, durably publish/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects using the mutable catalog discovery tag as deployment authority', () => {
|
test('rejects using the mutable catalog discovery tag as deployment authority', () => {
|
||||||
const mutated = releaseSource.replace(
|
const mutated = releaseSource.replace(
|
||||||
'artifact get --file "${file_name}" "${immutable_reference}"',
|
'artifact get --file "${file_name}" "${immutable_reference}"',
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ const path = require('node:path');
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const {
|
const {
|
||||||
ARTIFACT_TYPE,
|
ARTIFACT_TYPE,
|
||||||
|
CATALOG_PLAN_SCHEMA,
|
||||||
|
CATALOG_PUBLICATION_DECISION_SCHEMA,
|
||||||
|
CATALOG_RECEIPT_SCHEMA,
|
||||||
|
CATALOG_TAG_INVENTORY_DECISION_SCHEMA,
|
||||||
OCI_EMPTY_CONFIG_DIGEST,
|
OCI_EMPTY_CONFIG_DIGEST,
|
||||||
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||||
OCI_MANIFEST_MEDIA_TYPE,
|
OCI_MANIFEST_MEDIA_TYPE,
|
||||||
auditCatalogPlan,
|
auditCatalogPlan,
|
||||||
auditCatalogReceipt,
|
auditCatalogReceipt,
|
||||||
createCatalogPlan,
|
createCatalogPlan,
|
||||||
|
createCatalogPublicationDecision,
|
||||||
createCatalogReceipt,
|
createCatalogReceipt,
|
||||||
|
createCatalogTagInventoryDecision,
|
||||||
parseArguments,
|
parseArguments,
|
||||||
runCli,
|
runCli,
|
||||||
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
||||||
@@ -122,9 +128,15 @@ test('plans deterministic Local, Cluster and All OCI catalog entries', () => {
|
|||||||
`ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`,
|
`ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`,
|
||||||
);
|
);
|
||||||
assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE);
|
assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE);
|
||||||
|
assert.equal(plan.schema, CATALOG_PLAN_SCHEMA);
|
||||||
|
assert.equal(plan.publicationPolicy.stagingTagAuthority, 'none');
|
||||||
|
assert.equal(
|
||||||
|
plan.publicationPolicy.conflict,
|
||||||
|
'fail_closed_before_discovery_tag_mutation',
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
plan.publicationPolicy.recovery,
|
plan.publicationPolicy.recovery,
|
||||||
'republish_deterministic_content_then_verify_digest',
|
'reuse_exact_manifest_digest_only',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
plan.releaseSet.bytes,
|
plan.releaseSet.bytes,
|
||||||
@@ -147,17 +159,131 @@ test('creates one digest-addressed receipt from the exact OCI manifest', () => {
|
|||||||
const manifest = manifestFor(plan);
|
const manifest = manifestFor(plan);
|
||||||
const manifestDigest = sha256(manifest);
|
const manifestDigest = sha256(manifest);
|
||||||
const receipt = createCatalogReceipt(plan, manifest, manifestDigest);
|
const receipt = createCatalogReceipt(plan, manifest, manifestDigest);
|
||||||
|
assert.equal(receipt.schema, CATALOG_RECEIPT_SCHEMA);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
receipt.catalog.immutableReference,
|
receipt.catalog.immutableReference,
|
||||||
`ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`,
|
`ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`,
|
||||||
);
|
);
|
||||||
assert.equal(receipt.verification.discoveryTagAuthority, 'none');
|
assert.equal(receipt.verification.discoveryTagAuthority, 'none');
|
||||||
|
assert.equal(
|
||||||
|
receipt.verification.discoveryTagConflictPolicy,
|
||||||
|
'fail_closed_before_mutation',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
receipt.verification.responseLossRecovery,
|
||||||
|
'reuse_exact_manifest_digest_only',
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
auditCatalogReceipt(receipt, plan, manifest, manifestDigest).compatible,
|
auditCatalogReceipt(receipt, plan, manifest, manifestDigest).compatible,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('publishes only an absent discovery tag and exactly reuses response-loss recovery', () => {
|
||||||
|
const set = releaseSet('cluster');
|
||||||
|
const plan = createCatalogPlan(set, {
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
});
|
||||||
|
const manifest = manifestFor(plan);
|
||||||
|
const manifestDigest = sha256(manifest);
|
||||||
|
const first = createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifest,
|
||||||
|
manifestDigest,
|
||||||
|
'absent',
|
||||||
|
);
|
||||||
|
assert.equal(first.schema, CATALOG_PUBLICATION_DECISION_SCHEMA);
|
||||||
|
assert.equal(first.observation, 'absent');
|
||||||
|
assert.equal(first.action, 'publish_if_absent');
|
||||||
|
assert.equal(first.guards.overwriteConflicts, false);
|
||||||
|
const recovered = createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifest,
|
||||||
|
manifestDigest,
|
||||||
|
manifestDigest,
|
||||||
|
);
|
||||||
|
assert.equal(recovered.observation, 'exact_manifest_digest');
|
||||||
|
assert.equal(recovered.action, 'reuse_exact_digest');
|
||||||
|
assert.deepEqual(recovered.catalog, first.catalog);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifest,
|
||||||
|
manifestDigest,
|
||||||
|
`sha256:${'f'.repeat(64)}`,
|
||||||
|
),
|
||||||
|
/already points at another catalog manifest/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCatalogPublicationDecision(
|
||||||
|
plan,
|
||||||
|
manifest,
|
||||||
|
manifestDigest,
|
||||||
|
'missing',
|
||||||
|
),
|
||||||
|
/observed discovery tag digest is invalid/,
|
||||||
|
);
|
||||||
|
const weakenedPlan = JSON.parse(JSON.stringify(plan));
|
||||||
|
weakenedPlan.publicationPolicy.recovery =
|
||||||
|
'republish_deterministic_content_then_verify_digest';
|
||||||
|
const { planDigest: ignored, ...unsigned } = weakenedPlan;
|
||||||
|
weakenedPlan.planDigest = sha256(JSON.stringify(unsigned));
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCatalogPublicationDecision(
|
||||||
|
weakenedPlan,
|
||||||
|
manifest,
|
||||||
|
manifestDigest,
|
||||||
|
'absent',
|
||||||
|
),
|
||||||
|
/catalog plan shape is invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies one bounded exact catalog tag inventory before mutation', () => {
|
||||||
|
const set = releaseSet('cluster');
|
||||||
|
const plan = createCatalogPlan(set, {
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'cluster',
|
||||||
|
});
|
||||||
|
const absent = createCatalogTagInventoryDecision(
|
||||||
|
plan,
|
||||||
|
'staging-' + 'a'.repeat(64) + '\n',
|
||||||
|
);
|
||||||
|
assert.equal(absent.schema, CATALOG_TAG_INVENTORY_DECISION_SCHEMA);
|
||||||
|
assert.equal(absent.observation, 'absent');
|
||||||
|
assert.equal(absent.inventory.count, 1);
|
||||||
|
const present = createCatalogTagInventoryDecision(
|
||||||
|
plan,
|
||||||
|
`staging-${'a'.repeat(64)}\nv${version}-cluster\n`,
|
||||||
|
);
|
||||||
|
assert.equal(present.observation, 'present');
|
||||||
|
assert.equal(present.inventory.count, 2);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCatalogTagInventoryDecision(
|
||||||
|
plan,
|
||||||
|
`v${version}-cluster\nv${version}-cluster\n`,
|
||||||
|
),
|
||||||
|
/inventory is malformed/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createCatalogTagInventoryDecision(plan, 'not a tag\n'),
|
||||||
|
/inventory is malformed/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createCatalogTagInventoryDecision(plan, 'missing-newline'),
|
||||||
|
/inventory is invalid or unbounded/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createCatalogTagInventoryDecision(plan, 'a'.repeat(1024 * 1024 + 1)),
|
||||||
|
/inventory is invalid or unbounded/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects source ownership, mutable identity and release-set drift', () => {
|
test('rejects source ownership, mutable identity and release-set drift', () => {
|
||||||
const set = releaseSet('local');
|
const set = releaseSet('local');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -241,10 +367,56 @@ test('CLI plans, receipts and audits canonical no-replace evidence', (t) => {
|
|||||||
const output = { write() {} };
|
const output = { write() {} };
|
||||||
const plan = runCli(planArgs, output);
|
const plan = runCli(planArgs, output);
|
||||||
assert.equal(fs.statSync(planPath).mode & 0o777, 0o600);
|
assert.equal(fs.statSync(planPath).mode & 0o777, 0o600);
|
||||||
|
const tagInventoryPath = path.join(directory, 'tags.txt');
|
||||||
|
fs.writeFileSync(tagInventoryPath, `v${version}-local\n`, { mode: 0o600 });
|
||||||
|
assert.equal(
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=tag-inventory',
|
||||||
|
`--plan=${planPath}`,
|
||||||
|
`--tag-inventory=${tagInventoryPath}`,
|
||||||
|
`--output=${path.join(directory, 'tag-inventory-decision.json')}`,
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
).observation,
|
||||||
|
'present',
|
||||||
|
);
|
||||||
const manifest = manifestFor(plan);
|
const manifest = manifestFor(plan);
|
||||||
const manifestPath = path.join(directory, 'manifest.json');
|
const manifestPath = path.join(directory, 'manifest.json');
|
||||||
fs.writeFileSync(manifestPath, manifest, { mode: 0o600 });
|
fs.writeFileSync(manifestPath, manifest, { mode: 0o600 });
|
||||||
const manifestDigest = sha256(manifest);
|
const manifestDigest = sha256(manifest);
|
||||||
|
const decisionPath = path.join(directory, 'decision.json');
|
||||||
|
assert.equal(
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=publication-decision',
|
||||||
|
`--plan=${planPath}`,
|
||||||
|
`--manifest=${manifestPath}`,
|
||||||
|
`--manifest-digest=${manifestDigest}`,
|
||||||
|
'--observed-discovery-digest=absent',
|
||||||
|
`--output=${decisionPath}`,
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
).action,
|
||||||
|
'publish_if_absent',
|
||||||
|
);
|
||||||
|
const conflictDecisionPath = path.join(directory, 'conflict-decision.json');
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runCli(
|
||||||
|
[
|
||||||
|
'--mode=publication-decision',
|
||||||
|
'--plan=' + planPath,
|
||||||
|
'--manifest=' + manifestPath,
|
||||||
|
'--manifest-digest=' + manifestDigest,
|
||||||
|
'--observed-discovery-digest=sha256:' + 'f'.repeat(64),
|
||||||
|
'--output=' + conflictDecisionPath,
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
),
|
||||||
|
/already points at another catalog manifest/,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(conflictDecisionPath), false);
|
||||||
const receiptPath = path.join(directory, 'receipt.json');
|
const receiptPath = path.join(directory, 'receipt.json');
|
||||||
runCli(
|
runCli(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user