mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-12 15:26:16 -08:00
feat(release): [CI-NPM-03] add read-only registry recovery checks
This commit is contained in:
1 parent
783d1e3fd4
commit
5795492f0f
13 files changed
+570
-7
No files matched your search
+3
-2
@@ -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/权限、授权执行和读回;未启用远端流程。
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.'],
|
||||
}
|
||||
}
|
||||
@@ -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.' }
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user