feat(ql3): gate local releases through published catalogs

This commit is contained in:
whyour
2026-08-18 00:56:12 +08:00
parent 9cd42ff454
commit fd433c41cd
9 changed files with 794 additions and 19 deletions
+189
View File
@@ -881,6 +881,195 @@ jobs:
overwrite: false
include-hidden-files: false
release-catalog-local-deployment-live:
name: Consume the public catalog and prove both Local profiles
needs: release-set
if: always() && needs.release-set.result == 'success' && inputs.release_scope != 'cluster'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: read
attestations: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Install the frozen workspace without lifecycle scripts
run: |
set -euo pipefail
corepack enable
corepack prepare pnpm@8.3.1 --activate
pnpm install --frozen-lockfile --ignore-scripts
pnpm --filter @qinglong/local-owner-cli check
- name: Install the exact checksum-pinned catalog reader
env:
REGCTL: ${{ runner.temp }}/regctl
run: |
set -euo pipefail
curl --fail --location --proto '=https' --tlsv1.2 \
--output "${REGCTL}" \
https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64
echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${REGCTL}" | sha256sum --check --strict
chmod 0755 "${REGCTL}"
"${REGCTL}" version
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Consume and independently audit the published OCI catalog
id: local-catalog-consumption
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
SOURCE_REPOSITORY: ${{ github.repository }}
REGCTL: ${{ runner.temp }}/regctl
shell: bash
run: |
set -euo pipefail
umask 077
owner="${REPOSITORY_OWNER,,}"
source_repository="${SOURCE_REPOSITORY,,}"
private_root="${RUNNER_TEMP}/ql3-release-catalog-local-consumer"
bundle="${private_root}/bundle"
token="${private_root}/github-token"
install -d -m 0700 "${private_root}"
install -m 0600 /dev/null "${token}"
printf '%s' "${GH_TOKEN}" > "${token}"
trap 'rm -f "${token}"' EXIT
cosign="$(readlink -f "$(command -v cosign)")"
gh="$(readlink -f "$(command -v gh)")"
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
--mode=create \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${owner}" \
--source-repository="${source_repository}" \
--output-directory="${bundle}" \
--regctl="${REGCTL}" \
--cosign="${cosign}" \
--gh="${gh}" \
--github-token-file="${token}"
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
--mode=audit \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${owner}" \
--source-repository="${source_repository}" \
--output-directory="${bundle}"
rm -f "${token}"
trap - EXIT
echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}"
echo "repository-owner=${owner}" >> "${GITHUB_OUTPUT}"
echo "source-repository=${source_repository}" >> "${GITHUB_OUTPUT}"
- name: Materialize and audit the catalog-bound Local selection
id: local-selection
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
CONSUMPTION_BUNDLE: ${{ steps.local-catalog-consumption.outputs.bundle }}
REPOSITORY_OWNER: ${{ steps.local-catalog-consumption.outputs.repository-owner }}
SOURCE_REPOSITORY: ${{ steps.local-catalog-consumption.outputs.source-repository }}
shell: bash
run: |
set -euo pipefail
umask 077
private_root="${RUNNER_TEMP}/ql3-release-catalog-local-selection"
selection="${private_root}/selection.json"
install -d -m 0700 "${private_root}"
node scripts/ql3-deployment-lock-contract.cjs \
--mode=local-create \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${REPOSITORY_OWNER}" \
--source-repository="${SOURCE_REPOSITORY}" \
--consumption-bundle="${CONSUMPTION_BUNDLE}" \
--allow-root-service=false \
--output="${selection}" > /dev/null
node scripts/ql3-deployment-lock-contract.cjs \
--mode=local-audit \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${REPOSITORY_OWNER}" \
--source-repository="${SOURCE_REPOSITORY}" \
--consumption-bundle="${CONSUMPTION_BUNDLE}" \
--allow-root-service=false \
--selection="${selection}" > /dev/null
SELECTION="${selection}" node - <<'NODE' >> "${GITHUB_OUTPUT}"
const fs = require('node:fs');
const selection = JSON.parse(fs.readFileSync(process.env.SELECTION, 'utf8'));
if (!/^sha256:[a-f0-9]{64}$/.test(selection.selectionDigest || '') ||
!/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-application@sha256:[a-f0-9]{64}$/.test(selection.service?.image || '')) process.exit(1);
process.stdout.write(`selection=${process.env.SELECTION}\nselection-digest=${selection.selectionDigest}\nimage=${selection.service.image}\n`);
NODE
- name: Prove catalog-bound Edge and Standalone rollout
env:
IMAGE: ${{ steps.local-selection.outputs.image }}
RELEASE_SELECTION: ${{ steps.local-selection.outputs.selection }}
SELECTION_DIGEST: ${{ steps.local-selection.outputs.selection-digest }}
EVIDENCE_ROOT: ${{ runner.temp }}/ql3-release-catalog-local-deployment
RELEASE_SCOPE: ${{ inputs.release_scope }}
shell: bash
run: |
set -euo pipefail
umask 077
install -d -m 0700 "${EVIDENCE_ROOT}"
docker pull "${IMAGE}"
docker_executable="$(realpath "$(command -v docker)")"
docker_socket="$(realpath /var/run/docker.sock)"
for profile in edge standalone; do
node scripts/ql3-local-compose-rollout-live-contract.cjs \
--image="${IMAGE}" \
--docker-executable="${docker_executable}" \
--docker-socket="${docker_socket}" \
--profile="${profile}" \
--release-selection="${RELEASE_SELECTION}" \
--expected-selection-digest="${SELECTION_DIGEST}" > \
"${EVIDENCE_ROOT}/${profile}.json"
done
node -e \
"const fs=require('node:fs');const p=process.env.EVIDENCE_ROOT;const d=/^sha256:[a-f0-9]{64}$/;for(const n of ['edge','standalone']){const r=JSON.parse(fs.readFileSync(p+'/'+n+'.json','utf8'));if(r.profile!==n||r.compatible!==true||r.rolloutActive!==true||r.gracefulCleanup!==true||r.releaseAuthority?.mode!=='verified_release_catalog'||r.releaseAuthority?.sourceRevision!==process.env.GITHUB_SHA||r.releaseAuthority?.sourceRef!==process.env.GITHUB_REF||r.releaseAuthority?.scope!==process.env.RELEASE_SCOPE||r.releaseAuthority?.selectionDigest!==process.env.SELECTION_DIGEST||!d.test(r.releaseAuthority?.releaseSetDigest||'')||!d.test(r.releaseAuthority?.catalogManifestDigest||'')||!d.test(r.releaseAuthority?.catalogConsumptionDigest||''))process.exit(1)}"
- name: Verify isolated Local release-gate cleanup
if: always()
env:
IMAGE: ${{ steps.local-selection.outputs.image }}
shell: bash
run: |
set -euo pipefail
if [[ -n "${IMAGE}" ]]; then
test -z "$(docker ps -aq --filter ancestor="${IMAGE}")"
fi
- name: Upload content-free catalog Local deployment evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ql3-release-catalog-local-deployment
if-no-files-found: warn
retention-days: 90
compression-level: 9
overwrite: false
include-hidden-files: false
release-catalog-deployment-live:
name: Consume the public catalog and prove the cluster release on K3s
needs: release-set
+20
View File
@@ -11,6 +11,26 @@
最新增量证据(2026-08-18):
- D-345/ADR-0437(已接受;首份真实公开 catalog evidence 待实际 release tag):Local 发布不再只依赖 image publisher 在完整
release-set/catalog 形成前直接掌握 digest 的 Compose rollout。`release-set` 成功后新增只读
`release-catalog-local-deployment-live`,仅在 `local|all` scope 运行;它从公开 discovery ref 独立消费 immutable catalog,重建并审计
exact-three-file bundle,再以 `local-create|local-audit` 物化唯一 catalog-bound v2 selection。Edge 与 Standalone 必须顺序消费同一 selection
进入正式 deployment prepare、Compose revision/apply、SQLite backup/restore、evidence collection 与 graceful stop 链;真实模式在创建部署目录前
重验 selection canonical path/owner/`0600`/单链接/大小、自摘要、source tag/revision/scope、catalog 三摘要、image 与 root policy。普通 PR
rollout 保留显式 `synthetic_live_fixture`,不能混同公开发布。consumer 仅有 `contents|packages|attestations:read`,无 Docker login、package/
tag/attestation write、签名或 catalog mutation;设备侧不安装 regctl、Cosign、GitHub CLI、Node workspace 或发布 credential。新增工作只在
Local/All release runner 短生命周期运行,不新增 package、生产依赖、数据库、migration、listener、timer、watcher、updater 或稳态资源。
定向发布/selection/部署锁契约 116/116;完整 backend 1,353 项为 1,351 pass/2 条件 skip/0 fail18-package clean build/test
退出 0。package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Cluster dependency、Edge import、
image release、deployment-lock surface 与 release-version 审计全部 compatible。14 档 Local artifact 全部 compatible 且与 D-344 字节
基线完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication 为 3,632,769/3,632,889 bytes
application-api 为 3,800,322/3,800,466 bytesAI 为 3,069,143/3,069,233 bytesapplication+AI 为
4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes。阶段完整性 PostgreSQL HA 在 18.6/arm64 上重跑
142/142 gates、timeline `1→2`,独立 evidence audit 通过,mode-0600 报告 SHA-256 为
`555b31757194379f1a44bdfbb1ef78549ed6b6787781e5fffeada852d26dcd9d`container/network/volume 残留为 0。本机没有可供
独立消费者拉取的公开 QingLong 3 Local immutable GHCR image,因此不把 fixture、selection 单测或 workflow 静态审计写成真实公开
catalog-bound Compose 成功;第一份端到端线上成功仍只能由实际受保护 `v3` release tag dispatch 产生。
- D-344/ADR-0436(已接受;首份真实公开 catalog evidence 待实际 release tag):镜像发布流水线不再停在 durable catalog 与
receipt。`release-set` 成功后新增独立只读 `release-catalog-deployment-live`,仅在 `cluster|all` scope 运行;它不继承 publisher
文件或登录态,而是从公开 discovery ref 重新解析稳定 digest,以 exact Cosign workflow identity 和 GitHub source tag/revision
@@ -0,0 +1,88 @@
# ADR-0437:发布后 Catalog-bound Local Release Gate
- 状态:Accepted
- 日期:2026-08-18
- 关联 RFCQL-RFC-0001 D-01、D-03、D-05、D-14、D-338、D-339、D-345
- 关联 ADRADR-0196、ADR-0198、ADR-0199、ADR-0200、ADR-0201、ADR-0202、ADR-0425、ADR-0430、ADR-0431、ADR-0432、ADR-0436
## 上下文
Local image publisher 已在每个刚推送的 digest 上执行 Edge 与 Standalone Compose rollout,但该检查发生在完整 release-set、版本标签和
durable OCI catalog 形成之前。ADR-0430 至 ADR-0432 已定义公开 catalog 消费、Local selection 和目标侧 Compose revision,发布流水线却
没有证明一个不继承 publisher 登录态的下游消费者能够从公开 catalog 重新发现、验签、下载并重建 selection,再让实际 Local 产品入口消费
同一份 selection。
因此 Local 发布仍有一段未被连续证明的链:publisher 内直接掌握的 image digest 成功,不等于路由器、NAS 或单机用户最终拿到的 catalog
selection 能成功进入 Edge/Standalone。该缺口不能通过为设备安装 Cosign、regctl、GitHub CLI 或常驻 updater 来关闭;这些工具应只存在于
发布者外部的短生命周期 release gate。
## 决策
1. `ql3-image-release.yml``release-set` 完成后增加独立 `release-catalog-local-deployment-live`。它仅在 `local|all` scope 运行;
`cluster` scope 明确跳过。现有 Kubernetes gate 继续只处理 `cluster|all`,两族发布门互不替代。
2. Local gate 使用 GitHub-hosted runner、30 分钟硬上限和只读 `contents|packages|attestations` 权限。它没有 package write、OIDC signing、
attestation write、Docker login、tag promotion 或 catalog mutation authority。
3. consumer 不下载 release-set workflow artifact 作为 authority。它使用 checksum-pinned `regctl@0.11.5`、pinned Cosign installer 与
canonical GitHub CLI,从公开 discovery tag 两次稳定解析 immutable catalog,以 exact workflow identity、source tag/revision 和公开 OCI
provenance 完成 ADR-0430 three-file consumption bundle。短期 GitHub token 只通过当前 UID 的 `0600` 文件进入 verifier,完成即删除。
4. 同一 job 使用 `ql3-deployment-lock-contract.cjs local-create|local-audit` 从 bundle 物化唯一
`qinglong/local-compose-release-image@v2`。selection 必须绑定 release-set、catalog manifest、consumption report、source identity、唯一
Local image digest 和显式 `allowRootService=false`;输出为当前 UID、单链接、canonical `0600` no-replace 文件。
5. `ql3-local-compose-rollout-live-contract.cjs` 保留原 synthetic PR 模式,并增加 all-or-none 的
`--release-selection + --expected-selection-digest` 模式。真实模式在任何部署准备前重验 canonical path/parent、owner、mode、link count、大小、
canonical JSON、自摘要、source tag/revision/scope、catalog 三个 digest、Local image 和 root policy;任一漂移失败关闭。
6. Edge 与 Standalone 都必须把同一 selection 交给正式 `prepareLocalDeployment`、Compose revision switch/apply、SQLite rollout backup/restore、
evidence collection 与 graceful stop 路径。成功报告只投影 content-free `releaseAuthority` digest/identity,不上传 selection、bundle、token、
Docker socket、deployment root、command 或数据库。
7. Local gate 先公开拉取 selection 中的 immutable image,再依次运行两种 Profile;完成后要求没有以该 image 为 ancestor 的容器残留。失败也只
上传已有的 content-free reportrunner 结束即销毁私有 bundle/selection。
8. 不新增 workspace package、production dependency、数据库、migration、listener、timer、watcher、updater 或设备侧工具。Local/Edge/
Standalone 的镜像字节、模块闭包和稳态资源必须保持不变。
9. 第一份真实成功 evidence 只能来自实际受保护 `v3` release tag。仓库 fixture 与本机 Docker 可以证明 selection handoff、产品 rollout 和
失败关闭语义,但不能冒充公开 GHCR/Cosign/GitHub 在线 ceremony。
## 失败与恢复
- 公开 catalog 不可读、discovery digest 漂移或签名/provenance 不匹配:发布失败;不能退回 publisher artifact 或登录态。
- selection create/audit 不一致:修正 catalog/release-set 或源码后重跑;不能手工复制 image digest。
- Edge 或 Standalone 任一失败:Local release 失败;一种 Profile 的成功不能覆盖另一种。
- selection 文件被替换、扩权、硬链接、改写或 image 不匹配:在创建 deployment root 前拒绝。
- rollout 失败:既有 Compose/SQLite recovery contract 保持 authorityrelease gate 不增加自动清库、删卷或覆盖 receipt 的能力。
## 部署与资源影响
- 额外成本只发生在 Local/All 正式发布 runner:一次 catalog 消费、一次 selection 物化、一次 image pull 和 Edge/Standalone 各一轮 rollout。
- 设备只消费最终 selection 和 immutable image;不安装 Node workspace、regctl、Cosign、GitHub CLI 或发布凭据。
- Cluster-only 发布不运行本门;Local-only 发布不运行 K3s,也不等待 Worker/CloudNativePG 私有证据。
- 两次 rollout 顺序执行,复用一个小型 selection;没有 per-device queue、daemon 或后台连接。
## 被拒绝的替代方案
### 继续依赖 publisher 内的 pushed-digest rollout
拒绝。它早于完整 release-set/catalog,且继承 publisher 的文件和登录态,无法证明公开消费者得到的 selection 可用。
### 把 release-set artifact 直接交给 Compose
拒绝。90 天 artifact 不是长期 discovery authority,也绕过 durable catalog 的 immutable manifest、签名、provenance 和 byte round-trip。
### 只检查 selection JSON,不运行容器
拒绝。结构审计不能覆盖 Profile compose merge、只读根、SQLite backup/restore、健康收敛和 graceful stop 的实际产品链。
### 在路由设备上执行公开 catalog 验签
拒绝。它会给低配用户增加发布工具、网络、凭据和维护负担;设备目标侧只需消费已由可信工作站生成并审核的有界 selection。
## 验证
- catalog selection 参数、权限、摘要、image/source/catalog 漂移与 synthetic compatibility5/5
- release workflow、deployment lock、catalog consumption 与负向 mutation 定向契约:116/116
- 完整 backend 为 1,351 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package boundary 保持 18 packages 且
`singleSourcePackages=[]``shallowSourcePackages=[]`,相关依赖、Edge import、发布、部署锁与版本审计全部 compatible
- 14 档 Local artifact 全部 compatible 且与 D-344 字节基线完全一致;发布工具与工作区依赖没有进入 Edge/Standalone 制品;
- 本切片无数据库、migration、SQL、role、Pool 或 Cluster runtime 变化,不以 PostgreSQL HA 替代 Local 门;阶段完整性仍重跑
PostgreSQL 18.6/arm64 物理 HA 142/142、timeline `1→2`,独立 evidence audit 通过且 Docker 残留为 0
- 当前没有可供独立消费者拉取的公开 QingLong 3 Local immutable GHCR image,因此仓库内 selection/静态 workflow 证据不记为真实公开
catalog-bound Docker Compose 成功;
- 公开成功记录继续等待实际受保护 release tag,不能用 fixture 提前改为完成。
+1
View File
@@ -440,6 +440,7 @@
| [ADR-0434](./ADR-0434-resource-inventory-closed-kubernetes-deployment-head.md) | Resource-inventory-closed Kubernetes Deployment Head | Accepted |
| [ADR-0435](./ADR-0435-uid-resource-version-fenced-kubernetes-resource-retirement.md) | UID/resourceVersion 围栏的 Kubernetes 资源退役 Ceremony | Accepted |
| [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag |
| [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag |
## 规则
@@ -13,6 +13,18 @@ ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
`ghcr.io/<owner>/qinglong3-release-catalog@sha256:<manifest-digest>`,验证这个 immutable reference 后,再从 JSON 的
`images[].reference` 读取完整 `ghcr.io/<owner>/<repository>@sha256:<digest>`
## 发布流水线内置 Local 与 Cluster 下游门
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON
检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence
collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest
任一 Profile 失败都会阻断 Local release。
该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。regctl、Cosign、GitHub CLI、
Node workspace、token 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻 updater、
listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。
## 发布流水线内置 Cluster 下游门
`ql3-image-release.yml` 在 durable catalog、签名、provenance、receipt 和 90 天便利 bundle 全部发布后,才启动独立
+86 -6
View File
@@ -410,6 +410,8 @@ function auditReleaseWorkflow(source) {
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
const publishJob = workflow?.jobs?.publish;
const releaseSetJob = workflow?.jobs?.['release-set'];
const localCatalogDeploymentJob =
workflow?.jobs?.['release-catalog-local-deployment-live'];
const catalogDeploymentJob =
workflow?.jobs?.['release-catalog-deployment-live'];
if (
@@ -474,6 +476,12 @@ function auditReleaseWorkflow(source) {
attestations: 'write',
'artifact-metadata': 'write',
}) ||
JSON.stringify(localCatalogDeploymentJob?.permissions) !==
JSON.stringify({
contents: 'read',
packages: 'read',
attestations: 'read',
}) ||
JSON.stringify(catalogDeploymentJob?.permissions) !==
JSON.stringify({
contents: 'read',
@@ -777,6 +785,68 @@ function auditReleaseWorkflow(source) {
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
);
}
const localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps;
if (
localCatalogDeploymentJob?.needs !== 'release-set' ||
localCatalogDeploymentJob?.if !==
"always() && needs.release-set.result == 'success' && inputs.release_scope != 'cluster'" ||
localCatalogDeploymentJob?.['runs-on'] !== 'ubuntu-24.04' ||
localCatalogDeploymentJob?.['timeout-minutes'] !== 30 ||
!Array.isArray(localCatalogDeploymentSteps) ||
localCatalogDeploymentSteps.length !== 10 ||
localCatalogDeploymentSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
localCatalogDeploymentSteps[0]?.with?.['persist-credentials'] !== false ||
localCatalogDeploymentSteps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
localCatalogDeploymentSteps[1]?.with?.['node-version'] !== '24.18.0' ||
!/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts[\s\S]*pnpm --filter @qinglong\/local-owner-cli check/u.test(
localCatalogDeploymentSteps[2]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test(
localCatalogDeploymentSteps[3]?.run ?? '',
) ||
localCatalogDeploymentSteps[4]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
localCatalogDeploymentSteps[5]?.id !== 'local-catalog-consumption' ||
!/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
localCatalogDeploymentSteps[5]?.run ?? '',
) ||
localCatalogDeploymentSteps[6]?.id !== 'local-selection' ||
!/ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-create[\s\S]*--consumption-bundle="\$\{CONSUMPTION_BUNDLE\}"[\s\S]*--allow-root-service=false[\s\S]*--output="\$\{selection\}"[\s\S]*ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-audit[\s\S]*--selection="\$\{selection\}"[\s\S]*selection-digest=\$\{selection\.selectionDigest\}[\s\S]*image=\$\{selection\.service\.image\}/u.test(
localCatalogDeploymentSteps[6]?.run ?? '',
) ||
!/docker pull "\$\{IMAGE\}"[\s\S]*for profile in edge standalone[\s\S]*ql3-local-compose-rollout-live-contract\.cjs[\s\S]*--image="\$\{IMAGE\}"[\s\S]*--profile="\$\{profile\}"[\s\S]*--release-selection="\$\{RELEASE_SELECTION\}"[\s\S]*--expected-selection-digest="\$\{SELECTION_DIGEST\}"[\s\S]*verified_release_catalog[\s\S]*catalogConsumptionDigest/u.test(
localCatalogDeploymentSteps[7]?.run ?? '',
) ||
localCatalogDeploymentSteps[8]?.if !== 'always()' ||
!/docker ps -aq --filter ancestor="\$\{IMAGE\}"/u.test(
localCatalogDeploymentSteps[8]?.run ?? '',
) ||
localCatalogDeploymentSteps[9]?.if !== 'always()' ||
localCatalogDeploymentSteps[9]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(localCatalogDeploymentSteps[9]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-catalog-local-deployment',
'if-no-files-found': 'warn',
'retention-days': 90,
'compression-level': 9,
overwrite: false,
'include-hidden-files': false,
}) ||
localCatalogDeploymentSteps.some(
(step) =>
/(?:artifact put|\bimage copy\b|cosign sign)/u.test(step.run ?? '') ||
step.uses?.startsWith('actions/attest@') ||
step.uses?.startsWith('docker/login-action@'),
)
) {
throw new Error(
'Local release must independently consume the published catalog, reconstruct its exact selection and prove both Compose profiles without publication authority',
);
}
const catalogDeploymentSteps = catalogDeploymentJob?.steps;
if (
catalogDeploymentJob?.needs !== 'release-set' ||
@@ -943,20 +1013,20 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
7,
8,
'all release jobs must pin the reviewed immutable checkout action',
);
requireOccurrences(
source,
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
7,
8,
'all release jobs must pin the reviewed immutable Node setup action',
);
requireOccurrences(
source,
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
3,
'image publisher, catalog publisher and catalog consumer must pin the reviewed Cosign installer',
4,
'image publisher, catalog publisher and both catalog consumers must pin the reviewed Cosign installer',
);
requirePattern(
source,
@@ -996,8 +1066,8 @@ function auditReleaseWorkflow(source) {
requireExactOccurrences(
source,
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
3,
'image publisher, release-set publisher and catalog consumer must checksum-pin the exact regctl OCI copier and reader',
4,
'image publisher, release-set publisher and both catalog consumers must checksum-pin the exact regctl OCI copier and reader',
);
requirePattern(
source,
@@ -1199,6 +1269,15 @@ function auditReleaseWorkflow(source) {
fencedRetirementReceiptAudited: true,
publicationAuthority: false,
},
localCatalogDeploymentGate: {
scopes: ['local', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
selectionReconstructed: true,
profiles: ['edge', 'standalone'],
rolloutReceiptAudited: true,
gracefulCleanup: true,
publicationAuthority: false,
},
localRolloutPreflight: true,
localRolloutApply: true,
postPublishVerification: [
@@ -1211,6 +1290,7 @@ function auditReleaseWorkflow(source) {
'release-set',
'durable-catalog',
'catalog-consumption',
'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment',
'release-tags',
],
@@ -2,6 +2,7 @@
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@ -13,6 +14,8 @@ const {
const IMAGE_PATTERN =
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
const MAX_RELEASE_SELECTION_BYTES = 1024 * 1024;
function fail(message) {
throw new Error(`QingLong local Compose rollout failed: ${message}`);
@@ -28,15 +31,30 @@ function argumentsValue(argv) {
return [argument.slice(2, separator), argument.slice(separator + 1)];
}),
);
const keys = Object.keys(values).sort();
const baseKeys = [
'docker-executable',
'docker-socket',
'image',
'profile',
].sort();
const catalogKeys = [
...baseKeys,
'expected-selection-digest',
'release-selection',
].sort();
const syntheticInput = JSON.stringify(keys) === JSON.stringify(baseKeys);
const catalogInput = JSON.stringify(keys) === JSON.stringify(catalogKeys);
if (
Object.keys(values).length !== argv.length ||
(!syntheticInput && !catalogInput) ||
!IMAGE_PATTERN.test(values.image ?? '') ||
(values.profile !== 'edge' && values.profile !== 'standalone') ||
typeof values['docker-executable'] !== 'string' ||
typeof values['docker-socket'] !== 'string'
) {
fail(
'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone',
'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone [--release-selection=/canonical/selection.json --expected-selection-digest=sha256:digest]',
);
}
const dockerExecutable = fs.realpathSync(values['docker-executable']);
@@ -47,11 +65,114 @@ function argumentsValue(argv) {
) {
fail('Docker executable and socket must be canonical paths');
}
let releaseSelection;
if (catalogInput) {
if (
!path.isAbsolute(values['release-selection']) ||
!DIGEST_PATTERN.test(values['expected-selection-digest'])
) {
fail('catalog selection path and digest are invalid');
}
const selectionPath = fs.realpathSync(values['release-selection']);
if (selectionPath !== values['release-selection']) {
fail('catalog selection path must be canonical');
}
releaseSelection = Object.freeze({
path: selectionPath,
expectedSelectionDigest: values['expected-selection-digest'],
});
}
return Object.freeze({
image: values.image,
profile: values.profile,
dockerExecutable,
dockerSocket,
...(releaseSelection ? { releaseSelection } : {}),
});
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function resolveReleaseSelection(input, temporaryRoot, uid) {
if (!input.releaseSelection) {
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: input.image,
allowRootService: uid === 0,
sourceRevision: process.env.GITHUB_SHA,
});
return Object.freeze({
releaseSelection,
authority: Object.freeze({ mode: 'synthetic_live_fixture' }),
});
}
const selectionPath = input.releaseSelection.path;
const stat = fs.lstatSync(selectionPath);
const contents = fs.readFileSync(selectionPath, 'utf8');
let selection;
try {
selection = JSON.parse(contents);
} catch {
fail('catalog selection must be canonical JSON');
}
const unsigned = { ...selection };
delete unsigned.selectionDigest;
const computedDigest = sha256(JSON.stringify(unsigned));
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.nlink !== 1 ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.size < 2 ||
stat.size > MAX_RELEASE_SELECTION_BYTES ||
fs.realpathSync(selectionPath) !== selectionPath ||
fs.realpathSync(path.dirname(selectionPath)) !==
path.dirname(selectionPath) ||
contents !== `${JSON.stringify(selection)}\n` ||
computedDigest !== input.releaseSelection.expectedSelectionDigest ||
selection.selectionDigest !== computedDigest ||
selection.schemaVersion !== 1 ||
selection.schema !== 'qinglong/local-compose-release-image@v2' ||
!/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test(
selection.release?.version ?? '',
) ||
!/^[a-f0-9]{40}$/u.test(selection.release?.sourceRevision ?? '') ||
selection.release?.sourceRef !==
`refs/tags/v${selection.release?.version}` ||
!['local', 'all'].includes(selection.release?.scope) ||
!DIGEST_PATTERN.test(selection.releaseSetDigest ?? '') ||
selection.deploymentFamily !== 'local' ||
selection.service?.kind !== 'compose' ||
selection.service?.image !== input.image ||
selection.service?.allowRootService !== (uid === 0) ||
selection.catalog?.schema !==
'qinglong/release-catalog-consumption-ceremony@v1' ||
!DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') ||
!DIGEST_PATTERN.test(selection.catalog?.consumptionReportDigest ?? '') ||
selection.catalog?.releaseSetDigest !== selection.releaseSetDigest ||
selection.catalog?.discoveryTagAuthority !== 'none' ||
selection.verification?.catalogConsumption !== 'offline_reconstructed' ||
selection.verification?.externalToolResultsReplayed !== false ||
selection.verification?.networkAccess !== false ||
selection.verification?.deploymentMutation !== false
) {
fail('catalog-bound release selection is incompatible');
}
return Object.freeze({
releaseSelection: input.releaseSelection,
authority: Object.freeze({
mode: 'verified_release_catalog',
sourceRevision: selection.release.sourceRevision,
sourceRef: selection.release.sourceRef,
scope: selection.release.scope,
releaseSetDigest: selection.releaseSetDigest,
catalogManifestDigest: selection.catalog.manifestDigest,
catalogConsumptionDigest: selection.catalog.consumptionReportDigest,
selectionDigest: selection.selectionDigest,
}),
});
}
@@ -181,12 +302,8 @@ async function main() {
const deploymentRoot = path.join(temporaryRoot, 'deployment');
const commandPath = path.join(temporaryRoot, 'rollout.json');
const uid = process.getuid();
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: input.image,
allowRootService: uid === 0,
sourceRevision: process.env.GITHUB_SHA,
});
const selection = resolveReleaseSelection(input, temporaryRoot, uid);
const releaseSelection = selection.releaseSelection;
let composeResourcesPresent = false;
try {
@@ -852,6 +969,7 @@ async function main() {
sqliteEvidenceCollected: true,
sqliteCollectedRolloutReplayUnchanged: true,
gracefulCleanup: true,
releaseAuthority: selection.authority,
compatible: true,
})}\n`,
);
@@ -867,9 +985,16 @@ async function main() {
}
}
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
if (require.main === module) {
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
}
module.exports = Object.freeze({
argumentsValue,
resolveReleaseSelection,
});
+81 -1
View File
@@ -124,6 +124,15 @@ test('accepts the reviewed native CI and digest release contracts', () => {
fencedRetirementReceiptAudited: true,
publicationAuthority: false,
},
localCatalogDeploymentGate: {
scopes: ['local', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
selectionReconstructed: true,
profiles: ['edge', 'standalone'],
rolloutReceiptAudited: true,
gracefulCleanup: true,
publicationAuthority: false,
},
localRolloutPreflight: true,
localRolloutApply: true,
postPublishVerification: [
@@ -136,6 +145,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'release-set',
'durable-catalog',
'catalog-consumption',
'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment',
'release-tags',
],
@@ -729,7 +739,7 @@ test('rejects an image tag created before digest verification completes', () =>
});
test('rejects any publisher step after the deployment lock is published', () => {
const marker = '\n release-catalog-deployment-live:';
const marker = '\n release-catalog-local-deployment-live:';
assert.equal(releaseSource.includes(marker), true);
const mutated = releaseSource.replace(
marker,
@@ -742,6 +752,76 @@ test('rejects any publisher step after the deployment lock is published', () =>
);
});
test('rejects removal of the downstream Local catalog deployment gate', function rejectsMissingLocalCatalogDeploymentGate() {
const localMarker = '\n release-catalog-local-deployment-live:';
const clusterMarker = '\n release-catalog-deployment-live:';
const localOffset = releaseSource.indexOf(localMarker);
const clusterOffset = releaseSource.indexOf(clusterMarker);
assert.notEqual(localOffset, -1);
assert.equal(clusterOffset > localOffset, true);
const mutated = `${releaseSource.slice(0, localOffset)}${releaseSource.slice(
clusterOffset,
)}`;
assert.throws(
() => auditReleaseWorkflow(mutated),
/keep evidence read-only|Local release must independently consume/,
);
});
test('rejects running the Local catalog gate for Cluster-only releases', function rejectsClusterLocalCatalogDeploymentGate() {
const mutated = releaseSource.replace(
"inputs.release_scope != 'cluster'",
"inputs.release_scope == 'cluster'",
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/Local release must independently consume/,
);
});
test('rejects a Local rollout detached from the audited catalog selection', function rejectsDetachedLocalCatalogSelection() {
const localMarker = '\n release-catalog-local-deployment-live:';
const clusterMarker = '\n release-catalog-deployment-live:';
const localOffset = releaseSource.indexOf(localMarker);
const clusterOffset = releaseSource.indexOf(clusterMarker);
const mutatedLocal = releaseSource
.slice(localOffset, clusterOffset)
.replace(
'--release-selection="${RELEASE_SELECTION}"',
'--release-selection="/tmp/unreviewed.json"',
);
assert.throws(
() =>
auditReleaseWorkflow(
`${releaseSource.slice(
0,
localOffset,
)}${mutatedLocal}${releaseSource.slice(clusterOffset)}`,
),
/Local release must independently consume/,
);
});
test('rejects publication authority in the post-publish Local catalog consumer', function rejectsLocalCatalogConsumerPublicationAuthority() {
const localMarker = '\n release-catalog-local-deployment-live:';
const clusterMarker = '\n release-catalog-deployment-live:';
const localOffset = releaseSource.indexOf(localMarker);
const clusterOffset = releaseSource.indexOf(clusterMarker);
const mutatedLocal = releaseSource
.slice(localOffset, clusterOffset)
.replace('packages: read', 'packages: write');
assert.throws(
() =>
auditReleaseWorkflow(
`${releaseSource.slice(
0,
localOffset,
)}${mutatedLocal}${releaseSource.slice(clusterOffset)}`,
),
/keep evidence read-only|without publication authority/,
);
});
test('rejects removal of the downstream catalog deployment release gate', function rejectsMissingCatalogDeploymentGate() {
const marker = '\n release-catalog-deployment-live:';
const offset = releaseSource.indexOf(marker);
@@ -0,0 +1,180 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
argumentsValue,
resolveReleaseSelection,
} = require('../../scripts/ql3-local-compose-rollout-live-contract.cjs');
const {
writeSyntheticLocalReleaseSelection,
} = require('../../scripts/lib/ql3-local-release-selection-test-fixture.cjs');
const IMAGE = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat(
64,
)}`;
function privateDirectory(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-catalog-selection-')),
);
fs.chmodSync(directory, 0o700);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return directory;
}
function catalogSelection(t) {
const directory = privateDirectory(t);
return {
directory,
releaseSelection: writeSyntheticLocalReleaseSelection({
directory,
image: IMAGE,
allowRootService: process.getuid() === 0,
sourceRevision: 'b'.repeat(40),
}),
};
}
test('preserves the existing synthetic rollout arguments', (t) => {
const directory = privateDirectory(t);
const socket = path.join(directory, 'docker.sock');
fs.writeFileSync(socket, 'fixture', { mode: 0o600 });
assert.deepEqual(
argumentsValue([
`--image=${IMAGE}`,
`--docker-executable=${fs.realpathSync(process.execPath)}`,
`--docker-socket=${fs.realpathSync(socket)}`,
'--profile=edge',
]),
{
image: IMAGE,
profile: 'edge',
dockerExecutable: fs.realpathSync(process.execPath),
dockerSocket: fs.realpathSync(socket),
},
);
});
test('accepts only a complete catalog selection handoff', (t) => {
const { directory, releaseSelection } = catalogSelection(t);
const socket = path.join(directory, 'docker.sock');
fs.writeFileSync(socket, 'fixture', { mode: 0o600 });
const common = [
`--image=${IMAGE}`,
`--docker-executable=${fs.realpathSync(process.execPath)}`,
`--docker-socket=${fs.realpathSync(socket)}`,
'--profile=standalone',
];
assert.deepEqual(
argumentsValue([
...common,
`--release-selection=${releaseSelection.path}`,
`--expected-selection-digest=${releaseSelection.expectedSelectionDigest}`,
]).releaseSelection,
releaseSelection,
);
assert.throws(
() =>
argumentsValue([
...common,
`--release-selection=${releaseSelection.path}`,
]),
/usage/,
);
assert.throws(
() =>
argumentsValue([
...common,
`--expected-selection-digest=${releaseSelection.expectedSelectionDigest}`,
]),
/usage/,
);
});
test('projects a content-free verified catalog authority from the exact selection', (t) => {
const { directory, releaseSelection } = catalogSelection(t);
const result = resolveReleaseSelection(
{ image: IMAGE, releaseSelection },
directory,
process.getuid(),
);
assert.equal(result.releaseSelection, releaseSelection);
assert.deepEqual(result.authority, {
mode: 'verified_release_catalog',
sourceRevision: 'b'.repeat(40),
sourceRef: 'refs/tags/v3.0.0-alpha.0',
scope: 'local',
releaseSetDigest: JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8'))
.releaseSetDigest,
catalogManifestDigest: JSON.parse(
fs.readFileSync(releaseSelection.path, 'utf8'),
).catalog.manifestDigest,
catalogConsumptionDigest: JSON.parse(
fs.readFileSync(releaseSelection.path, 'utf8'),
).catalog.consumptionReportDigest,
selectionDigest: releaseSelection.expectedSelectionDigest,
});
});
test('rejects selection drift, image substitution and wider permissions', (t) => {
const first = catalogSelection(t);
assert.throws(
() =>
resolveReleaseSelection(
{
image: IMAGE.replace('qinglong3-local-application', 'other'),
releaseSelection: first.releaseSelection,
},
first.directory,
process.getuid(),
),
/incompatible/,
);
const second = catalogSelection(t);
fs.chmodSync(second.releaseSelection.path, 0o640);
assert.throws(
() =>
resolveReleaseSelection(
{ image: IMAGE, releaseSelection: second.releaseSelection },
second.directory,
process.getuid(),
),
/incompatible/,
);
const third = catalogSelection(t);
const selection = JSON.parse(
fs.readFileSync(third.releaseSelection.path, 'utf8'),
);
selection.release.sourceRevision = 'c'.repeat(40);
fs.writeFileSync(
third.releaseSelection.path,
`${JSON.stringify(selection)}\n`,
{ mode: 0o600 },
);
assert.throws(
() =>
resolveReleaseSelection(
{ image: IMAGE, releaseSelection: third.releaseSelection },
third.directory,
process.getuid(),
),
/incompatible/,
);
});
test('marks an ordinary PR rollout as a synthetic fixture', (t) => {
const directory = privateDirectory(t);
const result = resolveReleaseSelection(
{ image: IMAGE },
directory,
process.getuid(),
);
assert.deepEqual(result.authority, { mode: 'synthetic_live_fixture' });
assert.equal(fs.existsSync(result.releaseSelection.path), true);
});