diff --git a/package.json b/package.json
index 2fb9b6983..27e60bd7d 100644
--- a/package.json
+++ b/package.json
@@ -178,11 +178,12 @@
"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 refactor/scripts/release-verify.test.mjs",
+ "test:release-bundle": "node --test refactor/scripts/release-bundle.test.mjs refactor/scripts/release-verify.test.mjs refactor/scripts/release-registry.test.mjs",
"test:ecosystem-types": "node scripts/consumers/ecosystem.ts",
"check:versions": "node refactor/scripts/version-plan.mjs --prepared",
"release:verify-bundle": "yarn check:toolchain --strict && node scripts/verify-release.mjs",
- "check:site-links": "node scripts/check-site-links.mjs"
+ "check:site-links": "node scripts/check-site-links.mjs",
+ "release:registry": "yarn check:toolchain --strict && node scripts/check-release-registry.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/baselines/npm-registry-validation.json b/refactor/baselines/npm-registry-validation.json
new file mode 100644
index 000000000..0c991b027
--- /dev/null
+++ b/refactor/baselines/npm-registry-validation.json
@@ -0,0 +1,85 @@
+{
+ "schemaVersion": 1,
+ "task": "CI-NPM-03",
+ "startedFrom": "783d1e3fd49ba56ed0ea38c29d385de1ae1adde0",
+ "verifiedAt": "2026-09-16T03:05:28.815Z",
+ "node": "v24.21.0",
+ "yarn": "1.22.22",
+ "publicationAuthorized": false,
+ "tests": {
+ "tests": 47,
+ "pass": 47,
+ "fail": 0,
+ "skipped": 0,
+ "newCaseGroups": 11
+ },
+ "checks": {
+ "releaseTypes": 0,
+ "rootReadOnlyLint": 0,
+ "testLint": 0,
+ "strictToolchain": 0
+ },
+ "logs": [
+ {
+ "path": "refactor/.cache/ci-npm03-tests-final.log",
+ "sha256": "ec2100090c5047221445ad4cd4c9cbada68007ee8644cfa9c547cfdeb6071d76"
+ },
+ {
+ "path": "refactor/.cache/ci-npm03-types.log",
+ "sha256": "f7fa41d4b38f0aee7465d0bbd9a6dc4d897d68b614eca31e75ffe6c02f013238"
+ },
+ {
+ "path": "refactor/.cache/ci-npm03-lint-full.log",
+ "sha256": "0f8299250746d55589153f2637319b27269f72d4ca307ad02d5ac4977e8a3797"
+ },
+ {
+ "path": "refactor/.cache/ci-npm03-test-lint.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ {
+ "path": "refactor/.cache/ci-npm03-toolchain.log",
+ "sha256": "2ed5576669d9dfbe0cbfe752df3e28c268bc885b9c5626430044985a45e1bdc5"
+ }
+ ],
+ "live": {
+ "scope": "Transport and pure classification only; no real bundle gate was waived or passed.",
+ "historical": {
+ "name": "artplayer",
+ "version": "5.4.0",
+ "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
+ "tag": "latest",
+ "observedAt": "2026-09-16T03:02:07.060Z",
+ "responseSha256": "560fa27d689284c6e1743835b765e790613b0b11d7255ea0390eb0017a7e9748",
+ "httpStatus": 200,
+ "state": "already-present",
+ "currentTag": "5.4.0",
+ "reason": "Registry SHA-512 matches; do not republish. Tag changes require separate authorization."
+ },
+ "target": {
+ "name": "artplayer",
+ "version": "6.0.0",
+ "integrity": "sha512-3HMgVqBMQzTDNJtey+EPO8UlU9+b22SbFgwCFmwXHg/1gAg7iMREaeGi0BtZrYzTAc9B7uZRtP75jBjlDa6OfA==",
+ "tag": "next",
+ "observedAt": "2026-09-16T03:02:07.060Z",
+ "responseSha256": "560fa27d689284c6e1743835b765e790613b0b11d7255ea0390eb0017a7e9748",
+ "httpStatus": 200,
+ "state": "not-observed",
+ "currentTag": null,
+ "reason": "Version not found; absence does not prove it can be published."
+ },
+ "registryTarballDownloaded": false,
+ "response": {
+ "path": "refactor/.cache/ci-npm03-live-observation.json",
+ "sha256": "d2f5fdc6ccc30ebddb4b3cac6b4fde15eb0afbd82d0ead8ecabb2d09d7dc7566"
+ }
+ },
+ "limitations": [
+ "No publishing, tag mutation, remote workflow, registry tarball download, or authorization test.",
+ "Synthetic eligible ledgers test orchestration; actual package gates remain open.",
+ "Shared tool changes invalidate existing candidate input fingerprints."
+ ],
+ "sources": [
+ "https://docs.npmjs.com/cli/v11/commands/npm-publish/",
+ "https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md"
+ ]
+}
diff --git a/refactor/changes/2026-09-16-CI-NPM-03-registry.md b/refactor/changes/2026-09-16-CI-NPM-03-registry.md
new file mode 100644
index 000000000..1343cbab1
--- /dev/null
+++ b/refactor/changes/2026-09-16-CI-NPM-03-registry.md
@@ -0,0 +1,50 @@
+# CI-NPM-03:只读 registry 检查与部分发布恢复判断
+
+## 目的与边界
+
+从 CI-03 拆出独立可实施的 registry 预检,保留 CI-03 原有依赖和发布流程验收。
+没有进入 REVIEW-01/02/03,没有推送、发布、撤销版本或调整 tag。
+
+新增 `yarn release:registry`,五个必填参数与 `release:verify-bundle` 相同:
+`--directory`、`--packages`、`--tag`、`--source-commit`、`--manifest-sha256`。
+命令先验证干净源码、独立摘要、下载包与登记候选及实时发布台账,再读公共 registry,
+最后复核本地内容和门槛。当前真实候选仍有开放门槛,不能借此命令绕过。
+
+## 实现与维护
+
+- `scripts/release/registry.ts`:固定官方 origin 的无认证 GET、15 秒请求/响应期限、
+ 10 MiB 上限、禁重定向,以及只认自身字段的元数据解析与逐包判断。
+- `scripts/release/registry-check.ts`:查询前后调用现有候选验证器;部分成功的包分别保留
+ `not-observed`、`already-present`、`tag-change-required`、`conflict` 结果。
+- `scripts/check-release-registry.mjs`:固定 Node/Yarn、独立输入和退出状态;冲突输出报告后
+ exit 1,网络/格式/本地证据失败 exit 1,不生成成功报告。
+- 没有新增依赖或更改锁文件。TS 配置和既有 CI 类型入口自动覆盖新模块;新增测试加入
+ `test:release-bundle`,也由 `test:baseline` 自动发现。维护说明位于
+ [scripts/release/README.md](../../scripts/release/README.md)。
+
+查不到版本不证明可以使用该版本。可见的撤销历史直接阻断;没有历史也保留未知。
+已有相同 SHA-512 时禁止重复发布;tag 不同只报告待调整,包括可能倒退到旧版本的情况,
+不会自动更改。摘要匹配是元数据匹配,不是远端 tarball 下载或工作流来源验证。
+未来 CI-03 必须在授权使用时重新检查状态,并负责远端信任、权限、精确文件发布及读回。
+
+## 验证
+
+Node 24.21.0 / Yarn 1.22.22,起点 `783d1e3fd49ba56ed0ea38c29d385de1ae1adde0`。
+
+- 发布工具 47/47 测试通过,其中新增 11 个用例组覆盖混合部分发布、tag-only、缺失/冲突
+ 摘要、撤销历史、格式错误、HTTP/传输/响应体失败、大小限制、查询中本地变化、CLI 拒绝。
+ 成功路径注入的完整台账是合成测试数据,不代表真实包准入。
+- 严格 release 类型检查、全仓只读 lint、测试文件定向 lint、严格工具链检查通过。
+- 真实只读 GET 返回 200;冻结 `artplayer@5.4.0` 的 SHA-512 与 registry 相符,latest
+ 仍为 5.4.0;6.0.0 在本次响应中不存在。只测试底层传输与解析,不冒充通过真实候选
+ bundle 端到端发布门槛。时间、响应及日志摘要见[证据](../baselines/npm-registry-validation.json)。
+- 初次测试文件格式修正期间产生的多余右括号导致解析失败,修正后重跑 47/47;此前
+ quote-props 格式报错也已修复,未删除或跳过失败用例。
+
+共享工程源码和根脚本改变会使此前候选输入指纹失效;21 个库包必须在共享工程稳定后
+重新生成候选并绑定证据。运行时与公开声明没有改动,本任务不宣称候选仍可准入。
+
+## 回退与剩余工作
+
+回退本任务提交会移除新命令/模块/测试及文档,现有 bundle 准备和内容验证保留。
+CI-03 继续负责实际工作流、可信远端来源、OIDC/权限、授权执行和读回;未启用远端流程。
diff --git a/refactor/github-ci-cd.md b/refactor/github-ci-cd.md
index e8ab6fe7f..e74efd09a 100644
--- a/refactor/github-ci-cd.md
+++ b/refactor/github-ci-cd.md
@@ -60,6 +60,13 @@ CI-NPM-01 从 CI-03 拆出本地精确候选交付准备,供后续 artifact
验收全部保留。当前包仍被准入门槛阻止,详见[记录](changes/2026-09-15-CI-NPM-01-bundle.md)
及[实现维护](../scripts/release/README.md)。
+CI-NPM-03补齐只读 registry 预检:`yarn release:registry`沿用下载校验的五个必填参数,
+先后两次校验候选与实时台账,中间读取固定公共 registry。逐包区分未观察到版本、
+相同摘要且 tag 已就位、仅需 tag 调整、摘要/撤销历史冲突;网络和格式错误不算可用。
+缺失版本不能证明可发布,同摘要也只是 registry 元数据匹配,不是远端 tarball 下载验证。
+命令不发布、不调整 tag、不授权、不跳过原准入;CI-03仍负责远端信任、权限、执行和读回。
+用法与恢复边界见[scripts/release](../scripts/release/README.md)。
+
CI-NPM-02补齐下载后的内容校验:`yarn release:verify-bundle`要求独立的源码SHA、
manifest摘要和明确包批次/tag,重算当前仓库台账并比对已登记tarball,不能由
下载报告自证准入。它不读取GitHub/npm,不证明workflow/run/artifact来源或版本
diff --git a/refactor/plan.md b/refactor/plan.md
index f9fa2aea7..180c262a1 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
-基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 285 项,范围 22 个包及工作区/示例。
+基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 286 项,范围 22 个包及工作区/示例。
-状态:todo 36 / doing 24 / blocked 0 / done 225 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 36 / doing 24 / blocked 0 / done 226 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -96,7 +96,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| 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, CI-NPM-02 | 候选准备、精确 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, CI-NPM-03 | 候选准备、精确 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 |
@@ -104,6 +104,7 @@
| 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 |
+| CI-NPM-03 | workspace
建立只读 registry 预检和部分发布恢复判断 | CI-NPM-02, REL-09 | 绑定已验证 bundle 的 registry 元数据预检、摘要冲突与 tag-only 判断、网络/漂移失败测试和维护文档 | 不绕过实时发布门槛,不把缺失版本当作可发布授权;部分批次逐包记录、失败关闭、查询后复核内容;实际发布与远端工作流仍由 CI-03 验收 | H | done |
## 2.1 早期试点
@@ -752,4 +753,5 @@
- 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)
+- CI-NPM-03: [记录](changes/2026-09-16-CI-NPM-03-registry.md) [记录](baselines/npm-registry-validation.json)
- PKG-AUDIO-BUFFER-01: [记录](changes/2026-09-16-PKG-AUDIO-BUFFER-01-order.md) [记录](baselines/audio-buffer-order-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index 1c41067bc..052f7f615 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,15 @@
# 进度与证据
+## CI-NPM-03 只读 registry 与部分发布恢复判断
+
+新增 `yarn release:registry`,在真实候选验证前后夹住只读 registry 查询,区分缺失、
+同摘要已就位、tag-only 和冲突;始终不授权或执行发布。发布工具47/47、严格类型、
+全仓/测试lint和工具链通过,真实GET核对5.4.0冻结摘要与6.0.0缺失状态。
+[实现与限制](changes/2026-09-16-CI-NPM-03-registry.md)、
+[证据](baselines/npm-registry-validation.json)。CI-03保留原门槛并新增此依赖。
+共享脚本变动使旧候选输入指纹失效,后续共享工程稳定后重新生成,不沿用过期准入。
+226 done / 24 doing / 36 todo;不开始正式复盘,不推送或发布。
+
## PKG-CAST-05 实际 Chrome / 远端 SDK 检查点
Chrome连接恢复,使用152.0.7977.84实际打开8082示例。工作区5.4.1和重构6.0.0
diff --git a/refactor/scripts/release-registry.test.mjs b/refactor/scripts/release-registry.test.mjs
new file mode 100644
index 000000000..cb85a30f9
--- /dev/null
+++ b/refactor/scripts/release-registry.test.mjs
@@ -0,0 +1,189 @@
+import assert from 'node:assert/strict'
+import { Buffer } from 'node:buffer'
+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 { checkReleaseRegistry } from '../../scripts/release/registry-check.ts'
+import { assessRegistry, observeRegistry, registry } from '../../scripts/release/registry.ts'
+
+const hash = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
+const candidate = { name: 'artplayer', version: '6.0.0', integrity: `sha512-${hash('candidate', 'sha512', 'base64')}` }
+const metadata = (item = candidate, tag = item.version) => ({ 'name': item.name, 'versions': { [item.version]: { name: item.name, version: item.version, dist: { integrity: item.integrity } } }, 'dist-tags': tag ? { next: tag } : {} })
+const observation = (document, status = 200, name = candidate.name) => ({ url: `${registry}${name}`, status, observedAt: '2026-09-16T00:00:00.000Z', sha256: hash(JSON.stringify(document)), metadata: document })
+const assess = (document, status = 200) => assessRegistry(candidate, 'next', observation(document, status))
+
+test('Partial publication decisions distinguish absent, matching, tag-only and conflicting versions', () => {
+ assert.equal(assess({ ...metadata(candidate, '5.4.0'), versions: {} }).state, 'not-observed')
+ assert.equal(assess({ error: 'Not found' }, 404).state, 'not-observed')
+ assert.equal(assess(metadata()).state, 'already-present')
+ for (const tag of ['5.4.0', '7.0.0', null]) {
+ const result = assess(metadata(candidate, tag))
+ assert.equal(result.state, 'tag-change-required')
+ assert.equal(result.currentTag, tag)
+ }
+ for (const integrity of [undefined, 'sha1-old', `sha512-${hash('different', 'sha512', 'base64')}`]) {
+ const doc = metadata()
+ doc.versions['6.0.0'].dist = { integrity }
+ assert.equal(assess(doc).state, 'conflict')
+ }
+})
+
+test('Unpublish history never becomes an available-version claim', () => {
+ assert.equal(assess({ name: 'artplayer', time: { unpublished: { time: '2026-01-01' } } }).state, 'conflict')
+ assert.equal(assess({ ...metadata(candidate, '5.4.0'), versions: {}, time: { '6.0.0': '2026-01-01' } }).state, 'conflict')
+ assert.match(assess({ error: 'Not found' }, 404).reason, /unknown/)
+})
+
+test('Malformed or mismatched registry data never silently indicates an absent version', () => {
+ const cases = [null, [], {}, { ...metadata(), name: 'other' }, { ...metadata(), versions: null }, { ...metadata(), 'dist-tags': [] }, { ...metadata(), 'dist-tags': { next: 6 } }, { ...metadata(), time: [] }]
+ for (const document of cases)
+ assert.throws(() => assess(document))
+ for (const change of [p => p.name = 'other', p => p.version = '7.0.0', p => p.dist = null]) {
+ const doc = metadata()
+ change(doc.versions['6.0.0'])
+ assert.throws(() => assess(doc))
+ }
+ assert.throws(() => assess({ error: 'unauthorized' }, 404))
+ assert.throws(() => assess(metadata(), 500))
+ assert.throws(() => assessRegistry(candidate, 'next', observation(metadata(), 200, 'artplayer-plugin-chapter')))
+ assert.throws(() => assess({ ...metadata(), versions: {} }), /missing candidate/)
+ const inherited = Object.create({ '6.0.0': metadata().versions['6.0.0'] })
+ assert.equal(assess({ ...metadata(candidate, '5.4.0'), versions: inherited }).state, 'not-observed')
+})
+
+test('Invalid names, candidate integrity, tag and prerelease latest are rejected', () => {
+ for (const item of [{ ...candidate, name: '../escape' }, { ...candidate, version: 'latest' }, { ...candidate, integrity: 'sha512-invalid' }])
+ assert.throws(() => assessRegistry(item, 'next', observation(metadata())))
+ assert.throws(() => assessRegistry(candidate, 'other', observation(metadata())))
+ assert.throws(() => assessRegistry({ ...candidate, version: '6.0.0-rc.1' }, 'latest', observation(metadata())))
+})
+
+test('Transport requests only the official public GET endpoint with deadline and no redirects', async () => {
+ const document = metadata()
+ const result = await observeRegistry('artplayer', async (url, options) => {
+ assert.equal(url, 'https://registry.npmjs.org/artplayer')
+ assert.equal(options.method, 'GET')
+ assert.equal(options.redirect, 'error')
+ assert(options.signal instanceof AbortSignal)
+ assert.deepEqual(options.headers, { 'accept': 'application/json', 'cache-control': 'no-cache' })
+ assert.equal(options.body, undefined)
+ return new Response(JSON.stringify(document))
+ })
+ assert.deepEqual(result.metadata, document)
+ assert.equal(result.sha256, hash(JSON.stringify(document)))
+ assert(Number.isFinite(Date.parse(result.observedAt)))
+ await assert.rejects(observeRegistry('../escape', () => assert.fail('must not fetch')))
+})
+
+test('HTTP failures, malformed JSON, oversized and interrupted bodies reject instead of reporting availability', async () => {
+ for (const status of [301, 401, 403, 429, 500])
+ await assert.rejects(observeRegistry('artplayer', async () => new Response('{}', { status })), /registry HTTP/)
+ await assert.rejects(observeRegistry('artplayer', async () => new Response('gateway')))
+ await assert.rejects(observeRegistry('artplayer', async () => new Response('x'.repeat(10 * 1024 * 1024 + 1))), /exceeds/)
+ await assert.rejects(observeRegistry('artplayer', async () => {
+ throw new Error('connection reset')
+ }), /connection reset/)
+ await assert.rejects(observeRegistry('artplayer', async () => new Response(new ReadableStream({
+ start(controller) {
+ controller.error(new Error('body interrupted'))
+ },
+ }))), /body interrupted/)
+ const missing = await observeRegistry('artplayer', async () => new Response('{"error":"Not found"}', { status: 404 }))
+ assert.equal(assessRegistry(candidate, 'next', missing).state, 'not-observed')
+})
+
+// Synthetic complete gates and arbitrary bytes test orchestration, not real package admission.
+function fixture(t) {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-registry-test-'))
+ fs.mkdirSync(path.join(directory, 'refactor/.cache'), { recursive: true })
+ t.after(() => {
+ const resolved = fs.realpathSync(directory)
+ assert.equal(path.dirname(resolved), fs.realpathSync(os.tmpdir()))
+ assert(path.basename(resolved).startsWith('artplayer-registry-test-'))
+ fs.rmSync(resolved, { recursive: true })
+ })
+ const names = ['artplayer', 'artplayer-plugin-chapter', 'artplayer-plugin-ads', 'artplayer-plugin-chromecast']
+ const packages = names.map((name) => {
+ const bytes = Buffer.from(name)
+ const file = `refactor/.cache/${name}.tgz`
+ fs.writeFileSync(path.join(directory, file), bytes)
+ return { name, version: '6.0.0', distribution: 'npm', fingerprint: name, status: 'evidence-complete', blockers: [], candidate: { path: file, version: '6.0.0', sourceCommit: 'a'.repeat(40), inputFingerprint: name, integrity: `sha512-${hash(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 bundle = prepareReleaseBundle(directory, names, 'next', inspect)
+ const expected = { names, tag: 'next', sourceCommit: report.sourceCommit, manifestSha256: hash(fs.readFileSync(path.join(bundle.directory, 'manifest.json'))) }
+ const run = fetcher => checkReleaseRegistry(directory, bundle.directory, expected, inspect, fetcher)
+ return { directory, bundle, expected, report, run }
+}
+
+test('A partially published batch retains all four decisions and never authorizes mutations', async (t) => {
+ const f = fixture(t)
+ let calls = 0
+ const result = await f.run(async (url) => {
+ const index = calls++
+ const row = f.report.packages[index]
+ assert.equal(url, `${registry}${row.name}`)
+ const doc = metadata({ ...row, integrity: row.candidate.integrity }, index === 0 || index === 2 ? '5.0.0' : row.version)
+ if (index === 0)
+ doc.versions = {}
+ if (index === 3)
+ doc.versions[row.version].dist.integrity = candidate.integrity
+ return new Response(JSON.stringify(doc))
+ })
+ assert.equal(calls, 4)
+ assert.deepEqual(result.packages.map(p => p.state), ['not-observed', 'already-present', 'tag-change-required', 'conflict'])
+ assert.deepEqual(result.conflicts, ['artplayer-plugin-chromecast'])
+ assert.equal(result.publicationAuthorized, false)
+ assert.equal(result.workflowProvenanceVerified, false)
+ assert.equal(result.manifestSha256, f.expected.manifestSha256)
+})
+
+test('Blocked local evidence prevents even the first network request', async (t) => {
+ const f = fixture(t)
+ f.report.evidenceComplete = false
+ await assert.rejects(f.run(() => assert.fail('must not fetch')), /preflight is blocked/)
+})
+
+test('Changes to gates or bundle during registry lookup invalidate the whole result', async (t) => {
+ for (const mutate of [f => f.report.evidenceComplete = false, f => fs.appendFileSync(path.join(f.bundle.directory, 'artplayer-6.0.0.tgz'), 'changed')]) {
+ const f = fixture(t)
+ let calls = 0
+ await assert.rejects(f.run(async () => {
+ if (!calls++)
+ mutate(f)
+ return new Response('{"error":"Not found"}', { status: 404 })
+ }))
+ assert.equal(calls, 4)
+ }
+})
+
+test('An interrupted batch does not return a successful partial plan', async (t) => {
+ const f = fixture(t)
+ let calls = 0
+ await assert.rejects(f.run(async () => {
+ if (++calls === 2)
+ throw new Error('transport unavailable')
+ return new Response('{"error":"Not found"}', { status: 404 })
+ }), /transport unavailable/)
+ assert.equal(calls, 2)
+})
+
+test('Registry CLI rejects omitted inputs, unknown options and an unsupported package manager', (t) => {
+ const f = fixture(t)
+ const script = path.resolve('scripts/check-release-registry.mjs')
+ const full = ['--directory', f.bundle.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:registry/]]) {
+ 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
+ })
+ }
+})
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 6c5e8f075..a19b97e69 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -836,7 +836,8 @@
"REL-08",
"REL-04",
"CI-NPM-01",
- "CI-NPM-02"
+ "CI-NPM-02",
+ "CI-NPM-03"
],
"status": "todo",
"risk": "H",
@@ -6245,6 +6246,26 @@
"baselines/npm-bundle-verification.json"
]
},
+ {
+ "id": "CI-NPM-03",
+ "phase": "2.2 GitHub CI/CD",
+ "title": "建立只读 registry 预检和部分发布恢复判断",
+ "scope": [
+ "workspace"
+ ],
+ "dependsOn": [
+ "CI-NPM-02",
+ "REL-09"
+ ],
+ "status": "done",
+ "risk": "H",
+ "deliverable": "绑定已验证 bundle 的 registry 元数据预检、摘要冲突与 tag-only 判断、网络/漂移失败测试和维护文档",
+ "acceptance": "不绕过实时发布门槛,不把缺失版本当作可发布授权;部分批次逐包记录、失败关闭、查询后复核内容;实际发布与远端工作流仍由 CI-03 验收",
+ "evidence": [
+ "changes/2026-09-16-CI-NPM-03-registry.md",
+ "baselines/npm-registry-validation.json"
+ ]
+ },
{
"id": "PKG-AUDIO-BUFFER-01",
"phase": "5 包迁移:artplayer-plugin-audio-track",
diff --git a/scripts/check-release-registry.mjs b/scripts/check-release-registry.mjs
new file mode 100644
index 000000000..c71b748a0
--- /dev/null
+++ b/scripts/check-release-registry.mjs
@@ -0,0 +1,26 @@
+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 { checkReleaseRegistry } from './release/registry-check.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:registry')
+ const result = await checkReleaseRegistry(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, null, 2))
+ if (result.conflicts.length)
+ process.exitCode = 1
+}
+catch (error) {
+ console.error(error instanceof Error ? error.message : error)
+ process.exitCode = 1
+}
diff --git a/scripts/release/README.md b/scripts/release/README.md
index 65a9a40b6..363c6eb26 100644
--- a/scripts/release/README.md
+++ b/scripts/release/README.md
@@ -1,5 +1,46 @@
# Preparing exact npm candidate files
+## Read-only registry inspection
+
+`yarn release:registry` accepts the same five required arguments as
+`release:verify-bundle` below. It first runs that full verifier against a clean
+repository and fresh gates, reads public npm metadata, then runs the verifier
+again. It cannot inspect an unready bundle by trusting its downloaded report.
+No package is built, published or retagged; no new dependency is required.
+
+`registry.ts` owns bounded public GET requests (15 seconds including response
+body, 10 MiB maximum, redirects disabled) and pure per-package classification.
+`registry-check.ts` binds those observations to the verifier before and after
+network activity. `check-release-registry.mjs` owns CLI inputs and exit status.
+The registry origin is fixed and no npmrc or authentication token is loaded.
+
+| State | Meaning and eventual recovery action |
+| --- | --- |
+| `not-observed` | Package/version was not found; do not claim the name/version is publishable. A removed historical version may be permanently unavailable. |
+| `already-present` | Registry SHA-512 equals the verified candidate and selected tag already points there. Do not republish. |
+| `tag-change-required` | SHA-512 matches, but the tag differs or is missing. Keep the tarball; any tag change needs separately authorized promotion/recovery. This can include moving a newer tag backwards. |
+| `conflict` | Different/missing integrity or retained unpublish history. Stop the batch; never overwrite, unpublish or invent a replacement version. |
+
+The output preserves each package decision for a partially completed batch,
+timestamps and response digests. Conflicts exit 1 after printing the report;
+HTTP/schema/transport/local-verification failures exit 1 without a successful
+report. A zero exit means observations were collected without known conflicts,
+not release approval. Every report has `publicationAuthorized: false` and
+`workflowProvenanceVerified: false`. Registry tarballs are not downloaded by
+this metadata check. Registry state can change immediately; CI-03 still needs
+trusted workflow/artifact provenance, permission checks, exact-byte publication,
+explicit tag control and readback at authorized use time. Never execute a saved
+report as a command list or let it waive current release gates.
+
+`release-registry.test.mjs` covers partial batches, conflicts, unpublish markers,
+malformed data, bounded transport, stale local gates/content and CLI rejection.
+Its injected complete ledgers are synthetic, not acceptance for real packages.
+It runs through `test:release-bundle` and the existing `test:baseline` glob.
+The new TypeScript modules are included in `typecheck:release` and root lint.
+
+Sources checked 2026-09-16: [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/)
+and [npm registry API](https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md).
+
Run from the repository root with the pinned Node and Yarn:
```sh
diff --git a/scripts/release/registry-check.ts b/scripts/release/registry-check.ts
new file mode 100644
index 000000000..91acbe098
--- /dev/null
+++ b/scripts/release/registry-check.ts
@@ -0,0 +1,31 @@
+import type { LedgerSnapshot } from './bundle.ts'
+import type { BundleExpectation } from './verify.ts'
+import assert from 'node:assert/strict'
+import { buildLedger } from '../../refactor/scripts/release-ledger.mjs'
+import { assessRegistry, observeRegistry, registry } from './registry.ts'
+import { verifyReleaseBundle } from './verify.ts'
+
+// The CLI supplies a clean-source inspector. Test seams never enter CLI inputs.
+export async function checkReleaseRegistry(repository: string, directory: string, expected: BundleExpectation, inspect: (repository: string, names: string[]) => LedgerSnapshot = buildLedger, fetcher: typeof fetch = fetch) {
+ const verified = verifyReleaseBundle(repository, directory, expected, inspect)
+ const startedAt = new Date().toISOString()
+ const packages = []
+ for (const candidate of verified.packages)
+ packages.push(assessRegistry(candidate, expected.tag, await observeRegistry(candidate.name, fetcher)))
+ assert.deepEqual(verifyReleaseBundle(repository, directory, expected, inspect), verified, 'Candidate bundle changed during registry lookup')
+ return {
+ schemaVersion: 1,
+ sourceCommit: verified.sourceCommit,
+ manifestSha256: verified.manifestSha256,
+ registry,
+ tag: expected.tag,
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ packages,
+ conflicts: packages.filter(item => item.state === 'conflict').map(item => item.name),
+ contentVerified: true,
+ workflowProvenanceVerified: false,
+ publicationAuthorized: false,
+ limitations: ['Read-only metadata observations, not downloaded registry tarball verification or publish permission.', 'Registry state can change immediately; recheck at authorized use time.', 'Not-observed versions may have been unpublished and cannot necessarily be reused.', 'Never execute this report as a publication or tag-change command.'],
+ }
+}
diff --git a/scripts/release/registry.ts b/scripts/release/registry.ts
new file mode 100644
index 000000000..de5cdb7fd
--- /dev/null
+++ b/scripts/release/registry.ts
@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict'
+import { Buffer } from 'node:buffer'
+import { createHash } from 'node:crypto'
+
+export const registry = 'https://registry.npmjs.org/'
+const versionPattern = /^\d+\.\d+\.\d+(?:-[a-z0-9]+(?:[.-][a-z0-9]+)*)?$/i
+const maxBytes = 10 * 1024 * 1024
+
+export interface RegistryObservation {
+ url: string
+ observedAt: string
+ status: 200 | 404
+ sha256: string
+ metadata: unknown
+}
+
+function record(value: unknown): Record {
+ assert(value !== null && typeof value === 'object' && !Array.isArray(value), 'Invalid registry object')
+ return value as Record
+}
+
+function own(value: Record, key: string): unknown {
+ return Object.hasOwn(value, key) ? value[key] : undefined
+}
+
+function validateName(name: string): void {
+ assert(/^artplayer(?:-[a-z0-9]+)*$/.test(name), 'Invalid workspace package name')
+}
+
+// No npmrc, credentials, redirects, lifecycle commands or registry writes.
+export async function observeRegistry(name: string, fetcher: typeof fetch = fetch): Promise {
+ validateName(name)
+ const url = `${registry}${name}`
+ const response = await fetcher(url, { method: 'GET', headers: { 'accept': 'application/json', 'cache-control': 'no-cache' }, redirect: 'error', signal: AbortSignal.timeout(15000) })
+ if (response.status !== 200 && response.status !== 404) {
+ await response.body?.cancel()
+ throw new Error(`${name}: registry HTTP ${response.status}`)
+ }
+ assert(response.body, `${name}: empty registry response`)
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let length = 0
+ try {
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done)
+ break
+ length += chunk.value.byteLength
+ assert(length <= maxBytes, `${name}: registry response exceeds 10 MiB`)
+ chunks.push(chunk.value)
+ }
+ }
+ finally {
+ await reader.cancel()
+ reader.releaseLock()
+ }
+ const bytes = Buffer.concat(chunks)
+ return { url, status: response.status, observedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), metadata: JSON.parse(bytes.toString('utf8')) as unknown }
+}
+
+export function assessRegistry(candidate: { name: string, version: string, integrity: string }, tag: string, observation: RegistryObservation) {
+ const { name, version, integrity } = candidate
+ validateName(name)
+ assert(versionPattern.test(version), 'Invalid candidate version')
+ assert(/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity), 'Candidate requires exact SHA-512 integrity')
+ assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(tag), 'Unsupported release tag')
+ assert(tag !== 'latest' || !version.includes('-'), 'Prerelease versions cannot use latest')
+ assert.equal(observation.url, `${registry}${name}`, 'Registry observation package differs')
+ const document = record(observation.metadata)
+ const base = { name, version, integrity, tag, observedAt: observation.observedAt, responseSha256: observation.sha256, httpStatus: observation.status }
+ if (observation.status === 404) {
+ assert.equal(own(document, 'error'), 'Not found', 'Unrecognized registry 404 response')
+ return { ...base, state: 'not-observed', currentTag: null, reason: 'Package not found; prior publication and publish permission are unknown.' }
+ }
+ assert.equal(observation.status, 200, 'Unexpected registry status')
+ assert.equal(own(document, 'name'), name, 'Registry package identity differs')
+ const time = own(document, 'time')
+ const timestamps = time === undefined ? {} : record(time)
+ if (own(timestamps, 'unpublished') !== undefined)
+ return { ...base, state: 'conflict', currentTag: null, reason: 'Registry records an unpublished package; do not attempt version reuse.' }
+ const versions = record(own(document, 'versions'))
+ const tags = record(own(document, 'dist-tags'))
+ for (const value of Object.values(tags))
+ assert(typeof value === 'string' && versionPattern.test(value), 'Invalid registry dist-tag version')
+ const currentTag = own(tags, tag) as string | undefined
+ const existing = own(versions, version)
+ if (existing === undefined) {
+ assert(currentTag !== version, 'Registry tag points to a missing candidate version')
+ const previouslyPublished = own(timestamps, version) !== undefined
+ return { ...base, state: previouslyPublished ? 'conflict' : 'not-observed', currentTag: currentTag ?? null, reason: previouslyPublished ? 'Registry retains this version in publication history.' : 'Version not found; absence does not prove it can be published.' }
+ }
+ const published = record(existing)
+ assert.equal(own(published, 'name'), name, 'Registry version package identity differs')
+ assert.equal(own(published, 'version'), version, 'Registry version identity differs')
+ const dist = own(published, 'dist')
+ const actual = dist === undefined ? undefined : own(record(dist), 'integrity')
+ if (actual !== integrity)
+ return { ...base, state: 'conflict', currentTag: currentTag ?? null, reason: 'Published integrity differs or is missing; never overwrite or republish.' }
+ return { ...base, state: currentTag === version ? 'already-present' : 'tag-change-required', currentTag: currentTag ?? null, reason: 'Registry SHA-512 matches; do not republish. Tag changes require separate authorization.' }
+}
diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts
index 854d0bd4a..5eb0fc0ba 100644
--- a/scripts/release/verify.ts
+++ b/scripts/release/verify.ts
@@ -6,7 +6,7 @@ import path from 'node:path'
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
import { bundleManifest, checkBatch } from './bundle.ts'
-interface BundleExpectation {
+export interface BundleExpectation {
sourceCommit: string
manifestSha256: string
names: string[]