diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 3d1f6e43..efc9786a 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -1804,3 +1804,58 @@ jobs: compression-level: 0 overwrite: false include-hidden-files: false + + alpha-stage-index: + name: Finalize the cross-profile Alpha stage index + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && inputs.alpha_artifact_scope == 'all' + needs: + - local-alpha-milestone + - cluster-alpha-milestone + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + - name: Download the closed Local Alpha milestone + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-local-milestone + path: ${{ runner.temp }}/ql3-alpha-stage-input/local + - name: Download the closed Cluster Alpha milestone + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-cluster-milestone + path: ${{ runner.temp }}/ql3-alpha-stage-input/cluster + - name: Close both deployment classes into one stage index + run: | + set -euo pipefail + umask 077 + node scripts/ql3-alpha-stage-index.cjs \ + --mode=finalize \ + --local-milestone="${RUNNER_TEMP}/ql3-alpha-stage-input/local" \ + --cluster-milestone="${RUNNER_TEMP}/ql3-alpha-stage-input/cluster" \ + --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-alpha-stage-index.md \ + --output="${RUNNER_TEMP}/ql3-alpha-stage-index" + node scripts/ql3-alpha-stage-index.cjs \ + --mode=audit \ + --stage="${RUNNER_TEMP}/ql3-alpha-stage-index" \ + --local-milestone="${RUNNER_TEMP}/ql3-alpha-stage-input/local" \ + --cluster-milestone="${RUNNER_TEMP}/ql3-alpha-stage-input/cluster" + - name: Upload the closed cross-profile Alpha stage index + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-alpha-${{ github.sha }}-stage-index + path: ${{ runner.temp }}/ql3-alpha-stage-index + 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 b54349dc..7ae03ebc 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,12 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-27 +- 最后更新:2026-08-28 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-27): +最新增量证据(2026-08-28): + +- D-415/ADR-0510(已实现,首份真实 stage index 待授权):二十天开发形成的 Local 与 Cluster 候选现在有统一、面向部署者的最外层阶段交付入口。新增 `qinglong/alpha-stage-index@v1` finalizer/auditor,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 且 Local/Cluster milestone 都成功后运行;它重新离线审计两个索引,要求 version/source/workflow SHA/ref/run/attempt 完全一致,记录两个 milestone manifest digest,并为 amd64/arm64 写出机器可读选择:低配 Edge/Standalone 只下载一个 Local Trial Kit、稳态只运行 Application 且 Operator 短时启动;Cluster 下载 control/admin/worker,按需增加 control-ai。跨索引审计必须同时持有 stage 与两个 milestone,拒绝跨运行混用、内容/选择漂移和额外文件。该索引不重复十个大 archive,不改变 Local-only/Cluster-only 授权路径,也不冒充受保护 tag、GHCR immutable digest、签名、正式 OCI catalog、生产 deployment lock 或 HA;普通 push/PR 不生成实际阶段 artifact。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS。 - D-414/ADR-0509(已实现,首份真实 Cluster milestone artifact 待授权):开发约二十天后,Cluster 阶段产物不再是八个由 workflow 内联 shell 自报 `passed` 的松散 archive。新增统一 `qinglong/alpha-cluster-image@v1` materializer/auditor,固定 control/control-ai/admin/worker 的 repository、OCI title、non-root user 和 amd64/arm64 identity;每个六文件 bundle 含单镜像 Docker archive、精确 SBOM、绑定 source/workflow SHA/ref/run/attempt/role/architecture/image ID 的 verification evidence、README、manifest 与 `SHA256SUMS`,下载后可无 Docker/网络离线复核闭合文件集和内容 digest。新增完整 CI 后置 `cluster-alpha-milestone` finalizer,重新下载八个 artifact,要求同一 version/source/run/attempt 且八个 image ID/archive digest/verification digest 互异,再上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引;没有该索引的部分 archive 只是失败运行中间件。maturity 明确为 `cluster_integration_candidate_not_public_release`,适用于隔离 registry/K3s/Kubernetes 集成,不冒充受保护 tag、GHCR digest、签名、catalog 或生产 HA。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS;首份实际产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。 diff --git a/docs/adr/ADR-0510-cross-profile-alpha-stage-delivery-index.md b/docs/adr/ADR-0510-cross-profile-alpha-stage-delivery-index.md new file mode 100644 index 00000000..a282bd5e --- /dev/null +++ b/docs/adr/ADR-0510-cross-profile-alpha-stage-delivery-index.md @@ -0,0 +1,55 @@ +# ADR-0510:跨 Profile Alpha 阶段交付索引 + +- 状态:Accepted(首份实际 stage index 待维护者授权) +- 日期:2026-08-28 +- 决策:D-415 +- 关联:ADR-0503、ADR-0506、ADR-0508、ADR-0509 + +## 背景 + +QingLong 3.0 已开发约二十天。D-413 与 D-414 分别闭合了双架构 Local Alpha Trial Kit 和四角色乘双架构 Cluster Integration Candidate,但部署者仍需自行理解两个 milestone、十个大归档、三种 Profile 和不同成熟度。 + +两个 milestone 分别成功不能自动证明它们来自同一源码与同一次完整 CI。维护者也缺少一个最外层、机器可读的阶段交付入口来回答“本次 Alpha 到底交付了什么”和“路由器或集群节点应该下载哪些文件”。 + +## 决策 + +### 1. 只有完整 `all` 运行才生成最外层索引 + +新增 `alpha-stage-index` 后置 job,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 时运行,并只依赖已经成功的 `local-alpha-milestone` 与 `cluster-alpha-milestone`。它重新下载两个小索引并调用各自的离线 auditor,要求 version、source revision、workflow ref/SHA、run ID/attempt 完全一致。 + +成功后上传三文件 `qinglong/alpha-stage-index@v1`。其 maturity 固定为 `alpha_stage_delivery_not_public_release`,并记录两个 milestone artifact 名和 manifest digest。普通 push/PR、只生成 Local 或只生成 Cluster 的运行都不会产生该索引。 + +### 2. 部署选择进入机器可读契约 + +索引为 amd64、arm64 分别固定: + +- Edge/Standalone 选择一个 Local Trial Kit;稳态角色只有 Application,Operator 是短生命周期角色; +- Cluster 最小集选择 control、admin、worker;control-ai 是显式可选项; +- 部署者只下载目标架构,不要求复制另一架构或无关角色。 + +跨索引 auditor 必须同时持有 stage、Local milestone、Cluster milestone,逐层复审并核对 manifest digest 和选择结果,拒绝跨 run 混用、内容篡改、额外文件或选择漂移。 + +### 3. Stage index 不获得正式发布权威 + +该名称刻意使用 `stage index`,不复用 Public Release Set 的 OCI release catalog。它不提供受保护 tag、GHCR immutable digest、签名、attestation、生产 deployment lock、HA、升级或 LTS 承诺。Local 与 Cluster 原有 maturity 不被最外层索引抬高。 + +## 被拒绝的替代方案 + +- 只写一页人工说明:无法绑定 exact source/run,也不能阻止部署选择和产物清单漂移。 +- 把十个大归档再次合并:浪费下载与存储,尤其伤害低配设备和只需要部分 Cluster 角色的用户。 +- 每次普通 CI 都生成阶段索引:没有实际可下载归档时索引会形成伪里程碑。 +- 直接使用正式 release catalog:Alpha archive 没有 public immutable digest、签名和受保护 tag,不具备该 authority。 + +## 影响 + +- 显式 `scope=all` 增加两个小 artifact 下载、一次纯本地交叉审计和一个三文件小索引; +- Local-only/Cluster-only 授权继续独立工作,不被强迫生成另一部署档位的大归档; +- 不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或稳态 RSS; +- 低配设备获得明确的单 Trial Kit 选择,集群节点获得最小三角色与可选 AI 的精确选择。 + +## 验证 + +- 正向测试覆盖同 run 的两个 milestone 闭合、十个 artifact 选择与三 Profile 报告; +- 负向测试覆盖跨 attempt 混用、stage 内容篡改、额外文件、缺少 milestone dependency 和 scope 漂移; +- workflow audit 固定 `all` 条件、双 milestone dependency 和 `finalize → audit → upload` 顺序; +- 首份真实索引仍需维护者显式授权 `produce_alpha_artifacts=true + alpha_artifact_scope=all`。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 9f274edc..61fa9736 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -513,6 +513,7 @@ | [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 待维护者授权) | | [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) | +| [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index c75495cc..df949ae0 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -13,6 +13,8 @@ `Local Alpha Trial Kit + Local milestone index` 是本阶段的用户可试运行产物;`Cluster Integration Candidate + Cluster milestone index` 是集群部署者可下载、可离线验真的集成产物。单个 headless runtime、单个 Cluster archive 或没有 milestone index 的部分矩阵产物都只是工程中间件。Cluster milestone 仍不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 +当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。 + ## 当前阶段实物(2026-08-27) 提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选: @@ -36,6 +38,7 @@ ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verificatio - `ql3-alpha--local-milestone`; - `ql3-alpha--control-`、`control-ai-`、`admin-`、`worker-`。 - `ql3-alpha--cluster-milestone`。 +- 仅 `alpha_artifact_scope=all`:`ql3-alpha--stage-index`。 Local artifact 含: @@ -47,6 +50,8 @@ Local artifact 含: Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 +Stage index 是 `qinglong/alpha-stage-index@v1` 三文件闭包。它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把路由/NAS 的单 Local Trial Kit 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。 + 任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 ## 下载后验证与最小 smoke diff --git a/docs/operations/ql3-alpha-stage-index.md b/docs/operations/ql3-alpha-stage-index.md new file mode 100644 index 00000000..44fe1f00 --- /dev/null +++ b/docs/operations/ql3-alpha-stage-index.md @@ -0,0 +1,41 @@ +# QingLong 3.0 Alpha 阶段交付索引 + +本目录是一次 `alpha_artifact_scope=all` 运行的最外层交付入口。它把同一源码、同一 GitHub Actions run/attempt 的 Local Alpha milestone 与 Cluster Alpha milestone 绑定起来,并为 Edge、Standalone 与 Cluster 部署者给出机器可读的最小下载选择。它不是正式 release catalog,也不包含 Docker archive。 + +## 先判断设备类型 + +| 设备或环境 | 选择 | 稳态组件 | 需要下载 | +| --- | --- | --- | --- | +| 低配路由器、NAS、单机 | `deploymentSelections.local` | 仅 Local Application;Operator 只在 setup/upgrade/recovery 短暂运行 | 目标架构的一个 Local Trial Kit | +| 临时 K3s/Kubernetes 集群 | `deploymentSelections.cluster` | control、admin、worker;AI 按需增加 control-ai | 目标架构的三个 required artifact;需要 AI 时再下载 optional artifact | + +不得把四个 Cluster 角色部署到低配路由器,也不需要为一台 amd64 主机下载 arm64 归档。索引固定列出十个可选择 artifact,但每个部署者只下载目标 Profile、架构和角色所需的子集。 + +## 验证 + +1. 在本目录运行 `sha256sum --check SHA256SUMS`。 +2. 检查 `manifest.json`: + - schema 为 `qinglong/alpha-stage-index@v1`; + - maturity 为 `alpha_stage_delivery_not_public_release`; + - source revision、run ID/attempt 是准备验证的显式 workflow run; + - `milestones.local` 与 `milestones.cluster` 分别指向同一提交的 milestone artifact。 +3. 下载两个 milestone 小索引,比较各自 `manifest.json` 的长度与 SHA-256 是否等于本索引的记录。 +4. 按 `deploymentSelections` 下载目标大归档,再按对应 milestone 和 bundle README 逐层执行 checksum 与离线审计。 + +持有同一版本源码与 Node.js 24 时,可以一次复审三层索引: + +```sh +node scripts/ql3-alpha-stage-index.cjs \ + --mode=audit \ + --stage=/absolute/path/to/stage-index \ + --local-milestone=/absolute/path/to/local-milestone \ + --cluster-milestone=/absolute/path/to/cluster-milestone +``` + +该命令拒绝跨 source、version、run/attempt 混用,重新执行两个 milestone auditor,并核对 milestone manifest digest 与部署选择。它不访问 Docker 或网络,也不会重新执行漏洞扫描、真实用户旅程或 Kubernetes live gate。 + +## 成熟度和回退边界 + +本索引只证明一次显式 Alpha 运行同时形成了两个部署档位的闭合候选物。Local 仅供 fresh、隔离、非生产目录试用;Cluster 仅供隔离 registry、临时命名空间和可删除数据库集成。它不提供受保护 tag、公开 GHCR immutable digest、签名、attestation、生产 deployment lock、HA、升级或长期支持承诺。 + +Local 回退是停止并删除 Alpha 容器和 fresh 测试目录;Cluster 回退是删除临时 workload、测试数据和 credential,并恢复原 deployment lock。任何 2.x 数据迁移或生产写入仍必须走正式 cutover/reconciliation/rollback ceremony。 diff --git a/package.json b/package.json index 54032e6b..f540096c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "finalize:local-alpha-milestone:ql3": "node scripts/ql3-local-alpha-milestone.cjs", "bundle:cluster-alpha:ql3": "node scripts/ql3-cluster-alpha-bundle.cjs", "finalize:cluster-alpha-milestone:ql3": "node scripts/ql3-cluster-alpha-milestone.cjs", + "finalize:alpha-stage-index:ql3": "node scripts/ql3-alpha-stage-index.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-alpha-stage-index.cjs b/scripts/ql3-alpha-stage-index.cjs new file mode 100644 index 00000000..81c4eaf3 --- /dev/null +++ b/scripts/ql3-alpha-stage-index.cjs @@ -0,0 +1,683 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { auditLocalAlphaMilestone } = require('./ql3-local-alpha-milestone.cjs'); +const { + auditClusterAlphaMilestone, +} = require('./ql3-cluster-alpha-milestone.cjs'); +const { 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-stage-index@v1'; +const FILES = Object.freeze({ + readme: 'README.md', + manifest: 'manifest.json', + checksums: 'SHA256SUMS', +}); +const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); +const WORKFLOW_IDENTITY = Object.freeze({ + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + event: 'workflow_dispatch', + job: 'alpha-stage-index', +}); +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 assertCanonicalDirectory(directory, label) { + const resolved = fs.realpathSync(path.resolve(directory || '')); + if (!fs.lstatSync(resolved).isDirectory()) { + fail(`${label} must be a canonical directory`); + } + 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(`stage index 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 stageArtifactName(sourceRevision) { + return `ql3-alpha-${sourceRevision}-stage-index`; +} + +function milestoneArtifactName(sourceRevision, product) { + return `ql3-alpha-${sourceRevision}-${product}-milestone`; +} + +function validateWorkflow(document, sourceRevision) { + 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 || + !DECIMAL_ID_PATTERN.test(document.runId || '') || + !ATTEMPT_PATTERN.test(document.runAttempt || '') + ) { + fail('stage index workflow identity is incompatible'); + } +} + +function readMilestones(localMilestoneRoot, clusterMilestoneRoot) { + const localRoot = assertCanonicalDirectory( + localMilestoneRoot, + 'Local milestone root', + ); + const clusterRoot = assertCanonicalDirectory( + clusterMilestoneRoot, + 'Cluster milestone root', + ); + if (localRoot === clusterRoot) { + fail('Local and Cluster milestone roots must be distinct'); + } + const localReport = auditLocalAlphaMilestone({ milestoneRoot: localRoot }); + const clusterReport = auditClusterAlphaMilestone({ + milestoneRoot: clusterRoot, + }); + const local = readBoundedJson( + path.join(localRoot, 'manifest.json'), + 'Local milestone manifest', + ); + const cluster = readBoundedJson( + path.join(clusterRoot, 'manifest.json'), + 'Cluster milestone manifest', + ); + const identityFields = ['version', 'sourceRevision']; + if ( + identityFields.some((field) => local[field] !== cluster[field]) || + local.workflow.repository !== cluster.workflow.repository || + local.workflow.workflowRef !== cluster.workflow.workflowRef || + local.workflow.workflowSha !== cluster.workflow.workflowSha || + local.workflow.event !== cluster.workflow.event || + local.workflow.runId !== cluster.workflow.runId || + local.workflow.runAttempt !== cluster.workflow.runAttempt || + localReport.compatible !== true || + clusterReport.compatible !== true + ) { + fail('Local and Cluster milestones do not belong to one workflow run'); + } + return Object.freeze({ localRoot, clusterRoot, local, cluster }); +} + +function expectedSelections(local, cluster) { + return { + local: { + profiles: ['edge', 'standalone'], + intent: 'fresh_non_production_trial', + architectures: Object.fromEntries( + ARCHITECTURES.map((architecture) => [ + architecture, + { + requiredArtifacts: [local.artifacts[architecture].artifactName], + steadyStateRoles: ['application'], + transientRoles: ['operator'], + }, + ]), + ), + }, + cluster: { + profiles: ['cluster'], + intent: 'isolated_registry_non_production_integration', + architectures: Object.fromEntries( + ARCHITECTURES.map((architecture) => [ + architecture, + { + requiredArtifacts: [ + cluster.artifacts[`control-${architecture}`].artifactName, + cluster.artifacts[`admin-${architecture}`].artifactName, + cluster.artifacts[`worker-${architecture}`].artifactName, + ], + optionalArtifacts: [ + cluster.artifacts[`control-ai-${architecture}`].artifactName, + ], + }, + ]), + ), + }, + }; +} + +function validateMilestoneRecord(record, product, sourceRevision) { + const expectedMaturity = + product === 'local' + ? 'alpha_candidate_not_public_release' + : 'cluster_integration_candidate_not_public_release'; + const expectedSchema = + product === 'local' + ? 'qinglong/alpha-local-milestone@v1' + : 'qinglong/alpha-cluster-milestone@v1'; + if ( + !exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) || + record.artifactName !== milestoneArtifactName(sourceRevision, product) || + record.schema !== expectedSchema || + record.maturity !== expectedMaturity || + !exactKeys(record.manifest, ['file', 'sha256', 'bytes']) || + record.manifest.file !== 'manifest.json' || + !SHA256_PATTERN.test(record.manifest.sha256 || '') || + !Number.isSafeInteger(record.manifest.bytes) || + record.manifest.bytes < 2 + ) { + fail(`${product} stage milestone record is incompatible`); + } +} + +function validateSelections(selections, local, cluster) { + if ( + JSON.stringify(selections) !== + JSON.stringify(expectedSelections(local, cluster)) + ) { + fail('stage index deployment selections are incompatible'); + } +} + +function auditAlphaStageIndex(options) { + const stageRoot = assertCanonicalDirectory(options.stageRoot, 'stage root'); + const milestones = readMilestones( + options.localMilestoneRoot, + options.clusterMilestoneRoot, + ); + const expectedFiles = Object.values(FILES).sort(); + const actualFiles = fs + .readdirSync(stageRoot, { withFileTypes: true }) + .map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + fail(`stage index contains a non-regular entry: ${entry.name}`); + } + return entry.name; + }) + .sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail('stage index file set is not closed'); + } + const manifest = readBoundedJson( + path.join(stageRoot, FILES.manifest), + 'stage index manifest', + ); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'schema', + 'maturity', + 'product', + 'version', + 'sourceRevision', + 'workflow', + 'milestones', + 'deploymentSelections', + 'readme', + ]) || + manifest.schemaVersion !== 1 || + manifest.schema !== SCHEMA || + manifest.maturity !== 'alpha_stage_delivery_not_public_release' || + manifest.product !== 'qinglong3' || + manifest.version !== milestones.local.version || + !REVISION_PATTERN.test(manifest.sourceRevision || '') || + manifest.sourceRevision !== milestones.local.sourceRevision || + !exactKeys(manifest.milestones, ['local', 'cluster']) || + !exactKeys(manifest.deploymentSelections, ['local', 'cluster']) || + !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('stage index manifest identity or shape is incompatible'); + } + validateWorkflow(manifest.workflow, manifest.sourceRevision); + if ( + manifest.workflow.runId !== milestones.local.workflow.runId || + manifest.workflow.runAttempt !== milestones.local.workflow.runAttempt + ) { + fail('stage index is detached from the milestone workflow run'); + } + validateMilestoneRecord( + manifest.milestones.local, + 'local', + manifest.sourceRevision, + ); + validateMilestoneRecord( + manifest.milestones.cluster, + 'cluster', + manifest.sourceRevision, + ); + const milestoneManifests = { + local: fileRecord( + path.join(milestones.localRoot, 'manifest.json'), + 'manifest.json', + ), + cluster: fileRecord( + path.join(milestones.clusterRoot, 'manifest.json'), + 'manifest.json', + ), + }; + for (const product of ['local', 'cluster']) { + if ( + JSON.stringify(manifest.milestones[product].manifest) !== + JSON.stringify(milestoneManifests[product]) + ) { + fail(`${product} milestone manifest differs from stage index`); + } + } + validateSelections( + manifest.deploymentSelections, + milestones.local, + milestones.cluster, + ); + const actualReadme = fileRecord( + path.join(stageRoot, FILES.readme), + FILES.readme, + ); + if (JSON.stringify(actualReadme) !== JSON.stringify(manifest.readme)) { + fail('stage index README differs from manifest'); + } + if ( + fs.readFileSync(path.join(stageRoot, FILES.checksums), 'utf8') !== + checksumContents(stageRoot, [FILES.readme, FILES.manifest]) + ) { + fail('stage index SHA256SUMS differs from the closed file set'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-stage-index-audit@v1', + version: manifest.version, + sourceRevision: manifest.sourceRevision, + workflowRunId: manifest.workflow.runId, + workflowRunAttempt: manifest.workflow.runAttempt, + profiles: ['edge', 'standalone', 'cluster'], + artifactCount: 10, + compatible: true, + }); +} + +function validateFinalizeOptions(options) { + const milestones = readMilestones( + options.localMilestoneRoot, + options.clusterMilestoneRoot, + ); + 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 || + outputRoot === milestones.localRoot || + outputRoot === milestones.clusterRoot + ) { + fail('stage index output is invalid'); + } + if ( + !REVISION_PATTERN.test(options.sourceRevision || '') || + options.sourceRevision !== milestones.local.sourceRevision || + options.repository !== WORKFLOW_IDENTITY.repository || + options.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + options.workflowSha !== options.sourceRevision || + options.eventName !== WORKFLOW_IDENTITY.event || + options.runId !== milestones.local.workflow.runId || + options.runAttempt !== milestones.local.workflow.runAttempt || + !DECIMAL_ID_PATTERN.test(options.runId || '') || + !ATTEMPT_PATTERN.test(options.runAttempt || '') + ) { + fail('stage index workflow identity is invalid'); + } + const release = readReleaseIdentity(root); + if (release.version !== milestones.local.version) { + fail('stage index milestones do not match the release version'); + } + return Object.freeze({ + ...milestones, + root, + outputRoot, + readme: assertCanonicalFile( + options.readme, + MAX_README_BYTES, + 'stage index README', + ), + sourceRevision: options.sourceRevision, + repository: options.repository, + workflowRef: options.workflowRef, + workflowSha: options.workflowSha, + eventName: options.eventName, + runId: options.runId, + runAttempt: options.runAttempt, + }); +} + +function finalizeAlphaStageIndex(options) { + const normalized = validateFinalizeOptions(options); + 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_stage_delivery_not_public_release', + product: 'qinglong3', + version: normalized.local.version, + 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, + }, + milestones: { + local: { + artifactName: milestoneArtifactName( + normalized.sourceRevision, + 'local', + ), + schema: normalized.local.schema, + maturity: normalized.local.maturity, + manifest: fileRecord( + path.join(normalized.localRoot, 'manifest.json'), + 'manifest.json', + ), + }, + cluster: { + artifactName: milestoneArtifactName( + normalized.sourceRevision, + 'cluster', + ), + schema: normalized.cluster.schema, + maturity: normalized.cluster.maturity, + manifest: fileRecord( + path.join(normalized.clusterRoot, 'manifest.json'), + 'manifest.json', + ), + }, + }, + deploymentSelections: expectedSelections( + normalized.local, + normalized.cluster, + ), + 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]), + ); + auditAlphaStageIndex({ + stageRoot: normalized.outputRoot, + localMilestoneRoot: normalized.localRoot, + clusterMilestoneRoot: normalized.clusterRoot, + }); + return Object.freeze(manifest); + } catch (error) { + if (created) { + fs.rmSync(normalized.outputRoot, { recursive: true, force: true }); + } + throw error; + } +} + +function jobBlock(workflow, jobName) { + const header = `\n ${jobName}:\n`; + const start = workflow.indexOf(header); + if (start < 0) return ''; + const remaining = workflow.slice(start + header.length); + const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining); + const end = nextMatch + ? start + header.length + nextMatch.index + : workflow.length; + return workflow.slice(start, end); +} + +function auditAlphaStageIndexWorkflow(root = DEFAULT_ROOT) { + const workflow = fs.readFileSync( + path.join( + fs.realpathSync(path.resolve(root)), + '.github/workflows/ql3-ci.yml', + ), + 'utf8', + ); + const findings = []; + const stage = jobBlock(workflow, 'alpha-stage-index'); + const condition = + "github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && inputs.alpha_artifact_scope == 'all'"; + const tokens = [ + 'name: Finalize the cross-profile Alpha stage index', + `if: ${condition}`, + ' - local-alpha-milestone\n', + ' - cluster-alpha-milestone\n', + `name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`, + `name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`, + 'scripts/ql3-alpha-stage-index.cjs', + '--mode=finalize', + '--mode=audit', + `name: ql3-alpha-${'${{ github.sha }}'}-stage-index`, + 'retention-days: 30', + 'overwrite: false', + ]; + if (!stage || tokens.some((token) => !stage.includes(token))) { + findings.push('ALPHA_STAGE_INDEX_FINALIZER_CONTRACT_DRIFT'); + } + const finalizeIndex = stage.indexOf('--mode=finalize'); + const auditIndex = stage.indexOf('--mode=audit'); + const uploadIndex = stage.indexOf('actions/upload-artifact@'); + if ( + finalizeIndex < 0 || + auditIndex <= finalizeIndex || + uploadIndex <= auditIndex + ) { + findings.push('ALPHA_STAGE_INDEX_GATE_ORDER_DRIFT'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-stage-index-workflow-audit@v1', + requiredNeeds: ['local-alpha-milestone', 'cluster-alpha-milestone'], + requiredScope: 'all', + 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-workflow') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['mode', 'root']) + ) { + fail('workflow audit arguments are invalid'); + } + return { mode: values.mode, root: path.resolve(values.root) }; + } + if (values.mode === 'audit') { + const expected = ['cluster-milestone', 'local-milestone', 'mode', 'stage']; + if ( + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('stage audit arguments are invalid'); + } + return { + mode: values.mode, + stageRoot: path.resolve(values.stage), + localMilestoneRoot: path.resolve(values['local-milestone']), + clusterMilestoneRoot: path.resolve(values['cluster-milestone']), + }; + } + const expected = [ + 'cluster-milestone', + 'event', + 'local-milestone', + 'mode', + 'output', + 'readme', + 'repository', + 'run-attempt', + 'run-id', + 'source-revision', + 'workflow-ref', + 'workflow-sha', + ].sort(); + if ( + values.mode !== 'finalize' || + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('stage finalization arguments are invalid'); + } + return { + mode: values.mode, + outputRoot: path.resolve(values.output), + localMilestoneRoot: path.resolve(values['local-milestone']), + clusterMilestoneRoot: path.resolve(values['cluster-milestone']), + 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 = finalizeAlphaStageIndex(options); + } else if (options.mode === 'audit-workflow') { + report = auditAlphaStageIndexWorkflow(options.root); + if (!report.compatible) fail(JSON.stringify(report)); + } else { + report = auditAlphaStageIndex(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 : 'Alpha stage index failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + ARCHITECTURES, + FILES, + SCHEMA, + auditAlphaStageIndex, + auditAlphaStageIndexWorkflow, + finalizeAlphaStageIndex, + milestoneArtifactName, + parseArguments, + runCli, + stageArtifactName, +}); diff --git a/test/back/ql3AlphaStageIndex.test.cjs b/test/back/ql3AlphaStageIndex.test.cjs new file mode 100644 index 00000000..209746e4 --- /dev/null +++ b/test/back/ql3AlphaStageIndex.test.cjs @@ -0,0 +1,322 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + auditAlphaStageIndex, + auditAlphaStageIndexWorkflow, + finalizeAlphaStageIndex, + parseArguments, +} = require('../../scripts/ql3-alpha-stage-index.cjs'); +const { SUBJECTS } = require('../../scripts/ql3-cluster-alpha-milestone.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const revision = 'd'.repeat(40); +const runId = '33094481420'; +const runAttempt = '3'; +const workflowRef = + 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next'; + +function digest(character) { + return `sha256:${character.repeat(64)}`; +} + +function fileRecord(filePath, name) { + return { + file: name, + sha256: `sha256:${crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex')}`, + bytes: fs.statSync(filePath).size, + }; +} + +function checksums(directory) { + return ['README.md', 'manifest.json'] + .map( + (name) => + `${fileRecord(path.join(directory, name), name).sha256.slice( + 7, + )} ${name}`, + ) + .join('\n') + .concat('\n'); +} + +function workflow(job, attempt = runAttempt) { + return { + repository: 'whyour/qinglong', + workflowRef, + workflowSha: revision, + event: 'workflow_dispatch', + job, + runId, + runAttempt: attempt, + }; +} + +function writeMilestone(directory, manifest) { + fs.mkdirSync(directory, { mode: 0o700 }); + fs.writeFileSync( + path.join(directory, 'README.md'), + `# ${manifest.product} Alpha milestone\n`, + ); + manifest.readme = fileRecord(path.join(directory, 'README.md'), 'README.md'); + fs.writeFileSync( + path.join(directory, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + fs.writeFileSync(path.join(directory, 'SHA256SUMS'), checksums(directory)); +} + +function localManifest(attempt = runAttempt) { + return { + schemaVersion: 1, + schema: 'qinglong/alpha-local-milestone@v1', + maturity: 'alpha_candidate_not_public_release', + product: 'local', + version, + sourceRevision: revision, + workflow: workflow('local-alpha-milestone', attempt), + artifacts: { + amd64: { + artifactName: `ql3-alpha-${revision}-local-amd64`, + architecture: 'amd64', + bundleManifest: { + file: 'manifest.json', + sha256: digest('1'), + bytes: 2048, + }, + archiveSha256: digest('2'), + applicationImageId: digest('3'), + operatorImageId: digest('4'), + verificationSha256: digest('5'), + }, + arm64: { + artifactName: `ql3-alpha-${revision}-local-arm64`, + architecture: 'arm64', + bundleManifest: { + file: 'manifest.json', + sha256: digest('6'), + bytes: 2048, + }, + archiveSha256: digest('7'), + applicationImageId: digest('8'), + operatorImageId: digest('9'), + verificationSha256: digest('a'), + }, + }, + readme: null, + }; +} + +function clusterManifest(attempt = runAttempt) { + const characters = ['1', '2', '3', '4', '5', '6', '7', '8']; + const artifacts = Object.fromEntries( + SUBJECTS.map((subject, index) => { + const architecture = subject.endsWith('-amd64') ? 'amd64' : 'arm64'; + const role = subject.slice(0, -(architecture.length + 1)); + return [ + subject, + { + artifactName: `ql3-alpha-${revision}-${subject}`, + role, + architecture, + bundleManifest: { + file: 'manifest.json', + sha256: digest(characters[index]), + bytes: 4096 + index, + }, + archiveSha256: digest(characters[index]), + imageId: digest(characters[index]), + verificationSha256: digest(characters[index]), + }, + ]; + }), + ); + return { + schemaVersion: 1, + schema: 'qinglong/alpha-cluster-milestone@v1', + maturity: 'cluster_integration_candidate_not_public_release', + product: 'cluster', + version, + sourceRevision: revision, + workflow: workflow('cluster-alpha-milestone', attempt), + artifacts, + readme: null, + }; +} + +function fixture(t, options = {}) { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-stage-index-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const localMilestoneRoot = path.join(fixtureRoot, 'local'); + const clusterMilestoneRoot = path.join(fixtureRoot, 'cluster'); + writeMilestone(localMilestoneRoot, localManifest(options.localAttempt)); + writeMilestone(clusterMilestoneRoot, clusterManifest(options.clusterAttempt)); + const readme = path.join(fixtureRoot, 'README-source.md'); + fs.writeFileSync(readme, '# QingLong 3.0 Alpha stage index\n'); + return { + fixtureRoot, + localMilestoneRoot, + clusterMilestoneRoot, + readme, + outputRoot: path.join(fixtureRoot, 'stage'), + }; +} + +function finalizeOptions(paths) { + return { + root, + outputRoot: paths.outputRoot, + localMilestoneRoot: paths.localMilestoneRoot, + clusterMilestoneRoot: paths.clusterMilestoneRoot, + readme: paths.readme, + sourceRevision: revision, + repository: 'whyour/qinglong', + workflowRef, + workflowSha: revision, + eventName: 'workflow_dispatch', + runId, + runAttempt, + }; +} + +test('closes Local and Cluster milestones into one deployment-facing stage index', (t) => { + const paths = fixture(t); + const manifest = finalizeAlphaStageIndex(finalizeOptions(paths)); + assert.equal(manifest.schema, 'qinglong/alpha-stage-index@v1'); + assert.deepEqual(Object.keys(manifest.milestones), ['local', 'cluster']); + assert.deepEqual( + manifest.deploymentSelections.local.architectures.amd64.requiredArtifacts, + [`ql3-alpha-${revision}-local-amd64`], + ); + assert.deepEqual( + manifest.deploymentSelections.cluster.architectures.arm64.requiredArtifacts, + [ + `ql3-alpha-${revision}-control-arm64`, + `ql3-alpha-${revision}-admin-arm64`, + `ql3-alpha-${revision}-worker-arm64`, + ], + ); + assert.deepEqual( + manifest.deploymentSelections.cluster.architectures.arm64.optionalArtifacts, + [`ql3-alpha-${revision}-control-ai-arm64`], + ); + const report = auditAlphaStageIndex({ + stageRoot: paths.outputRoot, + localMilestoneRoot: paths.localMilestoneRoot, + clusterMilestoneRoot: paths.clusterMilestoneRoot, + }); + assert.equal(report.compatible, true); + assert.equal(report.artifactCount, 10); + assert.deepEqual(report.profiles, ['edge', 'standalone', 'cluster']); +}); + +test('rejects Local and Cluster milestones from different workflow attempts', (t) => { + const paths = fixture(t, { clusterAttempt: '2' }); + assert.throws( + () => finalizeAlphaStageIndex(finalizeOptions(paths)), + /do not belong to one workflow run/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('cross-index audit rejects stage and source milestone mutation', (t) => { + const paths = fixture(t); + finalizeAlphaStageIndex(finalizeOptions(paths)); + fs.appendFileSync(path.join(paths.outputRoot, 'README.md'), 'tamper\n'); + assert.throws( + () => + auditAlphaStageIndex({ + stageRoot: paths.outputRoot, + localMilestoneRoot: paths.localMilestoneRoot, + clusterMilestoneRoot: paths.clusterMilestoneRoot, + }), + /README differs/, + ); + fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); + assert.throws( + () => + auditAlphaStageIndex({ + stageRoot: paths.outputRoot, + localMilestoneRoot: paths.localMilestoneRoot, + clusterMilestoneRoot: paths.clusterMilestoneRoot, + }), + /file set is not closed/, + ); +}); + +test('workflow audit requires all scope and both milestone dependencies', () => { + const report = auditAlphaStageIndexWorkflow(root); + assert.equal(report.compatible, true); + assert.deepEqual(report.findings, []); + assert.deepEqual(report.requiredNeeds, [ + 'local-alpha-milestone', + 'cluster-alpha-milestone', + ]); + assert.equal(report.requiredScope, 'all'); +}); + +test('workflow audit rejects a partial or prematurely uploaded stage index', (t) => { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-stage-workflow-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + fs.mkdirSync(path.join(fixtureRoot, '.github/workflows'), { + recursive: true, + }); + const source = fs + .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + .replace(' - cluster-alpha-milestone\n', '') + .replace( + "inputs.alpha_artifact_scope == 'all'", + "inputs.alpha_artifact_scope == 'local'", + ); + fs.writeFileSync( + path.join(fixtureRoot, '.github/workflows/ql3-ci.yml'), + source, + ); + const report = auditAlphaStageIndexWorkflow(fixtureRoot); + assert.equal(report.compatible, false); + assert.ok( + report.findings.includes('ALPHA_STAGE_INDEX_FINALIZER_CONTRACT_DRIFT'), + ); +}); + +test('CLI grammar keeps cross-index audit explicit', () => { + assert.deepEqual( + parseArguments([ + '--mode=audit', + '--stage=/tmp/stage', + '--local-milestone=/tmp/local', + '--cluster-milestone=/tmp/cluster', + ]), + { + mode: 'audit', + stageRoot: '/tmp/stage', + localMilestoneRoot: '/tmp/local', + clusterMilestoneRoot: '/tmp/cluster', + }, + ); + assert.throws( + () => + parseArguments([ + '--mode=audit', + '--stage=/tmp/stage', + '--local-milestone=/tmp/local', + ]), + /stage audit arguments are invalid/, + ); +});