feat(release): [CI-NPM-03] add read-only registry recovery checks

This commit is contained in:
Harvey Zhao committed 2026-09-16 11:06:23 +08:00
1 parent 783d1e3fd4
commit 5795492f0f
13 files changed
+570 -7

No files matched your search

+3 -2
View File
@@ -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": {
@@ -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"
]
}
@@ -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/权限、授权执行和读回;未启用远端流程。
+7
View File
@@ -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来源或版本
+5 -3
View File
@@ -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<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, CI-NPM-02 | 候选准备、精确 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, CI-NPM-03 | 候选准备、精确 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 |
@@ -104,6 +104,7 @@
| 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 |
| CI-NPM-03 | workspace<br>建立只读 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)
+10
View File
@@ -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
+189
View File
@@ -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('<html>gateway</html>')))
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
})
}
})
+22 -1
View File
@@ -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",
+26
View File
@@ -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
}
+41
View File
@@ -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
+31
View File
@@ -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.'],
}
}
+100
View File
@@ -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<string, unknown> {
assert(value !== null && typeof value === 'object' && !Array.isArray(value), 'Invalid registry object')
return value as Record<string, unknown>
}
function own(value: Record<string, unknown>, 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<RegistryObservation> {
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.' }
}
+1 -1
View File
@@ -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[]