diff --git a/package.json b/package.json
index a73bea910..6c090815e 100644
--- a/package.json
+++ b/package.json
@@ -178,9 +178,10 @@
"verify:monaco-unicode": "node scripts/site-vendor/monaco/reproduce-unicode.ts",
"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",
- "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",
- "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",
"devDependencies": {
diff --git a/refactor/baselines/npm-bundle-verification.json b/refactor/baselines/npm-bundle-verification.json
new file mode 100644
index 000000000..190312182
--- /dev/null
+++ b/refactor/baselines/npm-bundle-verification.json
@@ -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
+ }
+}
diff --git a/refactor/changes/2026-09-16-CI-NPM-02-verify-bundle.md b/refactor/changes/2026-09-16-CI-NPM-02-verify-bundle.md
new file mode 100644
index 000000000..18a13c012
--- /dev/null
+++ b/refactor/changes/2026-09-16-CI-NPM-02-verify-bundle.md
@@ -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.
diff --git a/refactor/github-ci-cd.md b/refactor/github-ci-cd.md
index b77e2b18e..e8ab6fe7f 100644
--- a/refactor/github-ci-cd.md
+++ b/refactor/github-ci-cd.md
@@ -60,6 +60,12 @@ CI-NPM-01 从 CI-03 拆出本地精确候选交付准备,供后续 artifact
验收全部保留。当前包仍被准入门槛阻止,详见[记录](changes/2026-09-15-CI-NPM-01-bundle.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/主线检查及部署隔离基础 |
diff --git a/refactor/plan.md b/refactor/plan.md
index 926fe3ad3..01a2dce9e 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -2,9 +2,9 @@
> 由 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。
@@ -96,13 +96,14 @@
| --- | --- | --- | --- | --- | --- | --- |
| CI-01 | workspace
增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | doing |
| CI-02 | workspace
分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | done |
-| CI-03 | workspace
建立 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
建立 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
验收 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
分离源码与已安装产物浏览器验证范围 | ENG-05, ENG-07 | 完整源码入口、明确已安装包子集、分开的报告目录与失败传播 | 混用输入旧红新绿;源码默认保留所有spec,已安装入口严格校验四包来源;两类报告都保留,源码失败不能误报全绿;不代表完整远端或全包验收 | M | done |
| CI-NPM-01 | workspace
从已验收候选准备不可重建的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
接入 Audio/HLS 安装后严格类型消费者 | ENG-07, PKG-AUDIO-04, PKG-HLS-04 | 共享隔离编译器模块、真实 tarball 的五模式正反例和声明路径证据 | 旧/新编译器所有正例通过,每条无效调用实际报错;仓库外声明解析、缺声明/any/逃逸负例有效;保留独立运行时和浏览器验收 | M | done |
| CI-TYPES-02 | workspace
将全部库包的独立安装类型验证接入CI | CI-TYPES-01, ENG-07 | 21库包完整检查清单、实际构建和隔离安装测试调度、失败证据汇总及必需CI门槛 | 不得漏包或用通用导入替代历史类型契约;准备失败不消费旧产物;记录每包实际结果并传播失败,保留运行时/设备/未决兼容门槛 | M | done |
| CI-JASSUB-SOURCE-01 | workspace, artplayer-plugin-jassub
将当前源码纳入 JASSUB 原生字幕默认验证 | CI-BROWSER-01, PKG-JASSUB-07 | 源码与发布包原生字幕配对、来源标识及独立销毁验证,真实 CLI 收集回归和三引擎证据 | 默认各三核心覆盖当前源码和发布包,明确显式诊断与安装映射边界;候选九项真实 WASM 绘制和清理通过;保留旧版失败,不冒充全量或真机验收 | M | done |
+| CI-NPM-02 | workspace
校验下载候选与实时发布台账 | CI-NPM-01, REL-09 | 独立摘要/源码/包批次输入、完整下载文件核验、实时台账重算和篡改/失效测试;供CI-03复用 | 不能以下载报告自证准入,不重建或发布;拒绝摘要/候选/版本/路径/台账漂移,明确远端来源和registry仍待核实 | H | done |
## 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)
- 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)
+- CI-NPM-02: [记录](changes/2026-09-16-CI-NPM-02-verify-bundle.md) [记录](baselines/npm-bundle-verification.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index a691b66c7..118bd3ae2 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -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 版本落实
22个workspace已设置各自下一major并新增unreleased CHANGELOG;核心/弹幕6.0.0、
diff --git a/refactor/scripts/release-verify.test.mjs b/refactor/scripts/release-verify.test.mjs
new file mode 100644
index 000000000..39f0450e6
--- /dev/null
+++ b/refactor/scripts/release-verify.test.mjs
@@ -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))
+})
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 4abae6656..19a8eaba4 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -835,7 +835,8 @@
"CI-01",
"REL-08",
"REL-04",
- "CI-NPM-01"
+ "CI-NPM-01",
+ "CI-NPM-02"
],
"status": "todo",
"risk": "H",
@@ -6138,6 +6139,26 @@
"changes/2026-09-16-PKG-DANMUKU-START-01-first-sample.md",
"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"
+ ]
}
]
}
diff --git a/scripts/release/README.md b/scripts/release/README.md
index 579e0c08d..b42a0f2a0 100644
--- a/scripts/release/README.md
+++ b/scripts/release/README.md
@@ -9,6 +9,17 @@ yarn typecheck:release
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 --manifest-sha256
+```
+
+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
handoff step for candidates already recorded in `refactor/release-ledger.json`.
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
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.
+- `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,
input fingerprint, review, risk, source/license, device, rollback and CI checks.
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
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
ledger reports for byte-copy/failure tests, and temporary real Git repositories
for the clean-source guard. It covers stale inputs, changed outputs, missing
gates, site confusion, selection/tag errors, outside paths/junctions, partial
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.
+`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.
diff --git a/scripts/release/bundle.ts b/scripts/release/bundle.ts
index 654e69376..8dd5312e4 100644
--- a/scripts/release/bundle.ts
+++ b/scripts/release/bundle.ts
@@ -33,11 +33,37 @@ export interface LedgerSnapshot {
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 integrity = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
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.publicationAuthorized, false, 'A ledger report cannot authorize publication')
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)
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')
- const manifest = {
- 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.'],
- }
+ const manifest = bundleManifest(final, tag, sha256(preflight), packages)
// Completion marker is written last; no directory is returned on failure.
fs.writeFileSync(path.join(stage, 'manifest.json'), encode(manifest), { flag: 'wx' })
return { directory: stage, manifest }
diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts
new file mode 100644
index 000000000..854d0bd4a
--- /dev/null
+++ b/scripts/release/verify.ts
@@ -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
+ 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 }
+}
diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs
new file mode 100644
index 000000000..f5d0378de
--- /dev/null
+++ b/scripts/verify-release.mjs
@@ -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
+}