diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 341cfe98..b175b31c 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -591,6 +591,20 @@ jobs: run: | set -euo pipefail umask 077 + node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ + --mode=record-verification \ + --application-image="${IMAGE}" \ + --operator-image="${OPERATOR_IMAGE}" \ + --architecture=${{ matrix.image_arch }} \ + --source-revision=${{ github.sha }} \ + --repository=${{ github.repository }} \ + --workflow-ref="${{ github.workflow_ref }}" \ + --workflow-sha=${{ github.workflow_sha }} \ + --event=${{ github.event_name }} \ + --job=${{ github.job }} \ + --run-id=${{ github.run_id }} \ + --run-attempt=${{ github.run_attempt }} \ + --output="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json" node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ --mode=create \ --application-image="${IMAGE}" \ @@ -599,6 +613,7 @@ jobs: --source-revision=${{ github.sha }} \ --application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json" \ --operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json" \ + --verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json" \ --readme=docs/operations/ql3-local-alpha-trial-kit.md \ --output="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index cd855eac..03f8e86c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,12 +6,14 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-26 +- 最后更新:2026-08-27 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-26): +最新增量证据(2026-08-27): -- Alpha 阶段产物门(已有源码一致的 `3.0.0-alpha.2` 本地 runtime engineering candidate,用户试运行套件正在闭环):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。因尚未授权向 GitHub 上传源码派生镜像/SBOM,当前仍是 owner-private 的单架构工程产物,不是公开可下载 release;amd64 同级 archive、public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是升级为完整双架构里程碑或 Public Release Set 的硬门。 +- D-411/ADR-0506(已实现,真实 downloadable v2 artifact 待授权):Local Alpha materializer 不再凭调用 `create` 就把九个 gate 无条件写成 `passed`。bundle schema 升为 `qinglong/alpha-local-trial-kit@v2`,新增 `verification-evidence.json`,其 subject 精确绑定版本、source、Tier-1 架构与 Application/operator image ID,workflow 精确绑定 `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`、workflow SHA、`workflow_dispatch`、`local-image`、run ID/attempt。CI 静态门固定 `fresh journey → native cancellation → record-verification → create → audit → upload`,evidence 作为第七个闭合文件进入 manifest byte/SHA-256 与 `SHA256SUMS`;create/audit 均拒绝跨源码、跨架构、跨镜像或跨 workflow 复制。旧 v1 bundle 因没有来源证明只保留为工程候选。提交 `4239464a` 的主 CI run `32990652047` 已 40/40,Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。 + +- Alpha 阶段产物历史基线(当前性已由 D-411 收紧):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。该证据保留为演进记录,不再代表当前 v2 Alpha bundle 资格;public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是 Public Release Set 的硬门。 - D-408/ADR-0503(进行中):阶段产物成熟度现在按真实部署用户旅程而非“已有 Dockerfile/镜像”裁决。新增独立 `qinglong3-local-operator` 短生命周期镜像,复用既有 `@qinglong/local-owner-cli` 的统一 `ql3` 入口而不新增 workspace package;它默认 `65532:65532`、无端口、无 listener/daemon/timer、network none,和常驻 Local Application 保持物理制品分离,因此 Owner/bootstrap authority 不进入 runtime closure,Edge 稳态资源零变化。本机基于未提交工作树构建的 arm64 operator 原型 ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,运行库存为 9 package/904 files/9,479,647 bytes;read-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version` 与 `ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-.docker.tar`,manifest 同时绑定两个 image ID、共同 archive SHA-256、source/version/architecture,并从镜像入口完成 fresh setup exact replay、Identity provision、challenge、首 Owner claim/ack、Application active/SIGTERM drain 和 SQLite integrity。Docker Desktop bind mount 根目录会把宿主 UID 501 映射为容器 root、子文件仍为 501,不能等价满足完整 POSIX lineage;本机失败被记录为平台不等价,未放宽门禁或伪装通过。D-408 转 Accepted 仍需同一提交的原生 Linux x64/arm64 journey 成功和完整回归;实际上传双架构 trial kit 仍需维护者显式授权,Public Release Set 是否正式增加 operator artifact 另行决策。 diff --git a/docs/adr/ADR-0506-source-bound-local-alpha-verification-evidence.md b/docs/adr/ADR-0506-source-bound-local-alpha-verification-evidence.md new file mode 100644 index 00000000..565ab351 --- /dev/null +++ b/docs/adr/ADR-0506-source-bound-local-alpha-verification-evidence.md @@ -0,0 +1,68 @@ +# ADR-0506:源码绑定的 Local Alpha 验证证据 + +- 状态:Accepted +- 日期:2026-08-27 +- 决策:D-411 +- 关联:ADR-0503、ADR-0504、ADR-0505 + +## 背景 + +ADR-0504 把 Local Trial Kit 的写入与离线审计收敛为一个 materializer,但 v1 manifest 在 `create` 时会无条件写入九个 `passed` 字段。调用者只需提供可构建镜像和 SBOM,即可得到一份声称已经通过漏洞扫描、fresh Owner、Edge/Standalone lifecycle 与原生 cancellation 的 manifest;materializer 并未收到这些 gate 的任何证据。 + +提交 `4239464a` 的 macOS Docker Desktop 复验进一步暴露了该语义错误:bundle 内容、checksum、reload 和入口 smoke 均通过,但完整 Owner 旅程因 bind-mount UID 语义失败;同提交的原生 Linux CI 后续通过。内容完整性、源码实现通过和 exact artifact 已被验证是三种不同事实,不能由一个常量同时代替。 + +## 决策 + +### 1. Alpha bundle 必须消费独立的 verification evidence + +Local Alpha schema 升为 `qinglong/alpha-local-trial-kit@v2`。`create` 新增必需的 `--verification-evidence`,并把 `verification` 从九个常量改为 `verification-evidence.json` 的文件记录。闭合目录从六个文件增加为七个,evidence 同时进入 manifest byte/SHA-256 绑定与 `SHA256SUMS`。 + +缺少 evidence、旧 v1 manifest、额外文件、evidence mutation 或 subject 漂移全部失败关闭。旧 schema 尚未公开发布,因此不保留会继续接受无来源 `passed` 声明的兼容分支。 + +### 2. evidence 绑定 exact workflow 与 artifact subject + +新增同一 materializer 的 `record-verification` 模式,只接受: + +- repository `whyour/qinglong`; +- workflow ref `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`; +- `workflow_dispatch` event 与 `local-image` job; +- 完整 source/workflow SHA、十进制 run ID/attempt; +- Tier-1 architecture,以及从 Docker inspection 获得的 Application/operator exact image ID。 + +evidence 的 subject 同时绑定版本、源码、架构和两个不同镜像 ID;九个 gate 保持 exact closed set。bundle create 与 offline audit 都重新匹配 evidence subject 和 manifest,不允许把另一架构、另一镜像或另一 run 的 evidence 复制进来。 + +GitHub workflow 只能在 Trivy、inventory、SBOM、128 MiB entrypoint、fresh lifecycle、完整 Trial Kit journey 与原生 cancellation 均成功之后记录 evidence;静态审计固定 `journey → cancellation → record-verification → create → audit → upload` 顺序。 + +### 3. workflow provenance 不是公开发布签名 + +evidence 提供可定位、可交叉检查的 GitHub run identity,不声称调用者无法伪造 JSON。下载者仍需到 GitHub 检查该 run 的 source、attempt 和结论。公开 Release Set 继续使用独立的 immutable digest、Cosign 与 GitHub attestation,不以 Alpha evidence 替代供应链签名。 + +普通 push/PR 不生成或上传 Alpha artifact;本地构建、push CI 通过或 source-equivalent native gate 只能支持工程候选判断。只有显式 milestone dispatch 产生的 exact-image evidence 才允许 materializer 声称 Local Alpha Trial Kit。 + +## 被拒绝的替代方案 + +### 保留 manifest 内的固定 `passed` 字段 + +拒绝。它把“脚本知道有哪些 gate”错误提升为“这些 gate 已对当前 artifact 执行”。 + +### 仅加入 GitHub run URL,不绑定镜像 ID + +拒绝。同一源码可以因基础 package、构建平台或配置产生不同镜像;source-level run 不能自动证明另一个本地 archive 的 exact bytes。 + +### 在 Alpha 阶段实现第二套签名系统 + +拒绝。正式 release workflow 已承担签名和 attestation;Alpha 需要的是诚实、可定位的阶段证据,不应复制生产发布 authority。 + +## 影响 + +- 本地无 evidence 的 archive 不再冒充用户 Alpha,只能按工程候选处理; +- 手动 milestone artifact 多一个小型 JSON 文件,不增加 Docker archive layer、workspace package、设备常驻 RSS、端口或依赖; +- 下载者可从 bundle 确定 exact GitHub run/attempt,并验证 evidence 与 image ID/source/architecture 一致; +- 旧的本地 `2620be05` 与 `4239464a` v1 bundle 保留为历史工程证据,但不能通过 v2 auditor。 + +## 验证 + +- 聚焦测试覆盖 v2 七文件正常物化、非 milestone provenance、detached workflow、archive/SBOM/evidence mutation、额外文件和闭合 CLI grammar; +- Local operator workflow audit 要求 GitHub workflow identity contexts、evidence input和严格 gate 顺序; +- `record-verification` 和 `create` 都从 image inspection 绑定两个不同 image ID;offline audit 不调用 Docker或网络; +- 首个真实可下载 v2 双架构 Trial Kit 仍需维护者授权的 `produce_alpha_artifacts=true` workflow 生成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 8ec4d069..261ee210 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -509,6 +509,7 @@ | [ADR-0503](./ADR-0503-downloadable-local-alpha-trial-kit.md) | 可下载的 Local Alpha 试运行套件 | Proposed(原生 Linux 双架构已通过;实际可下载归档待维护者授权) | | [ADR-0504](./ADR-0504-canonical-local-alpha-trial-kit-materialization.md) | Local Alpha Trial Kit 单一物化与离线审计 | Accepted | | [ADR-0505](./ADR-0505-pinned-alpine-openssl-runtime-security-patch.md) | 固定 Alpine OpenSSL 运行时安全补丁 | Accepted | +| [ADR-0506](./ADR-0506-source-bound-local-alpha-verification-evidence.md) | 源码绑定的 Local Alpha 验证证据 | Accepted | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index dee342fb..a5c7efc5 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -13,28 +13,18 @@ 只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 -## 当前阶段实物(2026-08-26) +## 当前阶段实物(2026-08-27) -当前已经存在一份与 `3.0.0-alpha.2` 源码身份一致、owner-private、可重新加载的 Local arm64 runtime engineering candidate,而不是只有源码或 Dockerfile: +提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选: -- source revision:`e3c05862b8c2690d69f58b098cdc128a09c83f97`; -- image:`qinglong3-local-application:alpha2-e3c05862-arm64`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`; -- archive SHA-256:`01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`; -- 工作区目录 `.tmp/ql3-alpha-e3c05862b8c2690d69f58b098cdc128a09c83f97-local-arm64/` 包含 `manifest.json`、`verification-evidence.json`、release-candidate contract、CycloneDX 1.5 SBOM、`README.md` 与 `SHA256SUMS`;全套 checksum、`docker load` 后身份和资源约束 smoke 已复验; -- HIGH/CRITICAL OS vulnerability 为 0;128 MiB、0.5 CPU、read-only、no-network、drop-all 下的 Edge/Standalone fresh lifecycle、graceful stop 与 SQLite integrity 已通过;本机 Edge 首次运行曾在 Docker Desktop 文件桥上出现一次 startup receipt 发布瞬态,精确重跑通过,未将首次失败隐藏为成功; -- 原生 Linux arm64 Local image job `97986754052` 已覆盖 Docker Desktop 无法等价证明的 Local API cancellation。完整 CI run `32903679764` attempt 2 为 40/40,独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration run `32903679570` 同源通过。首轮 CI 的两项 `pnpm/action-setup` 内部 DNS 失败和一次 PostgreSQL 18 x64 scheduler 并发断言均在 failed-only rerun 收敛。 +- Application image ID `sha256:0d1d4b80ee46e9bb671d846f93d9a6d832c9856a91eed03f299055904da88a50`,operator image ID `sha256:b9122f481b1ba60d7eee9a3ed5ca57c9c141cbc389e7c7dbe19c6f6b1c98b49e`; +- 单一双镜像 archive 为 184,648,192 bytes,SHA-256 `145544c4a753192821bfbbb92000bb64af5978db57181595c9ffa9f404c1fd72`; +- checksum、旧 v1 离线内容审计、archive reload、实际 package inventory/SBOM 对账和 128 MiB 无网络只读入口 smoke 均通过; +- 同提交远端主 CI run `32990652047` 为 40/40,原生 Linux amd64/arm64 均通过 Application/operator Trivy、fresh Edge/Standalone、完整 Trial Kit journey 和 Local API cancellation;Kubernetes deployment run `32990652416` 与三节点 Security Administration run `32990653482` 同源通过。 -该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。公开下载仍需维护者明确授权上传。它只含 headless Application,没有可下载的 `ql3 setup/owner/task/...` 管理制品;因此它足以证明 runtime 工程可用性,但不能独立完成部署用户旅程。此前“单架构内部试运行材料”的表述按 D-408 收紧为“运行时工程候选”。 +该本地 archive 不是新的 v2 Local Alpha Trial Kit。它在 ADR-0506 前生成,manifest v1 会无条件写入 `passed`,且 macOS Docker Desktop 因 bind-mount UID 映射无法对 exact 本地 archive 完成 Owner pepper 旅程;原生 CI 证明同源码实现,不自动证明另一个 archive 的 exact image bytes。它因此保留为工程候选,不冒充已获 workflow evidence 的用户 Alpha。 -ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 `ql3` CLI,每次执行一个 command-file 命令后退出,不进入常驻 Application。提交 `2253b99066e0c221e11dc01384f496ec2a50e4bd` 的原生 Linux amd64/arm64 已同时通过 fresh setup、首 Owner ceremony、Application active/stop 和 SQLite integrity,CI run `32918632202` 为 40/40。ADR-0504 又把一次 `docker image save`、manifest、SBOM、README、`SHA256SUMS` 和离线审计收敛为同一个 materializer;下一项未完成的外部里程碑是维护者授权生成并保留两个可下载 archive。 - -提交 `2620be0587c29c2384e7f587c490dc11e357dfc8` 已通过该 materializer 生成新的本地私有 arm64 Trial Kit,位于 `.tmp/ql3-alpha-2620be0587c29c2384e7f587c490dc11e357dfc8-local-arm64/`: - -- 单一双镜像 archive 为 178,765,312 bytes,SHA-256 `7456202efb252e665d658664d69693cfbc170e02ec19923302d5c354bcaa140f`; -- Application image ID 为 `sha256:88c3027609c5f18a15111cb5820e34191d758ac6b4a41fc46d4a6bdf41fd71dd`,operator image ID 为 `sha256:529b86b85e18d6bd4ec8644d9da82d49ea45902534da27a99ee65ad4058e513b`; -- 两份 CycloneDX SBOM 均与镜像内实际 package inventory 对账为 `inventoryVerified=true`;闭合目录离线审计、`SHA256SUMS`、archive reload 及 128 MiB 无网络只读 entrypoint smoke 全部通过。 - -这证明当前提交已经存在可重复生成和离线复核的单架构完整 Trial Kit 实物,但它仍只在维护者工作区,不等于 amd64/arm64 两份可下载 GitHub artifact。 +ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verification-evidence.json`,绑定显式 `workflow_dispatch` 的 source、workflow SHA/ref、run/attempt、架构和两个 image ID。旧 `e3c05862` runtime-only archive、`2620be05` v1 Trial Kit 与 `4239464a` v1 archive 均为历史工程证据,不能通过 v2 auditor。下一项外部里程碑仍是维护者授权 `produce_alpha_artifacts=true`,由同一次原生 milestone job 生成 exact-image evidence 和双架构可下载 archive。 ## 生成 @@ -48,7 +38,8 @@ ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 ` Local artifact 含: - 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-.docker.tar`;共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v1` 的 `manifest.json`,通过 `archive/images/sboms/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID、文件长度/SHA-256 与已通过 gate; +- schema 为 `qinglong/alpha-local-trial-kit@v2` 的 `manifest.json`,通过 `archive/images/sboms/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名; - 与实际只读镜像 inventory 对账过的 CycloneDX SBOM; - 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。 diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index f9dc3c95..3f6c618b 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -1,6 +1,6 @@ # QingLong 3.0 Local Alpha Trial Kit -本目录是绑定一个 QingLong 3.0 源码提交和一个 Linux 架构的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 +本目录是绑定一个 QingLong 3.0 源码提交、一个 Linux 架构和一次显式 GitHub milestone run 的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 ## 适用范围 @@ -21,11 +21,13 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@v1`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v2`; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; - `maturity` 为 `alpha_candidate_not_public_release`。 +`manifest.json.verification` 必须指向同目录的 `verification-evidence.json`。该 evidence 的 subject 必须与 manifest 中的版本、源码、架构和两个 image ID 完全一致;workflow 必须是 `whyour/qinglong` 的 `ql3-ci.yml@refs/heads/next`、`workflow_dispatch`、`local-image`。使用 `workflow.runId` 和 `workflow.runAttempt` 打开对应 GitHub Actions run,确认 source 和结论;JSON provenance 是可交叉检查的阶段证据,不是 Cosign/GitHub attestation。 + 如果同时持有 QingLong 源码和 Node.js 24,可执行严格的闭合文件集、manifest、SBOM 和 checksum 审计: ```sh @@ -62,7 +64,7 @@ docker run --rm --read-only --network none --cap-drop ALL \ ## Fresh 试运行边界 -完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain 和 SQLite integrity 已在同一架构的原生 Linux CI 中验证。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 353008c7..8626a8c1 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,7 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-local-trial-kit@v1'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v2'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1'; const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const ARCHIVE_MIN_BYTES = 1024; const MAX_JSON_BYTES = 4 * 1024 * 1024; @@ -20,6 +21,7 @@ const REVISION_PATTERN = /^[0-9a-f]{40}$/u; const FILES = Object.freeze({ applicationSbom: 'qinglong3-local-application.cdx.json', operatorSbom: 'qinglong3-local-operator.cdx.json', + verificationEvidence: 'verification-evidence.json', readme: 'README.md', manifest: 'manifest.json', checksums: 'SHA256SUMS', @@ -35,6 +37,14 @@ const VERIFICATION = Object.freeze({ standaloneFreshLifecycle: 'passed', localApiCancellation: 'passed', }); +const WORKFLOW_IDENTITY = Object.freeze({ + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + event: 'workflow_dispatch', + job: 'local-image', +}); +const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u; +const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u; function fail(message) { throw new Error(message); @@ -230,6 +240,145 @@ function validateOfflineSbom(document, profile, version) { } } +function validateVerificationEvidence(document, expected) { + if ( + !exactKeys(document, [ + 'schemaVersion', + 'schema', + 'subject', + 'workflow', + 'gates', + ]) || + document.schemaVersion !== 1 || + document.schema !== VERIFICATION_SCHEMA || + !exactKeys(document.subject, [ + 'version', + 'sourceRevision', + 'architecture', + 'applicationImageId', + 'operatorImageId', + ]) || + document.subject.version !== expected.version || + document.subject.sourceRevision !== expected.sourceRevision || + document.subject.architecture !== expected.architecture || + document.subject.applicationImageId !== expected.applicationImageId || + document.subject.operatorImageId !== expected.operatorImageId || + document.subject.applicationImageId === document.subject.operatorImageId || + !exactKeys(document.workflow, [ + 'repository', + 'workflowRef', + 'workflowSha', + 'event', + 'job', + 'runId', + 'runAttempt', + ]) || + document.workflow.repository !== WORKFLOW_IDENTITY.repository || + document.workflow.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + document.workflow.workflowSha !== expected.sourceRevision || + document.workflow.event !== WORKFLOW_IDENTITY.event || + document.workflow.job !== WORKFLOW_IDENTITY.job || + !DECIMAL_ID_PATTERN.test(document.workflow.runId || '') || + !ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') || + !exactKeys(document.gates, Object.keys(VERIFICATION)) || + JSON.stringify(document.gates) !== JSON.stringify(VERIFICATION) + ) { + fail('trial kit verification evidence is incompatible'); + } + return document; +} + +function validateVerificationOptions(options) { + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const output = path.resolve(options.output || ''); + const parent = path.dirname(output); + if ( + !ARCHITECTURES.includes(options.architecture) || + !REVISION_PATTERN.test(options.sourceRevision || '') || + !path.isAbsolute(output) || + fs.existsSync(output) || + fs.realpathSync(parent) !== parent || + options.repository !== WORKFLOW_IDENTITY.repository || + options.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + options.workflowSha !== options.sourceRevision || + options.eventName !== WORKFLOW_IDENTITY.event || + options.job !== WORKFLOW_IDENTITY.job || + !DECIMAL_ID_PATTERN.test(options.runId || '') || + !ATTEMPT_PATTERN.test(options.runAttempt || '') + ) { + fail('verification evidence identity or output is invalid'); + } + return { + root, + output, + architecture: options.architecture, + sourceRevision: options.sourceRevision, + applicationImage: validateImageReference( + options.applicationImage, + 'application', + ), + operatorImage: validateImageReference(options.operatorImage, 'operator'), + repository: options.repository, + workflowRef: options.workflowRef, + workflowSha: options.workflowSha, + eventName: options.eventName, + job: options.job, + runId: options.runId, + runAttempt: options.runAttempt, + }; +} + +function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) { + const normalized = validateVerificationOptions(options); + const release = readReleaseIdentity(normalized.root); + const inspectImage = adapters.inspectImage || inspectDockerImage; + const application = normalizeImageInspection( + inspectImage(normalized.applicationImage), + { + architecture: normalized.architecture, + reference: normalized.applicationImage, + revision: normalized.sourceRevision, + role: 'application', + version: release.version, + }, + ); + const operator = normalizeImageInspection( + inspectImage(normalized.operatorImage), + { + architecture: normalized.architecture, + reference: normalized.operatorImage, + revision: normalized.sourceRevision, + role: 'operator', + version: release.version, + }, + ); + if (application.id === operator.id) fail('trial kit images must be distinct'); + const evidence = { + schemaVersion: 1, + schema: VERIFICATION_SCHEMA, + subject: { + version: release.version, + sourceRevision: normalized.sourceRevision, + architecture: normalized.architecture, + applicationImageId: application.id, + operatorImageId: operator.id, + }, + workflow: { + repository: normalized.repository, + workflowRef: normalized.workflowRef, + workflowSha: normalized.workflowSha, + event: normalized.eventName, + job: normalized.job, + runId: normalized.runId, + runAttempt: normalized.runAttempt, + }, + gates: { ...VERIFICATION }, + }; + validateVerificationEvidence(evidence, evidence.subject); + writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`); + return evidence; +} + function archiveName(architecture) { return `qinglong3-local-trial-kit-${architecture}.docker.tar`; } @@ -288,6 +437,11 @@ function validateCreateOptions(options) { MAX_JSON_BYTES, 'operator SBOM', ), + verificationEvidence: assertCanonicalFile( + options.verificationEvidence, + MAX_JSON_BYTES, + 'trial kit verification evidence', + ), readme: assertCanonicalFile( options.readme, MAX_README_BYTES, @@ -309,6 +463,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) { normalized.operatorSbom, 'operator SBOM', ); + const verificationEvidence = readBoundedJson( + normalized.verificationEvidence, + 'trial kit verification evidence', + ); validateSbom(applicationSbom, { root: normalized.root, profile: 'local', @@ -340,6 +498,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) { }, ); if (application.id === operator.id) fail('trial kit images must be distinct'); + validateVerificationEvidence(verificationEvidence, { + version: release.version, + sourceRevision: normalized.sourceRevision, + architecture: normalized.architecture, + applicationImageId: application.id, + operatorImageId: operator.id, + }); let created = false; try { @@ -368,12 +533,16 @@ function createLocalAlphaTrialKit(options, adapters = {}) { normalized.operatorSbom, path.join(normalized.outputRoot, FILES.operatorSbom), ); + copyExclusive( + normalized.verificationEvidence, + path.join(normalized.outputRoot, FILES.verificationEvidence), + ); copyExclusive( normalized.readme, path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 2, + schemaVersion: 3, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -387,7 +556,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) { operator: fileRecord(normalized.outputRoot, FILES.operatorSbom), }, readme: fileRecord(normalized.outputRoot, FILES.readme), - verification: { ...VERIFICATION }, + verification: fileRecord( + normalized.outputRoot, + FILES.verificationEvidence, + ), }; writeExclusive( path.join(normalized.outputRoot, FILES.manifest), @@ -397,6 +569,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { archive, FILES.applicationSbom, FILES.operatorSbom, + FILES.verificationEvidence, FILES.readme, FILES.manifest, ]; @@ -467,7 +640,7 @@ function auditLocalAlphaTrialKit(options) { 'readme', 'verification', ]) || - manifest.schemaVersion !== 2 || + manifest.schemaVersion !== 3 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -475,9 +648,7 @@ function auditLocalAlphaTrialKit(options) { !REVISION_PATTERN.test(manifest.sourceRevision || '') || !ARCHITECTURES.includes(manifest.architecture) || !exactKeys(manifest.images, ['application', 'operator']) || - !exactKeys(manifest.sboms, ['application', 'operator']) || - !exactKeys(manifest.verification, Object.keys(VERIFICATION)) || - JSON.stringify(manifest.verification) !== JSON.stringify(VERIFICATION) + !exactKeys(manifest.sboms, ['application', 'operator']) ) { fail('trial kit manifest identity or shape is incompatible'); } @@ -497,6 +668,11 @@ function auditLocalAlphaTrialKit(options) { bundleRoot, ); validateFileRecord(manifest.sboms.operator, FILES.operatorSbom, bundleRoot); + validateFileRecord( + manifest.verification, + FILES.verificationEvidence, + bundleRoot, + ); validateFileRecord(manifest.readme, FILES.readme, bundleRoot); validateOfflineSbom( readBoundedJson( @@ -511,12 +687,26 @@ function auditLocalAlphaTrialKit(options) { 'local-operator', manifest.version, ); + const verificationEvidence = validateVerificationEvidence( + readBoundedJson( + path.join(bundleRoot, FILES.verificationEvidence), + 'trial kit verification evidence', + ), + { + version: manifest.version, + sourceRevision: manifest.sourceRevision, + architecture: manifest.architecture, + applicationImageId: manifest.images.application.id, + operatorImageId: manifest.images.operator.id, + }, + ); const expectedFiles = [ FILES.checksums, FILES.manifest, FILES.readme, FILES.applicationSbom, FILES.operatorSbom, + FILES.verificationEvidence, expectedArchive, ].sort(); const actualFiles = fs @@ -535,6 +725,7 @@ function auditLocalAlphaTrialKit(options) { expectedArchive, FILES.applicationSbom, FILES.operatorSbom, + FILES.verificationEvidence, FILES.readme, FILES.manifest, ]; @@ -555,6 +746,9 @@ function auditLocalAlphaTrialKit(options) { archiveSha256: manifest.archive.sha256, applicationImageId: manifest.images.application.id, operatorImageId: manifest.images.operator.id, + verificationSha256: manifest.verification.sha256, + workflowRunId: verificationEvidence.workflow.runId, + workflowRunAttempt: verificationEvidence.workflow.runAttempt, compatible: true, }); } @@ -576,6 +770,43 @@ function parseArguments(argv) { } return { mode: 'audit', bundleRoot: path.resolve(values.bundle) }; } + if (values.mode === 'record-verification') { + const expected = [ + 'application-image', + 'architecture', + 'event', + 'job', + 'mode', + 'operator-image', + 'output', + 'repository', + 'run-attempt', + 'run-id', + 'source-revision', + 'workflow-ref', + 'workflow-sha', + ]; + if ( + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('record-verification arguments are invalid'); + } + return { + mode: 'record-verification', + output: path.resolve(values.output), + architecture: values.architecture, + sourceRevision: values['source-revision'], + applicationImage: values['application-image'], + operatorImage: values['operator-image'], + repository: values.repository, + workflowRef: values['workflow-ref'], + workflowSha: values['workflow-sha'], + eventName: values.event, + job: values.job, + runId: values['run-id'], + runAttempt: values['run-attempt'], + }; + } if (values.mode === 'create') { const expected = [ 'application-image', @@ -587,6 +818,7 @@ function parseArguments(argv) { 'output', 'readme', 'source-revision', + 'verification-evidence', ]; if ( JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) @@ -602,6 +834,7 @@ function parseArguments(argv) { operatorImage: values['operator-image'], applicationSbom: path.resolve(values['application-sbom']), operatorSbom: path.resolve(values['operator-sbom']), + verificationEvidence: path.resolve(values['verification-evidence']), readme: path.resolve(values.readme), }; } @@ -610,10 +843,14 @@ function parseArguments(argv) { function runCli(argv) { const options = parseArguments(argv); - const report = - options.mode === 'create' - ? createLocalAlphaTrialKit(options) - : auditLocalAlphaTrialKit(options); + let report; + if (options.mode === 'record-verification') { + report = createLocalAlphaTrialKitVerificationEvidence(options); + } else if (options.mode === 'create') { + report = createLocalAlphaTrialKit(options); + } else { + report = auditLocalAlphaTrialKit(options); + } process.stdout.write(`${JSON.stringify(report)}\n`); return report; } @@ -633,9 +870,11 @@ module.exports = Object.freeze({ FILES, SCHEMA, VERIFICATION, + VERIFICATION_SCHEMA, archiveName, auditLocalAlphaTrialKit, createLocalAlphaTrialKit, + createLocalAlphaTrialKitVerificationEvidence, parseArguments, runCli, sha256File, diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 70d70c2c..4393e9c0 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -196,16 +196,41 @@ function auditWorkflow(contents, findings) { '"${OPERATOR_IMAGE}" --version', 'scripts/ql3-local-alpha-trial-kit-live-contract.cjs', 'scripts/ql3-local-alpha-trial-kit-bundle.cjs', + '--mode=record-verification', '--mode=create', '--mode=audit', '--application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', + '--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"', '--readme=docs/operations/ql3-local-alpha-trial-kit.md', + '--repository=${{ github.repository }}', + '--workflow-ref="${{ github.workflow_ref }}"', + '--workflow-sha=${{ github.workflow_sha }}', + '--event=${{ github.event_name }}', + '--job=${{ github.job }}', + '--run-id=${{ github.run_id }}', + '--run-attempt=${{ github.run_attempt }}', ]; for (const value of required) { if (!contents.includes(value)) finding(findings, 'LOCAL_OPERATOR_CI_CONTRACT_DRIFT', value); } + let cursor = -1; + for (const value of [ + 'name: Run the downloadable Local Alpha trial kit journey', + 'name: Run authenticated Local API cancellation through real Linux processes', + '--mode=record-verification', + '--mode=create', + '--mode=audit', + 'name: Upload the tested native Local Alpha trial kit', + ]) { + const index = contents.indexOf(value, cursor + 1); + if (index <= cursor) { + finding(findings, 'LOCAL_OPERATOR_CI_GATE_ORDER_DRIFT', value); + break; + } + cursor = index; + } } function auditLocalOperatorImageContract(root) { diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index e3d943f9..a07fe4e0 100644 --- a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -8,6 +8,7 @@ const test = require('node:test'); const { auditLocalAlphaTrialKit, createLocalAlphaTrialKit, + createLocalAlphaTrialKitVerificationEvidence, parseArguments, } = require('../../scripts/ql3-local-alpha-trial-kit-bundle.cjs'); const { @@ -61,6 +62,10 @@ function fixture(t) { t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); const applicationSbom = path.join(fixtureRoot, 'application.json'); const operatorSbom = path.join(fixtureRoot, 'operator.json'); + const verificationEvidence = path.join( + fixtureRoot, + 'verification-evidence-source.json', + ); const readme = path.join(fixtureRoot, 'README-source.md'); fs.writeFileSync( applicationSbom, @@ -73,13 +78,38 @@ function fixture(t) { )}\n`, ); fs.writeFileSync(readme, '# Local Alpha Trial Kit\n'); - return { + const paths = { fixtureRoot, applicationSbom, operatorSbom, + verificationEvidence, readme, outputRoot: path.join(fixtureRoot, 'bundle'), }; + createLocalAlphaTrialKitVerificationEvidence( + verificationOptions(paths), + adapters(), + ); + return paths; +} + +function verificationOptions(paths, overrides = {}) { + return { + root, + output: paths.verificationEvidence, + architecture: 'arm64', + sourceRevision: revision, + applicationImage: 'qinglong3-local-application:test-arm64', + operatorImage: 'qinglong3-local-operator:test-arm64', + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + workflowSha: revision, + eventName: 'workflow_dispatch', + job: 'local-image', + runId: '32990652047', + runAttempt: '1', + ...overrides, + }; } function createOptions(paths) { @@ -92,6 +122,7 @@ function createOptions(paths) { operatorImage: 'qinglong3-local-operator:test-arm64', applicationSbom: paths.applicationSbom, operatorSbom: paths.operatorSbom, + verificationEvidence: paths.verificationEvidence, readme: paths.readme, }; } @@ -117,15 +148,17 @@ function adapters(overrides = {}) { test('materializes and offline-audits one closed two-image trial kit', (t) => { const paths = fixture(t); const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); - assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v1'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v2'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); assert.equal(manifest.images.application.architecture, 'arm64'); assert.equal(manifest.images.operator.architecture, 'arm64'); assert.notEqual(manifest.images.application.id, manifest.images.operator.id); + assert.equal(manifest.verification.file, 'verification-evidence.json'); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.sourceRevision, revision); + assert.equal(report.workflowRunId, '32990652047'); assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ 'README.md', 'SHA256SUMS', @@ -133,6 +166,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { 'qinglong3-local-application.cdx.json', 'qinglong3-local-operator.cdx.json', 'qinglong3-local-trial-kit-arm64.docker.tar', + 'verification-evidence.json', ]); }); @@ -159,8 +193,8 @@ test('fails closed and removes a partial output on incompatible image identity', assert.equal(fs.existsSync(paths.outputRoot), false); }); -test('offline audit rejects archive mutation, extra files and SBOM substitution', (t) => { - for (const mutation of ['archive', 'extra', 'sbom']) { +test('offline audit rejects archive, file-set, SBOM and verification mutation', (t) => { + for (const mutation of ['archive', 'extra', 'sbom', 'verification']) { const paths = fixture(t); paths.outputRoot = path.join(paths.fixtureRoot, `bundle-${mutation}`); createLocalAlphaTrialKit(createOptions(paths), adapters()); @@ -174,11 +208,16 @@ test('offline audit rejects archive mutation, extra files and SBOM substitution' ); } else if (mutation === 'extra') { fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); - } else { + } else if (mutation === 'sbom') { fs.copyFileSync( path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), path.join(paths.outputRoot, 'qinglong3-local-operator.cdx.json'), ); + } else { + fs.appendFileSync( + path.join(paths.outputRoot, 'verification-evidence.json'), + 'tamper', + ); } assert.throws( () => auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }), @@ -188,6 +227,34 @@ test('offline audit rejects archive mutation, extra files and SBOM substitution' } }); +test('create rejects verification detached from the reviewed workflow', (t) => { + const paths = fixture(t); + const evidence = JSON.parse( + fs.readFileSync(paths.verificationEvidence, 'utf8'), + ); + evidence.workflow.job = 'unreviewed-job'; + fs.writeFileSync(paths.verificationEvidence, `${JSON.stringify(evidence)}\n`); + assert.throws( + () => createLocalAlphaTrialKit(createOptions(paths), adapters()), + /verification evidence is incompatible/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('verification recorder rejects non-milestone workflow provenance', (t) => { + const paths = fixture(t); + const output = path.join(paths.fixtureRoot, 'unreviewed-verification.json'); + assert.throws( + () => + createLocalAlphaTrialKitVerificationEvidence( + verificationOptions(paths, { output, eventName: 'push' }), + adapters(), + ), + /verification evidence identity or output is invalid/, + ); + assert.equal(fs.existsSync(output), false); +}); + test('CLI grammar is exact and separates create from offline audit', () => { assert.deepEqual( parseArguments(['--mode=audit', '--bundle=/tmp/ql3-bundle']), @@ -206,4 +273,21 @@ test('CLI grammar is exact and separates create from offline audit', () => { () => parseArguments(['--mode=create', '--output=/tmp/output']), /create arguments are invalid/, ); + const recorded = parseArguments([ + '--mode=record-verification', + '--application-image=qinglong3-local-application:test-arm64', + '--operator-image=qinglong3-local-operator:test-arm64', + '--architecture=arm64', + `--source-revision=${revision}`, + '--repository=whyour/qinglong', + '--workflow-ref=whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + `--workflow-sha=${revision}`, + '--event=workflow_dispatch', + '--job=local-image', + '--run-id=32990652047', + '--run-attempt=1', + '--output=/tmp/verification-evidence.json', + ]); + assert.equal(recorded.mode, 'record-verification'); + assert.equal(recorded.runId, '32990652047'); }); diff --git a/test/back/ql3LocalOperatorImageAudit.test.cjs b/test/back/ql3LocalOperatorImageAudit.test.cjs index 1e238daf..15ec2b82 100644 --- a/test/back/ql3LocalOperatorImageAudit.test.cjs +++ b/test/back/ql3LocalOperatorImageAudit.test.cjs @@ -124,3 +124,44 @@ test('rejects removal of the fresh Owner journey or two-image manifest', () => { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); + +test('rejects Alpha evidence recorded before all native gates', () => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-local-alpha-evidence-order-'), + ); + try { + fs.mkdirSync(path.join(temporaryRoot, 'deploy/containers'), { + recursive: true, + }); + fs.cpSync( + path.join(root, 'deploy/containers/ql3-local-operator'), + path.join(temporaryRoot, 'deploy/containers/ql3-local-operator'), + { recursive: true }, + ); + fs.mkdirSync(path.join(temporaryRoot, '.github/workflows'), { + recursive: true, + }); + const workflow = fs + .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + .replace('--mode=record-verification', '--mode=temporary-marker') + .replace('--mode=audit', '--mode=record-verification') + .replace('--mode=temporary-marker', '--mode=audit'); + fs.writeFileSync( + path.join(temporaryRoot, '.github/workflows/ql3-ci.yml'), + workflow, + ); + fs.copyFileSync( + path.join(root, 'ql3-release.json'), + path.join(temporaryRoot, 'ql3-release.json'), + ); + const report = auditLocalOperatorImageContract(temporaryRoot); + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'LOCAL_OPERATOR_CI_GATE_ORDER_DRIFT', + ), + ); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +});