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[]