mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
feat(REL-08): bind package release gates to candidate evidence
This commit is contained in:
1 parent
3316b8a867
commit
04060aa8b6
14 files changed
+3383
-6
No files matched your search
+6
-2
@@ -47,7 +47,7 @@
|
||||
"check:plan": "node refactor/scripts/plan.mjs --check",
|
||||
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.test.js test/dev-server.test.js",
|
||||
"test:baseline": "node --test refactor/scripts/*.test.mjs",
|
||||
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
|
||||
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn check:release-ledger && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
@@ -133,7 +133,11 @@
|
||||
"typecheck:library": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.library.json --noEmit",
|
||||
"test:library": "node --test refactor/scripts/build.test.mjs test/library-build.test.js test/performance-report.test.js test/plugin-scaffold.test.js test/dev-server.test.js",
|
||||
"test:dev-server": "node --test test/dev-server.test.js",
|
||||
"probe:auto-thumbnail-native": "node refactor/scripts/auto-thumbnail-native-probe.mjs"
|
||||
"probe:auto-thumbnail-native": "node refactor/scripts/auto-thumbnail-native-probe.mjs",
|
||||
"check:release-ledger": "node refactor/scripts/release-ledger.mjs",
|
||||
"report:release-ledger": "node refactor/scripts/release-ledger.mjs --report",
|
||||
"release:preflight": "yarn check:toolchain --strict && node refactor/scripts/release-ledger.mjs --report --strict",
|
||||
"test:release-ledger": "node --test refactor/scripts/release-ledger.test.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
| [已落实的测试可靠性规则](test-reliability.md) | 历史失败、候选回归、设备缺口、精确异常及等待/重试的执行规则 |
|
||||
| [docs 页面与编辑器测试](docs-browser-testing.md) | 复用已有 HTML、加载版本、隔离状态和补强文档 smoke |
|
||||
| [多轮复盘与 npm 准入](release-reviews.md) | Chrome 验证分工、三轮全局复盘、问题闭环和候选发布门槛 |
|
||||
| [逐包发布准入台账](release-ledger.md) | 22包候选/证据绑定、源码和产物指纹、任务风险/许可阻断及严格预检 |
|
||||
| [工具链与发布](toolchain-release.md) | TypeScript、Bun、构建、版本管理和发布回退 |
|
||||
| [已实现的开发环境](toolchain-setup.md) | Node/Yarn 固定版本、锁文件、安装命令和实际验证范围 |
|
||||
| [全包大版本策略](version-policy.md) | 每包分别升级一个 major 的目标清单、兼容要求和版本落地步骤 |
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
# REL-08: candidate-bound package release ledger
|
||||
|
||||
The previous plan described batch release gates but had no executable per-package
|
||||
binding between current source, candidate archives and reviewed evidence. Added
|
||||
release-ledger.json for22 packages, a pure decision model, a filesystem/CLI adapter
|
||||
and a maintained procedure in release-ledger.md. This completes the ledger task,
|
||||
not package verification, version preparation, remote workflow activation or release.
|
||||
|
||||
## Scope and ownership
|
||||
|
||||
The JSON registry owns target versions, explicit historical distribution mappings,
|
||||
capability requirements, rollback basis and future candidate/evidence bindings. It
|
||||
does not duplicate task/risk status: the adapter reads tasks.json, risks.json,
|
||||
third-party.json and the existing dependency/impact model. Open package-specific
|
||||
risks propagate through dependencies; a generic review owner alone does not widen
|
||||
a concrete HLS finding to unrelated packages. Truly global risks stay global.
|
||||
|
||||
Source fingerprints include own/dependent packages and shared scripts, tests,
|
||||
toolchain, contracts and frozen release/SDK inputs. Text CRLF is normalized with
|
||||
explicit algorithm labels and separate raw-byte hashes; binary fonts/WASM/media,
|
||||
candidate SRI and evidence attachments use exact bytes. A per-report input table
|
||||
avoids duplicating hashes across22 package rows. Unrelated package source changes
|
||||
can leave another batch unchanged; shared engineering/test/provenance changes are
|
||||
conservatively revalidated. Unknown dynamic imports widen the dependency scope.
|
||||
|
||||
Actual tarball name/version, source commit existence, SRI and archive members are
|
||||
checked. Site manifests additionally require an exact build tree inventory and
|
||||
matching bytes. Evidence envelopes bind package/version/gate, candidate integrity,
|
||||
input fingerprint, actual environment, command, reviewer, successful check IDs and
|
||||
hashed underlying reports. Device requirements reject missing device identity,
|
||||
mock/emulation and skipped results. Remote CI requires a remote run reference;
|
||||
license coverage includes relevant vendor/SDK and direct runtime dependency IDs.
|
||||
|
||||
This is mechanical bookkeeping, not a verifier of the truth of prose. Candidate
|
||||
build origin, complete scenario coverage, report interpretation and device/vendor
|
||||
evidence still require the three independent reviews. A source fingerprint must
|
||||
be captured in the actual build/validation snapshot, not copied onto an old package.
|
||||
No historical done task or old report is automatically accepted for a new candidate.
|
||||
|
||||
## Distribution and compatibility
|
||||
|
||||
All22 independent next-major targets are recorded without changing manifests. The
|
||||
iframe row explicitly maps its frozen artplayer-plugin-iframe predecessor; the
|
||||
recovered Thumbnail tool remains blocked for missing complete historical archive.
|
||||
VitePress stays a site/URL/build gate, without inferred new npm publication.
|
||||
Rollback links the historical basis and requires an actual rehearsal report.
|
||||
|
||||
No core/plugin public API, runtime, declaration, dependency or lockfile changed.
|
||||
Four Yarn scripts expose structural check, immutable report output, strict
|
||||
preflight and tests. ci:check includes the structural check; test:baseline already
|
||||
discovers the new test. Strict preflight runs the existing pinned toolchain check.
|
||||
Normal check exit0 explicitly permits incomplete package evidence; only strict
|
||||
mode is a release gate. publicationAuthorized stays false even in the synthetic
|
||||
evidence-complete test. CI-03 already depends on REL-08 and will integrate strict
|
||||
preflight into the later publication workflow.
|
||||
|
||||
## Validation
|
||||
|
||||
See [recorded validation](../baselines/release-ledger-validation.json).
|
||||
|
||||
- 30 regression cases pass. They reject stale source/test/SDK/lock inputs, wrong
|
||||
version/package/gate, old tarball reports, tampered reports/attachments, missing
|
||||
license/capability/review evidence, simulated devices and local-as-remote CI.
|
||||
Real temporary archives/site files exercise SRI, manifest, complete output
|
||||
inventory and byte checks; these are synthetic tooling fixtures, not product
|
||||
package/browser/device acceptance.
|
||||
- Package source isolation, dependency propagation, unknown import fallback,
|
||||
22-package coverage and text/binary hashing are tested. Site cannot silently be
|
||||
changed to npm scope or lose mandatory review gates.
|
||||
- CI script suite50 passes; targeted lint passes after ordinary formatting fixes.
|
||||
Strict Node24.21.0/Yarn1.22.22 toolchain check passes with unchanged dependencies.
|
||||
- Current all-package report has22 blocked packages and zero candidate bindings.
|
||||
The explicit HLS strict preflight exits1 for the expected missing gates; this is
|
||||
correct rejection, not a failed tooling implementation. Generated reports use
|
||||
separate cache directories and previous evidence remains intact.
|
||||
|
||||
## Rollback and next work
|
||||
|
||||
Revert this task commit to remove the new scripts, registry, tests and ci:check
|
||||
step. No package rebuild is necessary. Continue outstanding runtime/device/source
|
||||
tasks; REL-01/09 prepare final versions, REL-02 generates real candidates, REL-04
|
||||
executes rollback and CI-03/04 plus the reviews supply candidate-bound evidence.
|
||||
Do not fill pass envelopes from plans or rename existing historical report fields
|
||||
to manufacture a ready state.
|
||||
@@ -8,6 +8,8 @@
|
||||
| --- | --- |
|
||||
| `yarn lint` | 只读 ESLint,覆盖包源码/声明、JS/MJS 工具、docs-smoke TS 模块、测试和编辑器声明 |
|
||||
| `yarn lint:fix` | 显式自动修复相同范围 |
|
||||
| `yarn check:release-ledger` | ci:check中的逐包准入登记结构检查;当前blocked不导致结构检查失败,不是发布准入通过 |
|
||||
| `yarn release:preflight --packages ...` | 严格候选/证据/任务/设备/许可预检,任一缺口退出1;CI-03后续发布工作流使用此入口 |
|
||||
| `yarn typecheck` | 根/迁移包严格检查、当前与兼容 TS 消费;历史 NodeNext ESM 错误单独核对,见 typechecking.md |
|
||||
| `yarn typecheck:react` / `yarn typecheck:vue` | 原 React TSX / Vue SFC 示例严格检查,ci:check 同时执行对应 lint |
|
||||
| `yarn typecheck:docs-tools` / `yarn check:docs-smoke` | 严格检查 TS 示例/声明生成器及 JS/MJS 门面;只读核对确定性生成的 readiness smoke,ci:check 执行 |
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 253 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 55 / doing 17 / blocked 0 / done 181 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 54 / doing 17 / blocked 0 / done 182 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -419,7 +419,7 @@
|
||||
|
||||
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| REL-08 | workspace<br>提前建立逐包发布准入台账 | BASE-08, ENG-07 | 每批包/版本/源码/锁文件/工具/tarball integrity、必需测试/设备证据、限制和回退映射 | 受影响能力缺证据明确阻止对应批次;无关批次可独立准备,旧证据在候选内容变化后失效;站点采用真实构建/URL/资源验收,npm 分发依据历史核实,不从版本清单推断新增发布范围;关联 risks.json 和 third-party.json;受影响 bundle/worker/WASM/font/模型的来源与许可通知缺口必须有审查结论,未决项阻止对应批次 | H | todo |
|
||||
| REL-08 | workspace<br>提前建立逐包发布准入台账 | BASE-08, ENG-07 | 每批包/版本/源码/锁文件/工具/tarball integrity、必需测试/设备证据、限制和回退映射 | 受影响能力缺证据明确阻止对应批次;无关批次可独立准备,旧证据在候选内容变化后失效;站点采用真实构建/URL/资源验收,npm 分发依据历史核实,不从版本清单推断新增发布范围;关联 risks.json 和 third-party.json;受影响 bundle/worker/WASM/font/模型的来源与许可通知缺口必须有审查结论,未决项阻止对应批次 | H | done |
|
||||
| REL-01 | workspace<br>提前确定分包版本与差异方案 | REL-08, CORE-21, SITE-03, PKG-CHAPTER-04, PKG-AMBILIGHT-04, PKG-AUDIO-04, PKG-AUTO-THUMB-04, PKG-VTT-THUMB-04, PKG-HLS-04, PKG-DASH-04, PKG-MULTI-SUB-04, PKG-JASSUB-04, PKG-MASK-04, PKG-ASR-04, PKG-ADS-04, PKG-VAST-04, PKG-CAST-04, PKG-DPIP-04, PKG-CANVAS-04, PKG-IFRAME-04, PKG-TOOL-THUMB-04, PKG-DANMUKU-06, PKG-MB-08, PKG-FACTORY-01 | 每包版本/变更日志/依赖/类型差异方案和独立准入状态;按 version-policy.md 冻结各包下一 major(minor/patch 归零)并核实 registry 占用 | 所有包有方案和剩余门槛,未决项明确阻止相应发布;本步骤不声称已经可发布;全部包有 major 目标,版本冲突明确处理,独立准备任务同步 manifest/锁/依赖/日志;按 BASE-05 区分 21 库与文档站分发,不机械把文档站当库上传 npm | H | todo |
|
||||
| REL-09 | workspace<br>落实全包下一 major 版本及依赖元数据 | REL-01, DOC-11 | 22 包版本目标落实、适用锁文件/依赖范围/示例/变更日志同步,核实 registry 版本占用 | 版本与 policy 一致,旧核心支持保留;必要拆子任务各自提交,候选构建前完成,不执行 publish | H | todo |
|
||||
| REL-02 | workspace<br>生成候选 tarball 并验证新旧组合 | REL-01, CORE-22, ENG-07, REL-09 | 各候选本地 tarball/integrity 与新旧核心/插件消费者验证报告,外部门槛单独标注 | 隔离安装和可自动化组合通过;设备结论不伪造,最终发布绑定同一候选内容;在目标 major 版本确定后构建 pack,不在测试后改版本 | H | todo |
|
||||
@@ -635,6 +635,7 @@
|
||||
- MOD-PLUGIN-01: [记录](changes/2026-09-14-MOD-PLUGIN-01-scaffold.md) [记录](baselines/scaffold-validation.json)
|
||||
- MOD-02: [记录](changes/2026-09-14-MOD-02-library-tooling.md) [记录](baselines/library-tooling-validation.json)
|
||||
- MOD-DEV-01: [记录](changes/2026-09-14-MOD-DEV-01-dev-server.md) [记录](baselines/dev-server-validation.json)
|
||||
- REL-08: [记录](changes/2026-09-14-REL-08-release-ledger.md) [记录](baselines/release-ledger-validation.json) [记录](release-ledger.md) [记录](release-ledger.json)
|
||||
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) [记录](type-compatibility-policy.md) [记录](baselines/factory-compatibility-validation.json) [记录](changes/2026-09-13-PKG-FACTORY-01-compatible-types.md)
|
||||
- CORE-25: [记录](changes/2026-09-13-CORE-25-defaults-ssr.md) [记录](baselines/defaults-ssr-validation.json)
|
||||
- ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json)
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 进度与证据
|
||||
|
||||
## REL-08 逐包发布准入台账完成
|
||||
|
||||
建立22包历史分发、独立major目标、能力/回退和候选证据登记。新增纯模型及只读
|
||||
文件/归档CLI,按实际依赖和共享输入计算指纹,核验tarball/站点完整输出及底层报告
|
||||
字节。旧done和历史报告不自动变成当前候选证据;设备替身、许可缺口、未决风险、
|
||||
缺失远端CI或三轮复盘都会阻止相应批次。站点不隐含新增npm发布,改名iframe和
|
||||
旧tarball缺失的Thumbnail工具明确区分。30项反向/文件回归、CI脚本50项、定向lint
|
||||
通过;HLS单包strict预检按预期退出1,当前22包都blocked且无候选绑定。
|
||||
见[维护说明](release-ledger.md)、[变更](changes/2026-09-14-REL-08-release-ledger.md)和[证据](baselines/release-ledger-validation.json)。
|
||||
已加入ci:check结构检查,test:baseline自动发现测试,strict发布预检与普通检查分开。
|
||||
253项:182 done、17 doing、54 todo。没有改包版本/依赖/公开API,也没有发布。
|
||||
下一步回到包的剩余源码与真实环境验收;REL-01/09/02、REL-04及CI-03可按台账逐步
|
||||
补真实候选、回退和流程证据,不能用当前历史报告填造pass。VAST选择仍待回复。
|
||||
|
||||
## PKG-HLS-SDK-01 普通HTTP销毁诊断检查点(仍未完成)
|
||||
|
||||
直接原生video、Hls1.5.17、Firefox155.0、普通HTTP且无ArtPlayer/插件/Worker观察器,
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"libraryGates": [
|
||||
"build",
|
||||
"runtime",
|
||||
"types",
|
||||
"combinations",
|
||||
"browser",
|
||||
"devices",
|
||||
"licenses",
|
||||
"rollback",
|
||||
"remote-ci",
|
||||
"review-01",
|
||||
"review-02",
|
||||
"review-03"
|
||||
],
|
||||
"siteGates": [
|
||||
"build",
|
||||
"site-urls",
|
||||
"site-assets",
|
||||
"editor",
|
||||
"devices",
|
||||
"licenses",
|
||||
"rollback",
|
||||
"remote-ci",
|
||||
"review-01",
|
||||
"review-02",
|
||||
"review-03"
|
||||
],
|
||||
"sharedTasks": [
|
||||
"CI-01",
|
||||
"CI-03",
|
||||
"CI-04",
|
||||
"REL-01",
|
||||
"REL-09",
|
||||
"REL-02",
|
||||
"REL-03",
|
||||
"REL-04",
|
||||
"REVIEW-01",
|
||||
"REVIEW-02",
|
||||
"REVIEW-03"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "artplayer",
|
||||
"targetVersion": "6.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/releases.json",
|
||||
"selector": "releases.0",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"apple-safari-media",
|
||||
"android-media",
|
||||
"native-fullscreen-pip"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/releases.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-ads",
|
||||
"targetVersion": "3.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/ads-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"ad-skip-content-restore"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/ads-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-ambilight",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/ambilight-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"canvas-cors-background"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/ambilight-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-asr",
|
||||
"targetVersion": "3.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/asr-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"audio-context-input-lifecycle"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/asr-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-audio-track",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/audio-track-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"separate-audio-source-switch"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/audio-track-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-auto-thumbnail",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/auto-thumbnail-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"native-first-frame-jpeg"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/auto-thumbnail-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-chapter",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/releases.json",
|
||||
"selector": "releases.1",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"touch-fullscreen-thumbnails"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/releases.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-chromecast",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/chromecast-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"cast-receiver-session"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/chromecast-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-danmuku",
|
||||
"targetVersion": "6.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/danmuku-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"mask-fullscreen-pip"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/danmuku-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-danmuku-mask",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/danmuku-mask-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"real-model-gpu-cleanup"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/danmuku-mask-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-dash-control",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/dash-control-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"real-dash-sdk-tracks"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/dash-control-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-document-pip",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/dpip-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"document-pip-native-window"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/dpip-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-hls-control",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/hls-control-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"native-hls-and-worker"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/hls-control-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-jassub",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/jassub-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"real-worker-wasm-hybrid"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/jassub-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-multiple-subtitles",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/multiple-subtitles-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"subtitle-offset-fullscreen"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/multiple-subtitles-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-vast",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/vast-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"google-ima-ad-content-restore"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/vast-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-vtt-thumbnail",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/vtt-thumbnail-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"touch-fullscreen-chapter"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/vtt-thumbnail-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-proxy-canvas",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/canvas-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"canvas-subtitle-document-pip"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/canvas-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-proxy-mediabunny",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/mb-release.json",
|
||||
"selector": "release",
|
||||
"rationale": "Frozen npm tarball provenance; not a new registry availability observation."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"webcodecs-hls-audio-video"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/mb-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-tool-iframe",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "renamed-npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/iframe-release.json",
|
||||
"selector": "release",
|
||||
"publishedName": "artplayer-plugin-iframe",
|
||||
"rationale": "Frozen published predecessor and documented rename commit; candidate keeps current workspace name. Registry target availability still requires REL-01."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"cross-origin-bfcache-device"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/iframe-release.json",
|
||||
"strategy": "Keep the frozen previous package integrity and tested consumer lock; publish a forward fix without overwriting npm versions. REL-04 rehearsal must cover any package rename."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-tool-thumbnail",
|
||||
"targetVersion": "5.0.0",
|
||||
"distribution": "recovered-npm",
|
||||
"history": {
|
||||
"file": "refactor/baselines/thumbnail-release.json",
|
||||
"selector": "recovered",
|
||||
"rationale": "Recovered CDN/Git bytes only; original registry tarball and complete archive unavailable. Not an intact rollback artifact."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"native-file-decode-encode"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/thumbnail-release.json",
|
||||
"strategy": "Original tarball is unavailable. Resolve a complete rollback alternative and migration scope under REL-04 before release."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
},
|
||||
{
|
||||
"name": "artplayer-vitepress",
|
||||
"targetVersion": "2.0.0",
|
||||
"distribution": "site",
|
||||
"history": {
|
||||
"file": "refactor/baselines/site-provenance.json",
|
||||
"rationale": "Existing docs/site build and URL distribution. No npm publication is inferred."
|
||||
},
|
||||
"extraRequirements": [
|
||||
"apple-safari-device",
|
||||
"android-chrome-device",
|
||||
"mobile-editor-and-site"
|
||||
],
|
||||
"rollback": {
|
||||
"basis": "refactor/baselines/site-provenance.json",
|
||||
"strategy": "Restore the previously verified Pages artifact and old URL map; rehearsal evidence required."
|
||||
},
|
||||
"candidate": null,
|
||||
"evidence": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# 逐包发布准入台账
|
||||
|
||||
REL-08 建立台账及机械校验,不代表任何包已经可以发布。唯一登记源是
|
||||
[release-ledger.json](release-ledger.json),覆盖全部22个workspace包。任务状态仍由
|
||||
[tasks.json](tasks.json)维护,问题和来源继续使用[风险](risks.json)及
|
||||
[第三方清单](third-party.json)。不要再创建一份手工维护的“已通过”状态表。
|
||||
|
||||
## 命令与退出语义
|
||||
|
||||
使用固定Node/Yarn,所有命令只读生产文件,不安装、构建、部署或发布:
|
||||
|
||||
| 命令 | 行为 |
|
||||
| --- | --- |
|
||||
| `yarn check:release-ledger` | 验证登记结构并计算当前缺口;合法台账即退出0,包仍可显示blocked;已加入ci:check |
|
||||
| `yarn report:release-ledger` | 同样计算,并在新的`.cache/release-ledger-*/report.json`保存报告,不覆盖旧报告 |
|
||||
| `yarn release:preflight --packages artplayer,artplayer-plugin-chapter` | 严格工具链后检查明确批次;任一包缺证据或不匹配即退出1;遗漏参数检查全部包 |
|
||||
| `yarn test:release-ledger` | 反向回归,包括过期/错误候选、设备替身、缺失许可和站点输出漂移;同时由test:baseline发现 |
|
||||
|
||||
空选择、未知包、重复包会拒绝执行,不能意外通过空批次。普通结构检查通过和严格
|
||||
准入通过含义不同,CI-03必须使用strict入口,不能使用check的退出0代替发布判断。
|
||||
即使全部机械条件满足,状态也只叫`evidence-complete`,`publicationAuthorized`
|
||||
恒为false。三轮复盘仍需实读底层证据;本工具不能判断一份人为编造的报告是否真实,
|
||||
也不能自动取得设备、执行测试或授权npm发布。
|
||||
|
||||
## 分发和版本边界
|
||||
|
||||
每行明确记录独立下一major目标,初始依据是package-inventory.json。正式版本占用、
|
||||
预发布标识和变更日志仍由REL-01/09落实;本步不修改任何包版本。
|
||||
|
||||
- 普通npm包使用各自冻结发布记录的name/version/integrity/SHA-256/URL作为历史依据。
|
||||
core和chapter引用releases.json各自成员;不把工作区版本当作npm版本。
|
||||
- iframe明确关联旧发布名`artplayer-plugin-iframe`和改名记录。新候选仍核验现有
|
||||
workspace名`artplayer-tool-iframe`;改名范围、目标可用性和回退演练不能省略。
|
||||
- Thumbnail工具只有恢复的CDN/Git内容,旧tarball和完整归档缺失。历史分发门槛保持
|
||||
blocked,不能将缺失CSS/归档解释为从未发布;REL-04需要完整、已验证的回退方案。
|
||||
- artplayer-vitepress按站点构建、URL、编辑器、资源和设备验收。列入版本策略不授权
|
||||
新增npm发布。站点候选是构建文件清单,不能绑定一个npm tarball冒充站点验收。
|
||||
|
||||
历史依据映射不联网刷新,也不代替现有冻结归档验证器、registry检查或回退演练。
|
||||
每包都有rollback.basis和strategy;严格准入另要求候选绑定的rollback执行报告。
|
||||
|
||||
## 候选绑定
|
||||
|
||||
所有初始candidate均为null,evidence为空。先完成目标版本准备和对应实际构建/pack,
|
||||
在构建快照中计算输入指纹,再登记真实文件;不能只复制当前指纹到旧产物冒充重建。
|
||||
build报告和REVIEW-03负责审查构建来源及实际内容对应关系。
|
||||
|
||||
候选登记字段:
|
||||
|
||||
```text
|
||||
candidate.kind = npm-tarball | site-manifest
|
||||
candidate.path = 仓库内真实候选文件路径
|
||||
candidate.version = 已准备的目标版本
|
||||
candidate.sourceCommit = 实际构建来源的40位Git提交
|
||||
candidate.inputFingerprint = 构建/验证输入指纹
|
||||
candidate.integrity = 候选文件实际sha512-base64 SRI
|
||||
```
|
||||
|
||||
npm候选会读取真实tarball并检查路径、重复成员、package.json名称和版本。站点
|
||||
site-manifest文件包含package、version、outputRoot和files,每个file为path/sha256。
|
||||
outputRoot必须在仓库内;目录完整文件集须等于files,新增、缺失、替换或符号链接输出
|
||||
均拒绝。清单SRI和每个实际部署文件字节分别验证;URL正确性仍必须有site-urls报告。
|
||||
|
||||
`inputFingerprint`包含本包、其实际依赖闭包,以及共享构建/工具链/测试/CI输入。
|
||||
依赖来自impact-model的显式关系和源码/声明/manifest扫描;未解释动态导入按全部包
|
||||
保守处理。站点还包含docs及example。第三方清单、本校验器、兼容/环境/版本/复盘
|
||||
契约以及固定release/SDK/历史core配置也参与输入指纹;不断新增的普通validation
|
||||
报告不作为构建输入,避免报告包含自己而无法完成绑定。
|
||||
|
||||
文本按明确扩展名和已知文本文件归一CRLF为LF,使Windows/Linux文本签出可以比较;
|
||||
报告同时保留原始字节SHA-256。字体/WASM/媒体等其他输入不作文本转换,候选SRI、
|
||||
站点实际输出、报告及其底层附件始终按原始字节校验。指纹不是单独的HEAD比较:
|
||||
未提交修改也进入计算,无关包源码的变化不使其他依赖闭包失效。共享测试或工程输入
|
||||
变化则保守地使所有相关候选失效,这是有意避免漏验;不用于省略现有CI检查。
|
||||
|
||||
报告包含完整sourceCommit、锁文件哈希、实际Node、配置packageManager、实际Yarn
|
||||
user-agent、声明工具版本和输入文件表。声明工具版本不是自动证明所有已安装依赖
|
||||
正确;strict命令先运行现有check:toolchain,并要求真正通过Yarn1.22.22和固定Node执行。
|
||||
|
||||
## 必需报告
|
||||
|
||||
库的必需门槛:build/runtime/types/combinations/browser/devices/licenses/rollback/
|
||||
remote-ci/review-01/review-02/review-03。站点用site-urls/site-assets/editor替换库接口
|
||||
门槛;三轮复盘、真实设备、许可和回退仍然必需。
|
||||
|
||||
每个`evidence.<gate>`只登记`{ path, sha256 }`,指向审阅后的标准JSON封套。不能
|
||||
直接把任意历史validation.json挂入。封套必须包含:
|
||||
|
||||
| 字段 | 约束 |
|
||||
| --- | --- |
|
||||
| schemaVersion | 1 |
|
||||
| package/version/gate | 与被验收包、当前版本和门槛精确匹配 |
|
||||
| candidateIntegrity/inputFingerprint | 与实际候选及当前输入一致 |
|
||||
| result | pass;未知、失败和skip不能转为通过 |
|
||||
| checks | 非空`{ id, result, mode }`列表,全部必要项pass;不能通过删除原失败项缩小范围 |
|
||||
| environment | 实际OS/浏览器/执行环境;设备项须有device、os、browser、emulated:false |
|
||||
| command/reviewedBy | 实际执行命令及审阅归属,不填计划命令或假身份 |
|
||||
| artifacts | 非空底层结果`{ path, sha256 }`列表,读取文件验证实际字节 |
|
||||
|
||||
devices还必须逐项覆盖该包extraRequirements,所有模式为native;viewport或mock不
|
||||
能冒充设备/能力通过。remote-ci须有`remote:true`和实际GitHub Actions run URL,
|
||||
且仍需审阅远端结果附件。URL本身不证明远端成功,本工具不会联网读取它。
|
||||
|
||||
licenses须覆盖所有适用vendor、SDK/外部集成及直接运行依赖ID,例如
|
||||
`dependency:artplayer:option-validator`。哈希只能说明来源字节,不能证明授权;
|
||||
来源/完整通知和实际bundle、worker、WASM、字体、模型分发的结论必须在附件中审查。
|
||||
|
||||
三轮报告不能复用同一封套充数,因为gate字段必须精确匹配。旧任务done或旧报告存在
|
||||
不自动提供候选证据:历史任务索引会显示,但historicalEvidenceAcceptedForCandidate
|
||||
恒为false。候选内容、源码、锁文件、共享测试或工程输入变化后,相关封套会失效。
|
||||
|
||||
## 任务、风险和批次范围
|
||||
|
||||
每包需关闭其依赖闭包的实施/组合/分发任务和共享准备、远端CI、回退及三轮复盘。
|
||||
风险先按具体包责任定位,只有没有具体包责任的全局风险才扩展全包;例如HLS问题
|
||||
同时由REVIEW-02负责,不会仅因此直接阻止无关Cast包的局部准备。核心问题通过依赖
|
||||
闭包影响其消费者。第三方资产使用同样范围映射,未决风险阻止相应批次。
|
||||
|
||||
当前共享三轮完整复盘尚未完成,所以任何包都没有发布就绪结论。逐包可独立准备
|
||||
候选和收集材料,不能借分批删除项目整体要求。未来如正式拆分独立批次复盘,须按
|
||||
release-reviews.md新建任务和明确范围,并更新本台账模型及反向测试,不能删共享门槛。
|
||||
|
||||
## 维护地图
|
||||
|
||||
- `scripts/release-ledger-model.mjs`:纯门槛、范围、依赖闭包和阻断计算。
|
||||
- `scripts/release-ledger.mjs`:只读文件/归档/输入指纹,装配当前报告和CLI。
|
||||
- `scripts/release-ledger.test.mjs`:合成合格控制与逐项失败反例,真实临时tar/site文件
|
||||
验证;这些测试不算任何产品包的设备或发布验收。
|
||||
- `release-ledger.json`:历史分发、目标、能力、回退及候选/证据绑定,初始无候选。
|
||||
|
||||
更改门槛先核对兼容、环境、版本和复盘契约;不要把当前红灯改成绿色来证明工具有效。
|
||||
ci:check只做结构校验,strict红灯是当前产品验收尚未完成的正确结果。
|
||||
@@ -57,4 +57,9 @@ REVIEW-01 -> REVIEW-02 -> REVIEW-03 -> REL-05(经授权候选发布)-> REL-0
|
||||
|
||||
用户在复盘后审阅具体发布批次:包/版本、目标 registry/tag、兼容结论、候选 integrity、剩余非阻断项和回退方法。表达将来发布到 npm 的目标不等同当前执行 publish;可以自主完成本地准备与复盘。
|
||||
|
||||
REL-08的[逐包台账](release-ledger.md)负责候选和证据的机械绑定。CI-03及REVIEW-03应对
|
||||
具体批次运行`yarn release:preflight --packages ...`,缺口会退出1;普通
|
||||
`check:release-ledger`的结构检查通过不能代替它。封套和实际附件仍须逐项审阅,
|
||||
脚本不会验证报告叙述真实性、执行设备测试或授权发布。
|
||||
|
||||
候选反馈导致修复时,先创建修复和复验任务,重新核对前三轮受影响结论及最终候选内容,再进入正式发布。即使前三轮任务历史已 done,发生变化后也不能直接发布。分批交付遵循 execution-gates.md,每批具备同等三轮审查和必要环境证据,不以分批绕过全项目要求。
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const libraryGates = ['build', 'runtime', 'types', 'combinations', 'browser', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
|
||||
export const siteGates = ['build', 'site-urls', 'site-assets', 'editor', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
|
||||
export const sharedTasks = ['CI-01', 'CI-03', 'CI-04', 'REL-01', 'REL-09', 'REL-02', 'REL-03', 'REL-04', 'REVIEW-01', 'REVIEW-02', 'REVIEW-03']
|
||||
|
||||
export function dependencyClosure(name, names, edges, dynamicImports = []) {
|
||||
if (dynamicImports.length)
|
||||
return [...names].sort()
|
||||
const dependencies = new Set([name])
|
||||
let count = -1
|
||||
while (count !== dependencies.size) {
|
||||
count = dependencies.size
|
||||
for (const edge of edges) {
|
||||
if (dependencies.has(edge.consumer))
|
||||
dependencies.add(edge.dependency)
|
||||
}
|
||||
}
|
||||
return [...dependencies].sort()
|
||||
}
|
||||
|
||||
export function findingPackages(item, tasks, names) {
|
||||
const explicit = new Set(item.owners.flatMap(id => tasks.get(id)?.scope || []).filter(scope => names.includes(scope)))
|
||||
// A generic review owner must not turn a package-specific finding into a global one.
|
||||
return explicit.size ? [...explicit] : [...names]
|
||||
}
|
||||
|
||||
export function validateLedger(ledger, inventory, tasks) {
|
||||
assert.equal(ledger.schemaVersion, 1)
|
||||
const expected = inventory.map(pkg => pkg.name).sort()
|
||||
assert.deepEqual(ledger.packages.map(pkg => pkg.name).sort(), expected, 'Ledger must cover exactly every workspace package once')
|
||||
assert.deepEqual(ledger.libraryGates, libraryGates, 'Missing mandatory library gate')
|
||||
assert.deepEqual(ledger.siteGates, siteGates, 'Missing mandatory site gate')
|
||||
assert.deepEqual(ledger.sharedTasks, sharedTasks, 'Missing mandatory shared release/review task')
|
||||
for (const row of ledger.packages) {
|
||||
const initial = inventory.find(pkg => pkg.name === row.name)
|
||||
assert.equal(row.targetVersion, `${Number(initial.version.split('.')[0]) + 1}.0.0`, `Unexpected next major: ${row.name}`)
|
||||
assert(['npm', 'renamed-npm', 'recovered-npm', 'site'].includes(row.distribution), `Unknown distribution: ${row.name}`)
|
||||
assert.equal(row.distribution === 'site', initial.kind === 'docs', 'A site must not become a new npm publication implicitly')
|
||||
assert(row.history?.file && row.history.rationale, `Missing distribution basis: ${row.name}`)
|
||||
assert(row.rollback?.basis && row.rollback.strategy, `Missing rollback mapping: ${row.name}`)
|
||||
assert(Array.isArray(row.extraRequirements) && row.extraRequirements.length, `Missing capability requirements: ${row.name}`)
|
||||
assert(row.extraRequirements.every(item => typeof item === 'string' && item.trim()), 'Invalid capability requirement')
|
||||
assert(new Set(row.extraRequirements).size === row.extraRequirements.length, 'Duplicate requirement')
|
||||
assert(row.evidence && !Array.isArray(row.evidence) && typeof row.evidence === 'object')
|
||||
assert(Object.keys(row.evidence).every(gate => (row.distribution === 'site' ? siteGates : libraryGates).includes(gate)), 'Unknown evidence gate')
|
||||
assert(row.candidate === null || typeof row.candidate === 'object', 'Invalid candidate')
|
||||
}
|
||||
for (const id of ledger.sharedTasks)
|
||||
assert(tasks.has(id), `Unknown release task: ${id}`)
|
||||
}
|
||||
|
||||
export function evaluatePackage({ row, fingerprint, version, candidate, evidence, taskGaps, risks, assets, history }) {
|
||||
const gates = row.distribution === 'site' ? siteGates : libraryGates
|
||||
const blockers = []
|
||||
const add = (kind, detail) => blockers.push({ kind, detail })
|
||||
if (version !== row.targetVersion)
|
||||
add('version', `${version} must be prepared as ${row.targetVersion} before final candidate validation`)
|
||||
if (!history.verified)
|
||||
add('distribution-history', history.reason)
|
||||
if (!candidate) {
|
||||
add('candidate', 'No candidate artifact is bound')
|
||||
}
|
||||
else {
|
||||
for (const error of candidate.errors)
|
||||
add('candidate', error)
|
||||
if (candidate.inputFingerprint !== fingerprint)
|
||||
add('stale-candidate', 'Source, dependencies, tooling or test inputs changed')
|
||||
if (candidate.version !== version)
|
||||
add('candidate-version', 'Artifact version differs from current manifest')
|
||||
}
|
||||
for (const id of taskGaps) add('task', id)
|
||||
for (const risk of risks.filter(risk => risk.status === 'open')) add('risk', risk.id)
|
||||
const checks = {}
|
||||
for (const gate of gates) {
|
||||
const report = evidence[gate]
|
||||
const errors = []
|
||||
if (!report) {
|
||||
errors.push('Missing candidate-bound evidence')
|
||||
}
|
||||
else {
|
||||
errors.push(...report.errors)
|
||||
if (report.result !== 'pass')
|
||||
errors.push(`Result is ${report.result}`)
|
||||
if (report.package !== row.name || report.gate !== gate)
|
||||
errors.push('Package/gate scope mismatch')
|
||||
if (report.version !== version)
|
||||
errors.push('Evidence version mismatch')
|
||||
if (!candidate || report.candidateIntegrity !== candidate.integrity)
|
||||
errors.push('Candidate integrity mismatch')
|
||||
if (report.inputFingerprint !== fingerprint)
|
||||
errors.push('Evidence input fingerprint is stale')
|
||||
if (!report.checks?.length || report.checks.some(check => check.result !== 'pass'))
|
||||
errors.push('Required checks contain missing, failed, skipped or unknown results')
|
||||
if (!report.environment?.length || !report.command || !report.reviewedBy)
|
||||
errors.push('Missing environment, command or review attribution')
|
||||
if (gate === 'devices' && report.environment?.some(env => env.emulated !== false || !env.device || !env.os || !env.browser))
|
||||
errors.push('Device evidence must explicitly identify real devices, OS and browser')
|
||||
if (gate === 'devices' && report.checks?.some(check => check.mode !== 'native'))
|
||||
errors.push('Device/capability checks cannot use mocks or emulation')
|
||||
if (gate === 'remote-ci' && !report.environment?.some(env => env.remote === true && /^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+$/.test(env.runUrl)))
|
||||
errors.push('Remote CI evidence needs an actual GitHub Actions run reference')
|
||||
const required = gate === 'devices' ? row.extraRequirements : gate === 'licenses' ? assets.map(asset => asset.id) : []
|
||||
const covered = new Set(report.checks?.filter(check => check.result === 'pass').map(check => check.id))
|
||||
for (const requirement of required) {
|
||||
if (!covered.has(requirement))
|
||||
errors.push(`Missing coverage: ${requirement}`)
|
||||
}
|
||||
}
|
||||
checks[gate] = errors.length ? { status: 'blocked', errors } : { status: 'evidence-recorded' }
|
||||
for (const error of errors) add(`evidence:${gate}`, error)
|
||||
}
|
||||
return { name: row.name, version, targetVersion: row.targetVersion, distribution: row.distribution, fingerprint, candidate, history, rollback: row.rollback, risks, assets, checks, blockers, status: blockers.length ? 'blocked' : 'evidence-complete', publicationAuthorized: false }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { readImpactModel, repositoryPath } from './impact-model.mjs'
|
||||
import { dependencyClosure, evaluatePackage, findingPackages, validateLedger } from './release-ledger-model.mjs'
|
||||
import { archiveFiles, hash, readMember } from './releases.mjs'
|
||||
|
||||
export const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
|
||||
export function localFile(directory, relative) {
|
||||
const normalized = repositoryPath(relative)
|
||||
const resolved = fs.realpathSync(path.resolve(directory, normalized))
|
||||
assert(resolved.startsWith(fs.realpathSync(directory) + path.sep), `File escapes repository: ${relative}`)
|
||||
assert(fs.statSync(resolved).isFile(), `Not a file: ${relative}`)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function read(directory, relative) {
|
||||
return JSON.parse(fs.readFileSync(localFile(directory, relative), 'utf8'))
|
||||
}
|
||||
|
||||
export function checkedReport(directory, binding) {
|
||||
if (!binding)
|
||||
return null
|
||||
const errors = []
|
||||
try {
|
||||
const bytes = fs.readFileSync(localFile(directory, binding.path))
|
||||
assert.equal(hash(bytes), binding.sha256, 'Report bytes changed')
|
||||
const report = JSON.parse(bytes)
|
||||
assert.equal(report.schemaVersion, 1, 'Unsupported evidence schema')
|
||||
assert(Array.isArray(report.artifacts) && report.artifacts.length, 'No underlying result artifacts')
|
||||
for (const artifact of report.artifacts) {
|
||||
assert.equal(hash(fs.readFileSync(localFile(directory, artifact.path))), artifact.sha256, `Underlying evidence changed: ${artifact.path}`)
|
||||
}
|
||||
return { ...report, errors }
|
||||
}
|
||||
catch (error) {
|
||||
return { errors: [error.message] }
|
||||
}
|
||||
}
|
||||
|
||||
export function checkedCandidate(directory, row) {
|
||||
const binding = row.candidate
|
||||
if (!binding)
|
||||
return null
|
||||
const result = { ...binding, errors: [] }
|
||||
try {
|
||||
assert(/^[a-f\d]{40}$/.test(binding.sourceCommit), 'Missing candidate source commit')
|
||||
execFileSync('git', ['cat-file', '-e', `${binding.sourceCommit}^{commit}`], { cwd: directory, stdio: 'pipe' })
|
||||
const artifact = localFile(directory, binding.path)
|
||||
const bytes = fs.readFileSync(artifact)
|
||||
assert.equal(`sha512-${hash(bytes, 'sha512', 'base64')}`, binding.integrity, 'Candidate integrity mismatch')
|
||||
if (row.distribution === 'site') {
|
||||
assert.equal(binding.kind, 'site-manifest', 'Site requires a build-file manifest, not an inferred npm tarball')
|
||||
const manifest = JSON.parse(bytes)
|
||||
assert.equal(manifest.package, row.name)
|
||||
assert.equal(manifest.version, binding.version)
|
||||
assert(Array.isArray(manifest.files) && manifest.files.length, 'Empty site output')
|
||||
assert.equal(new Set(manifest.files.map(file => file.path)).size, manifest.files.length, 'Duplicate site outputs')
|
||||
const outputRoot = fs.realpathSync(path.resolve(directory, repositoryPath(manifest.outputRoot)))
|
||||
assert(outputRoot.startsWith(fs.realpathSync(directory) + path.sep), 'Site output escapes repository')
|
||||
const walk = folder => fs.readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
|
||||
assert(!entry.isSymbolicLink(), 'Site output contains a redirected file')
|
||||
const child = path.join(folder, entry.name)
|
||||
return entry.isDirectory() ? walk(child) : [path.relative(directory, child).replaceAll('\\', '/')]
|
||||
})
|
||||
assert.deepEqual(walk(outputRoot).sort(), manifest.files.map(file => repositoryPath(file.path)).sort(), 'Site output inventory differs')
|
||||
for (const file of manifest.files)
|
||||
assert.equal(hash(fs.readFileSync(localFile(directory, file.path))), file.sha256, `Site build changed: ${file.path}`)
|
||||
}
|
||||
else {
|
||||
assert.equal(binding.kind, 'npm-tarball')
|
||||
archiveFiles(artifact)
|
||||
const manifest = JSON.parse(readMember(artifact, 'package/package.json'))
|
||||
assert.equal(manifest.name, row.name, 'Tarball package name mismatch')
|
||||
assert.equal(manifest.version, binding.version, 'Tarball version mismatch')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
result.errors.push(error.message)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function historyFor(directory, row) {
|
||||
const data = read(directory, row.history.file)
|
||||
const selected = row.history.selector?.split('.').reduce((object, key) => object?.[key], data)
|
||||
if (row.distribution === 'site')
|
||||
return { verified: true, kind: 'site-only', file: row.history.file, rationale: row.history.rationale, npmPublication: false }
|
||||
if (!selected?.integrity || !selected?.tarball || !selected?.sha256)
|
||||
return { verified: false, reason: 'Historical archive unavailable; recovered content is not a complete rollback tarball', file: row.history.file }
|
||||
const expectedName = row.history.publishedName || row.name
|
||||
assert.equal(selected.name, expectedName, `Wrong historical package: ${row.name}`)
|
||||
return { verified: true, kind: row.distribution, file: row.history.file, name: selected.name, version: selected.version, integrity: selected.integrity, sha256: selected.sha256, tarball: selected.tarball, rationale: row.history.rationale, limitation: 'Frozen historical distribution mapping; not a fresh registry/version-availability check or rollback rehearsal' }
|
||||
}
|
||||
|
||||
export function fingerprintInputs(directory, files, dependencies, site) {
|
||||
const selected = files.filter(file => dependencies.some(name => file.startsWith(`packages/${name}/`))
|
||||
|| /^(?:scripts\/|types\/|test\/|\.github\/workflows\/)/.test(file)
|
||||
|| /^(?:package\.json|yarn\.lock|\.node-version|\.gitattributes|\.yarnrc|\.npmrc|tsconfig[^/]*\.json|playwright[^/]*\.js|eslint\.config\.js|lerna\.json)$/.test(file)
|
||||
|| /^refactor\/(?:third-party\.json|scripts\/release-ledger[^/]*\.mjs)$/.test(file)
|
||||
|| /^refactor\/(?:compatibility\.md|quality-contract\.md|environment-matrix\.md|release-reviews\.md|version-policy\.md|package-inventory\.json|impact-policy\.json)$/.test(file)
|
||||
|| /^refactor\/baselines\/(?:releases|.*-release|.*-sdk(?:-matrix)?|.*-core)\.json$/.test(file)
|
||||
|| (site && /^(?:docs\/|example\/)/.test(file)))
|
||||
return selected.sort().map((file) => {
|
||||
const bytes = fs.readFileSync(localFile(directory, file))
|
||||
const basename = path.basename(file)
|
||||
const text = /\.(?:[cm]?[jt]sx?|vue|json|md|txt|html|css|less|svg|ya?ml|lock)$/.test(file)
|
||||
|| ['.node-version', '.npmignore', '.gitignore', '.yarnrc', '.npmrc', '.gitattributes'].includes(basename)
|
||||
|| /^(?:LICENSE|NOTICE|README)(?:\..*)?$/i.test(basename)
|
||||
return { path: file, algorithm: text ? 'sha256-lf' : 'sha256', sha256: hash(text ? bytes.toString('utf8').replaceAll('\r\n', '\n') : bytes), rawSha256: hash(bytes) }
|
||||
})
|
||||
}
|
||||
|
||||
export function fingerprintOf(inputs) {
|
||||
return hash(JSON.stringify(inputs.map(({ path, algorithm, sha256 }) => ({ path, algorithm, sha256 }))))
|
||||
}
|
||||
|
||||
export function buildLedger(directory = root, selectedNames) {
|
||||
const ledger = read(directory, 'refactor/release-ledger.json')
|
||||
const inventory = read(directory, 'refactor/package-inventory.json').packages
|
||||
const tasks = new Map(read(directory, 'refactor/tasks.json').tasks.map(task => [task.id, task]))
|
||||
validateLedger(ledger, inventory, tasks)
|
||||
const names = inventory.map(pkg => pkg.name)
|
||||
const selected = selectedNames || names
|
||||
assert(selected.length && new Set(selected).size === selected.length && selected.every(name => names.includes(name)), 'Unknown, empty or duplicate batch package selection')
|
||||
const model = readImpactModel(directory)
|
||||
const risks = read(directory, 'refactor/risks.json').items
|
||||
const thirdParty = read(directory, 'refactor/third-party.json')
|
||||
const assets = [...thirdParty.vendored, ...thirdParty.integrations]
|
||||
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: directory, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }).split('\0').filter(file => file && fs.existsSync(path.join(directory, file))))]
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
const inputFiles = {}
|
||||
const rows = selected.map((name) => {
|
||||
const row = ledger.packages.find(pkg => pkg.name === name)
|
||||
const dependencies = dependencyClosure(name, names, model.edges, model.dynamicImports)
|
||||
const applies = item => findingPackages(item, tasks, names).some(pkg => dependencies.includes(pkg))
|
||||
const relevantRisks = risks.filter(applies)
|
||||
const relevantAssets = [
|
||||
...assets.filter(applies),
|
||||
...thirdParty.packages.filter(pkg => dependencies.includes(pkg.name)).flatMap(pkg => pkg.resolvedRuntimeDependencies.map(dependency => ({ id: `dependency:${pkg.name}:${dependency.name}`, sourceStatus: `Locked ${dependency.lockedVersion}; declared ${dependency.range}`, licenseStatus: dependency.licenseEvidence }))),
|
||||
]
|
||||
const inputs = fingerprintInputs(directory, files, dependencies, row.distribution === 'site')
|
||||
Object.assign(inputFiles, Object.fromEntries(inputs.map(({ path, ...input }) => [path, input])))
|
||||
const fingerprint = fingerprintOf(inputs)
|
||||
const taskGaps = [...tasks.values()].filter(task => (ledger.sharedTasks.includes(task.id)
|
||||
|| (task.scope.some(scope => dependencies.includes(scope)) && /^(?:PKG-|CORE-|SITE-|EX-)/.test(task.id))) && task.status !== 'done').map(task => task.id)
|
||||
const evidence = Object.fromEntries(Object.entries(row.evidence).map(([gate, binding]) => [gate, checkedReport(directory, binding)]))
|
||||
const result = evaluatePackage({
|
||||
row,
|
||||
fingerprint,
|
||||
version: read(directory, `packages/${name}/package.json`).version,
|
||||
candidate: checkedCandidate(directory, row),
|
||||
evidence,
|
||||
taskGaps,
|
||||
risks: relevantRisks.map(({ id, status, owners, evidence, compatibleResolution, closureCriteria }) => ({ id, status, owners, evidence, compatibleResolution, closureCriteria })),
|
||||
assets: relevantAssets.map(({ id, riskId, sourceStatus, licenseStatus, validationStatus, reviewEvidence }) => ({ id, riskId, sourceStatus, licenseStatus, validationStatus, reviewEvidence })),
|
||||
history: historyFor(directory, row),
|
||||
})
|
||||
return { ...result, dependencies, inputs: inputs.map(input => input.path), historicalTaskEvidence: [...tasks.values()].filter(task => task.scope.includes(name)).map(({ id, status, evidence }) => ({ id, status, evidence })), historicalEvidenceAcceptedForCandidate: false }
|
||||
})
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sourceCommit: head,
|
||||
toolchain: {
|
||||
node: process.version,
|
||||
canonicalNode: fs.readFileSync(localFile(directory, '.node-version'), 'utf8').trim(),
|
||||
packageManager: read(directory, 'package.json').packageManager,
|
||||
observedUserAgent: process.env.npm_config_user_agent || null,
|
||||
declaredTools: read(directory, 'package.json').devDependencies,
|
||||
lock: { path: 'yarn.lock', sha256: hash(fs.readFileSync(localFile(directory, 'yarn.lock'))) },
|
||||
},
|
||||
ledger: { path: 'refactor/release-ledger.json', sha256: hash(fs.readFileSync(localFile(directory, 'refactor/release-ledger.json'))) },
|
||||
inputFiles,
|
||||
packages: rows,
|
||||
evidenceComplete: rows.every(row => row.status === 'evidence-complete'),
|
||||
publicationAuthorized: false,
|
||||
limitation: 'Mechanical evidence binding and blocking report. Report contents still require the three reviews; this tool neither executes tests nor verifies devices nor authorizes publishing.',
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const { values } = parseArgs({ options: { packages: { type: 'string' }, report: { type: 'boolean' }, strict: { type: 'boolean' } } })
|
||||
const result = buildLedger(root, values.packages?.split(','))
|
||||
if (values.strict) {
|
||||
assert.equal(process.version, `v${result.toolchain.canonicalNode}`, 'Strict release preflight requires canonical Node')
|
||||
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Strict release preflight requires Yarn Classic1.22.22')
|
||||
}
|
||||
if (values.report) {
|
||||
const directory = fs.mkdtempSync(path.join(root, 'refactor/.cache/release-ledger-'))
|
||||
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify(result, null, 2)}\n`)
|
||||
console.log(`Release ledger report: ${directory}`)
|
||||
}
|
||||
console.log(JSON.stringify({ packages: result.packages.map(({ name, status, blockers }) => ({ name, status, blockers: blockers.length })), publicationAuthorized: false }))
|
||||
if (values.strict && !result.evidenceComplete)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Release evidence must fail closed under stale or mis-scoped input.
|
||||
import test from 'node:test'
|
||||
import { dependencyClosure, evaluatePackage, findingPackages, libraryGates, sharedTasks, siteGates, validateLedger } from './release-ledger-model.mjs'
|
||||
import { checkedCandidate, checkedReport, fingerprintInputs, fingerprintOf, localFile, root } from './release-ledger.mjs'
|
||||
import { hash } from './releases.mjs'
|
||||
|
||||
function fixture() {
|
||||
const row = { name: 'example', distribution: 'npm', targetVersion: '2.0.0', extraRequirements: ['cast-session'], rollback: { strategy: 'restore previous tested candidate' } }
|
||||
const candidate = { version: '2.0.0', integrity: 'candidate-digest', inputFingerprint: 'current-inputs', errors: [] }
|
||||
const evidence = Object.fromEntries(libraryGates.map(gate => [gate, { errors: [], result: 'pass', package: 'example', version: '2.0.0', gate, candidateIntegrity: candidate.integrity, inputFingerprint: 'current-inputs', checks: [{ id: gate === 'devices' ? 'cast-session' : 'normal-path', result: 'pass', mode: 'native' }], environment: [{ os: 'device-os', browser: 'browser-version', device: 'physical-model', emulated: false, remote: true, runUrl: 'https://github.com/example/repo/actions/runs/1' }], command: 'actual test command', reviewedBy: 'review record' }]))
|
||||
return { row, fingerprint: 'current-inputs', version: '2.0.0', candidate, evidence, taskGaps: [], risks: [], assets: [], history: { verified: true } }
|
||||
}
|
||||
|
||||
test('Release ledger synthetic complete evidence remains publication-unauthorized', () => {
|
||||
const result = evaluatePackage(fixture())
|
||||
assert.equal(result.status, 'evidence-complete')
|
||||
assert.equal(result.publicationAuthorized, false)
|
||||
})
|
||||
|
||||
for (const [name, mutate, kind] of [
|
||||
['missing candidate', input => input.candidate = null, 'candidate'],
|
||||
['changed sources', input => input.fingerprint = 'changed-source', 'stale-candidate'],
|
||||
['unprepared major', input => input.version = '1.1.0', 'version'],
|
||||
['wrong artifact version', input => input.candidate.version = '9.0.0', 'candidate-version'],
|
||||
['archive bytes differ', input => input.candidate.errors.push('digest differs'), 'candidate'],
|
||||
['missing rollback archive', input => input.history = { verified: false, reason: 'not recovered' }, 'distribution-history'],
|
||||
['open affected risk', input => input.risks.push({ id: 'CAST-01', status: 'open' }), 'risk'],
|
||||
['unfinished review', input => input.taskGaps.push('REVIEW-03'), 'task'],
|
||||
['missing format report', input => delete input.evidence.runtime, 'evidence:runtime'],
|
||||
['wrong package scope', input => input.evidence.runtime.package = 'other-package', 'evidence:runtime'],
|
||||
['wrong gate scope', input => input.evidence.runtime.gate = 'types', 'evidence:runtime'],
|
||||
['wrong report version', input => input.evidence.runtime.version = '1.0.0', 'evidence:runtime'],
|
||||
['old tarball report', input => input.evidence.runtime.candidateIntegrity = 'old-digest', 'evidence:runtime'],
|
||||
['stale type report', input => input.evidence.types.inputFingerprint = 'old-inputs', 'evidence:types'],
|
||||
['skipped native check', input => input.evidence.devices.checks[0].result = 'skipped', 'evidence:devices'],
|
||||
['emulated device', input => input.evidence.devices.environment[0].emulated = true, 'evidence:devices'],
|
||||
['unidentified device', input => delete input.evidence.devices.environment[0].device, 'evidence:devices'],
|
||||
['mock capability on real device', input => input.evidence.devices.checks[0].mode = 'mock', 'evidence:devices'],
|
||||
['local CI as remote proof', input => input.evidence['remote-ci'].environment[0].remote = false, 'evidence:remote-ci'],
|
||||
['missing capability', input => input.evidence.devices.checks[0].id = 'viewport-only', 'evidence:devices'],
|
||||
['missing source/license coverage', input => input.assets.push({ id: 'worker-wasm-font' }), 'evidence:licenses'],
|
||||
['empty successful report', input => input.evidence.browser.checks = [], 'evidence:browser'],
|
||||
]) {
|
||||
test(`Release ledger blocks ${name}`, () => {
|
||||
const input = fixture()
|
||||
mutate(input)
|
||||
const result = evaluatePackage(input)
|
||||
assert.equal(result.status, 'blocked')
|
||||
assert(result.blockers.some(blocker => blocker.kind === kind))
|
||||
})
|
||||
}
|
||||
|
||||
test('Release ledger maps dependency risks without generic review owners tainting unrelated packages', () => {
|
||||
const names = ['core', 'hls', 'cast', 'site']
|
||||
const edges = [{ consumer: 'hls', dependency: 'core' }, { consumer: 'cast', dependency: 'core' }, { consumer: 'site', dependency: 'hls' }]
|
||||
assert.deepEqual(dependencyClosure('cast', names, edges), ['cast', 'core'])
|
||||
assert.deepEqual(dependencyClosure('site', names, edges), ['core', 'hls', 'site'])
|
||||
assert.deepEqual(dependencyClosure('cast', names, edges, ['unknown-import']), [...names].sort())
|
||||
const tasks = new Map([['HLS-01', { scope: ['hls'] }], ['REVIEW-02', { scope: ['workspace'] }]])
|
||||
assert.deepEqual(findingPackages({ owners: ['HLS-01', 'REVIEW-02'] }, tasks, names), ['hls'])
|
||||
assert.deepEqual(findingPackages({ owners: ['REVIEW-02'] }, tasks, names), names)
|
||||
})
|
||||
|
||||
function temp(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-ledger-test-'))
|
||||
t.after(() => {
|
||||
const resolved = fs.realpathSync(directory)
|
||||
assert.equal(path.dirname(resolved), fs.realpathSync(os.tmpdir()))
|
||||
assert(path.basename(resolved).startsWith('artplayer-ledger-test-'))
|
||||
fs.rmSync(resolved, { recursive: true, force: true })
|
||||
})
|
||||
const save = (file, value) => {
|
||||
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true })
|
||||
fs.writeFileSync(path.join(directory, file), value)
|
||||
}
|
||||
return { directory, save }
|
||||
}
|
||||
|
||||
test('Release ledger hashes actual report and underlying artifacts; replaced bytes cannot pass', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
save('actual.log', 'actual observed output')
|
||||
const report = { schemaVersion: 1, result: 'pass', artifacts: [{ path: 'actual.log', sha256: hash('actual observed output') }] }
|
||||
save('report.json', JSON.stringify(report))
|
||||
const binding = { path: 'report.json', sha256: hash(JSON.stringify(report)) }
|
||||
assert.deepEqual(checkedReport(directory, binding).errors, [])
|
||||
save('actual.log', 'different output')
|
||||
assert.match(checkedReport(directory, binding).errors[0], /Underlying evidence changed/)
|
||||
save('report.json', '{}')
|
||||
assert.match(checkedReport(directory, binding).errors[0], /Report bytes changed/)
|
||||
assert.throws(() => localFile(directory, '../outside'), /escapes/)
|
||||
})
|
||||
|
||||
test('Release input hashing isolates unrelated package edits but invalidates shared lock/test and dependencies', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
const files = ['packages/core/src.js', 'packages/hls/src.js', 'packages/cast/src.js', 'yarn.lock', 'test/shared.js', 'refactor/baselines/hls-sdk-matrix.json', 'refactor/release-reviews.md']
|
||||
files.forEach(file => save(file, 'original'))
|
||||
const fingerprint = () => fingerprintOf(fingerprintInputs(directory, files, ['core', 'cast'], false))
|
||||
const before = fingerprint()
|
||||
save('packages/hls/src.js', 'unrelated')
|
||||
assert.equal(fingerprint(), before)
|
||||
for (const file of ['packages/core/src.js', 'packages/cast/src.js', 'yarn.lock', 'test/shared.js', 'refactor/baselines/hls-sdk-matrix.json', 'refactor/release-reviews.md']) {
|
||||
save(file, 'changed')
|
||||
assert.notEqual(fingerprint(), before)
|
||||
save(file, 'original')
|
||||
}
|
||||
})
|
||||
|
||||
test('Release input fingerprint normalizes text EOL but records raw bytes and preserves binary differences', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
const files = ['packages/core/src.ts', 'packages/core/font.woff2']
|
||||
save(files[0], 'line1\r\nline2\r\n')
|
||||
save(files[1], 'binary\r\n')
|
||||
const before = fingerprintInputs(directory, files, ['core'], false)
|
||||
save(files[0], 'line1\nline2\n')
|
||||
const after = fingerprintInputs(directory, files, ['core'], false)
|
||||
assert.equal(fingerprintOf(before), fingerprintOf(after))
|
||||
assert.notDeepEqual(before, after)
|
||||
save(files[1], 'binary\n')
|
||||
assert.notEqual(fingerprintOf(before), fingerprintOf(fingerprintInputs(directory, files, ['core'], false)))
|
||||
})
|
||||
|
||||
test('Release ledger requires exactly22 packages, all reviews and site-specific distribution', () => {
|
||||
const ledger = JSON.parse(fs.readFileSync(path.join(root, 'refactor/release-ledger.json')))
|
||||
const inventory = JSON.parse(fs.readFileSync(path.join(root, 'refactor/package-inventory.json'))).packages
|
||||
const tasks = new Map(sharedTasks.map(id => [id, {}]))
|
||||
validateLedger(ledger, inventory, tasks)
|
||||
assert.equal(ledger.packages.length, 22)
|
||||
assert.equal(ledger.packages.find(row => row.name === 'artplayer-vitepress').distribution, 'site')
|
||||
assert.deepEqual(ledger.siteGates, siteGates)
|
||||
for (const mutate of [data => data.packages.pop(), data => data.libraryGates.pop(), data => data.sharedTasks.pop(), data => data.packages.find(row => row.distribution === 'site').distribution = 'npm']) {
|
||||
const changed = structuredClone(ledger)
|
||||
mutate(changed)
|
||||
assert.throws(() => validateLedger(changed, inventory, tasks))
|
||||
}
|
||||
})
|
||||
|
||||
test('Release candidate verifies site output bytes and refuses using an npm artifact for site scope', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
execFileSync('git', ['init', '-q', directory])
|
||||
execFileSync('git', ['-c', 'user.name=Ledger test', '-c', 'user.email=ledger@example.invalid', 'commit', '--allow-empty', '-qm', 'fixture'], { cwd: directory })
|
||||
const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
save('output/index.html', '<html>verified build</html>')
|
||||
const manifest = JSON.stringify({ package: 'site', version: '2.0.0', outputRoot: 'output', files: [{ path: 'output/index.html', sha256: hash('<html>verified build</html>') }] })
|
||||
save('manifest.json', manifest)
|
||||
const row = { name: 'site', distribution: 'site', candidate: { kind: 'site-manifest', path: 'manifest.json', sourceCommit, version: '2.0.0', integrity: `sha512-${hash(manifest, 'sha512', 'base64')}` } }
|
||||
assert.deepEqual(checkedCandidate(directory, row).errors, [])
|
||||
save('output/index.html', 'stale')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site build changed/)
|
||||
save('output/extra.js', 'unlisted build output')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site output inventory differs/)
|
||||
row.candidate.kind = 'npm-tarball'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site requires/)
|
||||
})
|
||||
|
||||
test('Release candidate checks actual tarball manifest and detects digest/version/name mismatches', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
execFileSync('git', ['init', '-q', directory])
|
||||
execFileSync('git', ['-c', 'user.name=Ledger test', '-c', 'user.email=ledger@example.invalid', 'commit', '--allow-empty', '-qm', 'fixture'], { cwd: directory })
|
||||
const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
save('staging/package/package.json', JSON.stringify({ name: 'example', version: '2.0.0' }))
|
||||
execFileSync('tar', ['-czf', path.join(directory, 'candidate.tgz'), '-C', path.join(directory, 'staging'), 'package'])
|
||||
const integrity = `sha512-${hash(fs.readFileSync(path.join(directory, 'candidate.tgz')), 'sha512', 'base64')}`
|
||||
const row = { name: 'example', distribution: 'npm', candidate: { kind: 'npm-tarball', path: 'candidate.tgz', integrity, version: '2.0.0', sourceCommit } }
|
||||
assert.deepEqual(checkedCandidate(directory, row).errors, [])
|
||||
row.candidate.version = '2.1.0'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Tarball version mismatch/)
|
||||
row.candidate.version = '2.0.0'
|
||||
row.name = 'wrong'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Tarball package name mismatch/)
|
||||
row.name = 'example'
|
||||
save('candidate.tgz', 'tampered')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Candidate integrity mismatch/)
|
||||
})
|
||||
+7
-2
@@ -4585,9 +4585,14 @@
|
||||
"scope": [
|
||||
"workspace"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"evidence": [],
|
||||
"evidence": [
|
||||
"changes/2026-09-14-REL-08-release-ledger.md",
|
||||
"baselines/release-ledger-validation.json",
|
||||
"release-ledger.md",
|
||||
"release-ledger.json"
|
||||
],
|
||||
"id": "REL-08",
|
||||
"phase": "8 发布验收",
|
||||
"title": "提前建立逐包发布准入台账",
|
||||
|
||||
Reference in new issue
Block a user