mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-28 18:41:20 -08:00
feat(ci): [CI-NPM-02] verify downloaded bundles against fresh release evidence
This commit is contained in:
1 parent
2983cb7c51
commit
6f7fd7931d
12 files changed
+560
-19
No files matched your search
+3
-2
@@ -178,9 +178,10 @@
|
|||||||
"verify:monaco-unicode": "node scripts/site-vendor/monaco/reproduce-unicode.ts",
|
"verify:monaco-unicode": "node scripts/site-vendor/monaco/reproduce-unicode.ts",
|
||||||
"release:bundle": "yarn check:toolchain --strict && node scripts/prepare-release.mjs",
|
"release:bundle": "yarn check:toolchain --strict && node scripts/prepare-release.mjs",
|
||||||
"typecheck:release": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.release.json --noEmit",
|
"typecheck:release": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.release.json --noEmit",
|
||||||
"test:release-bundle": "node --test refactor/scripts/release-bundle.test.mjs",
|
"test:release-bundle": "node --test refactor/scripts/release-bundle.test.mjs refactor/scripts/release-verify.test.mjs",
|
||||||
"test:ecosystem-types": "node scripts/consumers/ecosystem.ts",
|
"test:ecosystem-types": "node scripts/consumers/ecosystem.ts",
|
||||||
"check:versions": "node refactor/scripts/version-plan.mjs --prepared"
|
"check:versions": "node refactor/scripts/version-plan.mjs --prepared",
|
||||||
|
"release:verify-bundle": "yarn check:toolchain --strict && node scripts/verify-release.mjs"
|
||||||
},
|
},
|
||||||
"browserslist": "last 1 Chrome version",
|
"browserslist": "last 1 Chrome version",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"task": "CI-NPM-02",
|
||||||
|
"verifiedAt": "2026-09-15T16:59:12.794Z",
|
||||||
|
"startedFrom": "2983cb7c51f65e6cf1bdd8c8c3387c75e0c3287c",
|
||||||
|
"node": "v24.21.0",
|
||||||
|
"yarn": "1.22.22",
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "yarn test:release-bundle",
|
||||||
|
"exitCode": 0,
|
||||||
|
"tests": 36,
|
||||||
|
"pass": 36,
|
||||||
|
"fail": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"durationMs": 2158.2169,
|
||||||
|
"log": {
|
||||||
|
"path": "refactor/.cache/ci-npm02-tests-final.log",
|
||||||
|
"sha256": "0f4f27201e3f03cf6d20add909e901024209e8b04ded143780a17b05bf533b6a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.release.json --noEmit",
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "node node_modules/eslint/bin/eslint.js scripts/release/bundle.ts scripts/release/verify.ts scripts/verify-release.mjs refactor/scripts/release-verify.test.mjs",
|
||||||
|
"exitCode": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentRepositoryRejection": {
|
||||||
|
"directory": "D:\\github\\ArtPlayer\\refactor\\.cache\\npm02-blocked-fmr1O8",
|
||||||
|
"expected": {
|
||||||
|
"names": [
|
||||||
|
"artplayer"
|
||||||
|
],
|
||||||
|
"tag": "next",
|
||||||
|
"sourceCommit": "2983cb7c51f65e6cf1bdd8c8c3387c75e0c3287c",
|
||||||
|
"manifestSha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||||
|
},
|
||||||
|
"syntheticManifest": true,
|
||||||
|
"inspector": "default buildLedger, actual current repository",
|
||||||
|
"rejected": true,
|
||||||
|
"message": "Release preflight is blocked; candidate bundle was not prepared",
|
||||||
|
"record": {
|
||||||
|
"path": "refactor/.cache/ci-npm02-repository-rejection.json",
|
||||||
|
"sha256": "fd7a0058dfd08c3240c5b4ca757bf388a890033a7c049432c497944aeebe0b5e"
|
||||||
|
},
|
||||||
|
"cleanSourceCliUsed": false,
|
||||||
|
"explanation": "The component default inspector computed the actual current repository ledger; a synthetic empty manifest with its digest did not override blocked gates. This is a rejection check, not real bundle acceptance."
|
||||||
|
},
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "scripts/release/bundle.ts",
|
||||||
|
"sha256": "30c1f2334ed88a60e2b340b15a1a74423b08fb278425088d32f809715903c70d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/release/verify.ts",
|
||||||
|
"sha256": "27ac68bde4fb8b364bc23247ba9af1a2234b0db6ec594d6a66dda44b84b94e9e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/verify-release.mjs",
|
||||||
|
"sha256": "215d684511e598da8703277471d871c756ddae5fe3df23670e2e8495f661df58"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "refactor/scripts/release-verify.test.mjs",
|
||||||
|
"sha256": "ef83c92c9d768cf04b3d8da3726166a1477fcee4b5b20461f4fe2325e14ba724"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limits": {
|
||||||
|
"successfulFixturesAreSynthetic": true,
|
||||||
|
"realCandidateAccepted": false,
|
||||||
|
"workflowProvenanceVerified": false,
|
||||||
|
"registryOccupancyVerified": false,
|
||||||
|
"remoteWorkflowRun": false,
|
||||||
|
"publicationAuthorized": false,
|
||||||
|
"formalReviewStarted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CI-NPM-02: verify downloaded candidate contents
|
||||||
|
|
||||||
|
The previous bundle preparation step copied exact registered tarballs, but had
|
||||||
|
no consumer-side validator for a downloaded copy. CI-NPM-02 is an independent
|
||||||
|
local prerequisite of CI-03, alongside CI-NPM-01. The original workflow and
|
||||||
|
remote-validation requirements remain; no unfinished dependency was removed.
|
||||||
|
|
||||||
|
`scripts/release/verify.ts` now requires independently supplied source commit,
|
||||||
|
manifest digest, package batch and tag. It validates the manifest digest before
|
||||||
|
reading its claims, computes the repository release ledger, and rejects blocked
|
||||||
|
or stale candidate evidence. The downloaded preflight must exactly equal the
|
||||||
|
fresh report. Downloaded tarballs must equal registered candidate bytes and
|
||||||
|
match their SHA-512 integrity. Metadata is compared with the shared manifest
|
||||||
|
description generated from the fresh report, including versions, source/input
|
||||||
|
fingerprints, registry, tag and toolchain. All files are checked again after a
|
||||||
|
second ledger read to detect changes during verification.
|
||||||
|
|
||||||
|
The file roster must contain exactly the manifest, preflight and selected
|
||||||
|
tarballs. Nested paths, symlinks/junctions, redirected directories/parents,
|
||||||
|
non-files, missing files and additional files are rejected. Verification only
|
||||||
|
reads files and retains failed downloads for diagnosis. The CLI enforces the
|
||||||
|
canonical Node/Yarn and clean source guard on each ledger read. Its test seam
|
||||||
|
cannot be supplied through a CLI argument. No dependencies were added.
|
||||||
|
|
||||||
|
Preparation and verification share batch validation and manifest construction
|
||||||
|
in `bundle.ts`. This preserves the existing bundle format, including the
|
||||||
|
publication-authority limitation, while avoiding two separately maintained
|
||||||
|
definitions. `yarn release:verify-bundle` exposes the operation; the existing
|
||||||
|
release test command now includes its tests, and `test:baseline` automatically
|
||||||
|
includes the new file. The release TypeScript project already covers the new
|
||||||
|
module. Maintenance instructions are in `scripts/release/README.md`.
|
||||||
|
|
||||||
|
The tests use real local tarballs with explicitly synthetic ready reports. They
|
||||||
|
cover successful read-only validation and rejection of independent-digest
|
||||||
|
changes, 17 self-consistent manifest modifications, stale/current blockers,
|
||||||
|
changed preflight, changed registered/downloaded bytes, missing/extra files,
|
||||||
|
redirected paths, invalid independent inputs and mutation during the second
|
||||||
|
inspection. The original preparation and cleanup tests remain in the same run.
|
||||||
|
These tests do not establish that any real ArtPlayer package is publishable.
|
||||||
|
|
||||||
|
Actual command results and the current-repository rejection are recorded in
|
||||||
|
[the validation record](../baselines/npm-bundle-verification.json).
|
||||||
|
On canonical Node 24.21.0/Yarn 1.22.22 the final release tests passed 36/36
|
||||||
|
(zero failures/skips, 2,158.2169 ms); strict release TypeScript and targeted lint
|
||||||
|
passed. A direct call with the default inspector read the actual repository
|
||||||
|
ledger and correctly rejected blocked preflight. That call used a synthetic
|
||||||
|
empty manifest/digest and bypassed only the CLI's clean-worktree wrapper to test
|
||||||
|
the component during implementation; it did not inject a replacement ledger or
|
||||||
|
accept a real candidate. CLI tests separately verify required arguments, unknown
|
||||||
|
flags and the Yarn boundary, while the existing tests cover the clean Git guard.
|
||||||
|
|
||||||
|
This is local implementation only. CI-03 must still establish trusted remote
|
||||||
|
repository/workflow/run/attempt/artifact identity and pass independent expected
|
||||||
|
values into the verifier, check registry occupancy, and implement publication
|
||||||
|
and partial-failure recovery. CI-04 must exercise the real remote workflow.
|
||||||
|
Different environments or missing restored evidence may fail exact preflight
|
||||||
|
comparison; that failure requires diagnosis, never forged reports or replacement
|
||||||
|
builds. A verification result is not authorization or a durable guarantee for
|
||||||
|
files changed afterward. No push, deployment, npm publication or formal review
|
||||||
|
round was performed.
|
||||||
@@ -60,6 +60,12 @@ CI-NPM-01 从 CI-03 拆出本地精确候选交付准备,供后续 artifact
|
|||||||
验收全部保留。当前包仍被准入门槛阻止,详见[记录](changes/2026-09-15-CI-NPM-01-bundle.md)
|
验收全部保留。当前包仍被准入门槛阻止,详见[记录](changes/2026-09-15-CI-NPM-01-bundle.md)
|
||||||
及[实现维护](../scripts/release/README.md)。
|
及[实现维护](../scripts/release/README.md)。
|
||||||
|
|
||||||
|
CI-NPM-02补齐下载后的内容校验:`yarn release:verify-bundle`要求独立的源码SHA、
|
||||||
|
manifest摘要和明确包批次/tag,重算当前仓库台账并比对已登记tarball,不能由
|
||||||
|
下载报告自证准入。它不读取GitHub/npm,不证明workflow/run/artifact来源或版本
|
||||||
|
占用,也不授权发布。CI-03仍须建立可信来源传递、下载与发布步骤;不能以此
|
||||||
|
本地子任务代替CI-01、CI-03或CI-04的完整验收。
|
||||||
|
|
||||||
| 任务 | 交付 |
|
| 任务 | 交付 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ENG-02 | 只读脚本、PR/主线检查及部署隔离基础 |
|
| ENG-02 | 只读脚本、PR/主线检查及部署隔离基础 |
|
||||||
|
|||||||
+5
-3
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
|
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
|
||||||
|
|
||||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 283 项,范围 22 个包及工作区/示例。
|
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 284 项,范围 22 个包及工作区/示例。
|
||||||
|
|
||||||
状态:todo 41 / doing 20 / blocked 0 / done 222 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
状态:todo 41 / doing 20 / blocked 0 / done 223 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||||
|
|
||||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||||
|
|
||||||
@@ -96,13 +96,14 @@
|
|||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| CI-01 | workspace<br>增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | doing |
|
| CI-01 | workspace<br>增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | doing |
|
||||||
| CI-02 | workspace<br>分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | done |
|
| CI-02 | workspace<br>分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | done |
|
||||||
| CI-03 | workspace<br>建立 npm 分包候选与发布工作流 | DOC-10, CI-01, REL-08, REL-04, CI-NPM-01 | 候选准备、精确 artifact 发布配置、OIDC 评估、版本/tag/registry 预检和部分失败恢复 | 不自动发布;明确逐包信任前置和 dry run 限制,不能重建未验证内容或重发冲突版本;按版本清单校验各包下一 major 和预发布/正式 tag,保留旧核心支持范围 | H | todo |
|
| CI-03 | workspace<br>建立 npm 分包候选与发布工作流 | DOC-10, CI-01, REL-08, REL-04, CI-NPM-01, CI-NPM-02 | 候选准备、精确 artifact 发布配置、OIDC 评估、版本/tag/registry 预检和部分失败恢复 | 不自动发布;明确逐包信任前置和 dry run 限制,不能重建未验证内容或重发冲突版本;按版本清单校验各包下一 major 和预发布/正式 tag,保留旧核心支持范围 | H | todo |
|
||||||
| CI-04 | workspace<br>验收 GitHub 流水线与远端发布准入 | CI-01, CI-02, CI-03, SITE-06, CI-BROWSER-01, CI-TYPES-01, CI-TYPES-02, SITE-EDITOR-VAST-01, CI-JASSUB-SOURCE-01 | 静态/干净环境检查、真实 PR 正反例、候选 dry run、required checks/Pages/npm 必需配置状态及运维指南 | 必要 Actions 证据和远端配置核对齐全;缺失保持未完成,真实 publish/deploy 仍在授权发布步骤执行 | H | todo |
|
| CI-04 | workspace<br>验收 GitHub 流水线与远端发布准入 | CI-01, CI-02, CI-03, SITE-06, CI-BROWSER-01, CI-TYPES-01, CI-TYPES-02, SITE-EDITOR-VAST-01, CI-JASSUB-SOURCE-01 | 静态/干净环境检查、真实 PR 正反例、候选 dry run、required checks/Pages/npm 必需配置状态及运维指南 | 必要 Actions 证据和远端配置核对齐全;缺失保持未完成,真实 publish/deploy 仍在授权发布步骤执行 | H | todo |
|
||||||
| CI-BROWSER-01 | workspace<br>分离源码与已安装产物浏览器验证范围 | ENG-05, ENG-07 | 完整源码入口、明确已安装包子集、分开的报告目录与失败传播 | 混用输入旧红新绿;源码默认保留所有spec,已安装入口严格校验四包来源;两类报告都保留,源码失败不能误报全绿;不代表完整远端或全包验收 | M | done |
|
| CI-BROWSER-01 | workspace<br>分离源码与已安装产物浏览器验证范围 | ENG-05, ENG-07 | 完整源码入口、明确已安装包子集、分开的报告目录与失败传播 | 混用输入旧红新绿;源码默认保留所有spec,已安装入口严格校验四包来源;两类报告都保留,源码失败不能误报全绿;不代表完整远端或全包验收 | M | done |
|
||||||
| CI-NPM-01 | workspace<br>从已验收候选准备不可重建的npm交付包 | DOC-10, REL-08, REL-04 | 复用严格准入台账、复制精确tarball、绑定源码/工具链/证据/摘要的本地准备命令及反向测试;供CI-03后续受信任artifact工作流使用 | 缺候选或任一准入缺口即拒绝;不构建、不安装、不联网或发布;阻止路径越界、脏源码、复制期间漂移及半成品冒充完成,声明远端信任/OIDC/registry预检仍未实现 | H | done |
|
| CI-NPM-01 | workspace<br>从已验收候选准备不可重建的npm交付包 | DOC-10, REL-08, REL-04 | 复用严格准入台账、复制精确tarball、绑定源码/工具链/证据/摘要的本地准备命令及反向测试;供CI-03后续受信任artifact工作流使用 | 缺候选或任一准入缺口即拒绝;不构建、不安装、不联网或发布;阻止路径越界、脏源码、复制期间漂移及半成品冒充完成,声明远端信任/OIDC/registry预检仍未实现 | H | done |
|
||||||
| CI-TYPES-01 | workspace, artplayer-plugin-audio-track, artplayer-plugin-hls-control<br>接入 Audio/HLS 安装后严格类型消费者 | ENG-07, PKG-AUDIO-04, PKG-HLS-04 | 共享隔离编译器模块、真实 tarball 的五模式正反例和声明路径证据 | 旧/新编译器所有正例通过,每条无效调用实际报错;仓库外声明解析、缺声明/any/逃逸负例有效;保留独立运行时和浏览器验收 | M | done |
|
| CI-TYPES-01 | workspace, artplayer-plugin-audio-track, artplayer-plugin-hls-control<br>接入 Audio/HLS 安装后严格类型消费者 | ENG-07, PKG-AUDIO-04, PKG-HLS-04 | 共享隔离编译器模块、真实 tarball 的五模式正反例和声明路径证据 | 旧/新编译器所有正例通过,每条无效调用实际报错;仓库外声明解析、缺声明/any/逃逸负例有效;保留独立运行时和浏览器验收 | M | done |
|
||||||
| CI-TYPES-02 | workspace<br>将全部库包的独立安装类型验证接入CI | CI-TYPES-01, ENG-07 | 21库包完整检查清单、实际构建和隔离安装测试调度、失败证据汇总及必需CI门槛 | 不得漏包或用通用导入替代历史类型契约;准备失败不消费旧产物;记录每包实际结果并传播失败,保留运行时/设备/未决兼容门槛 | M | done |
|
| CI-TYPES-02 | workspace<br>将全部库包的独立安装类型验证接入CI | CI-TYPES-01, ENG-07 | 21库包完整检查清单、实际构建和隔离安装测试调度、失败证据汇总及必需CI门槛 | 不得漏包或用通用导入替代历史类型契约;准备失败不消费旧产物;记录每包实际结果并传播失败,保留运行时/设备/未决兼容门槛 | M | done |
|
||||||
| CI-JASSUB-SOURCE-01 | workspace, artplayer-plugin-jassub<br>将当前源码纳入 JASSUB 原生字幕默认验证 | CI-BROWSER-01, PKG-JASSUB-07 | 源码与发布包原生字幕配对、来源标识及独立销毁验证,真实 CLI 收集回归和三引擎证据 | 默认各三核心覆盖当前源码和发布包,明确显式诊断与安装映射边界;候选九项真实 WASM 绘制和清理通过;保留旧版失败,不冒充全量或真机验收 | M | done |
|
| CI-JASSUB-SOURCE-01 | workspace, artplayer-plugin-jassub<br>将当前源码纳入 JASSUB 原生字幕默认验证 | CI-BROWSER-01, PKG-JASSUB-07 | 源码与发布包原生字幕配对、来源标识及独立销毁验证,真实 CLI 收集回归和三引擎证据 | 默认各三核心覆盖当前源码和发布包,明确显式诊断与安装映射边界;候选九项真实 WASM 绘制和清理通过;保留旧版失败,不冒充全量或真机验收 | M | done |
|
||||||
|
| CI-NPM-02 | workspace<br>校验下载候选与实时发布台账 | CI-NPM-01, REL-09 | 独立摘要/源码/包批次输入、完整下载文件核验、实时台账重算和篡改/失效测试;供CI-03复用 | 不能以下载报告自证准入,不重建或发布;拒绝摘要/候选/版本/路径/台账漂移,明确远端来源和registry仍待核实 | H | done |
|
||||||
|
|
||||||
## 2.1 早期试点
|
## 2.1 早期试点
|
||||||
|
|
||||||
@@ -744,3 +745,4 @@
|
|||||||
- CI-JASSUB-SOURCE-01: [记录](changes/2026-09-15-CI-JASSUB-SOURCE-01-native-inputs.md) [记录](baselines/jassub-source-selection-validation.json)
|
- CI-JASSUB-SOURCE-01: [记录](changes/2026-09-15-CI-JASSUB-SOURCE-01-native-inputs.md) [记录](baselines/jassub-source-selection-validation.json)
|
||||||
- DOC-REVIEW-01: [记录](changes/2026-09-15-DOC-REVIEW-01-user-guidance.md) [记录](release-reviews.md) [记录](ai-workflow.md)
|
- DOC-REVIEW-01: [记录](changes/2026-09-15-DOC-REVIEW-01-user-guidance.md) [记录](release-reviews.md) [记录](ai-workflow.md)
|
||||||
- PKG-DANMUKU-START-01: [记录](baselines/ci-installed-webkit-validation.json) [记录](changes/2026-09-16-PKG-DANMUKU-START-01-first-sample.md) [记录](baselines/danmuku-start-validation.json)
|
- PKG-DANMUKU-START-01: [记录](baselines/ci-installed-webkit-validation.json) [记录](changes/2026-09-16-PKG-DANMUKU-START-01-first-sample.md) [记录](baselines/danmuku-start-validation.json)
|
||||||
|
- CI-NPM-02: [记录](changes/2026-09-16-CI-NPM-02-verify-bundle.md) [记录](baselines/npm-bundle-verification.json)
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
# 进度与证据
|
# 进度与证据
|
||||||
|
|
||||||
|
## CI-NPM-02 下载候选内容核验
|
||||||
|
|
||||||
|
新增`release:verify-bundle`,以独立提供的源码SHA、manifest摘要、包批次/tag
|
||||||
|
核验下载内容,并两次重算当前仓库发布台账。严格比对preflight、登记tarball字节、
|
||||||
|
SHA-512、版本/工具链及文件清单;拒绝额外文件、路径重定向和校验过程中变化。
|
||||||
|
没有新增依赖。36项准备/下载校验测试、严格TS及lint通过;真实仓库台账仍正确
|
||||||
|
拒绝放行。合成通过夹具不作为真实候选准入。见[记录](changes/2026-09-16-CI-NPM-02-verify-bundle.md)
|
||||||
|
及[证据](baselines/npm-bundle-verification.json)。
|
||||||
|
CI-NPM-02从CI-03拆出并成为其依赖;CI-03原依赖与远端来源、registry、发布流程
|
||||||
|
全部保留。223 done、20 doing、41 todo,共284项。没有启动复盘或执行远端写入。
|
||||||
|
|
||||||
## REL-09 全包下一 major 版本落实
|
## REL-09 全包下一 major 版本落实
|
||||||
|
|
||||||
22个workspace已设置各自下一major并新增unreleased CHANGELOG;核心/弹幕6.0.0、
|
22个workspace已设置各自下一major并新增unreleased CHANGELOG;核心/弹幕6.0.0、
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
// eslint-disable-next-line test/no-import-node-test -- Release boundary tests use the existing Node baseline runner.
|
||||||
|
import test from 'node:test'
|
||||||
|
import { prepareReleaseBundle } from '../../scripts/release/bundle.ts'
|
||||||
|
import { verifyReleaseBundle } from '../../scripts/release/verify.ts'
|
||||||
|
|
||||||
|
const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
|
||||||
|
|
||||||
|
// The successful reports below are synthetic. No real ArtPlayer release gate is waived.
|
||||||
|
function fixture(t) {
|
||||||
|
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-verify-test-'))
|
||||||
|
fs.mkdirSync(path.join(repository, 'refactor/.cache'), { recursive: true })
|
||||||
|
t.after(() => {
|
||||||
|
assert.equal(path.dirname(fs.realpathSync(repository)), fs.realpathSync(os.tmpdir()))
|
||||||
|
assert(path.basename(repository).startsWith('artplayer-verify-test-'))
|
||||||
|
fs.rmSync(repository, { recursive: true })
|
||||||
|
})
|
||||||
|
const names = ['artplayer', 'artplayer-plugin-chapter']
|
||||||
|
const packages = names.map((name) => {
|
||||||
|
const source = path.join(repository, name, 'package')
|
||||||
|
fs.mkdirSync(source, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name, version: '6.0.0', main: 'index.js' }))
|
||||||
|
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = 42\n')
|
||||||
|
const file = `refactor/.cache/${name}.tgz`
|
||||||
|
execFileSync('tar', ['-czf', path.join(repository, file), '-C', path.dirname(source), 'package'], { stdio: 'pipe' })
|
||||||
|
const bytes = fs.readFileSync(path.join(repository, file))
|
||||||
|
return { name, version: '6.0.0', distribution: 'npm', fingerprint: `input-${name}`, status: 'evidence-complete', blockers: [], candidate: { path: file, version: '6.0.0', sourceCommit: 'a'.repeat(40), inputFingerprint: `input-${name}`, integrity: `sha512-${digest(bytes, 'sha512', 'base64')}`, errors: [] } }
|
||||||
|
})
|
||||||
|
const report = { schemaVersion: 1, sourceCommit: 'b'.repeat(40), evidenceComplete: true, publicationAuthorized: false, toolchain: { node: 'v24.21.0', canonicalNode: '24.21.0', packageManager: 'yarn@1.22.22', lock: { sha256: 'c'.repeat(64) } }, packages }
|
||||||
|
const inspect = () => structuredClone(report)
|
||||||
|
const prepared = prepareReleaseBundle(repository, names, 'next', inspect)
|
||||||
|
const directory = path.join(repository, 'downloaded')
|
||||||
|
fs.cpSync(prepared.directory, directory, { recursive: true })
|
||||||
|
const manifestFile = path.join(directory, 'manifest.json')
|
||||||
|
const expected = { sourceCommit: report.sourceCommit, manifestSha256: digest(fs.readFileSync(manifestFile)), names, tag: 'next' }
|
||||||
|
const verify = (inspector = inspect) => verifyReleaseBundle(repository, directory, expected, inspector)
|
||||||
|
const modifyManifest = (change) => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestFile))
|
||||||
|
change(manifest)
|
||||||
|
fs.writeFileSync(manifestFile, JSON.stringify(manifest))
|
||||||
|
expected.manifestSha256 = digest(fs.readFileSync(manifestFile))
|
||||||
|
}
|
||||||
|
return { repository, directory, report, expected, verify, inspect, modifyManifest, manifestFile }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Downloaded bundle is checked against independent inputs and two fresh ledger reads without gaining authority', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const before = fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))])
|
||||||
|
let reads = 0
|
||||||
|
const result = f.verify(() => {
|
||||||
|
reads++
|
||||||
|
return f.inspect()
|
||||||
|
})
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
assert.equal(result.packages.length, 2)
|
||||||
|
assert.equal(result.contentVerified, true)
|
||||||
|
assert.equal(result.publicationAuthorized, false)
|
||||||
|
assert.equal(result.workflowProvenanceVerified, false)
|
||||||
|
assert.equal(result.registryOccupancyVerified, false)
|
||||||
|
assert.deepEqual(fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))]), before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A changed manifest is rejected before interpreting its report or inspecting the repository', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
fs.appendFileSync(f.manifestFile, ' ')
|
||||||
|
assert.throws(() => f.verify(() => {
|
||||||
|
throw new Error('must not inspect')
|
||||||
|
}), /independent digest/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Self-consistent forged manifest fields cannot replace fresh repository evidence', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const original = fs.readFileSync(f.manifestFile)
|
||||||
|
const cases = [
|
||||||
|
m => m.publicationAuthorized = true,
|
||||||
|
m => m.registry = 'https://example.invalid/',
|
||||||
|
m => m.tag = 'latest',
|
||||||
|
m => m.sourceCommit = 'd'.repeat(40),
|
||||||
|
m => m.toolchain.lock.sha256 = 'e'.repeat(64),
|
||||||
|
m => m.preflight.sha256 = 'e'.repeat(64),
|
||||||
|
m => m.preflight.file = '../preflight.json',
|
||||||
|
m => m.packages.reverse(),
|
||||||
|
m => m.packages.push(m.packages[0]),
|
||||||
|
m => m.packages[0].version = '7.0.0',
|
||||||
|
m => m.packages[0].file = '../escape.tgz',
|
||||||
|
m => m.packages[0].sha256 = 'e'.repeat(64),
|
||||||
|
m => m.packages[0].integrity = 'sha512-forged',
|
||||||
|
m => m.packages[0].sourceCommit = 'd'.repeat(40),
|
||||||
|
m => m.packages[0].inputFingerprint = 'new-inputs',
|
||||||
|
m => m.kind = 'other-bundle',
|
||||||
|
m => m.extraApproval = true,
|
||||||
|
]
|
||||||
|
for (const change of cases) {
|
||||||
|
fs.writeFileSync(f.manifestFile, original)
|
||||||
|
f.modifyManifest(change)
|
||||||
|
assert.throws(() => f.verify(), /metadata differs/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A freshly blocked or changed ledger rejects even an intact formerly accepted bundle', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const changes = [
|
||||||
|
r => r.evidenceComplete = false,
|
||||||
|
r => r.packages[0].status = 'blocked',
|
||||||
|
r => r.packages[0].blockers.push('new finding'),
|
||||||
|
r => r.packages[0].distribution = 'site',
|
||||||
|
r => r.packages[0].candidate.errors.push('changed candidate'),
|
||||||
|
r => r.packages[0].candidate.inputFingerprint = 'stale',
|
||||||
|
r => r.sourceCommit = 'd'.repeat(40),
|
||||||
|
r => r.toolchain.lock.sha256 = 'd'.repeat(64),
|
||||||
|
]
|
||||||
|
for (const change of changes) {
|
||||||
|
assert.throws(() => f.verify(() => {
|
||||||
|
const report = f.inspect()
|
||||||
|
change(report)
|
||||||
|
return report
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'preflight.json'), JSON.stringify({ ...f.report, extraApproval: true }))
|
||||||
|
f.modifyManifest(m => m.preflight.sha256 = digest(fs.readFileSync(path.join(f.directory, 'preflight.json'))))
|
||||||
|
assert.throws(() => f.verify(), /Downloaded preflight differs/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Changed archive bytes, even if copied into the registered location, cannot reuse candidate integrity', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const file = path.join(f.directory, 'artplayer-6.0.0.tgz')
|
||||||
|
fs.appendFileSync(file, 'tampered')
|
||||||
|
assert.throws(() => f.verify(), /downloaded tarball differs/)
|
||||||
|
fs.copyFileSync(file, path.join(f.repository, f.report.packages[0].candidate.path))
|
||||||
|
assert.throws(() => f.verify(), /candidate integrity changed/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Extra files and missing archives fail without deleting downloaded evidence', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'extra.sh'), 'exit 1')
|
||||||
|
assert.throws(() => f.verify(), /Unexpected or missing bundle files/)
|
||||||
|
assert(fs.existsSync(path.join(f.directory, 'extra.sh')))
|
||||||
|
fs.unlinkSync(path.join(f.directory, 'extra.sh'))
|
||||||
|
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
|
||||||
|
assert.throws(() => f.verify(), /ENOENT/)
|
||||||
|
assert(fs.existsSync(f.manifestFile))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Redirected directories, redirected parents and non-file members are rejected', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const link = path.join(f.repository, 'redirect')
|
||||||
|
fs.symlinkSync(f.directory, link, 'junction')
|
||||||
|
assert.throws(() => verifyReleaseBundle(f.repository, link, f.expected, f.inspect), /regular directory|Redirected bundle/)
|
||||||
|
const parent = path.join(f.repository, 'redirect-parent')
|
||||||
|
fs.symlinkSync(f.repository, parent, 'junction')
|
||||||
|
assert.throws(() => verifyReleaseBundle(f.repository, path.join(parent, 'downloaded'), f.expected, f.inspect), /Redirected bundle/)
|
||||||
|
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
|
||||||
|
fs.symlinkSync(path.join(f.repository, 'artplayer'), path.join(f.directory, 'artplayer-6.0.0.tgz'), 'junction')
|
||||||
|
assert.throws(() => f.verify(), /not a regular file/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Source or downloaded content changed during verification cannot produce a successful result', (t) => {
|
||||||
|
const mutations = [
|
||||||
|
f => fs.appendFileSync(f.manifestFile, ' '),
|
||||||
|
f => fs.appendFileSync(path.join(f.directory, 'preflight.json'), ' '),
|
||||||
|
f => fs.appendFileSync(path.join(f.directory, 'artplayer-6.0.0.tgz'), ' '),
|
||||||
|
f => fs.writeFileSync(path.join(f.directory, 'late-file'), ' '),
|
||||||
|
f => f.report.toolchain.lock.sha256 = 'd'.repeat(64),
|
||||||
|
]
|
||||||
|
for (const mutate of mutations) {
|
||||||
|
const f = fixture(t)
|
||||||
|
let reads = 0
|
||||||
|
assert.throws(() => f.verify(() => {
|
||||||
|
if (++reads === 2)
|
||||||
|
mutate(f)
|
||||||
|
return f.inspect()
|
||||||
|
}), /changed during verification/)
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Invalid independent selection, tag, source and digest fail before reading downloaded contents', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
for (const change of [{ names: [] }, { names: ['artplayer', 'artplayer'] }, { names: ['../artplayer'] }, { tag: 'arbitrary' }, { sourceCommit: 'master' }, { manifestSha256: '' }])
|
||||||
|
assert.throws(() => verifyReleaseBundle(f.repository, 'not-a-directory', { ...f.expected, ...change }, f.inspect), /Supply|Select/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Verifier CLI rejects omitted inputs, unknown options and an unsupported package manager', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const script = path.resolve('scripts/verify-release.mjs')
|
||||||
|
const full = ['--directory', f.directory, '--packages', f.expected.names.join(','), '--tag', 'next', '--source-commit', f.expected.sourceCommit, '--manifest-sha256', f.expected.manifestSha256]
|
||||||
|
for (const [args, message] of [[[], /are required/], [['--unknown'], /Unknown option/], [full, /Use yarn release:verify-bundle/]]) {
|
||||||
|
assert.throws(() => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe', env: { ...process.env, npm_config_user_agent: 'npm/11.5.1' } }), (error) => {
|
||||||
|
assert.equal(error.status, 1)
|
||||||
|
assert.match(error.stderr.toString(), message)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A second-inspection exception is preserved and downloaded evidence is retained', (t) => {
|
||||||
|
const f = fixture(t)
|
||||||
|
const failure = new Error('fresh evidence read failed')
|
||||||
|
let reads = 0
|
||||||
|
assert.throws(() => f.verify(() => {
|
||||||
|
if (++reads === 2)
|
||||||
|
throw failure
|
||||||
|
return f.inspect()
|
||||||
|
}), error => error === failure)
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
assert(fs.existsSync(f.manifestFile))
|
||||||
|
})
|
||||||
+22
-1
@@ -835,7 +835,8 @@
|
|||||||
"CI-01",
|
"CI-01",
|
||||||
"REL-08",
|
"REL-08",
|
||||||
"REL-04",
|
"REL-04",
|
||||||
"CI-NPM-01"
|
"CI-NPM-01",
|
||||||
|
"CI-NPM-02"
|
||||||
],
|
],
|
||||||
"status": "todo",
|
"status": "todo",
|
||||||
"risk": "H",
|
"risk": "H",
|
||||||
@@ -6138,6 +6139,26 @@
|
|||||||
"changes/2026-09-16-PKG-DANMUKU-START-01-first-sample.md",
|
"changes/2026-09-16-PKG-DANMUKU-START-01-first-sample.md",
|
||||||
"baselines/danmuku-start-validation.json"
|
"baselines/danmuku-start-validation.json"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CI-NPM-02",
|
||||||
|
"phase": "2.2 GitHub CI/CD",
|
||||||
|
"title": "校验下载候选与实时发布台账",
|
||||||
|
"scope": [
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"dependsOn": [
|
||||||
|
"CI-NPM-01",
|
||||||
|
"REL-09"
|
||||||
|
],
|
||||||
|
"status": "done",
|
||||||
|
"risk": "H",
|
||||||
|
"deliverable": "独立摘要/源码/包批次输入、完整下载文件核验、实时台账重算和篡改/失效测试;供CI-03复用",
|
||||||
|
"acceptance": "不能以下载报告自证准入,不重建或发布;拒绝摘要/候选/版本/路径/台账漂移,明确远端来源和registry仍待核实",
|
||||||
|
"evidence": [
|
||||||
|
"changes/2026-09-16-CI-NPM-02-verify-bundle.md",
|
||||||
|
"baselines/npm-bundle-verification.json"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,17 @@ yarn typecheck:release
|
|||||||
yarn test:release-bundle
|
yarn test:release-bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a downloaded copy of an already prepared bundle, use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn release:verify-bundle --directory refactor/.cache/downloaded-bundle --packages artplayer,artplayer-plugin-chapter --tag next --source-commit <expected-40-hex-commit> --manifest-sha256 <independent-64-hex-digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the expected commit, batch, tag and manifest digest from the trusted handoff,
|
||||||
|
not from the downloaded bundle itself. Passing a digest copied from an untrusted
|
||||||
|
manifest does not establish authenticity. The workflow that establishes this
|
||||||
|
handoff is still CI-03 work; this command only verifies local contents and gates.
|
||||||
|
|
||||||
`release:bundle` does not build, pack, install, contact npm or publish. It is a
|
`release:bundle` does not build, pack, install, contact npm or publish. It is a
|
||||||
handoff step for candidates already recorded in `refactor/release-ledger.json`.
|
handoff step for candidates already recorded in `refactor/release-ledger.json`.
|
||||||
Both commands currently refuse release acceptance because the repository still
|
Both commands currently refuse release acceptance because the repository still
|
||||||
@@ -24,6 +35,15 @@ reports are not evidence that an ArtPlayer package is ready to publish.
|
|||||||
- `bundle.ts` consumes the repository ledger, checks the batch, copies exact
|
- `bundle.ts` consumes the repository ledger, checks the batch, copies exact
|
||||||
tarball bytes and writes a hash-bound manifest. Its injected inspector is an
|
tarball bytes and writes a hash-bound manifest. Its injected inspector is an
|
||||||
internal test seam; the CLI never trusts a supplied JSON report as approval.
|
internal test seam; the CLI never trusts a supplied JSON report as approval.
|
||||||
|
- `bundle.ts` also owns the shared batch rules and manifest description, so
|
||||||
|
preparation and verification agree on the same versioned handoff format.
|
||||||
|
- `verify.ts` compares every downloaded file with independent expectations and
|
||||||
|
two fresh ledger reads. It checks registered candidate bytes and SHA-512,
|
||||||
|
strict manifest metadata, preflight contents and the exact file roster, then
|
||||||
|
rechecks the downloaded files before returning. It performs no writes.
|
||||||
|
- `../verify-release.mjs` is the verification CLI. It requires explicit arguments,
|
||||||
|
canonical Node/Yarn and clean Git state on both fresh ledger reads. A downloaded
|
||||||
|
JSON report cannot be injected as the inspector through the CLI.
|
||||||
- `../../refactor/scripts/release-ledger.mjs` remains the source of candidate,
|
- `../../refactor/scripts/release-ledger.mjs` remains the source of candidate,
|
||||||
input fingerprint, review, risk, source/license, device, rollback and CI checks.
|
input fingerprint, review, risk, source/license, device, rollback and CI checks.
|
||||||
Do not introduce another manually maintained green-status list here.
|
Do not introduce another manually maintained green-status list here.
|
||||||
@@ -65,9 +85,31 @@ The future publisher must validate downloaded contents and fresh release evidenc
|
|||||||
it must not trust an artifact solely because it has this manifest shape. Do not
|
it must not trust an artifact solely because it has this manifest shape. Do not
|
||||||
add a rebuild fallback to preparation or publishing when an artifact is missing.
|
add a rebuild fallback to preparation or publishing when an artifact is missing.
|
||||||
|
|
||||||
|
CI-NPM-02 supplies that content-validation step. A successful verification still
|
||||||
|
returns `workflowProvenanceVerified: false`, `registryOccupancyVerified: false`
|
||||||
|
and `publicationAuthorized: false`. It neither contacts GitHub/npm nor executes
|
||||||
|
package lifecycle scripts. CI-03 must separately verify the repository/workflow,
|
||||||
|
run, attempt, source commit and artifact identity using trusted remote metadata,
|
||||||
|
and check registry occupancy immediately before an authorized publication.
|
||||||
|
The verifier does not preserve a publishable approval across later file changes;
|
||||||
|
the future publisher must revalidate and consume those same bytes at use time.
|
||||||
|
|
||||||
|
Restore the registered candidate files and required evidence to their ledger
|
||||||
|
locations before verification. The downloaded preflight must equal the newly
|
||||||
|
computed report, including toolchain and file hashes. A different environment,
|
||||||
|
missing ignored evidence, changed files or new blockers can therefore reject
|
||||||
|
an otherwise intact historical download. Keep and diagnose that rejection;
|
||||||
|
do not edit the downloaded report to make it pass or rebuild a replacement.
|
||||||
|
Extra files, directories, symlinks/junctions and redirected parent paths are
|
||||||
|
rejected. Failed verification leaves the downloaded evidence intact.
|
||||||
|
|
||||||
`release-bundle.test.mjs` uses small real tar archives with explicitly synthetic
|
`release-bundle.test.mjs` uses small real tar archives with explicitly synthetic
|
||||||
ledger reports for byte-copy/failure tests, and temporary real Git repositories
|
ledger reports for byte-copy/failure tests, and temporary real Git repositories
|
||||||
for the clean-source guard. It covers stale inputs, changed outputs, missing
|
for the clean-source guard. It covers stale inputs, changed outputs, missing
|
||||||
gates, site confusion, selection/tag errors, outside paths/junctions, partial
|
gates, site confusion, selection/tag errors, outside paths/junctions, partial
|
||||||
cleanup and original exception retention. These tests run in `test:baseline`;
|
cleanup and original exception retention. These tests run in `test:baseline`;
|
||||||
strict TypeScript checking runs in `ci:check`, and the TS source is in root lint.
|
strict TypeScript checking runs in `ci:check`, and the TS source is in root lint.
|
||||||
|
`release-verify.test.mjs` additionally tests downloaded copies, self-consistent
|
||||||
|
manifest/preflight forgeries, stale fresh gates, changed registered/downloaded
|
||||||
|
tarballs, missing/extra members, redirected paths and mid-verification changes.
|
||||||
|
Successful synthetic tests are not actual ArtPlayer candidate acceptance.
|
||||||
+28
-13
@@ -33,11 +33,37 @@ export interface LedgerSnapshot {
|
|||||||
packages: PackageRow[]
|
packages: PackageRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PackedCandidate {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
file: string
|
||||||
|
bytes: number
|
||||||
|
sha256: string
|
||||||
|
integrity: string
|
||||||
|
sourceCommit: string
|
||||||
|
inputFingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundleManifest(report: LedgerSnapshot, tag: string, preflightSha256: string, packages: PackedCandidate[]) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'artplayer-npm-bundle',
|
||||||
|
publicationAuthorized: false,
|
||||||
|
sourceCommit: report.sourceCommit,
|
||||||
|
registry: 'https://registry.npmjs.org/',
|
||||||
|
tag,
|
||||||
|
toolchain: report.toolchain,
|
||||||
|
preflight: { file: 'preflight.json', sha256: preflightSha256 },
|
||||||
|
packages,
|
||||||
|
limitations: ['This bundle is not publication authorization.', 'A future publisher must verify trusted workflow/run provenance, current registry occupancy, all bundle hashes and fresh release evidence before publishing these exact tarballs.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
||||||
const integrity = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
const integrity = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
||||||
const encode = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`)
|
const encode = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`)
|
||||||
|
|
||||||
function checkBatch(report: LedgerSnapshot, names: string[], tag: string): void {
|
export function checkBatch(report: LedgerSnapshot, names: string[], tag: string): void {
|
||||||
assert.equal(report.schemaVersion, 1, 'Unsupported release ledger report')
|
assert.equal(report.schemaVersion, 1, 'Unsupported release ledger report')
|
||||||
assert.equal(report.publicationAuthorized, false, 'A ledger report cannot authorize publication')
|
assert.equal(report.publicationAuthorized, false, 'A ledger report cannot authorize publication')
|
||||||
assert(/^[a-f\d]{40}$/.test(report.sourceCommit), 'Missing source commit')
|
assert(/^[a-f\d]{40}$/.test(report.sourceCommit), 'Missing source commit')
|
||||||
@@ -88,18 +114,7 @@ export function prepareReleaseBundle(directory: string, names: string[], tag: st
|
|||||||
const preflight = encode(final)
|
const preflight = encode(final)
|
||||||
fs.writeFileSync(path.join(stage, 'preflight.json'), preflight, { flag: 'wx' })
|
fs.writeFileSync(path.join(stage, 'preflight.json'), preflight, { flag: 'wx' })
|
||||||
assert.equal(sha256(fs.readFileSync(path.join(stage, 'preflight.json'))), sha256(preflight), 'Written preflight report differs')
|
assert.equal(sha256(fs.readFileSync(path.join(stage, 'preflight.json'))), sha256(preflight), 'Written preflight report differs')
|
||||||
const manifest = {
|
const manifest = bundleManifest(final, tag, sha256(preflight), packages)
|
||||||
schemaVersion: 1,
|
|
||||||
kind: 'artplayer-npm-bundle',
|
|
||||||
publicationAuthorized: false,
|
|
||||||
sourceCommit: final.sourceCommit,
|
|
||||||
registry: 'https://registry.npmjs.org/',
|
|
||||||
tag,
|
|
||||||
toolchain: final.toolchain,
|
|
||||||
preflight: { file: 'preflight.json', sha256: sha256(preflight) },
|
|
||||||
packages,
|
|
||||||
limitations: ['This bundle is not publication authorization.', 'A future publisher must verify trusted workflow/run provenance, current registry occupancy, all bundle hashes and fresh release evidence before publishing these exact tarballs.'],
|
|
||||||
}
|
|
||||||
// Completion marker is written last; no directory is returned on failure.
|
// Completion marker is written last; no directory is returned on failure.
|
||||||
fs.writeFileSync(path.join(stage, 'manifest.json'), encode(manifest), { flag: 'wx' })
|
fs.writeFileSync(path.join(stage, 'manifest.json'), encode(manifest), { flag: 'wx' })
|
||||||
return { directory: stage, manifest }
|
return { directory: stage, manifest }
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { LedgerSnapshot } from './bundle.ts'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
|
||||||
|
import { bundleManifest, checkBatch } from './bundle.ts'
|
||||||
|
|
||||||
|
interface BundleExpectation {
|
||||||
|
sourceCommit: string
|
||||||
|
manifestSha256: string
|
||||||
|
names: string[]
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = (bytes: Uint8Array, algorithm = 'sha256', encoding: 'hex' | 'base64' = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
|
||||||
|
|
||||||
|
function readFile(directory: string, name: string) {
|
||||||
|
assert(name && path.basename(name) === name && !/[\\/:]/.test(name) && name !== '.' && name !== '..', 'Bundle member must be a single filename')
|
||||||
|
const file = path.join(directory, name)
|
||||||
|
assert(fs.lstatSync(file).isFile(), `Bundle member is not a regular file: ${name}`)
|
||||||
|
assert.equal(fs.realpathSync(file), file, `Redirected bundle member: ${name}`)
|
||||||
|
return fs.readFileSync(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CLI computes inspect from the current repository. A downloaded report is
|
||||||
|
// never used to decide whether the current candidate's release gates have passed.
|
||||||
|
export function verifyReleaseBundle(repository: string, directory: string, expected: BundleExpectation, inspect: (repository: string, names: string[]) => LedgerSnapshot = buildLedger) {
|
||||||
|
assert(/^[a-f\d]{40}$/.test(expected.sourceCommit), 'Supply the independently expected source commit')
|
||||||
|
assert(/^[a-f\d]{64}$/.test(expected.manifestSha256), 'Supply the independently expected manifest SHA-256')
|
||||||
|
assert(expected.names.length && new Set(expected.names).size === expected.names.length && expected.names.every(name => /^artplayer(?:-[a-z0-9]+)*$/.test(name)), 'Select explicit, unique workspace packages')
|
||||||
|
assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(expected.tag), 'Select an explicit supported tag')
|
||||||
|
repository = fs.realpathSync(repository)
|
||||||
|
directory = path.resolve(directory)
|
||||||
|
assert(fs.lstatSync(directory).isDirectory(), 'Bundle path is not a regular directory')
|
||||||
|
assert.equal(fs.realpathSync(directory), directory, 'Redirected bundle directory')
|
||||||
|
const initialFiles = fs.readdirSync(directory).sort()
|
||||||
|
const bytes = readFile(directory, 'manifest.json')
|
||||||
|
assert.equal(digest(bytes), expected.manifestSha256, 'Bundle manifest differs from the independent digest')
|
||||||
|
// JSON stays untrusted until exact comparison with a freshly computed manifest.
|
||||||
|
const manifest = JSON.parse(bytes.toString('utf8')) as Record<string, unknown>
|
||||||
|
const fresh = inspect(repository, expected.names)
|
||||||
|
checkBatch(fresh, expected.names, expected.tag)
|
||||||
|
assert.equal(fresh.sourceCommit, expected.sourceCommit, 'Current source differs from the expected commit')
|
||||||
|
const preflight = readFile(directory, 'preflight.json')
|
||||||
|
assert.deepEqual(JSON.parse(preflight.toString('utf8')), fresh, 'Downloaded preflight differs from the fresh repository ledger')
|
||||||
|
const packages = fresh.packages.map((row) => {
|
||||||
|
const candidate = row.candidate!
|
||||||
|
const file = `${row.name}-${row.version}.tgz`
|
||||||
|
const packed = readFile(directory, file)
|
||||||
|
const registered = fs.readFileSync(localFile(repository, candidate.path))
|
||||||
|
assert.deepEqual(packed, registered, `${row.name}: downloaded tarball differs from registered candidate`)
|
||||||
|
assert.equal(`sha512-${digest(packed, 'sha512', 'base64')}`, candidate.integrity, `${row.name}: candidate integrity changed`)
|
||||||
|
return { name: row.name, version: row.version, file, bytes: packed.length, sha256: digest(packed), integrity: candidate.integrity, sourceCommit: candidate.sourceCommit, inputFingerprint: row.fingerprint }
|
||||||
|
})
|
||||||
|
assert.deepEqual(manifest, bundleManifest(fresh, expected.tag, digest(preflight), packages), 'Bundle metadata differs from the freshly verified candidate batch')
|
||||||
|
assert.deepEqual(initialFiles, ['manifest.json', 'preflight.json', ...packages.map(pkg => pkg.file)].sort(), 'Unexpected or missing bundle files')
|
||||||
|
const final = inspect(repository, expected.names)
|
||||||
|
checkBatch(final, expected.names, expected.tag)
|
||||||
|
assert.deepEqual(final, fresh, 'Release inputs or evidence changed during verification')
|
||||||
|
assert.deepEqual(fs.readdirSync(directory).sort(), initialFiles, 'Bundle membership changed during verification')
|
||||||
|
assert.deepEqual(readFile(directory, 'manifest.json'), bytes, 'Bundle manifest changed during verification')
|
||||||
|
assert.deepEqual(readFile(directory, 'preflight.json'), preflight, 'Bundle preflight changed during verification')
|
||||||
|
for (const item of packages)
|
||||||
|
assert.equal(digest(readFile(directory, item.file)), item.sha256, `${item.name}: bundle changed during verification`)
|
||||||
|
return { sourceCommit: fresh.sourceCommit, manifestSha256: expected.manifestSha256, packages, contentVerified: true, workflowProvenanceVerified: false, registryOccupancyVerified: false, publicationAuthorized: false }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { parseArgs } from 'node:util'
|
||||||
|
import { buildLedger, root } from '../refactor/scripts/release-ledger.mjs'
|
||||||
|
import { assertCleanSource } from './release/prepare.ts'
|
||||||
|
import { verifyReleaseBundle } from './release/verify.ts'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { values } = parseArgs({ options: { 'directory': { type: 'string' }, 'packages': { type: 'string' }, 'tag': { type: 'string' }, 'source-commit': { type: 'string' }, 'manifest-sha256': { type: 'string' } }, strict: true })
|
||||||
|
assert(values.directory && values.packages && values.tag && values['source-commit'] && values['manifest-sha256'], 'Explicit --directory, --packages, --tag, --source-commit and --manifest-sha256 are required')
|
||||||
|
assert.equal(process.version, `v${fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()}`, 'Use the canonical Node version')
|
||||||
|
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:verify-bundle')
|
||||||
|
const result = verifyReleaseBundle(root, values.directory, { names: values.packages.split(','), tag: values.tag, sourceCommit: values['source-commit'], manifestSha256: values['manifest-sha256'] }, (directory, names) => {
|
||||||
|
assertCleanSource(directory)
|
||||||
|
return buildLedger(directory, names)
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(result))
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
Reference in new issue
Block a user