feat(ci): [CI-NPM-02] verify downloaded bundles against fresh release evidence

This commit is contained in:
Harvey Zhao committed 2026-09-16 00:59:42 +08:00
1 parent 2983cb7c51
commit 6f7fd7931d
12 files changed
+560 -19

No files matched your search

+3 -2
View File
@@ -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": {
@@ -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.
+6
View File
@@ -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/主线检查及部署隔离基础 |
+5 -3
View File
@@ -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<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-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-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-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-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 早期试点
@@ -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)
+11
View File
@@ -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、
+213
View File
@@ -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
View File
@@ -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"
]
}
]
}
+42
View File
@@ -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 <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
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.
+28 -13
View File
@@ -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 }
+67
View File
@@ -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 }
}
+24
View File
@@ -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
}