From a7308c78f98f4e02e8201e4e9e2f48afdc4a89b0 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 27 Aug 2026 20:44:33 +0800 Subject: [PATCH] feat(ql3): close local alpha milestone --- .github/workflows/ql3-ci.yml | 92 ++- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...hitecture-local-alpha-milestone-closure.md | 77 +++ docs/adr/README.md | 1 + docs/operations/ql3-local-alpha-milestone.md | 44 ++ docs/operations/ql3-local-alpha-trial-kit.md | 2 + package.json | 1 + scripts/ql3-local-alpha-milestone.cjs | 644 ++++++++++++++++++ test/back/ql3LocalAlphaMilestone.test.cjs | 299 ++++++++ 9 files changed, 1156 insertions(+), 6 deletions(-) create mode 100644 docs/adr/ADR-0508-cross-architecture-local-alpha-milestone-closure.md create mode 100644 docs/operations/ql3-local-alpha-milestone.md create mode 100644 scripts/ql3-local-alpha-milestone.cjs create mode 100644 test/back/ql3LocalAlphaMilestone.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 85d02acf..f6908ba0 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -14,13 +14,22 @@ on: required: true default: false type: boolean + alpha_artifact_scope: + description: Select the product family packaged by an authorized Alpha milestone + required: true + default: local + type: choice + options: + - local + - cluster + - all permissions: contents: read concurrency: - group: ql3-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ql3-ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && github.run_id || 'validation' }} + cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }} jobs: backend: @@ -366,6 +375,7 @@ jobs: run: | node --test test/back/ql3LocalImageAudit.test.cjs node --test test/back/ql3LocalOperatorImageAudit.test.cjs + node --test test/back/ql3LocalAlphaMilestone.test.cjs pnpm audit:local-image:ql3 pnpm audit:local-operator-image:ql3 - name: Build the native AI-excluded image @@ -587,7 +597,7 @@ jobs: node scripts/ql3-local-api-cancellation-live-audit.cjs \ --report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json" - name: Materialize and offline-audit the native Local Alpha trial kit - if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} @@ -622,7 +632,7 @@ jobs: node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ --mode=audit --bundle="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" - name: Upload the tested native Local Alpha trial kit - if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ql3-alpha-${{ github.sha }}-local-${{ matrix.image_arch }} @@ -825,7 +835,7 @@ jobs: --image=${{ matrix.image }} --inventory-root=/opt/qinglong/node_modules - name: Package the tested native Cluster Alpha image - if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all') env: IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} IMAGE_ARCH: ${{ matrix.image_arch }} @@ -874,7 +884,7 @@ jobs: fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' }); NODE - name: Upload the tested native Cluster Alpha image - if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ql3-alpha-${{ github.sha }}-${{ matrix.image }}-${{ matrix.image_arch }} @@ -1632,3 +1642,73 @@ jobs: path: ${{ runner.temp }}/ql3-plugin-package-recovery-e2e/report.json if-no-files-found: warn retention-days: 14 + + local-alpha-milestone: + name: Finalize the Local Alpha milestone + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') + needs: + - backend + - service-manager-bridge + - linux-resource-envelopes + - linux-resource-release-evidence + - supply-chain + - local-image + - cluster-image + - cluster-console-capacity-release-evidence + - image-oci + - worker-runtime + - local-profiles + - cluster-postgres + - cluster-postgres-ha + - cluster-cloudnativepg-live + - cluster-provider-credential-test-kubernetes-live + - cluster-secret-binding-mounted-provider-kubernetes-live + - cluster-vault-kv-worker-secret-live + - cluster-plugin-package-kubernetes-live + - cluster-plugin-package-recovery-e2e + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + - name: Download the exact amd64 Local Alpha trial kit + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-local-amd64 + path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/amd64 + - name: Download the exact arm64 Local Alpha trial kit + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-local-arm64 + path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/arm64 + - name: Close both native trial kits into one milestone index + run: | + set -euo pipefail + umask 077 + node scripts/ql3-local-alpha-milestone.cjs \ + --mode=finalize \ + --amd64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/amd64" \ + --arm64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/arm64" \ + --source-revision=${{ github.sha }} \ + --repository=${{ github.repository }} \ + --workflow-ref="${{ github.workflow_ref }}" \ + --workflow-sha=${{ github.workflow_sha }} \ + --event=${{ github.event_name }} \ + --run-id=${{ github.run_id }} \ + --run-attempt=${{ github.run_attempt }} \ + --readme=docs/operations/ql3-local-alpha-milestone.md \ + --output="${RUNNER_TEMP}/ql3-alpha-local-milestone" + node scripts/ql3-local-alpha-milestone.cjs \ + --mode=audit \ + --milestone="${RUNNER_TEMP}/ql3-alpha-local-milestone" + - name: Upload the closed Local Alpha milestone index + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-alpha-${{ github.sha }}-local-milestone + path: ${{ runner.temp }}/ql3-alpha-local-milestone + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index ca6cd256..607abf2a 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-27): +- D-413/ADR-0508(已实现,首份真实 milestone artifact 待授权):Local Alpha 不再把两个矩阵 job 各自上传的 amd64/arm64 大归档直接视为阶段版本。新增独立发布期 `local-alpha-milestone` finalizer,只有完整 `QingLong 3.0 CI` 的 19 个 job dependency 全部成功后,才重新下载并离线审计两套 v2 Trial Kit,要求同一 version/source/workflow SHA/run/attempt、精确 `amd64|arm64`、四个不同 Application/operator image ID、两个不同 archive digest 与 verification digest,再上传 `qinglong/alpha-local-milestone@v1` 三文件小型索引。没有 index 的孤立 artifact 明确是失败运行中间文件,不能交付用户。手动门新增 `alpha_artifact_scope=local|cluster|all` 且默认 Local,避免路由/NAS 试用无谓生成八套 Cluster archive;完整测试不因 scope 缩小。显式 milestone 以 run ID 隔离并发域且不被后续普通 push 取消,source 仍绑定触发时 exact SHA。该 finalizer 不新增 workspace package、镜像 layer、设备依赖、daemon、listener、timer、连接池或稳态 RSS;真实可下载产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=local` 后记录 index 与双 bundle digest。 + - D-412/ADR-0507(已实现,真实公开发布待受保护 tag):Public Local Release Set 从“只签 Application、用户旅程却依赖另一个未发布 operator”的断层收敛为一对分别构建、扫描、签名和 multi-arch attestation 的镜像:`local` 是唯一常驻 Application,`local-operator` 只承担 setup/upgrade/recovery 等短生命周期 Owner authority。release candidate 的 Local scope 精确包含两者,并分别要求 Application Edge/Standalone rollout 与 operator `--version`/`setup --help` 门;CI OCI 证据、OS 漏洞策略、release-set、catalog consumption、final tag closure 和 Local selection 均按六镜像总闭包升级。`qinglong/release-set-image-record@v2`、`qinglong/release-set@v4`、`application/vnd.qinglong.release-set.v4+json`、`qinglong/local-compose-release-image@v3` 与 `qinglong/local-compose-image-selection@v3` 失败关闭旧孵化 schema。Compose revision 只保存 operator digest 作为管理 authority,不生成 operator service,所以路由设备稳态仍只有 Application,没有新增进程、listener、timer、端口或 RSS;管理动作才短暂下载/运行 operator。18-package clean build 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 fail,backend 为 1,610 total / 1,608 pass / 2 conditional skip / 0 fail,静态 release workflow 审计 100/100。该切片证明发布机制闭合,不冒充已经存在 GHCR tag 或真实用户可下载 Public Release Set;首份正式可交付物仍需受保护 release tag 的六镜像、签名、catalog-bound Local/Cluster 部署证据和终态 closure。 - D-411/ADR-0506(已实现,真实 downloadable v2 artifact 待授权):Local Alpha materializer 不再凭调用 `create` 就把九个 gate 无条件写成 `passed`。bundle schema 升为 `qinglong/alpha-local-trial-kit@v2`,新增 `verification-evidence.json`,其 subject 精确绑定版本、source、Tier-1 架构与 Application/operator image ID,workflow 精确绑定 `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`、workflow SHA、`workflow_dispatch`、`local-image`、run ID/attempt。CI 静态门固定 `fresh journey → native cancellation → record-verification → create → audit → upload`,evidence 作为第七个闭合文件进入 manifest byte/SHA-256 与 `SHA256SUMS`;create/audit 均拒绝跨源码、跨架构、跨镜像或跨 workflow 复制。旧 v1 bundle 因没有来源证明只保留为工程候选。提交 `4239464a` 的主 CI run `32990652047` 已 40/40,Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。 diff --git a/docs/adr/ADR-0508-cross-architecture-local-alpha-milestone-closure.md b/docs/adr/ADR-0508-cross-architecture-local-alpha-milestone-closure.md new file mode 100644 index 00000000..b42343ad --- /dev/null +++ b/docs/adr/ADR-0508-cross-architecture-local-alpha-milestone-closure.md @@ -0,0 +1,77 @@ +# ADR-0508:跨架构 Local Alpha 里程碑闭合 + +- 状态:Accepted(首份实际 milestone artifact 待维护者授权) +- 日期:2026-08-27 +- 决策:D-413 +- 关联:ADR-0503、ADR-0504、ADR-0506、ADR-0507 + +## 背景 + +Local Alpha workflow 已能在原生 amd64、arm64 runner 上分别生成 Application/operator 双镜像 Trial Kit,但两个矩阵作业各自上传 artifact。只要其中一个架构上传成功,GitHub Actions 页面就会留下一个可下载大归档,即使另一架构、PostgreSQL HA、资源门或其余完整 CI 随后失败。 + +这类孤立文件证明一个矩阵作业曾走到上传步骤,不能证明 QingLong 3.0 已形成阶段版本。开发二十天后的阶段产物必须有一个用户可识别的成功终态,而不是让部署者从几十个 job 和零散 artifact 猜测“哪些可以用”。 + +原有 boolean 还会同时生成 Local 两套与 Cluster 八套原生归档;只想验证低配路由/NAS 的维护者必须无谓承担 Cluster artifact 的构建归档和存储成本。手动 milestone 与普通 `next` push 共享 `cancel-in-progress` 并发域,也可能在大归档生成过程中被后续提交取消。 + +## 决策 + +### 1. Local Alpha 必须以跨架构 milestone index 为完成信号 + +新增发布期脚本 `ql3-local-alpha-milestone.cjs`。`local-alpha-milestone` job 只有在完整 `QingLong 3.0 CI` 的所有现有 job 成功后才运行,并重新下载、离线审计同一 run/attempt 的 amd64 与 arm64 Trial Kit。 + +finalizer 精确要求: + +- 两套 bundle 具有同一版本、完整 source revision、workflow SHA、run ID 与 attempt; +- 架构集合恰好为 `amd64|arm64`; +- 两个 archive digest、两份 verification digest 和四个 Application/operator image ID 相互分离; +- 每套 bundle 自身的七文件闭包、SBOM、manifest、verification evidence 与 checksum 继续由 v2 auditor 通过; +- artifact 名称从 source 与架构唯一推导,调用方不能自报。 + +成功后只上传三文件小型索引:`manifest.json`、`README.md`、`SHA256SUMS`,schema 为 `qinglong/alpha-local-milestone@v1`。索引绑定两个 artifact 名、各自 bundle manifest digest、archive digest、镜像 ID、verification digest 和 workflow identity。没有该索引的零散 Trial Kit 明确是失败或未闭合运行的中间文件,不是阶段交付物。 + +### 2. 手动产物按产品域选择 + +保留显式 `produce_alpha_artifacts=true` 授权,并增加 `alpha_artifact_scope=local|cluster|all`,默认 `local`: + +- `local` 只归档两套 Local Trial Kit 并生成 milestone index; +- `cluster` 只归档 Cluster Integration Candidate 原生镜像,不生成 Local index; +- `all` 同时生成两类。 + +无论选择哪个归档 scope,完整 CI 仍执行;scope 只控制大体积 artifact 的物化和上传,不跳过测试门,也不改变 Edge/Standalone/Cluster runtime closure。 + +### 3. Milestone run 不由普通 push 取消 + +普通 push/PR 继续共享 validation 并发域并允许 newer run 取消旧 run。显式 artifact milestone 使用自身 run ID 作为并发域,`cancel-in-progress=false`;后续 push 不会中断已经授权的双架构产物。checkout、evidence 与 index 仍绑定触发时的 exact SHA,不读取移动后的 branch head。 + +## 被拒绝的替代方案 + +### 把两个独立 artifact 都称为 Alpha + +拒绝。部分上传、另一架构失败或完整 CI 失败时没有唯一成功信号,部署者无法可靠裁决成熟度。 + +### 把两个架构 archive 再复制进一个总 artifact + +拒绝。每位用户只需要自己的架构;总包会重复下载和存储数百 MiB,并对低容量设备无益。小型 index 足以闭合身份和 digest。 + +### Local 两个矩阵 job 成功后立即发布 index + +拒绝。同一源码的 backend、资源、供应链、PostgreSQL HA 或 Kubernetes live gate 仍可能失败。阶段版本必须等待完整 CI,而不是只等待镜像局部路径。 + +### 赋予 finalizer 删除孤立 artifact 的权限 + +拒绝。索引缺失已经能失败关闭,增加 `actions: write` 和删除 authority 会扩大 workflow 权限与事故半径。孤立文件按 30 天 retention 自动过期。 + +## 影响 + +- 首个真实 Local Alpha milestone 多一次双 artifact 下载和离线复核,只发生在显式里程碑运行; +- 普通 push/PR 不生成大归档或 milestone index,CI 成本基本不变; +- 默认 Local scope 不再无谓生成八套 Cluster archive; +- 新脚本和索引属于发布期仓库工具,不新增 workspace package、设备依赖、镜像 layer、常驻进程、端口、timer、连接池或 RSS; +- Public Release Set 仍由受保护 tag、不可变 registry digest、签名、attestation、catalog 和 deployment closure 独立裁决。 + +## 验证 + +- finalizer 正向测试生成 exact 三文件 index,并复核双架构、run/attempt、四镜像主体与 archive digest; +- 负向测试覆盖跨 attempt、跨架构 image identity 复用、index mutation、额外文件和 CLI grammar; +- 静态 workflow audit 固定 scope 条件、milestone 独立并发域、19 个完整 CI dependency、双 artifact 下载、`finalize → audit → upload` 顺序和 30 天 retention; +- 首份真实 `produce_alpha_artifacts=true + alpha_artifact_scope=local` 产物仍需维护者显式授权,生成后再把 ADR-0503 从 Proposed 转为 Accepted 并记录 index/bundle digest。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 28a01288..584adc5f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -511,6 +511,7 @@ | [ADR-0505](./ADR-0505-pinned-alpine-openssl-runtime-security-patch.md) | 固定 Alpine OpenSSL 运行时安全补丁 | Accepted | | [ADR-0506](./ADR-0506-source-bound-local-alpha-verification-evidence.md) | 源码绑定的 Local Alpha 验证证据 | Accepted | | [ADR-0507](./ADR-0507-public-local-application-and-operator-release-pair.md) | Public Local Application 与 Operator 发布对 | Accepted(首份真实公开发布待受保护 tag) | +| [ADR-0508](./ADR-0508-cross-architecture-local-alpha-milestone-closure.md) | 跨架构 Local Alpha 里程碑闭合 | Accepted(首份实际 milestone artifact 待维护者授权) | ## 规则 diff --git a/docs/operations/ql3-local-alpha-milestone.md b/docs/operations/ql3-local-alpha-milestone.md new file mode 100644 index 00000000..0a2bfb31 --- /dev/null +++ b/docs/operations/ql3-local-alpha-milestone.md @@ -0,0 +1,44 @@ +# QingLong 3.0 Local Alpha Milestone + +本目录是一次完整 Local Alpha milestone run 的跨架构闭合索引。它本身不包含大体积 Docker archive;`manifest.json` 精确列出同一次 GitHub Actions run 生成并重新审计的 amd64、arm64 Trial Kit artifact。只有该索引 artifact 与对应架构 Trial Kit 同时存在时,才能把那次运行称为阶段性可下载 Local Alpha。 + +## 成熟度边界 + +- `maturity` 固定为 `alpha_candidate_not_public_release`; +- 适用于 fresh、隔离、非生产数据上的 Edge/Standalone 试运行; +- 不提供公开 GHCR tag、Cosign 签名、GitHub attestation、catalog、生产升级或长期支持承诺; +- Cluster/Kubernetes 仍按独立 Integration Candidate 与 Public Release Set 门验收。 + +单个架构 artifact 提前上传并不代表 milestone 成功。没有 `ql3-alpha--local-milestone` 索引、索引 workflow 不是成功终态、run/attempt 不一致或索引审计失败时,已有的大归档只能作为失败运行的中间文件,不得交付用户。 + +## 选择并验证下载物 + +1. 对本目录执行: + + ```sh + sha256sum --check SHA256SUMS + ``` + +2. 打开 `manifest.json`,确认: + - `schema` 为 `qinglong/alpha-local-milestone@v1`; + - `sourceRevision` 是准备试用的完整 40 位提交; + - `workflow.event` 为 `workflow_dispatch`,`workflow.job` 为 `local-alpha-milestone`; + - GitHub Actions 中对应 `runId/runAttempt` 的整条 `QingLong 3.0 CI` 为成功终态; + - `artifacts` 恰好包含 `amd64` 与 `arm64`。 +3. 根据主机架构下载 `artifacts..artifactName` 指向的 Trial Kit。 +4. 对 Trial Kit 先执行其 `SHA256SUMS`,再确认其中 `manifest.json` 的 SHA-256 与 milestone 的 `bundleManifest.sha256` 完全一致。 +5. 按 Trial Kit 自带 `README.md` 完成 Docker archive 加载、镜像 ID 对账和受限资源 smoke。 + +若持有同一版本源码与 Node.js 24,可额外审计 milestone 索引: + +```sh +node scripts/ql3-local-alpha-milestone.cjs \ + --mode=audit \ + --milestone=/absolute/path/to/ql3-alpha-local-milestone +``` + +该命令验证索引目录的闭合文件集、checksum、双架构记录、镜像主体分离和 workflow identity。对应架构 Trial Kit 仍必须使用它自己的离线 auditor;索引审计不会重新执行 Docker、漏洞扫描或真实用户旅程。 + +## 运行与回退 + +实际运行只需要匹配主机架构的一套 Trial Kit,不需要同时下载另一架构。低配路由器稳态只运行 Local Application;Operator 只在 setup、upgrade 或 recovery 动作期间短暂运行。停止并删除 Alpha 容器及 fresh 测试目录即可回退,不能把本套件直接指向 2.x 唯一数据目录。 diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 3f6c618b..ba8abccb 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -2,6 +2,8 @@ 本目录是绑定一个 QingLong 3.0 源码提交、一个 Linux 架构和一次显式 GitHub milestone run 的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 +一套 Trial Kit 只有被同一 run 的 `ql3-alpha--local-milestone` 跨架构索引收录后才是可交付阶段产物。单个矩阵 job 提前上传、另一架构或完整 CI 随后失败时留下的 artifact 只是中间文件。先按 milestone `manifest.json` 选择本机架构并核对本 bundle manifest digest,再执行下述离线验收。 + ## 适用范围 - `amd64` 或 `arm64` Linux Docker 主机; diff --git a/package.json b/package.json index a000141a..4e6d9529 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-contract.cjs", "test:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-live-contract.cjs", "bundle:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-bundle.cjs", + "finalize:local-alpha-milestone:ql3": "node scripts/ql3-local-alpha-milestone.cjs", "audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs", "test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs", "audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs", diff --git a/scripts/ql3-local-alpha-milestone.cjs b/scripts/ql3-local-alpha-milestone.cjs new file mode 100644 index 00000000..777bf7a7 --- /dev/null +++ b/scripts/ql3-local-alpha-milestone.cjs @@ -0,0 +1,644 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { + auditLocalAlphaTrialKit, + sha256File, +} = require('./ql3-local-alpha-trial-kit-bundle.cjs'); +const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); + +const DEFAULT_ROOT = path.resolve(__dirname, '..'); +const SCHEMA = 'qinglong/alpha-local-milestone@v1'; +const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); +const FILES = Object.freeze({ + readme: 'README.md', + manifest: 'manifest.json', + checksums: 'SHA256SUMS', +}); +const WORKFLOW_IDENTITY = Object.freeze({ + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + event: 'workflow_dispatch', + job: 'local-alpha-milestone', +}); +const REQUIRED_WORKFLOW_NEEDS = Object.freeze([ + 'backend', + 'service-manager-bridge', + 'linux-resource-envelopes', + 'linux-resource-release-evidence', + 'supply-chain', + 'local-image', + 'cluster-image', + 'cluster-console-capacity-release-evidence', + 'image-oci', + 'worker-runtime', + 'local-profiles', + 'cluster-postgres', + 'cluster-postgres-ha', + 'cluster-cloudnativepg-live', + 'cluster-provider-credential-test-kubernetes-live', + 'cluster-secret-binding-mounted-provider-kubernetes-live', + 'cluster-vault-kv-worker-secret-live', + 'cluster-plugin-package-kubernetes-live', + 'cluster-plugin-package-recovery-e2e', +]); +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; +const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u; +const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_README_BYTES = 512 * 1024; + +function fail(message) { + throw new Error(message); +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function assertCanonicalFile(filePath, maximumBytes, label) { + const resolved = path.resolve(filePath); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return resolved; +} + +function readBoundedJson(filePath, label) { + const resolved = assertCanonicalFile(filePath, MAX_JSON_BYTES, label); + try { + return JSON.parse(fs.readFileSync(resolved, 'utf8')); + } catch { + fail(`${label} must contain valid JSON`); + } +} + +function writeExclusive(filePath, contents, mode = 0o600) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + mode, + ); + try { + fs.writeFileSync(descriptor, contents); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function copyExclusive(source, destination) { + fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); + fs.chmodSync(destination, 0o600); +} + +function fileRecord(filePath, name) { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) { + fail(`milestone file is invalid: ${name}`); + } + return Object.freeze({ + file: name, + sha256: sha256File(filePath), + bytes: stat.size, + }); +} + +function checksumContents(root, names) { + return `${names + .map((name) => `${sha256File(path.join(root, name)).slice(7)} ${name}`) + .join('\n')}\n`; +} + +function artifactName(sourceRevision, architecture) { + return `ql3-alpha-${sourceRevision}-local-${architecture}`; +} + +function validateIdentity(options) { + if ( + !REVISION_PATTERN.test(options.sourceRevision || '') || + options.repository !== WORKFLOW_IDENTITY.repository || + options.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + options.workflowSha !== options.sourceRevision || + options.eventName !== WORKFLOW_IDENTITY.event || + !DECIMAL_ID_PATTERN.test(options.runId || '') || + !ATTEMPT_PATTERN.test(options.runAttempt || '') + ) { + fail('milestone workflow identity is invalid'); + } +} + +function validateFinalizeOptions(options) { + validateIdentity(options); + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const outputRoot = path.resolve(options.outputRoot || ''); + const parent = path.dirname(outputRoot); + if ( + !path.isAbsolute(outputRoot) || + fs.existsSync(outputRoot) || + fs.realpathSync(parent) !== parent + ) { + fail('milestone output is invalid'); + } + const bundles = {}; + for (const architecture of ARCHITECTURES) { + const bundleRoot = fs.realpathSync( + path.resolve(options.bundles?.[architecture] || ''), + ); + if (!fs.lstatSync(bundleRoot).isDirectory()) { + fail(`${architecture} bundle root is invalid`); + } + bundles[architecture] = bundleRoot; + } + if (bundles.amd64 === bundles.arm64) { + fail('milestone architectures must use distinct bundles'); + } + return Object.freeze({ + root, + outputRoot, + bundles: Object.freeze(bundles), + readme: assertCanonicalFile( + options.readme, + MAX_README_BYTES, + 'milestone README', + ), + sourceRevision: options.sourceRevision, + repository: options.repository, + workflowRef: options.workflowRef, + workflowSha: options.workflowSha, + eventName: options.eventName, + runId: options.runId, + runAttempt: options.runAttempt, + }); +} + +function bundleRecord(options, architecture) { + const bundleRoot = options.bundles[architecture]; + const report = auditLocalAlphaTrialKit({ bundleRoot }); + if ( + report.compatible !== true || + report.architecture !== architecture || + report.sourceRevision !== options.sourceRevision || + report.workflowRunId !== options.runId || + report.workflowRunAttempt !== options.runAttempt + ) { + fail(`${architecture} trial kit is detached from the milestone run`); + } + return Object.freeze({ + artifactName: artifactName(options.sourceRevision, architecture), + architecture, + bundleManifest: fileRecord( + path.join(bundleRoot, 'manifest.json'), + 'manifest.json', + ), + archiveSha256: report.archiveSha256, + applicationImageId: report.applicationImageId, + operatorImageId: report.operatorImageId, + verificationSha256: report.verificationSha256, + }); +} + +function validateArtifactRecord(record, architecture, manifest) { + if ( + !exactKeys(record, [ + 'artifactName', + 'architecture', + 'bundleManifest', + 'archiveSha256', + 'applicationImageId', + 'operatorImageId', + 'verificationSha256', + ]) || + record.artifactName !== + artifactName(manifest.sourceRevision, architecture) || + record.architecture !== architecture || + !exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) || + record.bundleManifest.file !== 'manifest.json' || + !SHA256_PATTERN.test(record.bundleManifest.sha256 || '') || + !Number.isSafeInteger(record.bundleManifest.bytes) || + record.bundleManifest.bytes < 2 || + !SHA256_PATTERN.test(record.archiveSha256 || '') || + !SHA256_PATTERN.test(record.applicationImageId || '') || + !SHA256_PATTERN.test(record.operatorImageId || '') || + !SHA256_PATTERN.test(record.verificationSha256 || '') || + record.applicationImageId === record.operatorImageId + ) { + fail(`${architecture} milestone artifact record is incompatible`); + } +} + +function validateWorkflow(document, sourceRevision, runId, runAttempt) { + if ( + !exactKeys(document, [ + 'repository', + 'workflowRef', + 'workflowSha', + 'event', + 'job', + 'runId', + 'runAttempt', + ]) || + document.repository !== WORKFLOW_IDENTITY.repository || + document.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + document.workflowSha !== sourceRevision || + document.event !== WORKFLOW_IDENTITY.event || + document.job !== WORKFLOW_IDENTITY.job || + document.runId !== runId || + document.runAttempt !== runAttempt || + !DECIMAL_ID_PATTERN.test(document.runId || '') || + !ATTEMPT_PATTERN.test(document.runAttempt || '') + ) { + fail('milestone manifest workflow identity is incompatible'); + } +} + +function auditLocalAlphaMilestone(options) { + const milestoneRoot = fs.realpathSync( + path.resolve(options.milestoneRoot || ''), + ); + if (!fs.lstatSync(milestoneRoot).isDirectory()) { + fail('milestone root must be a canonical directory'); + } + const expectedFiles = Object.values(FILES).sort(); + const actualFiles = fs + .readdirSync(milestoneRoot, { withFileTypes: true }) + .map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + fail(`milestone contains a non-regular entry: ${entry.name}`); + } + return entry.name; + }) + .sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail('milestone file set is not closed'); + } + const manifest = readBoundedJson( + path.join(milestoneRoot, FILES.manifest), + 'milestone manifest', + ); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'schema', + 'maturity', + 'product', + 'version', + 'sourceRevision', + 'workflow', + 'artifacts', + 'readme', + ]) || + manifest.schemaVersion !== 1 || + manifest.schema !== SCHEMA || + manifest.maturity !== 'alpha_candidate_not_public_release' || + manifest.product !== 'local' || + typeof manifest.version !== 'string' || + manifest.version.length < 3 || + manifest.version.length > 64 || + !REVISION_PATTERN.test(manifest.sourceRevision || '') || + !exactKeys(manifest.artifacts, ARCHITECTURES) || + !exactKeys(manifest.readme, ['file', 'sha256', 'bytes']) || + manifest.readme.file !== FILES.readme || + !SHA256_PATTERN.test(manifest.readme.sha256 || '') || + !Number.isSafeInteger(manifest.readme.bytes) || + manifest.readme.bytes < 2 + ) { + fail('milestone manifest identity or shape is incompatible'); + } + validateWorkflow( + manifest.workflow, + manifest.sourceRevision, + manifest.workflow?.runId, + manifest.workflow?.runAttempt, + ); + for (const architecture of ARCHITECTURES) { + validateArtifactRecord( + manifest.artifacts[architecture], + architecture, + manifest, + ); + } + const records = ARCHITECTURES.map( + (architecture) => manifest.artifacts[architecture], + ); + const imageIds = records.flatMap((record) => [ + record.applicationImageId, + record.operatorImageId, + ]); + if ( + new Set(imageIds).size !== imageIds.length || + new Set(records.map((record) => record.archiveSha256)).size !== + ARCHITECTURES.length || + new Set(records.map((record) => record.verificationSha256)).size !== + ARCHITECTURES.length + ) { + fail('milestone architecture subjects are not distinct'); + } + const actualReadme = fileRecord( + path.join(milestoneRoot, FILES.readme), + FILES.readme, + ); + if ( + actualReadme.sha256 !== manifest.readme.sha256 || + actualReadme.bytes !== manifest.readme.bytes + ) { + fail('milestone README differs from manifest'); + } + const expectedChecksums = checksumContents(milestoneRoot, [ + FILES.readme, + FILES.manifest, + ]); + const actualChecksums = fs.readFileSync( + path.join(milestoneRoot, FILES.checksums), + 'utf8', + ); + if (actualChecksums !== expectedChecksums) { + fail('milestone SHA256SUMS differs from the closed file set'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-local-milestone-audit@v1', + sourceRevision: manifest.sourceRevision, + version: manifest.version, + workflowRunId: manifest.workflow.runId, + workflowRunAttempt: manifest.workflow.runAttempt, + architectures: [...ARCHITECTURES], + compatible: true, + }); +} + +function finalizeLocalAlphaMilestone(options) { + const normalized = validateFinalizeOptions(options); + const artifacts = { + amd64: bundleRecord(normalized, 'amd64'), + arm64: bundleRecord(normalized, 'arm64'), + }; + const versions = new Set( + ARCHITECTURES.map((architecture) => { + const manifest = readBoundedJson( + path.join(normalized.bundles[architecture], 'manifest.json'), + `${architecture} trial kit manifest`, + ); + return manifest.version; + }), + ); + const release = readReleaseIdentity(normalized.root); + if (versions.size !== 1 || !versions.has(release.version)) { + fail('milestone trial kits must have one release version'); + } + const allImageIds = ARCHITECTURES.flatMap((architecture) => [ + artifacts[architecture].applicationImageId, + artifacts[architecture].operatorImageId, + ]); + if ( + new Set(allImageIds).size !== allImageIds.length || + artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 || + artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 + ) { + fail('milestone architecture subjects must be distinct'); + } + let created = false; + try { + fs.mkdirSync(normalized.outputRoot, { mode: 0o700 }); + created = true; + copyExclusive( + normalized.readme, + path.join(normalized.outputRoot, FILES.readme), + ); + const manifest = { + schemaVersion: 1, + schema: SCHEMA, + maturity: 'alpha_candidate_not_public_release', + product: 'local', + version: [...versions][0], + sourceRevision: normalized.sourceRevision, + workflow: { + repository: normalized.repository, + workflowRef: normalized.workflowRef, + workflowSha: normalized.workflowSha, + event: normalized.eventName, + job: WORKFLOW_IDENTITY.job, + runId: normalized.runId, + runAttempt: normalized.runAttempt, + }, + artifacts, + readme: fileRecord( + path.join(normalized.outputRoot, FILES.readme), + FILES.readme, + ), + }; + writeExclusive( + path.join(normalized.outputRoot, FILES.manifest), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + writeExclusive( + path.join(normalized.outputRoot, FILES.checksums), + checksumContents(normalized.outputRoot, [FILES.readme, FILES.manifest]), + ); + auditLocalAlphaMilestone({ milestoneRoot: normalized.outputRoot }); + return Object.freeze(manifest); + } catch (error) { + if (created) { + fs.rmSync(normalized.outputRoot, { recursive: true, force: true }); + } + throw error; + } +} + +function countOccurrences(contents, token) { + return contents.split(token).length - 1; +} + +function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { + const workflowPath = path.join( + fs.realpathSync(path.resolve(root)), + '.github/workflows/ql3-ci.yml', + ); + const workflow = fs.readFileSync(workflowPath, 'utf8'); + const findings = []; + const milestoneStart = workflow.indexOf('\n local-alpha-milestone:\n'); + const milestone = milestoneStart >= 0 ? workflow.slice(milestoneStart) : ''; + const requiredWorkflowTokens = [ + 'alpha_artifact_scope:', + 'default: local', + '- local', + '- cluster', + '- all', + "github.run_id || 'validation'", + "cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}", + ]; + if (requiredWorkflowTokens.some((token) => !workflow.includes(token))) { + findings.push('MILESTONE_DISPATCH_OR_CONCURRENCY_DRIFT'); + } + const localScopeCondition = + "github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')"; + const clusterScopeCondition = + "github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')"; + if ( + countOccurrences(workflow, localScopeCondition) !== 3 || + countOccurrences(workflow, clusterScopeCondition) !== 2 + ) { + findings.push('MILESTONE_SCOPE_CONTRACT_DRIFT'); + } + const milestoneTokens = [ + ' name: Finalize the Local Alpha milestone', + ' needs:', + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', + `name: ql3-alpha-${'${{ github.sha }}'}-local-amd64`, + `name: ql3-alpha-${'${{ github.sha }}'}-local-arm64`, + 'scripts/ql3-local-alpha-milestone.cjs', + '--mode=finalize', + '--mode=audit', + `name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`, + 'retention-days: 30', + 'overwrite: false', + ]; + if ( + milestoneStart < 0 || + milestoneTokens.some((token) => !milestone.includes(token)) || + REQUIRED_WORKFLOW_NEEDS.some( + (job) => !milestone.includes(` - ${job}\n`), + ) + ) { + findings.push('MILESTONE_FINALIZER_CONTRACT_DRIFT'); + } + const finalizerIndex = milestone.indexOf('--mode=finalize'); + const auditIndex = milestone.indexOf('--mode=audit'); + const uploadIndex = milestone.lastIndexOf('actions/upload-artifact@'); + if ( + finalizerIndex < 0 || + auditIndex <= finalizerIndex || + uploadIndex <= auditIndex + ) { + findings.push('MILESTONE_FINALIZER_GATE_ORDER_DRIFT'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-local-milestone-workflow-audit@v1', + requiredNeeds: [...REQUIRED_WORKFLOW_NEEDS], + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z0-9-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) { + fail('arguments are invalid'); + } + values[match[1]] = match[2]; + } + if (values.mode === 'audit') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['milestone', 'mode']) + ) { + fail('audit arguments are invalid'); + } + return { mode: 'audit', milestoneRoot: path.resolve(values.milestone) }; + } + if (values.mode === 'audit-workflow') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['mode', 'root']) + ) { + fail('workflow audit arguments are invalid'); + } + return { mode: 'audit-workflow', root: path.resolve(values.root) }; + } + const expected = [ + 'amd64-bundle', + 'arm64-bundle', + 'event', + 'mode', + 'output', + 'readme', + 'repository', + 'run-attempt', + 'run-id', + 'source-revision', + 'workflow-ref', + 'workflow-sha', + ]; + if ( + values.mode !== 'finalize' || + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('finalize arguments are invalid'); + } + return { + mode: 'finalize', + outputRoot: path.resolve(values.output), + bundles: { + amd64: path.resolve(values['amd64-bundle']), + arm64: path.resolve(values['arm64-bundle']), + }, + readme: path.resolve(values.readme), + sourceRevision: values['source-revision'], + repository: values.repository, + workflowRef: values['workflow-ref'], + workflowSha: values['workflow-sha'], + eventName: values.event, + runId: values['run-id'], + runAttempt: values['run-attempt'], + }; +} + +function runCli(argv) { + const options = parseArguments(argv); + let report; + if (options.mode === 'finalize') { + report = finalizeLocalAlphaMilestone(options); + } else if (options.mode === 'audit-workflow') { + report = auditLocalAlphaMilestoneWorkflow(options.root); + if (!report.compatible) fail(JSON.stringify(report)); + } else { + report = auditLocalAlphaMilestone(options); + } + process.stdout.write(`${JSON.stringify(report)}\n`); + return report; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error ? error.message : 'Local Alpha milestone failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + ARCHITECTURES, + FILES, + REQUIRED_WORKFLOW_NEEDS, + SCHEMA, + artifactName, + auditLocalAlphaMilestone, + auditLocalAlphaMilestoneWorkflow, + finalizeLocalAlphaMilestone, + parseArguments, + runCli, +}); diff --git a/test/back/ql3LocalAlphaMilestone.test.cjs b/test/back/ql3LocalAlphaMilestone.test.cjs new file mode 100644 index 00000000..e4771d3e --- /dev/null +++ b/test/back/ql3LocalAlphaMilestone.test.cjs @@ -0,0 +1,299 @@ +'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 { + auditLocalAlphaTrialKit, + createLocalAlphaTrialKit, + createLocalAlphaTrialKitVerificationEvidence, +} = require('../../scripts/ql3-local-alpha-trial-kit-bundle.cjs'); +const { + auditLocalAlphaMilestone, + auditLocalAlphaMilestoneWorkflow, + finalizeLocalAlphaMilestone, + parseArguments, +} = require('../../scripts/ql3-local-alpha-milestone.cjs'); +const { + createClusterImageSbom, +} = require('../../scripts/ql3-cluster-image-sbom.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const revision = 'a'.repeat(40); +const runId = '33047425710'; +const runAttempt = '2'; + +function imageInspection(role, architecture, idCharacter) { + return { + Id: `sha256:${idCharacter.repeat(64)}`, + Os: 'linux', + Architecture: architecture, + Config: { + User: '65532:65532', + Labels: { + 'org.opencontainers.image.title': + role === 'application' + ? 'QingLong 3.0 Local Application' + : 'QingLong 3.0 Local Operator', + 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', + 'org.opencontainers.image.revision': revision, + 'org.opencontainers.image.version': version, + ...(role === 'application' + ? { + 'io.qinglong.profile': 'edge,standalone', + 'io.qinglong.ai': 'excluded', + } + : { + 'io.qinglong.lifecycle': 'short-lived', + 'io.qinglong.authority': 'local-owner-management', + 'io.qinglong.network': 'none-by-default', + }), + }, + }, + }; +} + +function createBundle(fixtureRoot, architecture, options = {}) { + const bundleFixture = path.join(fixtureRoot, architecture); + fs.mkdirSync(bundleFixture); + const applicationSbom = path.join(bundleFixture, 'application.json'); + const operatorSbom = path.join(bundleFixture, 'operator.json'); + const verificationEvidence = path.join(bundleFixture, 'verification.json'); + const readme = path.join(bundleFixture, 'README-source.md'); + const outputRoot = path.join(bundleFixture, 'bundle'); + fs.writeFileSync( + applicationSbom, + `${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, + ); + fs.writeFileSync( + operatorSbom, + `${JSON.stringify( + createClusterImageSbom({ root, image: 'local-operator' }), + )}\n`, + ); + fs.writeFileSync(readme, '# Local Alpha Trial Kit\n'); + const defaultCharacters = + architecture === 'amd64' + ? { application: '1', operator: '2' } + : { application: '3', operator: '4' }; + const characters = options.characters || defaultCharacters; + const applicationImage = `qinglong3-local-application:test-${architecture}`; + const operatorImage = `qinglong3-local-operator:test-${architecture}`; + const adapters = { + inspectImage(image) { + const role = image.includes('operator') ? 'operator' : 'application'; + return imageInspection(role, architecture, characters[role]); + }, + saveImages(images, archivePath) { + assert.deepEqual(images, [applicationImage, operatorImage]); + fs.writeFileSync( + archivePath, + Buffer.alloc(2048, architecture === 'amd64' ? 7 : 8), + { flag: 'wx' }, + ); + }, + }; + createLocalAlphaTrialKitVerificationEvidence( + { + root, + output: verificationEvidence, + architecture, + sourceRevision: revision, + applicationImage, + operatorImage, + repository: 'whyour/qinglong', + workflowRef: + 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + workflowSha: revision, + eventName: 'workflow_dispatch', + job: 'local-image', + runId, + runAttempt: options.runAttempt || runAttempt, + }, + adapters, + ); + createLocalAlphaTrialKit( + { + root, + outputRoot, + architecture, + sourceRevision: revision, + applicationImage, + operatorImage, + applicationSbom, + operatorSbom, + verificationEvidence, + readme, + }, + adapters, + ); + assert.equal( + auditLocalAlphaTrialKit({ bundleRoot: outputRoot }).compatible, + true, + ); + return outputRoot; +} + +function fixture(t, options = {}) { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-alpha-milestone-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const readme = path.join(fixtureRoot, 'README-source.md'); + fs.writeFileSync(readme, '# QingLong 3.0 Local Alpha Milestone\n'); + return { + fixtureRoot, + readme, + outputRoot: path.join(fixtureRoot, 'milestone'), + bundles: { + amd64: createBundle(fixtureRoot, 'amd64', options.amd64), + arm64: createBundle(fixtureRoot, 'arm64', options.arm64), + }, + }; +} + +function finalizeOptions(paths) { + return { + root, + outputRoot: paths.outputRoot, + bundles: paths.bundles, + readme: paths.readme, + sourceRevision: revision, + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + workflowSha: revision, + eventName: 'workflow_dispatch', + runId, + runAttempt, + }; +} + +test('finalizes two exact native trial kits into one closed milestone index', (t) => { + const paths = fixture(t); + const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); + assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v1'); + assert.equal(manifest.sourceRevision, revision); + assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); + assert.equal( + manifest.artifacts.amd64.artifactName, + `ql3-alpha-${revision}-local-amd64`, + ); + assert.equal( + manifest.artifacts.arm64.artifactName, + `ql3-alpha-${revision}-local-arm64`, + ); + assert.notEqual( + manifest.artifacts.amd64.archiveSha256, + manifest.artifacts.arm64.archiveSha256, + ); + assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ + 'README.md', + 'SHA256SUMS', + 'manifest.json', + ]); + const report = auditLocalAlphaMilestone({ + milestoneRoot: paths.outputRoot, + }); + assert.equal(report.compatible, true); + assert.deepEqual(report.architectures, ['amd64', 'arm64']); + assert.equal(report.workflowRunId, runId); + assert.equal(report.workflowRunAttempt, runAttempt); +}); + +test('rejects a trial kit from another run attempt before publishing', (t) => { + const paths = fixture(t, { arm64: { runAttempt: '1' } }); + assert.throws( + () => finalizeLocalAlphaMilestone(finalizeOptions(paths)), + /detached from the milestone run/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('rejects cross-architecture image or archive identity reuse', (t) => { + const paths = fixture(t, { + arm64: { characters: { application: '1', operator: '2' } }, + }); + assert.throws( + () => finalizeLocalAlphaMilestone(finalizeOptions(paths)), + /architecture subjects must be distinct/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('offline milestone audit rejects mutation and extra files', (t) => { + const paths = fixture(t); + finalizeLocalAlphaMilestone(finalizeOptions(paths)); + fs.appendFileSync(path.join(paths.outputRoot, 'README.md'), 'tamper\n'); + assert.throws( + () => auditLocalAlphaMilestone({ milestoneRoot: paths.outputRoot }), + /README differs/, + ); + fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); + assert.throws( + () => auditLocalAlphaMilestone({ milestoneRoot: paths.outputRoot }), + /file set is not closed/, + ); +}); + +test('workflow audit requires full-CI needs, scoped packaging and finalizer order', () => { + const report = auditLocalAlphaMilestoneWorkflow(root); + assert.equal(report.compatible, true); + assert.deepEqual(report.findings, []); + assert.equal(report.requiredNeeds.includes('cluster-postgres-ha'), true); +}); + +test('workflow audit rejects a partial milestone finalizer', (t) => { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-alpha-workflow-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + fs.mkdirSync(path.join(fixtureRoot, '.github/workflows'), { + recursive: true, + }); + const workflow = fs + .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + .replace(' - cluster-postgres-ha\n', '') + .replace( + 'scripts/ql3-local-alpha-milestone.cjs', + 'scripts/unreviewed-finalizer.cjs', + ); + fs.writeFileSync( + path.join(fixtureRoot, '.github/workflows/ql3-ci.yml'), + workflow, + ); + const report = auditLocalAlphaMilestoneWorkflow(fixtureRoot); + assert.equal(report.compatible, false); + assert.equal( + report.findings.includes('MILESTONE_FINALIZER_CONTRACT_DRIFT'), + true, + ); +}); + +test('CLI grammar separates finalization, index audit and workflow audit', () => { + assert.deepEqual( + parseArguments(['--mode=audit', '--milestone=/tmp/ql3-alpha-milestone']), + { + mode: 'audit', + milestoneRoot: '/tmp/ql3-alpha-milestone', + }, + ); + assert.deepEqual( + parseArguments(['--mode=audit-workflow', `--root=${root}`]), + { mode: 'audit-workflow', root }, + ); + assert.throws( + () => + parseArguments([ + '--mode=audit', + '--milestone=/tmp/ql3-alpha-milestone', + '--allow-partial=true', + ]), + /audit arguments are invalid/, + ); +});