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 +}