mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate local releases through published catalogs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 fail,18-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 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 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
|
||||
- 关联 RFC:QL-RFC-0001 D-01、D-03、D-05、D-14、D-338、D-339、D-345
|
||||
- 关联 ADR:ADR-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 report,runner 结束即销毁私有 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 保持 authority;release 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 compatibility:5/5;
|
||||
- release workflow、deployment lock、catalog consumption 与负向 mutation 定向契约:116/116;
|
||||
- 完整 backend 为 1,351 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package 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 提前改为完成。
|
||||
@@ -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 全部发布后,才启动独立
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user