feat(ql3): enforce architecture support tiers

This commit is contained in:
whyour
2026-08-20 11:02:17 +08:00
parent 3979707f6d
commit 6e11258d45
9 changed files with 299 additions and 33 deletions
+27 -8
View File
@@ -11,6 +11,20 @@
最新增量证据(2026-08-20): 最新增量证据(2026-08-20):
- D-371/ADR-0464(已接受并机器化):接受 ADR-0006 的 Node 24 与多架构分层,并把根 `ql3-release.json` 升级为
`qinglong/release-identity@v2` 唯一事实源。3.0 Tier 1 精确为 `amd64``arm64``ppc64le``s390x` 是通过固定
Node 24 与同等级原生门禁前不可发布的候选;`arm/v7` 在缺少 owner、可重复 toolchain 与设备报告时为 experimental blocked
`arm/v6``386` 明确留在 `2.x` legacy line。release candidate 的 OCI platforms、原生 OS runner matrix、release set digest 与
version audit 均从该身份派生,新增 Tier 1 却无原生 runner 会失败关闭;发布必需门新增 `architecture-support-tier`。这不新增
workspace package、dependency、服务、timer、数据库对象或 Edge 常驻成本,也不把旧设备排除出后续受限 Worker 兼容路径。
聚焦 release/version/downstream closure 门为 `55/55`;完整 backend 工作区为
`1,503 total / 1,501 pass / 2 conditional skip / 0 fail`(含一条不会提交的既有用户测试;D-371 提交范围为
`1,502 total / 1,500 pass / 2 skip`),18-package clean build/逐包测试退出 0。release version、package boundary、
cluster dependency、Edge import、cluster deployment 与 image release 六项审计全部 compatibleworkspace 仍为 18 packages、
无 single/shallow package。14 档 Local artifact audit 串行复核全部 compatible;基础 Edge/Standalone 仍为
`2,589,998 / 2,590,076` bytesApplication+AI 为 `4,493,151 / 4,493,283` bytesMCP 为
`7,315,930 / 7,316,038` bytes,证明发布政策未进入低资源部署闭包。
- D-370/ADR-0463(实现与本地协议门已接受;首份原生 CI 双架构报告待实际 workflow 产生):为显式按需的 Cluster Admin - D-370/ADR-0463(实现与本地协议门已接受;首份原生 CI 双架构报告待实际 workflow 产生):为显式按需的 Cluster Admin
Copilot Console 增加固定 Linux x64/arm64 容量与 assertion 生命周期发布证据。两个原生 Admin image matrix job 分别在 Copilot Console 增加固定 Linux x64/arm64 容量与 assertion 生命周期发布证据。两个原生 Admin image matrix job 分别在
`192 MiB / swap 0 / 0.25 CPU / 32 PIDs`、只读 root、非 root、cap-drop ALL、no-new-privileges、默认 seccomp、8 MiB tmpfs 与 `192 MiB / swap 0 / 0.25 CPU / 32 PIDs`、只读 root、非 root、cap-drop ALL、no-new-privileges、默认 seccomp、8 MiB tmpfs 与
@@ -4959,9 +4973,9 @@
| D-11 | 新领域 API 使用 `/api/v3`,旧 API 通过兼容层继续服务 | Proposed | 允许渐进迁移而非一次性切换 | | D-11 | 新领域 API 使用 `/api/v3`,旧 API 通过兼容层继续服务 | Proposed | 允许渐进迁移而非一次性切换 |
| D-12 | Workflow 首版只支持有界、可恢复的步骤模型 | Proposed | 避免无界 DAG 和 Agent 循环扩大可靠性风险 | | D-12 | Workflow 首版只支持有界、可恢复的步骤模型 | Proposed | 避免无界 DAG 和 Agent 循环扩大可靠性风险 |
| D-13 | Prompt、模型结果和 Tool 内容采集默认关闭 | Proposed | 降低 Secret 和业务数据泄漏风险 | | D-13 | Prompt、模型结果和 Tool 内容采集默认关闭 | Proposed | 降低 Secret 和业务数据泄漏风险 |
| D-14 | ql-core 按 Tier 发布明确的架构支持矩阵,不把“现有镜像可构建”等同于“Node 24 可支持” | Proposed | 路由、NAS 和异构 Worker 是核心用户场景,但 Node 24 官方产物当前不能覆盖 ARMv6、ARMv7 和 386 | | D-14 | ql-core 按 Tier 发布明确的架构支持矩阵,不把“现有镜像可构建”等同于“Node 24 可支持” | AcceptedADR-0006/0464release identity v2 已机器化当前支持矩阵) | 路由、NAS 和异构 Worker 是核心用户场景,但 Node 24 官方产物当前不能覆盖 ARMv6、ARMv7 和 386 |
| D-15 | UI Extension 不进入 3.0 首个稳定版本 | Deferred | 先稳定 Package、Tool 和 Runtime Extension 契约 | | D-15 | UI Extension 不进入 3.0 首个稳定版本 | Deferred | 先稳定 Package、Tool 和 Runtime Extension 契约 |
| D-16 | 3.0 ql-core 以固定 Node.js 24 LTS 为运行时基线 | Proposed | 使用稳定 Web/SQLite 能力并消除不同发行版浮动 Node 版本造成的行为漂移 | | D-16 | 3.0 ql-core 以固定 Node.js 24 LTS 为运行时基线 | AcceptedADR-0006/0464:当前精确固定 24.18.0,变更必须升级发布身份并重跑完整矩阵) | 使用稳定 Web/SQLite 能力并消除不同发行版浮动 Node 版本造成的行为漂移 |
| D-17 | 3.0 新领域持久化通过 typed schema 与 Repository 端口访问;SQLite 目标为 Drizzle + node:sqlite | Proposed | 避免新 Runtime 继续耦合 Sequelize,同时保留 PostgreSQL adapter 边界和可审查 migration | | D-17 | 3.0 新领域持久化通过 typed schema 与 Repository 端口访问;SQLite 目标为 Drizzle + node:sqlite | Proposed | 避免新 Runtime 继续耦合 Sequelize,同时保留 PostgreSQL adapter 边界和可审查 migration |
| D-18 | 取消先持久化为 Run 意图事件,再调用 Executor;实际终态由完成事实或 Reconciler 收敛 | Proposed | 使取消可跨重启恢复,并消除“已发信号但无审计事实”和迟到成功覆盖取消的竞态 | | D-18 | 取消先持久化为 Run 意图事件,再调用 Executor;实际终态由完成事实或 Reconciler 收敛 | Proposed | 使取消可跨重启恢复,并消除“已发信号但无审计事实”和迟到成功覆盖取消的竞态 |
| D-19 | 跨进程取消派发使用独立的 durable lease/fencing 状态,不以 RunEvent 或内存锁代替并发控制 | Proposed | 在 edge SQLite 与 cluster PostgreSQL 上共享一致语义,限制多 Worker 重复 signal 和崩溃重试风暴 | | D-19 | 跨进程取消派发使用独立的 durable lease/fencing 状态,不以 RunEvent 或内存锁代替并发控制 | Proposed | 在 edge SQLite 与 cluster PostgreSQL 上共享一致语义,限制多 Worker 重复 signal 和崩溃重试风暴 |
@@ -6392,7 +6406,8 @@ ql-observability
### 7.9 多架构发布 ### 7.9 多架构发布
QingLong 当前容器发布覆盖 `amd64``arm/v6``arm/v7``arm64``ppc64le``386`3.0 应维护明确的架构支持矩阵: QingLong 2.x 容器发布覆盖 `amd64``arm/v6``arm/v7``arm64``ppc64le``s390x``386`
3.0 由 release identity v2 维护明确的架构支持矩阵:
- `ql-core` 和 edge 镜像优先保持现有多架构覆盖,但每个架构必须标记 `supported``experimental``legacy-only`,不能只凭构建成功宣称支持。 - `ql-core` 和 edge 镜像优先保持现有多架构覆盖,但每个架构必须标记 `supported``experimental``legacy-only`,不能只凭构建成功宣称支持。
- Alpine 与 Debian 发布物必须固定同一 Node.js 24 小版本,不能依赖发行版仓库的浮动 `nodejs`;升级 Node 小版本需要通过完整多架构门禁。 - Alpine 与 Debian 发布物必须固定同一 Node.js 24 小版本,不能依赖发行版仓库的浮动 `nodejs`;升级 Node 小版本需要通过完整多架构门禁。
@@ -6405,15 +6420,19 @@ QingLong 当前容器发布覆盖 `amd64`、`arm/v6`、`arm/v7`、`arm64`、`ppc
截至 2026-07-18 的上游产物审计显示,Node.js 24 官方 release binary 提供 x64、arm64、ppc64le 和 s390x,没有 ARMv6、ARMv7 或 386;官方 `node:24` Docker manifest 的 Debian/Alpine 覆盖也小于 QingLong 当前矩阵。Node.js BUILDING 文档把 ARMv7 降为实验支持,不能把自定义构建当作官方维护基线。参考:[Node.js 24 release](https://nodejs.org/en/blog/release/v24.18.0)、[Node.js official image manifest](https://github.com/docker-library/official-images/blob/master/library/node)、[Node.js BUILDING](https://github.com/nodejs/node/blob/main/BUILDING.md)。 截至 2026-07-18 的上游产物审计显示,Node.js 24 官方 release binary 提供 x64、arm64、ppc64le 和 s390x,没有 ARMv6、ARMv7 或 386;官方 `node:24` Docker manifest 的 Debian/Alpine 覆盖也小于 QingLong 当前矩阵。Node.js BUILDING 文档把 ARMv7 降为实验支持,不能把自定义构建当作官方维护基线。参考:[Node.js 24 release](https://nodejs.org/en/blog/release/v24.18.0)、[Node.js official image manifest](https://github.com/docker-library/official-images/blob/master/library/node)、[Node.js BUILDING](https://github.com/nodejs/node/blob/main/BUILDING.md)。
因此 D-14 与 D-16 之间存在必须在首个 Beta 前解决的发布决策,当前建议的候选分层为: D-371/ADR-0464 已接受 D-14 与 D-16 的一致解,当前分层为:
| Tier | 3.0 候选架构 | 约束 | | Tier | 3.0 候选架构 | 约束 |
| --- | --- | --- | | --- | --- | --- |
| Tier 1 supported | amd64、arm64ppc64le、s390x 在对应 libc 镜像通过后纳入 | 固定官方 Node 24 patch,完整 migration、任务恢复门禁 | | Tier 1 supported | amd64、arm64 | 固定 Node 24.18.0;完整原生 OS、OCI、migration、任务恢复与资源门禁;只进入默认 3.0 manifest |
| Tier 2 experimental | ARMv7 自定义 Node 24 构建 | 必须有可重复 toolchain、设备测试和明确无官方 binary 的提示,不进入默认 `latest` | | candidate | ppc64le、s390x | 在固定 Node 24 镜像、原生 runner/设备和同等级门禁全部具备前不得进入 3.0 manifest |
| legacy-only | ARMv6、386;以及未通过 Node 24 门禁的架构 | 继续使用受维护的 2.x 镜像;不能把 Node 20/22 镜像标为满足 D-16 的完整 3.0 | | experimental blocked | ARMv7 | 当前没有已接受的 owner、可重复 toolchain 与设备证据;未来若补齐只能进入独立 experimental tag |
| legacy-only | ARMv6、386 | 继续使用显式 2.x legacy line;不能把 Node 20/22 镜像标为满足 D-16 的完整 3.0 |
这个分层是待评审候选,不等于已经决定放弃小设备。若 Maintainers 要求 ARMv6、ARMv7、386 在 3.0 与 Node 24 同时成为正式支持项,则必须先拥有并长期维护对应 Node 24 构建链;否则应明确修改 D-16 或维持 2.x legacy support line,不能发布互相矛盾的承诺。 这个分层不等于放弃小设备。2.x legacy line 继续承担约定兼容窗口,旧设备参与 3.0 集群的受限 Worker 协议仍是
独立后续决策;但不能因此把没有受支持 Node 24、数据库恢复与原生资源证据的设备标成完整 3.0 ql-core。若
Maintainers 要求 ARMv6、ARMv7 或 386 成为 3.0 正式支持项,必须先修改 D-16 或长期维护对应 Node 24 构建链,
升级 release identity schema,并通过与现有 Tier 1 同等级的发布门禁。
### 7.10 资源基准与发布门禁 ### 7.10 资源基准与发布门禁
@@ -1,10 +1,11 @@
# ADR-0006Node.js 24 与多架构支持分层 # ADR-0006Node.js 24 与多架构支持分层
- 状态:Proposed - 状态:Accepted(由 ADR-0464 机器化;新增架构必须先满足对应原生门禁)
- 日期:2026-07-18 - 日期:2026-07-18
- 决策者:QingLong Maintainers - 决策者:QingLong Maintainers
- 关联 RFC[QL-RFC-0001](../QINGLONG_3_0_ARCHITECTURE_RFC.md) - 关联 RFC[QL-RFC-0001](../QINGLONG_3_0_ARCHITECTURE_RFC.md)
- 关联决策:D-05、D-14、D-16、D-17 - 关联决策:D-05、D-14、D-16、D-17
- Operationalized byADR-0464
官方参考: 官方参考:
@@ -166,3 +167,15 @@ Node 22 能覆盖更多 32-bit ARM 镜像,但把同一个 3.0 tag 在不同架
- 接受 ARMv6、386 默认进入有 EOL 的 2.x legacy line,而不是伪装成 Node 24 3.0。 - 接受 ARMv6、386 默认进入有 EOL 的 2.x legacy line,而不是伪装成 Node 24 3.0。
- 接受旧设备可通过受限 Worker 兼容路径参与 3.0,但不拥有完整 ql-core 权限。 - 接受旧设备可通过受限 Worker 兼容路径参与 3.0,但不拥有完整 ql-core 权限。
- 接受任何 Node 22 的 3.0 compatibility runtime 都必须先显式修订 RFC。 - 接受任何 Node 22 的 3.0 compatibility runtime 都必须先显式修订 RFC。
## 9. 2026-08-20 实施状态
ADR-0464 已把本 ADR 从候选分层转为唯一发布身份中的机器契约。当前 3.0 Tier 1 精确为 `amd64``arm64`
`ppc64le``s390x` 保持候选,必须在进入默认 manifest 前取得固定 Node 24 镜像与同等级原生门禁;`arm/v7`
因没有受维护 toolchain、owner 与设备证据而保持 experimental blocked`arm/v6``386` 留在显式 `2.x`
legacy line。该状态不是删除小设备支持:2.x 兼容窗口和未来受限 Worker 路径仍需单独关闭,但不能冒充完整 3.0
ql-core 支持。
`ql3-release.json``qinglong/release-identity@v2` 是唯一事实源;release candidate、原生 OS matrix、OCI
platform 列表、release set 和 version audit 都从它派生。任何新增 Tier 1 架构若没有对应原生 runner mapping
发布候选会失败关闭。实现未新增 workspace package、运行时依赖、服务、timer、数据库对象或 Edge 常驻成本。
@@ -0,0 +1,89 @@
# ADR-0464:机器化 Node 24 架构支持分层
- 状态:Accepted
- 日期:2026-08-20
- 关联 RFCQL-RFC-0001 D-14、D-16、D-371
- 关联 ADRADR-0006、ADR-0088、ADR-0281、ADR-0329、ADR-0463
- Accepts and operationalizesADR-0006
## 上下文
QingLong 部署跨度从低性能路由设备、NAS 到 Cluster control plane 与 Worker 节点。2.x 历史镜像覆盖的架构数量,
不能直接等价为完整 3.0 ql-core 的支持承诺:3.0 固定使用 Node 24 和 `node:sqlite`,而上游官方产物、镜像
variant 与原生测试资源没有覆盖 ARMv6、ARMv7 和 386。此前 ADR-0006、D-14 与 D-16 仍是 Proposed,发布脚本
虽然实际只生成 amd64/arm64,却没有一个机器可读、可审计的支持分层,因此文档、OCI manifest 与未来 matrix
可能独立漂移。
继续把所有历史架构放进一个默认 manifest 会让低配设备用户收到虚假升级承诺;反过来静默删除这些架构也会
损害现有部署。3.0 必须同时诚实声明完整核心的边界,以及 2.x legacy 和未来受限 Worker 兼容路径的边界。
## 决策
1. 接受 ADR-0006。3.0 ql-core 的唯一生产运行时基线固定为 Node `24.18.0`engine 为
`>=24.18.0 <25`;不同架构不能在同一个 3.0 tag 下静默回退 Node major。
2.`ql3-release.json` 升级为 `qinglong/release-identity@v2`,增加 exact-shape
`architectureSupport`,作为版本、Node、workspace 边界和架构政策的单一发布事实源。
3. 当前 Tier 1 精确为 `amd64``arm64`。只有它们进入默认 3.0 OCI platform 列表与原生 OS vulnerability
matrix;每个 image 都必须在对应原生 runner 构建、扫描并绑定 immutable OCI evidence。
4. `ppc64le``s390x` 为 candidates,不是已支持平台。只有获得固定 Node 24 image variant、原生
runner/设备、数据库 migration/backup/restore、任务、资源和发布证据后,才能通过新的 release identity schema
变更进入 Tier 1。
5. `arm/v7` 当前为 experimental blocked:没有已接受的 maintainer owner、可重复 Node 24 toolchain 与设备门。
补齐后也必须使用独立 experimental tag,不自动进入默认 manifest。
6. `arm/v6``386` 为 legacy-only,显式绑定 `2.x` line。它们不能被标成满足 D-16 的完整 3.0 ql-core
EOL、迁移、备份和受限 Worker 兼容协议分别治理,不能由本 ADR 虚构完成状态。
7. `scripts/lib/ql3-release-identity.cjs` 对 v2 字段、顺序和值失败关闭并深冻结架构列表;非 canonical JSON、
添加未知字段或把 candidate 擅自加入 Tier 1 都会被拒绝。
8. release candidate 从 Tier 1 派生 `linux/<arch>` platforms 和每镜像原生 runner matrix,并加入
`architecture-support-tier` 必需门。Tier 1 没有受审 runner mapping 时发布失败,而不是降级为 QEMU 或跳过扫描。
9. release identity digest 继续传入 candidate、image record、release set、catalog、publication closure 与
deployment lock,支持矩阵变化会自然改变整条发布证据链;version audit 同时输出架构分层。
10. 本实现不新增 workspace package、生产 dependency、runtime binary、服务、timer、端口、数据库对象或
部署 workload。它只改变离线发布权威和 CI matrix,因此 Edge/Standalone 常驻资源闭包保持不变。
## 被拒绝的替代方案
### 保留文档表格,发布脚本继续硬编码 amd64/arm64
拒绝。两份事实源会在新增架构、Node patch 或 workflow 重构时漂移,且 release set 无法证明自己遵循哪份政策。
### 为架构政策新增一个 workspace package
拒绝。该政策只有一个根发布事实源和离线 validator,没有独立生产生命周期或依赖边界;新增 package 会重现
单文件微包问题,并让低资源制品审计承担无收益的结构成本。
### 立即把 ppc64le、s390x 加入 Tier 1
拒绝。上游可能存在 Node binary 不等于当前 Debian image、数据库、恢复、资源和发布门已经完成;支持承诺必须
由同等级证据产生。
### 把 ARMv7 直接归入 legacy-only 或 Tier 1
拒绝。它仍保留 experimental 的技术路径,但当前资产不足以发布;既不能掩盖未来可能性,也不能伪造现有支持。
### 让旧设备在 3.0 tag 下运行 Node 20/22
拒绝。同一版本跨架构拥有不同 SQLite、Web API、插件 engine 与安全周期,会使 ql-core capability 不可判断。
## 验证与证据
- release candidate、version transition、release set、catalog、publication closure 和 deployment lock 聚焦回归为
`55/55`,覆盖 v2 schema、精确支持分层、来源派生 platforms/matrix、必需门与 post-create mutation 拒绝。
- 架构漂移负向用例把 `ppc64le` 未经 schema 变更加入 Tier 1release identity audit 必须失败关闭。
- 完整 backend 工作区为 `1,503 total / 1,501 pass / 2 conditional skip / 0 fail`;其中包含一条既有、未跟踪且
不会提交的用户测试,因此 D-371 提交范围对应 `1,502 total / 1,500 pass / 2 skip`。沙箱首次运行唯一失败为
loopback `listen EPERM`,允许 `127.0.0.1` 的宿主环境原样重跑后零失败,未把权限限制误判成代码回归。
- 18-package clean build 与逐包测试退出 0。release version、package boundary、cluster dependency、Edge import、
cluster deployment 与 image release 六项审计全部 compatibleworkspace 保持 18 packages、无 single/shallow
package,证明本决策没有重新扩大 package 或依赖树。
- 14 档 Local artifact audit 必须串行执行,因为并行 build/pack 会争用相同 package `dist`、产生不可信的短暂
字节差;串行复核全部 compatible。基础 Edge/Standalone 为 `2,589,998 / 2,590,076` bytesApplication+AI 为
`4,493,151 / 4,493,283` bytesMCP 为 `7,315,930 / 7,316,038` bytes,与 D-370 稳定基线一致。
## 后续边界
- 制定 2.x legacy line 的可见 EOL、备份/迁移说明,不能只在内部 RFC 中声明。
- 为旧设备参与 3.0 设计最小 capability、身份、调度限制和 EOL 明确的受限 Worker 协议;legacy Worker 不获得
Plugin Host、任意 Tool 或控制面数据库访问权。
- ppc64le/s390x 进入 Tier 1 或 ARMv7 进入 experimental 前,必须先提交独立 ADR、runner/toolchain owner 与原生
证据;不能只修改数组让 CI 看似扩容。
+20 -2
View File
@@ -1,12 +1,30 @@
{ {
"schemaVersion": 1, "schemaVersion": 2,
"schema": "qinglong/release-identity@v1", "schema": "qinglong/release-identity@v2",
"product": "qinglong3", "product": "qinglong3",
"version": "3.0.0-alpha.0", "version": "3.0.0-alpha.0",
"node": { "node": {
"version": "24.18.0", "version": "24.18.0",
"engine": ">=24.18.0 <25" "engine": ">=24.18.0 <25"
}, },
"architectureSupport": {
"tier1": [
"amd64",
"arm64"
],
"candidates": [
"ppc64le",
"s390x"
],
"experimentalBlocked": [
"arm/v7"
],
"legacyOnly": [
"arm/v6",
"386"
],
"legacyLine": "2.x"
},
"workspacePackageCount": 18, "workspacePackageCount": 18,
"legacyRootPackageExcluded": true "legacyRootPackageExcluded": true
} }
+29 -2
View File
@@ -5,7 +5,14 @@ const path = require('node:path');
const semver = require('semver'); const semver = require('semver');
const RELEASE_IDENTITY_PATH = 'ql3-release.json'; const RELEASE_IDENTITY_PATH = 'ql3-release.json';
const RELEASE_IDENTITY_SCHEMA = 'qinglong/release-identity@v1'; const RELEASE_IDENTITY_SCHEMA = 'qinglong/release-identity@v2';
const ARCHITECTURE_SUPPORT = Object.freeze({
tier1: Object.freeze(['amd64', 'arm64']),
candidates: Object.freeze(['ppc64le', 's390x']),
experimentalBlocked: Object.freeze(['arm/v7']),
legacyOnly: Object.freeze(['arm/v6', '386']),
legacyLine: '2.x',
});
const MAX_RELEASE_IDENTITY_BYTES = 4096; const MAX_RELEASE_IDENTITY_BYTES = 4096;
const VERSION_PATTERN = const VERSION_PATTERN =
/^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u; /^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u;
@@ -38,10 +45,11 @@ function normalizeReleaseIdentity(value) {
'product', 'product',
'version', 'version',
'node', 'node',
'architectureSupport',
'workspacePackageCount', 'workspacePackageCount',
'legacyRootPackageExcluded', 'legacyRootPackageExcluded',
]) || ]) ||
value.schemaVersion !== 1 || value.schemaVersion !== 2 ||
value.schema !== RELEASE_IDENTITY_SCHEMA || value.schema !== RELEASE_IDENTITY_SCHEMA ||
value.product !== 'qinglong3' || value.product !== 'qinglong3' ||
typeof value.version !== 'string' || typeof value.version !== 'string' ||
@@ -50,6 +58,15 @@ function normalizeReleaseIdentity(value) {
!exactKeys(value.node, ['version', 'engine']) || !exactKeys(value.node, ['version', 'engine']) ||
value.node.version !== '24.18.0' || value.node.version !== '24.18.0' ||
value.node.engine !== '>=24.18.0 <25' || value.node.engine !== '>=24.18.0 <25' ||
!exactKeys(value.architectureSupport, [
'tier1',
'candidates',
'experimentalBlocked',
'legacyOnly',
'legacyLine',
]) ||
JSON.stringify(value.architectureSupport) !==
JSON.stringify(ARCHITECTURE_SUPPORT) ||
value.workspacePackageCount !== 18 || value.workspacePackageCount !== 18 ||
value.legacyRootPackageExcluded !== true value.legacyRootPackageExcluded !== true
) { ) {
@@ -58,6 +75,15 @@ function normalizeReleaseIdentity(value) {
return Object.freeze({ return Object.freeze({
...value, ...value,
node: Object.freeze({ ...value.node }), node: Object.freeze({ ...value.node }),
architectureSupport: Object.freeze({
...value.architectureSupport,
tier1: Object.freeze([...value.architectureSupport.tier1]),
candidates: Object.freeze([...value.architectureSupport.candidates]),
experimentalBlocked: Object.freeze([
...value.architectureSupport.experimentalBlocked,
]),
legacyOnly: Object.freeze([...value.architectureSupport.legacyOnly]),
}),
}); });
} }
@@ -89,6 +115,7 @@ function readReleaseIdentity(root) {
} }
module.exports = Object.freeze({ module.exports = Object.freeze({
ARCHITECTURE_SUPPORT,
MAX_RELEASE_IDENTITY_BYTES, MAX_RELEASE_IDENTITY_BYTES,
RELEASE_IDENTITY_PATH, RELEASE_IDENTITY_PATH,
RELEASE_IDENTITY_SCHEMA, RELEASE_IDENTITY_SCHEMA,
+45 -16
View File
@@ -17,6 +17,18 @@ const PREDICATE_TYPE =
'https://qinglong.dev/attestations/release-candidate-contract/v1'; 'https://qinglong.dev/attestations/release-candidate-contract/v1';
const MAX_REPORT_BYTES = 1024 * 1024; const MAX_REPORT_BYTES = 1024 * 1024;
const RELEASE_SCOPES = Object.freeze(['all', 'cluster', 'local']); const RELEASE_SCOPES = Object.freeze(['all', 'cluster', 'local']);
const NATIVE_ARCHITECTURES = Object.freeze({
amd64: Object.freeze({
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
}),
arm64: Object.freeze({
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
}),
});
const LOCAL_IMAGES = Object.freeze([ const LOCAL_IMAGES = Object.freeze([
Object.freeze({ Object.freeze({
image: 'local', image: 'local',
@@ -92,6 +104,18 @@ function selectedImages(scope) {
return [...CLUSTER_IMAGES, ...LOCAL_IMAGES]; return [...CLUSTER_IMAGES, ...LOCAL_IMAGES];
} }
function nativeArchitectureMatrix(architectureSupport) {
return architectureSupport.tier1.map((architecture) => {
const native = NATIVE_ARCHITECTURES[architecture];
if (!native) {
fail(
`Tier 1 architecture lacks a native release runner: ${architecture}`,
);
}
return native;
});
}
function validateIdentity(options) { function validateIdentity(options) {
if ( if (
typeof options.version !== 'string' || typeof options.version !== 'string' ||
@@ -147,6 +171,9 @@ function createReleaseCandidateContract(options) {
}) })
.sort((left, right) => left.name.localeCompare(right.name, 'en')); .sort((left, right) => left.name.localeCompare(right.name, 'en'));
const images = selectedImages(options.releaseScope); const images = selectedImages(options.releaseScope);
const nativeArchitectures = nativeArchitectureMatrix(
releaseIdentity.architectureSupport,
);
const imageManifests = images.map((image) => { const imageManifests = images.map((image) => {
const manifest = readJson( const manifest = readJson(
path.join(root, image.runtime_root, 'package.json'), path.join(root, image.runtime_root, 'package.json'),
@@ -182,24 +209,14 @@ function createReleaseCandidateContract(options) {
}); });
}); });
const publishMatrix = images.map(({ dockerfile, target, ...image }) => image); const publishMatrix = images.map(({ dockerfile, target, ...image }) => image);
const osMatrix = images.flatMap((image) => [ const osMatrix = images.flatMap((image) =>
{ nativeArchitectures.map((architecture) => ({
image: image.image, image: image.image,
runner: 'ubuntu-24.04', ...architecture,
node_arch: 'x64',
image_arch: 'amd64',
dockerfile: image.dockerfile, dockerfile: image.dockerfile,
target: image.target, target: image.target,
}, })),
{ );
image: image.image,
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
dockerfile: image.dockerfile,
target: image.target,
},
]);
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: SCHEMA, schema: SCHEMA,
@@ -220,7 +237,18 @@ function createReleaseCandidateContract(options) {
), ),
nodeVersion: releaseIdentity.node.version, nodeVersion: releaseIdentity.node.version,
nodeEngine: releaseIdentity.node.engine, nodeEngine: releaseIdentity.node.engine,
platforms: ['linux/amd64', 'linux/arm64'], architectureSupport: {
tier1: [...releaseIdentity.architectureSupport.tier1],
candidates: [...releaseIdentity.architectureSupport.candidates],
experimentalBlocked: [
...releaseIdentity.architectureSupport.experimentalBlocked,
],
legacyOnly: [...releaseIdentity.architectureSupport.legacyOnly],
legacyLine: releaseIdentity.architectureSupport.legacyLine,
},
platforms: releaseIdentity.architectureSupport.tier1.map(
(architecture) => `linux/${architecture}`,
),
}, },
workspace: { workspace: {
packageCount: workspacePackages.length, packageCount: workspacePackages.length,
@@ -248,6 +276,7 @@ function createReleaseCandidateContract(options) {
requiredGates: [ requiredGates: [
'package-boundary', 'package-boundary',
'source-tag-version-identity', 'source-tag-version-identity',
'architecture-support-tier',
'native-os-vulnerability', 'native-os-vulnerability',
'multiarch-oci-layout', 'multiarch-oci-layout',
'production-dependency-audit', 'production-dependency-audit',
+10 -1
View File
@@ -284,11 +284,20 @@ function auditReleaseVersionContract(rootInput = DEFAULT_ROOT) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: identity.schemaVersion,
schema: RELEASE_IDENTITY_SCHEMA, schema: RELEASE_IDENTITY_SCHEMA,
version: identity.version, version: identity.version,
nodeVersion: identity.node.version, nodeVersion: identity.node.version,
nodeEngine: identity.node.engine, nodeEngine: identity.node.engine,
architectureSupport: Object.freeze({
tier1: Object.freeze([...identity.architectureSupport.tier1]),
candidates: Object.freeze([...identity.architectureSupport.candidates]),
experimentalBlocked: Object.freeze([
...identity.architectureSupport.experimentalBlocked,
]),
legacyOnly: Object.freeze([...identity.architectureSupport.legacyOnly]),
legacyLine: identity.architectureSupport.legacyLine,
}),
legacyRootPackageVersion: legacyRoot.version, legacyRootPackageVersion: legacyRoot.version,
legacyRootExcluded: true, legacyRootExcluded: true,
workspacePackageCount: workspacePaths.length, workspacePackageCount: workspacePaths.length,
+43 -1
View File
@@ -41,13 +41,28 @@ test('freezes an independent low-resource local release family', () => {
assert.equal(contract.workspace.packageCount, 18); assert.equal(contract.workspace.packageCount, 18);
assert.equal( assert.equal(
contract.compatibility.releaseIdentitySchema, contract.compatibility.releaseIdentitySchema,
'qinglong/release-identity@v1', 'qinglong/release-identity@v2',
); );
assert.deepEqual(contract.compatibility.architectureSupport, {
tier1: ['amd64', 'arm64'],
candidates: ['ppc64le', 's390x'],
experimentalBlocked: ['arm/v7'],
legacyOnly: ['arm/v6', '386'],
legacyLine: '2.x',
});
assert.deepEqual(contract.compatibility.platforms, [
'linux/amd64',
'linux/arm64',
]);
assert.match( assert.match(
contract.compatibility.releaseIdentityDigest, contract.compatibility.releaseIdentityDigest,
/^sha256:[a-f0-9]{64}$/u, /^sha256:[a-f0-9]{64}$/u,
); );
assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u); assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u);
assert.equal(
contract.requiredGates.includes('architecture-support-tier'),
true,
);
assert.equal( assert.equal(
contract.requiredGates.includes('offline-deployment-lock-materialization'), contract.requiredGates.includes('offline-deployment-lock-materialization'),
true, true,
@@ -81,6 +96,33 @@ test('closes the cluster release family with the Worker image', () => {
); );
assert.equal(contract.releasePlan.clusterEvidenceRequired, true); assert.equal(contract.releasePlan.clusterEvidenceRequired, true);
assert.equal(contract.releasePlan.osMatrix.length, 8); assert.equal(contract.releasePlan.osMatrix.length, 8);
assert.deepEqual(
[
...new Set(
contract.releasePlan.osMatrix.map((entry) => entry.image_arch),
),
],
['amd64', 'arm64'],
);
assert.deepEqual(
contract.releasePlan.osMatrix.slice(0, 2).map((entry) => ({
runner: entry.runner,
node_arch: entry.node_arch,
image_arch: entry.image_arch,
})),
[
{
runner: 'ubuntu-24.04',
node_arch: 'x64',
image_arch: 'amd64',
},
{
runner: 'ubuntu-24.04-arm',
node_arch: 'arm64',
image_arch: 'arm64',
},
],
);
assert.equal( assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'), contract.requiredGates.includes('edge-and-standalone-rollout'),
false, false,
+22 -2
View File
@@ -86,11 +86,18 @@ function replaceVersion(
test('audits one source-derived QingLong 3 release identity', () => { test('audits one source-derived QingLong 3 release identity', () => {
assert.deepEqual(auditReleaseVersionContract(root), { assert.deepEqual(auditReleaseVersionContract(root), {
schemaVersion: 1, schemaVersion: 2,
schema: 'qinglong/release-identity@v1', schema: 'qinglong/release-identity@v2',
version: SOURCE_VERSION, version: SOURCE_VERSION,
nodeVersion: '24.18.0', nodeVersion: '24.18.0',
nodeEngine: '>=24.18.0 <25', nodeEngine: '>=24.18.0 <25',
architectureSupport: {
tier1: ['amd64', 'arm64'],
candidates: ['ppc64le', 's390x'],
experimentalBlocked: ['arm/v7'],
legacyOnly: ['arm/v6', '386'],
legacyLine: '2.x',
},
legacyRootPackageVersion: LEGACY_VERSION, legacyRootPackageVersion: LEGACY_VERSION,
legacyRootExcluded: true, legacyRootExcluded: true,
workspacePackageCount: 18, workspacePackageCount: 18,
@@ -226,6 +233,19 @@ test('rejects invalid SemVer, downgrade, plan mutation and a symbolic-link ident
/identity shape or value is incompatible/, /identity shape or value is incompatible/,
); );
copyFile(root, fixture, 'ql3-release.json'); copyFile(root, fixture, 'ql3-release.json');
const architectureDrift = JSON.parse(
fs.readFileSync(invalidIdentityPath, 'utf8'),
);
architectureDrift.architectureSupport.tier1.push('ppc64le');
fs.writeFileSync(
invalidIdentityPath,
`${JSON.stringify(architectureDrift, null, 2)}\n`,
);
assert.throws(
() => auditReleaseVersionContract(fixture),
/identity shape or value is incompatible/,
);
copyFile(root, fixture, 'ql3-release.json');
fs.renameSync( fs.renameSync(
path.join(fixture, 'ql3-release.json'), path.join(fixture, 'ql3-release.json'),
path.join(fixture, 'identity-target.json'), path.join(fixture, 'identity-target.json'),