feat(ql3): close cross-profile alpha stage index

This commit is contained in:
whyour
2026-08-28 01:07:04 +08:00
parent 07d1dd97c1
commit 82d74cf56f
9 changed files with 1167 additions and 2 deletions
+55
View File
@@ -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
+4 -2
View File
@@ -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`
@@ -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;稳态角色只有 ApplicationOperator 是短生命周期角色;
- Cluster 最小集选择 control、admin、workercontrol-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 catalogAlpha 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`
+1
View File
@@ -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 待维护者授权) |
## 规则
+5
View File
@@ -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-<commit>-local-milestone`
- `ql3-alpha-<commit>-control-<arch>``control-ai-<arch>``admin-<arch>``worker-<arch>`
- `ql3-alpha-<commit>-cluster-milestone`
-`alpha_artifact_scope=all``ql3-alpha-<commit>-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
+41
View File
@@ -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 ApplicationOperator 只在 setup/upgrade/recovery 短暂运行 | 目标架构的一个 Local Trial Kit |
| 临时 K3s/Kubernetes 集群 | `deploymentSelections.cluster` | control、admin、workerAI 按需增加 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。
+1
View File
@@ -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",
+683
View File
@@ -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,
});
+322
View File
@@ -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/,
);
});