mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
feat(ci): [CI-NPM-02] verify downloaded bundles against fresh release evidence
This commit is contained in:
1 parent
2983cb7c51
commit
6f7fd7931d
12 files changed
+560
-19
No files matched your search
+3
-2
@@ -178,9 +178,10 @@
|
||||
"verify:monaco-unicode": "node scripts/site-vendor/monaco/reproduce-unicode.ts",
|
||||
"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.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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、
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Release boundary tests use the existing Node baseline runner.
|
||||
import test from 'node:test'
|
||||
import { prepareReleaseBundle } from '../../scripts/release/bundle.ts'
|
||||
import { verifyReleaseBundle } from '../../scripts/release/verify.ts'
|
||||
|
||||
const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
|
||||
|
||||
// The successful reports below are synthetic. No real ArtPlayer release gate is waived.
|
||||
function fixture(t) {
|
||||
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-verify-test-'))
|
||||
fs.mkdirSync(path.join(repository, 'refactor/.cache'), { recursive: true })
|
||||
t.after(() => {
|
||||
assert.equal(path.dirname(fs.realpathSync(repository)), fs.realpathSync(os.tmpdir()))
|
||||
assert(path.basename(repository).startsWith('artplayer-verify-test-'))
|
||||
fs.rmSync(repository, { recursive: true })
|
||||
})
|
||||
const names = ['artplayer', 'artplayer-plugin-chapter']
|
||||
const packages = names.map((name) => {
|
||||
const source = path.join(repository, name, 'package')
|
||||
fs.mkdirSync(source, { recursive: true })
|
||||
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name, version: '6.0.0', main: 'index.js' }))
|
||||
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = 42\n')
|
||||
const file = `refactor/.cache/${name}.tgz`
|
||||
execFileSync('tar', ['-czf', path.join(repository, file), '-C', path.dirname(source), 'package'], { stdio: 'pipe' })
|
||||
const bytes = fs.readFileSync(path.join(repository, file))
|
||||
return { name, version: '6.0.0', distribution: 'npm', fingerprint: `input-${name}`, status: 'evidence-complete', blockers: [], candidate: { path: file, version: '6.0.0', sourceCommit: 'a'.repeat(40), inputFingerprint: `input-${name}`, integrity: `sha512-${digest(bytes, 'sha512', 'base64')}`, errors: [] } }
|
||||
})
|
||||
const report = { schemaVersion: 1, sourceCommit: 'b'.repeat(40), evidenceComplete: true, publicationAuthorized: false, toolchain: { node: 'v24.21.0', canonicalNode: '24.21.0', packageManager: 'yarn@1.22.22', lock: { sha256: 'c'.repeat(64) } }, packages }
|
||||
const inspect = () => structuredClone(report)
|
||||
const prepared = prepareReleaseBundle(repository, names, 'next', inspect)
|
||||
const directory = path.join(repository, 'downloaded')
|
||||
fs.cpSync(prepared.directory, directory, { recursive: true })
|
||||
const manifestFile = path.join(directory, 'manifest.json')
|
||||
const expected = { sourceCommit: report.sourceCommit, manifestSha256: digest(fs.readFileSync(manifestFile)), names, tag: 'next' }
|
||||
const verify = (inspector = inspect) => verifyReleaseBundle(repository, directory, expected, inspector)
|
||||
const modifyManifest = (change) => {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile))
|
||||
change(manifest)
|
||||
fs.writeFileSync(manifestFile, JSON.stringify(manifest))
|
||||
expected.manifestSha256 = digest(fs.readFileSync(manifestFile))
|
||||
}
|
||||
return { repository, directory, report, expected, verify, inspect, modifyManifest, manifestFile }
|
||||
}
|
||||
|
||||
test('Downloaded bundle is checked against independent inputs and two fresh ledger reads without gaining authority', (t) => {
|
||||
const f = fixture(t)
|
||||
const before = fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))])
|
||||
let reads = 0
|
||||
const result = f.verify(() => {
|
||||
reads++
|
||||
return f.inspect()
|
||||
})
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(result.packages.length, 2)
|
||||
assert.equal(result.contentVerified, true)
|
||||
assert.equal(result.publicationAuthorized, false)
|
||||
assert.equal(result.workflowProvenanceVerified, false)
|
||||
assert.equal(result.registryOccupancyVerified, false)
|
||||
assert.deepEqual(fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))]), before)
|
||||
})
|
||||
|
||||
test('A changed manifest is rejected before interpreting its report or inspecting the repository', (t) => {
|
||||
const f = fixture(t)
|
||||
fs.appendFileSync(f.manifestFile, ' ')
|
||||
assert.throws(() => f.verify(() => {
|
||||
throw new Error('must not inspect')
|
||||
}), /independent digest/)
|
||||
})
|
||||
|
||||
test('Self-consistent forged manifest fields cannot replace fresh repository evidence', (t) => {
|
||||
const f = fixture(t)
|
||||
const original = fs.readFileSync(f.manifestFile)
|
||||
const cases = [
|
||||
m => m.publicationAuthorized = true,
|
||||
m => m.registry = 'https://example.invalid/',
|
||||
m => m.tag = 'latest',
|
||||
m => m.sourceCommit = 'd'.repeat(40),
|
||||
m => m.toolchain.lock.sha256 = 'e'.repeat(64),
|
||||
m => m.preflight.sha256 = 'e'.repeat(64),
|
||||
m => m.preflight.file = '../preflight.json',
|
||||
m => m.packages.reverse(),
|
||||
m => m.packages.push(m.packages[0]),
|
||||
m => m.packages[0].version = '7.0.0',
|
||||
m => m.packages[0].file = '../escape.tgz',
|
||||
m => m.packages[0].sha256 = 'e'.repeat(64),
|
||||
m => m.packages[0].integrity = 'sha512-forged',
|
||||
m => m.packages[0].sourceCommit = 'd'.repeat(40),
|
||||
m => m.packages[0].inputFingerprint = 'new-inputs',
|
||||
m => m.kind = 'other-bundle',
|
||||
m => m.extraApproval = true,
|
||||
]
|
||||
for (const change of cases) {
|
||||
fs.writeFileSync(f.manifestFile, original)
|
||||
f.modifyManifest(change)
|
||||
assert.throws(() => f.verify(), /metadata differs/)
|
||||
}
|
||||
})
|
||||
|
||||
test('A freshly blocked or changed ledger rejects even an intact formerly accepted bundle', (t) => {
|
||||
const f = fixture(t)
|
||||
const changes = [
|
||||
r => r.evidenceComplete = false,
|
||||
r => r.packages[0].status = 'blocked',
|
||||
r => r.packages[0].blockers.push('new finding'),
|
||||
r => r.packages[0].distribution = 'site',
|
||||
r => r.packages[0].candidate.errors.push('changed candidate'),
|
||||
r => r.packages[0].candidate.inputFingerprint = 'stale',
|
||||
r => r.sourceCommit = 'd'.repeat(40),
|
||||
r => r.toolchain.lock.sha256 = 'd'.repeat(64),
|
||||
]
|
||||
for (const change of changes) {
|
||||
assert.throws(() => f.verify(() => {
|
||||
const report = f.inspect()
|
||||
change(report)
|
||||
return report
|
||||
}))
|
||||
}
|
||||
fs.writeFileSync(path.join(f.directory, 'preflight.json'), JSON.stringify({ ...f.report, extraApproval: true }))
|
||||
f.modifyManifest(m => m.preflight.sha256 = digest(fs.readFileSync(path.join(f.directory, 'preflight.json'))))
|
||||
assert.throws(() => f.verify(), /Downloaded preflight differs/)
|
||||
})
|
||||
|
||||
test('Changed archive bytes, even if copied into the registered location, cannot reuse candidate integrity', (t) => {
|
||||
const f = fixture(t)
|
||||
const file = path.join(f.directory, 'artplayer-6.0.0.tgz')
|
||||
fs.appendFileSync(file, 'tampered')
|
||||
assert.throws(() => f.verify(), /downloaded tarball differs/)
|
||||
fs.copyFileSync(file, path.join(f.repository, f.report.packages[0].candidate.path))
|
||||
assert.throws(() => f.verify(), /candidate integrity changed/)
|
||||
})
|
||||
|
||||
test('Extra files and missing archives fail without deleting downloaded evidence', (t) => {
|
||||
const f = fixture(t)
|
||||
fs.writeFileSync(path.join(f.directory, 'extra.sh'), 'exit 1')
|
||||
assert.throws(() => f.verify(), /Unexpected or missing bundle files/)
|
||||
assert(fs.existsSync(path.join(f.directory, 'extra.sh')))
|
||||
fs.unlinkSync(path.join(f.directory, 'extra.sh'))
|
||||
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
|
||||
assert.throws(() => f.verify(), /ENOENT/)
|
||||
assert(fs.existsSync(f.manifestFile))
|
||||
})
|
||||
|
||||
test('Redirected directories, redirected parents and non-file members are rejected', (t) => {
|
||||
const f = fixture(t)
|
||||
const link = path.join(f.repository, 'redirect')
|
||||
fs.symlinkSync(f.directory, link, 'junction')
|
||||
assert.throws(() => verifyReleaseBundle(f.repository, link, f.expected, f.inspect), /regular directory|Redirected bundle/)
|
||||
const parent = path.join(f.repository, 'redirect-parent')
|
||||
fs.symlinkSync(f.repository, parent, 'junction')
|
||||
assert.throws(() => verifyReleaseBundle(f.repository, path.join(parent, 'downloaded'), f.expected, f.inspect), /Redirected bundle/)
|
||||
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
|
||||
fs.symlinkSync(path.join(f.repository, 'artplayer'), path.join(f.directory, 'artplayer-6.0.0.tgz'), 'junction')
|
||||
assert.throws(() => f.verify(), /not a regular file/)
|
||||
})
|
||||
|
||||
test('Source or downloaded content changed during verification cannot produce a successful result', (t) => {
|
||||
const mutations = [
|
||||
f => fs.appendFileSync(f.manifestFile, ' '),
|
||||
f => fs.appendFileSync(path.join(f.directory, 'preflight.json'), ' '),
|
||||
f => fs.appendFileSync(path.join(f.directory, 'artplayer-6.0.0.tgz'), ' '),
|
||||
f => fs.writeFileSync(path.join(f.directory, 'late-file'), ' '),
|
||||
f => f.report.toolchain.lock.sha256 = 'd'.repeat(64),
|
||||
]
|
||||
for (const mutate of mutations) {
|
||||
const f = fixture(t)
|
||||
let reads = 0
|
||||
assert.throws(() => f.verify(() => {
|
||||
if (++reads === 2)
|
||||
mutate(f)
|
||||
return f.inspect()
|
||||
}), /changed during verification/)
|
||||
assert.equal(reads, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('Invalid independent selection, tag, source and digest fail before reading downloaded contents', (t) => {
|
||||
const f = fixture(t)
|
||||
for (const change of [{ names: [] }, { names: ['artplayer', 'artplayer'] }, { names: ['../artplayer'] }, { tag: 'arbitrary' }, { sourceCommit: 'master' }, { manifestSha256: '' }])
|
||||
assert.throws(() => verifyReleaseBundle(f.repository, 'not-a-directory', { ...f.expected, ...change }, f.inspect), /Supply|Select/)
|
||||
})
|
||||
|
||||
test('Verifier CLI rejects omitted inputs, unknown options and an unsupported package manager', (t) => {
|
||||
const f = fixture(t)
|
||||
const script = path.resolve('scripts/verify-release.mjs')
|
||||
const full = ['--directory', f.directory, '--packages', f.expected.names.join(','), '--tag', 'next', '--source-commit', f.expected.sourceCommit, '--manifest-sha256', f.expected.manifestSha256]
|
||||
for (const [args, message] of [[[], /are required/], [['--unknown'], /Unknown option/], [full, /Use yarn release:verify-bundle/]]) {
|
||||
assert.throws(() => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe', env: { ...process.env, npm_config_user_agent: 'npm/11.5.1' } }), (error) => {
|
||||
assert.equal(error.status, 1)
|
||||
assert.match(error.stderr.toString(), message)
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('A second-inspection exception is preserved and downloaded evidence is retained', (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('fresh evidence read failed')
|
||||
let reads = 0
|
||||
assert.throws(() => f.verify(() => {
|
||||
if (++reads === 2)
|
||||
throw failure
|
||||
return f.inspect()
|
||||
}), error => error === failure)
|
||||
assert.equal(reads, 2)
|
||||
assert(fs.existsSync(f.manifestFile))
|
||||
})
|
||||
+22
-1
@@ -835,7 +835,8 @@
|
||||
"CI-01",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LedgerSnapshot } from './bundle.ts'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
|
||||
import { bundleManifest, checkBatch } from './bundle.ts'
|
||||
|
||||
interface BundleExpectation {
|
||||
sourceCommit: string
|
||||
manifestSha256: string
|
||||
names: string[]
|
||||
tag: string
|
||||
}
|
||||
|
||||
const digest = (bytes: Uint8Array, algorithm = 'sha256', encoding: 'hex' | 'base64' = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
|
||||
|
||||
function readFile(directory: string, name: string) {
|
||||
assert(name && path.basename(name) === name && !/[\\/:]/.test(name) && name !== '.' && name !== '..', 'Bundle member must be a single filename')
|
||||
const file = path.join(directory, name)
|
||||
assert(fs.lstatSync(file).isFile(), `Bundle member is not a regular file: ${name}`)
|
||||
assert.equal(fs.realpathSync(file), file, `Redirected bundle member: ${name}`)
|
||||
return fs.readFileSync(file)
|
||||
}
|
||||
|
||||
// The CLI computes inspect from the current repository. A downloaded report is
|
||||
// never used to decide whether the current candidate's release gates have passed.
|
||||
export function verifyReleaseBundle(repository: string, directory: string, expected: BundleExpectation, inspect: (repository: string, names: string[]) => LedgerSnapshot = buildLedger) {
|
||||
assert(/^[a-f\d]{40}$/.test(expected.sourceCommit), 'Supply the independently expected source commit')
|
||||
assert(/^[a-f\d]{64}$/.test(expected.manifestSha256), 'Supply the independently expected manifest SHA-256')
|
||||
assert(expected.names.length && new Set(expected.names).size === expected.names.length && expected.names.every(name => /^artplayer(?:-[a-z0-9]+)*$/.test(name)), 'Select explicit, unique workspace packages')
|
||||
assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(expected.tag), 'Select an explicit supported tag')
|
||||
repository = fs.realpathSync(repository)
|
||||
directory = path.resolve(directory)
|
||||
assert(fs.lstatSync(directory).isDirectory(), 'Bundle path is not a regular directory')
|
||||
assert.equal(fs.realpathSync(directory), directory, 'Redirected bundle directory')
|
||||
const initialFiles = fs.readdirSync(directory).sort()
|
||||
const bytes = readFile(directory, 'manifest.json')
|
||||
assert.equal(digest(bytes), expected.manifestSha256, 'Bundle manifest differs from the independent digest')
|
||||
// JSON stays untrusted until exact comparison with a freshly computed manifest.
|
||||
const manifest = JSON.parse(bytes.toString('utf8')) as Record<string, unknown>
|
||||
const fresh = inspect(repository, expected.names)
|
||||
checkBatch(fresh, expected.names, expected.tag)
|
||||
assert.equal(fresh.sourceCommit, expected.sourceCommit, 'Current source differs from the expected commit')
|
||||
const preflight = readFile(directory, 'preflight.json')
|
||||
assert.deepEqual(JSON.parse(preflight.toString('utf8')), fresh, 'Downloaded preflight differs from the fresh repository ledger')
|
||||
const packages = fresh.packages.map((row) => {
|
||||
const candidate = row.candidate!
|
||||
const file = `${row.name}-${row.version}.tgz`
|
||||
const packed = readFile(directory, file)
|
||||
const registered = fs.readFileSync(localFile(repository, candidate.path))
|
||||
assert.deepEqual(packed, registered, `${row.name}: downloaded tarball differs from registered candidate`)
|
||||
assert.equal(`sha512-${digest(packed, 'sha512', 'base64')}`, candidate.integrity, `${row.name}: candidate integrity changed`)
|
||||
return { name: row.name, version: row.version, file, bytes: packed.length, sha256: digest(packed), integrity: candidate.integrity, sourceCommit: candidate.sourceCommit, inputFingerprint: row.fingerprint }
|
||||
})
|
||||
assert.deepEqual(manifest, bundleManifest(fresh, expected.tag, digest(preflight), packages), 'Bundle metadata differs from the freshly verified candidate batch')
|
||||
assert.deepEqual(initialFiles, ['manifest.json', 'preflight.json', ...packages.map(pkg => pkg.file)].sort(), 'Unexpected or missing bundle files')
|
||||
const final = inspect(repository, expected.names)
|
||||
checkBatch(final, expected.names, expected.tag)
|
||||
assert.deepEqual(final, fresh, 'Release inputs or evidence changed during verification')
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), initialFiles, 'Bundle membership changed during verification')
|
||||
assert.deepEqual(readFile(directory, 'manifest.json'), bytes, 'Bundle manifest changed during verification')
|
||||
assert.deepEqual(readFile(directory, 'preflight.json'), preflight, 'Bundle preflight changed during verification')
|
||||
for (const item of packages)
|
||||
assert.equal(digest(readFile(directory, item.file)), item.sha256, `${item.name}: bundle changed during verification`)
|
||||
return { sourceCommit: fresh.sourceCommit, manifestSha256: expected.manifestSha256, packages, contentVerified: true, workflowProvenanceVerified: false, registryOccupancyVerified: false, publicationAuthorized: false }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { buildLedger, root } from '../refactor/scripts/release-ledger.mjs'
|
||||
import { assertCleanSource } from './release/prepare.ts'
|
||||
import { verifyReleaseBundle } from './release/verify.ts'
|
||||
|
||||
try {
|
||||
const { values } = parseArgs({ options: { 'directory': { type: 'string' }, 'packages': { type: 'string' }, 'tag': { type: 'string' }, 'source-commit': { type: 'string' }, 'manifest-sha256': { type: 'string' } }, strict: true })
|
||||
assert(values.directory && values.packages && values.tag && values['source-commit'] && values['manifest-sha256'], 'Explicit --directory, --packages, --tag, --source-commit and --manifest-sha256 are required')
|
||||
assert.equal(process.version, `v${fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()}`, 'Use the canonical Node version')
|
||||
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:verify-bundle')
|
||||
const result = verifyReleaseBundle(root, values.directory, { names: values.packages.split(','), tag: values.tag, sourceCommit: values['source-commit'], manifestSha256: values['manifest-sha256'] }, (directory, names) => {
|
||||
assertCleanSource(directory)
|
||||
return buildLedger(directory, names)
|
||||
})
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
Reference in new issue
Block a user