diff --git a/package.json b/package.json index 426e6764c..351cee3d6 100644 --- a/package.json +++ b/package.json @@ -40,14 +40,14 @@ "test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/dash-cleanup-errors.test.js", "dev": "npx cross-env NODE_ENV=development node ./scripts/dev.js", "build": "npx cross-env NODE_ENV=production node ./scripts/build.js", - "lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages,site-vendor,browser-validation}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix", + "lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages,site-vendor,browser-validation,release}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix", "build:all": "yarn ci:build && yarn lint", "check:toolchain": "node scripts/check-toolchain.mjs", - "lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages,site-vendor,browser-validation}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix", + "lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages,site-vendor,browser-validation,release}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix", "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/pages-artifact.test.js test/site-notices.test.js test/monaco-provenance.test.js test/monaco-unicode.test.js test/site-console.test.js test/vconsole-lifecycle.test.js test/browser-validation.test.js test/rollback-files.test.js test/pages-recovery.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 check:release-ledger && yarn check:rollback-inventory && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn check:vconsole && yarn check:console && yarn check:site-notices && 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 typecheck:release && yarn check:rollback-inventory && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn check:vconsole && yarn check:console && yarn check:site-notices && 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:vconsole && yarn build:console && yarn build:site-notices && 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", @@ -175,7 +175,10 @@ "verify:monaco-css": "node scripts/site-vendor/monaco/reproduce-css.ts", "verify:monaco-node-path": "node scripts/site-vendor/monaco/reproduce-node-path.ts", "verify:monaco-dom-origins": "node scripts/site-vendor/monaco/reproduce-dom-origins.ts", - "verify:monaco-unicode": "node scripts/site-vendor/monaco/reproduce-unicode.ts" + "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" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/npm-bundle-validation.json b/refactor/baselines/npm-bundle-validation.json new file mode 100644 index 000000000..025ad0fe1 --- /dev/null +++ b/refactor/baselines/npm-bundle-validation.json @@ -0,0 +1,249 @@ +{ + "schemaVersion": 1, + "task": "CI-NPM-01", + "sourceCommit": "25faf88e80ab1fec5c1623c6bfb01655ede6c457", + "capturedAt": "2026-09-15T08:18:57.486Z", + "status": "local-preparation-tool-verified", + "sourceHashesLF": { + "scripts/release/bundle.ts": "e9c59d8ded3ca0e893660aee5c57bed011f222255f90425dfb6f88b6633cc7dc", + "scripts/release/prepare.ts": "f4e5ed9b251e82a56e2730c02dcc6d1361ea78caf7251611f9a0b17574cc770e", + "scripts/prepare-release.mjs": "4e77956cc63f27f0b7abf28c5335dd41fb6e63e7f3be2856d97083b0a23edd04", + "scripts/tsconfig.release.json": "d39021762f220055e9f09eba5ae4c0817dd1f936958353ea518cb96d21f7ebf3", + "scripts/release/README.md": "5aa556c16021e6afbfb7812c183b210032f25a2d77cc75e55a826adf18ffb3d8", + "refactor/scripts/release-bundle.test.mjs": "ea7af0e748fccc83ed2dc3583b09760f361a668dda8d0d84cc5bcde823c142f8", + "package.json": "4d8efdc523f6e840434276ff2dde4e3d433499bcf2fe8bee213e4ba71b3537c7" + }, + "environment": { + "platform": "win32", + "node": "v24.21.0", + "yarn": "1.22.22", + "tar": "bsdtar 3.8.8 - libarchive 3.8.8 zlib/1.2.13.1-motley liblzma/5.8.1 bz2lib/1.0.8 libzstd/1.5.7 cng/2.0 libb2/bundled", + "git": "git version 2.51.0.windows.1" + }, + "checks": { + "tests-verified": { + "file": "refactor/.cache/npm-bundle-tests-verified.log", + "sha256": "9b0939dc62e26ca2de8cde20dc4ee2cb6c652c47d0fab518d41b613a7e8234b2", + "bytes": 2814, + "passed": 25, + "failed": 0 + }, + "ledger-tests": { + "file": "refactor/.cache/npm-bundle-ledger-tests.log", + "sha256": "6c7281c2f3afba7eeb21482adc741331a60a3e8cda20772d744e1de9ae38c1de", + "bytes": 2942, + "passed": 33, + "failed": 0 + }, + "ci-tests": { + "file": "refactor/.cache/npm-bundle-ci-tests.log", + "sha256": "a8a08a0d5590fa8b4c71b54e5023f13ec13b1aa3d3fb22fff0841e233ed357b4", + "bytes": 6153, + "passed": 77, + "failed": 0 + }, + "types-verified": { + "file": "refactor/.cache/npm-bundle-types-verified.log", + "sha256": "8f544f353837c45b656b9490b87cc27967b8469e779dd1f6e104602535a21139", + "bytes": 420, + "exitCode": 0 + }, + "lint-verified": { + "file": "refactor/.cache/npm-bundle-lint-verified.log", + "sha256": "a22111786cefa1418493bfdd62d93153720da0661b2c00f949dddf5c82d1dd9b", + "bytes": 493, + "exitCode": 0 + }, + "ci-check": { + "file": "refactor/.cache/npm-bundle-ci-check.log", + "sha256": "13f80b8fb2e0b52caaa95f76dd4564793f9c51642007105954bab2835e0f2979", + "bytes": 849, + "exitCode": 0 + }, + "impact-check": { + "file": "refactor/.cache/npm-bundle-impact-check.log", + "sha256": "7a6909df622329496ac673901289a9199c929e81565d6de9e133d0bcd9296992", + "bytes": 702, + "exitCode": 0 + } + }, + "priorLintFailure": { + "file": "refactor/.cache/npm-bundle-lint-first.log", + "sha256": "1fb8a79063e265fbffbd91e1933c29c1f6071470d691fa2ac92fb8d0565b21fe", + "bytes": 1493, + "errors": 7, + "resolution": "Explicit Node Buffer import and Node test-runner rationale, multiline formatting and documented intentional undefined throw; final lint rerun passes." + }, + "actualWorkspace": { + "cli": { + "file": "refactor/.cache/npm-bundle-cli-dirty.log", + "sha256": "14909cdbae6ff6b7c01b0fb36e69c048a49b886ebb4eec0154041a28a66fb60e", + "bytes": 1339, + "exitCode": 1, + "reason": "Source/evidence modifications are not committed; no bundle was prepared. This earlier log includes the assertion diff before the error message was simplified." + }, + "preflight": { + "file": "refactor/.cache/npm-bundle-current-preflight.log", + "sha256": "573d02b0e542f8ada1e5219c4486b81e42c6c1247d88893915c51cecfa7e4923", + "bytes": 1221, + "exitCode": 1 + }, + "report": { + "file": "refactor/.cache/release-ledger-FBF47r/report.json", + "sha256": "94618a9675cb6aca49b7e5a30f9f7b49fa7887b54fdeaccaa08aada2f7492066", + "bytes": 460005 + }, + "sourceCommit": "25faf88e80ab1fec5c1623c6bfb01655ede6c457", + "packages": [ + { + "name": "artplayer", + "version": "5.4.1", + "status": "blocked", + "blockers": [ + { + "kind": "version", + "detail": "5.4.1 must be prepared as 6.0.0 before final candidate validation" + }, + { + "kind": "candidate", + "detail": "No candidate artifact is bound" + }, + { + "kind": "task", + "detail": "CI-01" + }, + { + "kind": "task", + "detail": "CI-03" + }, + { + "kind": "task", + "detail": "CI-04" + }, + { + "kind": "task", + "detail": "REL-01" + }, + { + "kind": "task", + "detail": "REL-09" + }, + { + "kind": "task", + "detail": "REL-02" + }, + { + "kind": "task", + "detail": "REL-03" + }, + { + "kind": "task", + "detail": "REVIEW-01" + }, + { + "kind": "task", + "detail": "REVIEW-02" + }, + { + "kind": "task", + "detail": "REVIEW-03" + }, + { + "kind": "risk", + "detail": "ENG-PERF-01" + }, + { + "kind": "risk", + "detail": "ENG-PERF-02" + }, + { + "kind": "risk", + "detail": "BASE-SOURCE-01" + }, + { + "kind": "risk", + "detail": "BASE-SOURCE-02" + }, + { + "kind": "risk", + "detail": "SDK-03" + }, + { + "kind": "risk", + "detail": "SDK-04" + }, + { + "kind": "risk", + "detail": "SDK-05" + }, + { + "kind": "risk", + "detail": "BASE-ENV-01" + }, + { + "kind": "risk", + "detail": "MULTI-SUB-SWITCH-01" + }, + { + "kind": "evidence:build", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:runtime", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:types", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:combinations", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:browser", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:devices", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:licenses", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:rollback", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:remote-ci", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:review-01", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:review-02", + "detail": "Missing candidate-bound evidence" + }, + { + "kind": "evidence:review-03", + "detail": "Missing candidate-bound evidence" + } + ] + } + ], + "evidenceComplete": false, + "publicationAuthorized": false + }, + "scope": [ + "Small real tar archives and synthetic complete ledger reports exercise exact copies, metadata hashes and failure behavior. These synthetic reports do not certify any actual ArtPlayer candidate, hardware, remote CI or review.", + "Temporary real Git repositories verify clean, ignored-output, untracked, staged and unstaged conditions. Windows directory junctions verify resolved candidate/cache escapes are refused without changing the external target.", + "The CLI recomputes the existing repository ledger twice with clean-source guards; it has no JSON-report override. The internal inspector argument is a unit-test seam, not a CLI trust bypass.", + "Preparation does not build, pack, install, contact a registry or publish. The registry string and allowed tags are output policy only. Copied archives are not reconstructed, and missing/stale inputs are not replaced with workspace builds.", + "CI-03 still needs trusted workflow/run artifact provenance, registry occupancy checks, OIDC/permissions, exact publication, partial-failure recovery and post-publication verification. Parent dependencies, physical device and all review gates remain unchanged.", + "No player package source, public API, version, dependency or lockfile changed. This tool is strict TypeScript with a thin MJS CLI; ci:check, lint and test:baseline include its checks. There was no remote run, push, deployment or publication." + ] +} diff --git a/refactor/changes/2026-09-15-CI-NPM-01-bundle.md b/refactor/changes/2026-09-15-CI-NPM-01-bundle.md new file mode 100644 index 000000000..6dfabd1fc --- /dev/null +++ b/refactor/changes/2026-09-15-CI-NPM-01-bundle.md @@ -0,0 +1,55 @@ +# CI-NPM-01 已验收候选的本地交付准备 + +来源 HEAD:`25faf88e80ab1fec5c1623c6bfb01655ede6c457`。 +本任务从 CI-03 拆出本地文件准备能力,依赖已完成的 DOC-10、REL-08、REL-04。 +CI-03 原有的 CI-01 等依赖全部保留,并新增本任务依赖;远端 CI、三轮复盘、 +设备与最终候选要求没有删除,也未将原 CI-03 标为完成。 + +## 实现与使用 + +新增 `yarn release:bundle --packages artplayer,artplayer-plugin-chapter --tag next`。 +脚本先执行严格工具链检查,拒绝脏 Git 工作区,再重新计算已有发布准入台账。 +只有批次全部通过才复制其登记的精确 tarball。它不构建、不 pack、不安装、 +不联网,也不执行 npm publish。缺证据时没有“先打包再补报告”的回退路径。 + +实现使用两个严格 TS 模块与一个薄 MJS 命令入口。prepare.ts 负责参数、工具链 +与 Git 检查,bundle.ts 负责批次验证、文件复制、再次核验与失败清理。继续复用 +release-ledger 的真实候选、来源、任务、风险、设备、回退与复盘检查;不创建 +另一份手工状态表。包级 API、版本和分发文件不变,没有新增依赖或锁文件变化。 + +输出在新的 npm-bundle 缓存目录,manifest 最后写入,绑定完整 preflight 报告、 +源码提交、工具链/锁、包版本、文件大小、SHA-256 与 SHA-512。第二次准入读取 +必须与第一次一致,复制后的文件也再次核验,防止复制期间输入或输出漂移。 +失败只清理确认位于缓存内的本次目录;若清理失败,保留原始错误及清理错误。 + +使用边界和后续 AI 维护入口见 +[脚本架构](../../scripts/release/README.md)。CI-03 后续仍要建立受信任 artifact +下载、registry 占用检查、OIDC/权限、精确文件发布、部分失败恢复和读回核对。 +manifest 明确 publicationAuthorized=false;自带摘要不能证明远端来源可信。 + +## 验证范围 + +新增回归使用真实小型 tar 归档与明确的合成准入报告,验证字节身份与拒绝路径; +Git 检查使用真实临时仓库。合成绿色报告不代表任何实际 ArtPlayer 包已验收。 +覆盖批次/版本/tag 错误、站点混入、缺候选/缺证据、复制前后内容漂移、越界路径 +和 junction、半成品清理、falsy 原始异常以及清理再次失败。没有网络调用或发布。 + +最终新增测试 25/25、既有 release-ledger 33/33、CI 相关 77/77 通过;严格 TS、 +定向 lint、check:ci 和 check:impact 通过。环境为 Windows、Node 24.21.0、 +Yarn Classic 1.22.22。首轮 lint 的 7 项导入/格式/测试异常写法错误保留在原日志, +修正后重新验证,没有把首轮写成通过。该任务不涉及播放器运行代码,未重复 +运行浏览器、真实 SDK 或设备测试。 + +本仓库实际调用另验证未提交修改会被拒绝。现有严格 preflight 对 artplayer +仍返回 blocked,33 项缺口、publicationAuthorized=false;没有生成实际交付包。 +检查计数、源码指纹、日志与原始失败轮见 +[机器证据](../baselines/npm-bundle-validation.json)。 + +新增 `typecheck:release` 接入 ci:check,TS 目录进入 lint/lint:fix 范围;新增 +`test:release-bundle` 同时被现有 test:baseline 通配发现。类型和脚本变更的检查 +仅是本地结果,不能写作远端 CI 已运行或 npm 流程已启用。 + +## 回退 + +回退本任务独立提交会移除准备命令、TS 模块、对应测试/文档和脚本注册。 +既有 release-ledger 及其严格发布门槛不变,不影响任何已发布包。 diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md index 14273f5b2..d6fd04195 100644 --- a/refactor/ci-setup.md +++ b/refactor/ci-setup.md @@ -29,6 +29,8 @@ history 仍保留源码/显式工具加核心 map 的独立语义,不因新增 | `yarn lint:fix` | 显式自动修复相同范围 | | `yarn check:release-ledger` | ci:check中的逐包准入登记结构检查;当前blocked不导致结构检查失败,不是发布准入通过 | | `yarn release:preflight --packages ...` | 严格候选/证据/任务/设备/许可预检,任一缺口退出1;CI-03后续发布工作流使用此入口 | +| `yarn release:bundle --packages ... --tag next` | 干净源码下重新执行准入检查,仅复制已验证候选并绑定完整报告/文件摘要;不构建、不联网、不发布,当前缺口仍阻止输出 | +| `yarn typecheck:release` / `yarn test:release-bundle` | 严格检查候选交付 TS 模块及字节身份、输入漂移、路径和失败清理回归;分别接入 ci:check/test:baseline | | `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 执行 | diff --git a/refactor/github-ci-cd.md b/refactor/github-ci-cd.md index 846a61d7c..b77e2b18e 100644 --- a/refactor/github-ci-cd.md +++ b/refactor/github-ci-cd.md @@ -53,6 +53,13 @@ npm 保持 Lerna independent 的分包版本;用户已要求全部包分别升 ## 任务归属 +CI-NPM-01 从 CI-03 拆出本地精确候选交付准备,供后续 artifact 工作流复用。 +`yarn release:bundle --packages ... --tag next` 只复制严格准入已通过的登记 tarball, +保留 publicationAuthorized=false。Git 必须干净,复制前后状态和文件哈希必须一致。 +它不构建、安装或发布,也不证明 artifact 来源可信;CI-03 原依赖和远端/发布 +验收全部保留。当前包仍被准入门槛阻止,详见[记录](changes/2026-09-15-CI-NPM-01-bundle.md) +及[实现维护](../scripts/release/README.md)。 + | 任务 | 交付 | | --- | --- | | ENG-02 | 只读脚本、PR/主线检查及部署隔离基础 | diff --git a/refactor/plan.md b/refactor/plan.md index 913ea7205..cb7bda346 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -2,9 +2,9 @@ > 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。 -基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 269 项,范围 22 个包及工作区/示例。 +基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 270 项,范围 22 个包及工作区/示例。 -状态:todo 43 / doing 21 / blocked 0 / done 205 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 43 / doing 21 / blocked 0 / done 206 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -95,9 +95,10 @@ | --- | --- | --- | --- | --- | --- | --- | | 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 | 候选准备、精确 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 | 候选准备、精确 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 | 静态/干净环境检查、真实 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 | ## 2.1 早期试点 @@ -704,3 +705,4 @@ - PKG-AUTO-THUMB-12: [记录](changes/2026-09-15-PKG-AUTO-THUMB-12-metadata.md) [记录](baselines/auto-thumbnail-metadata-validation.json) - PKG-TOOL-THUMB-07: [记录](changes/2026-09-15-PKG-TOOL-THUMB-07-cleanup-errors.md) [记录](baselines/thumbnail-cleanup-errors-validation.json) - PKG-DASH-CLEANUP-01: [记录](changes/2026-09-15-PKG-DASH-CLEANUP-01-errors.md) [记录](baselines/dash-cleanup-errors-validation.json) +- CI-NPM-01: [记录](changes/2026-09-15-CI-NPM-01-bundle.md) [记录](baselines/npm-bundle-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index 131c792b3..69076e455 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,20 @@ # 进度与证据 +## CI-NPM-01 精确候选的本地交付准备 + +新增严格 TS 准备模块和 yarn release:bundle:干净 Git 状态、两次完整准入读取、 +精确 tarball 复制及输入/输出哈希复核;完成标记最后写入,失败清理仅限本次目录。 +不构建、安装、联网或发布。CLI 不接受外部绿色 JSON 报告;输出仍为 +publicationAuthorized=false,后续 artifact 来源信任/OIDC/registry/发布恢复由 CI-03 负责。 + +新回归25、既有准入33、CI77项全部通过,TS/lint/CI静态/影响检查通过。 +合成完整报告只验证工具;实际仓库的脏状态拒绝和 artplayer 严格预检退出1已核验, +核心仍有33项准入缺口,没有准备实际发布交付包。详见 +[记录](changes/2026-09-15-CI-NPM-01-bundle.md)和[证据](baselines/npm-bundle-validation.json)。 + +CI-NPM-01 独立完成,CI-03 原有依赖和发布门槛全部保留;206 done、21 doing、 +43 todo,共270项。没有包版本、公开API、依赖或锁文件变化,未推送或发布。 + ## PKG-DASH-CLEANUP-01 清理异常与职责归一 四处清理循环改为私有 cleanup.ts,保留首个原始异常值、剩余资源释放和重入保护。 diff --git a/refactor/scripts/release-bundle.test.mjs b/refactor/scripts/release-bundle.test.mjs new file mode 100644 index 000000000..b2a26f685 --- /dev/null +++ b/refactor/scripts/release-bundle.test.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +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' +// eslint-disable-next-line test/no-import-node-test -- Filesystem fault injection uses the existing Node baseline runner. +import test from 'node:test' +import { prepareReleaseBundle } from '../../scripts/release/bundle.ts' +import { assertCleanSource } from '../../scripts/release/prepare.ts' + +const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding) + +// These are synthetic complete reports, not release evidence for an actual package. +function fixture(t, names = ['artplayer', 'artplayer-plugin-chapter']) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-bundle-test-')) + const cache = path.join(directory, 'refactor/.cache') + fs.mkdirSync(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-bundle-test-')) + fs.rmSync(resolved, { recursive: true }) + }) + const packages = names.map((name) => { + const version = '2.0.0' + const source = path.join(directory, name, 'package') + fs.mkdirSync(source, { recursive: true }) + fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name, version, main: 'index.js' })) + fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = 42\n') + const filename = `refactor/.cache/${name}.tgz` + execFileSync('tar', ['-czf', path.join(directory, filename), '-C', path.dirname(source), 'package'], { stdio: 'pipe' }) + const bytes = fs.readFileSync(path.join(directory, filename)) + return { name, version, distribution: 'npm', fingerprint: 'frozen-inputs', status: 'evidence-complete', blockers: [], candidate: { path: filename, version, sourceCommit: 'a'.repeat(40), inputFingerprint: 'frozen-inputs', integrity: `sha512-${digest(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 stages = () => fs.readdirSync(cache).filter(name => name.startsWith('npm-bundle-')) + return { directory, cache, names, report, stages, inspect: () => structuredClone(report) } +} + +test('Release bundle copies exact tarballs and binds the final ledger without publication authority', (t) => { + const f = fixture(t) + let inspected = 0 + const result = prepareReleaseBundle(f.directory, f.names, 'next', () => { + inspected++ + return f.inspect() + }) + assert.equal(inspected, 2) + const manifest = JSON.parse(fs.readFileSync(path.join(result.directory, 'manifest.json'))) + assert.deepEqual(manifest, result.manifest) + assert.equal(manifest.publicationAuthorized, false) + assert.equal(manifest.registry, 'https://registry.npmjs.org/') + assert.equal(manifest.tag, 'next') + assert.equal(manifest.sourceCommit, f.report.sourceCommit) + assert.equal(manifest.preflight.sha256, digest(fs.readFileSync(path.join(result.directory, 'preflight.json')))) + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(result.directory, 'preflight.json'))), f.report) + for (const item of manifest.packages) { + const original = f.report.packages.find(row => row.name === item.name) + const bytes = fs.readFileSync(path.join(result.directory, item.file)) + assert.deepEqual(bytes, fs.readFileSync(path.join(f.directory, original.candidate.path))) + assert.equal(item.sha256, digest(bytes)) + assert.equal(item.integrity, `sha512-${digest(bytes, 'sha512', 'base64')}`) + assert.equal(item.sourceCommit, original.candidate.sourceCommit) + } + assert.equal(f.stages().length, 1) +}) + +for (const [name, mutate, expected] of [ + ['incomplete preflight', r => r.evidenceComplete = false, /preflight is blocked/], + ['incomplete package', r => r.packages[0].status = 'blocked', /incomplete evidence/], + ['blocked package', r => r.packages[0].blockers.push('device'), /blocking findings/], + ['missing candidate', r => r.packages[0].candidate = null, /missing candidate/], + ['candidate errors', r => r.packages[0].candidate.errors.push('changed'), /invalid candidate/], + ['stale fingerprint', r => r.packages[0].fingerprint = 'new', /stale candidate/], + ['version mismatch', r => r.packages[0].version = '3.0.0', /version drift/], + ['site distribution', r => r.packages[0].distribution = 'site', /site output/], + ['implicit approval', r => r.publicationAuthorized = true, /cannot authorize/], + ['missing source identity', r => r.sourceCommit = 'master', /source commit/], + ['missing candidate identity', r => r.packages[0].candidate.sourceCommit = 'master', /candidate commit/], + ['different selected packages', r => r.packages.reverse(), /selection differs/], + ['path-like version', r => r.packages[0].version = '../../elsewhere', /invalid prepared version/], +]) { + test(`Release bundle rejects ${name} before creating output`, (t) => { + const f = fixture(t) + mutate(f.report) + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', f.inspect), expected) + assert.deepEqual(f.stages(), []) + }) +} + +test('Release bundle rejects empty, duplicate, path-like package selections and unapproved tag syntax', (t) => { + const f = fixture(t) + for (const names of [[], ['artplayer', 'artplayer'], ['../artplayer'], ['Artplayer']]) + assert.throws(() => prepareReleaseBundle(f.directory, names, 'next', f.inspect), /explicit, unique/) + for (const tag of ['', '1.0.0', '--tag=latest', 'NEXT']) + assert.throws(() => prepareReleaseBundle(f.directory, f.names, tag, f.inspect), /Select next/) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle prevents assigning latest to prerelease candidates', (t) => { + const f = fixture(t) + f.report.packages[0].version = f.report.packages[0].candidate.version = '2.0.0-rc.1' + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'latest', f.inspect), /Prerelease versions/) +}) + +test('Release bundle removes partial copies when a later candidate changed', (t) => { + const f = fixture(t) + fs.appendFileSync(path.join(f.directory, f.report.packages[1].candidate.path), 'changed after inspection') + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', f.inspect), /changed after preflight/) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle rejects a candidate path outside the repository without deleting that file', (t) => { + const f = fixture(t) + const other = fixture(t, ['artplayer']) + const original = path.join(other.directory, other.report.packages[0].candidate.path) + const bytes = fs.readFileSync(original) + f.report.packages[0].candidate.path = path.relative(f.directory, original).replaceAll('\\', '/') + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', f.inspect), /path|repository/i) + assert.deepEqual(fs.readFileSync(original), bytes) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle does not prepare from inputs or evidence changed during the copy', (t) => { + const f = fixture(t) + let reads = 0 + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', () => { + const report = f.inspect() + if (++reads === 2) + report.toolchain.lock.sha256 = 'd'.repeat(64) + return report + }), /changed while preparing/) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle rejects previously copied files changed during the final ledger read', (t) => { + const f = fixture(t) + let reads = 0 + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', () => { + if (++reads === 2) { + const [stage] = f.stages() + fs.appendFileSync(path.join(f.cache, stage, 'artplayer-2.0.0.tgz'), 'changed output') + } + return f.inspect() + }), /staged artifact changed/) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle source guard rejects untracked, staged and unstaged changes but allows ignored outputs', (t) => { + const f = fixture(t) + const git = args => execFileSync('git', args, { cwd: f.directory, stdio: 'pipe' }) + git(['init', '--quiet']) + fs.writeFileSync(path.join(f.directory, '.gitignore'), 'refactor/.cache/\n') + git(['add', '.']) + git(['-c', 'user.name=ArtPlayer test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture']) + assert.doesNotThrow(() => assertCleanSource(f.directory)) + fs.writeFileSync(path.join(f.directory, 'new.txt'), 'untracked') + assert.throws(() => assertCleanSource(f.directory), /Commit source/) + git(['add', 'new.txt']) + assert.throws(() => assertCleanSource(f.directory), /Commit source/) + git(['-c', 'user.name=ArtPlayer test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture addition']) + fs.appendFileSync(path.join(f.directory, 'new.txt'), 'unstaged') + assert.throws(() => assertCleanSource(f.directory), /Commit source/) +}) + +test('Release bundle preserves the original falsy failure while deleting partial output', (t) => { + const f = fixture(t) + let reads = 0 + let threw = false + try { + prepareReleaseBundle(f.directory, f.names, 'next', () => { + if (++reads === 2) { + // eslint-disable-next-line no-throw-literal -- Preserve the original thrown primitive, including undefined. + throw undefined + } + return f.inspect() + }) + } + catch (error) { + threw = true + assert.equal(error, undefined) + } + assert.equal(threw, true) + assert.deepEqual(f.stages(), []) +}) + +test('Release bundle reports both preparation and cleanup failures without an approval marker', (t) => { + const f = fixture(t) + const cleanupError = new Error('test removal denied') + const preparationError = new Error('test ledger read failed') + let reads = 0 + const mocked = t.mock.method(fs, 'rmSync', () => { + throw cleanupError + }) + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', () => { + if (++reads === 2) + throw preparationError + return f.inspect() + }), (error) => { + assert(error instanceof AggregateError) + assert.deepEqual(error.errors, [preparationError, cleanupError]) + return true + }) + mocked.mock.restore() + const [stage] = f.stages() + assert(stage) + assert.equal(fs.existsSync(path.join(f.cache, stage, 'manifest.json')), false) +}) + +test('Release bundle rejects a candidate symlink that resolves outside the repository', (t) => { + const f = fixture(t) + const other = fixture(t, ['artplayer']) + fs.symlinkSync(other.directory, path.join(f.directory, 'redirect'), 'junction') + f.report.packages[0].candidate.path = 'redirect/refactor/.cache/artplayer.tgz' + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', f.inspect), /escapes repository/) + assert.deepEqual(f.stages(), []) + assert(fs.existsSync(path.join(other.directory, other.report.packages[0].candidate.path))) +}) + +test('Release bundle refuses a cache redirected outside the repository before writing there', (t) => { + const f = fixture(t) + const other = fixture(t, ['artplayer']) + const before = fs.readdirSync(other.cache) + fs.renameSync(f.cache, `${f.cache}-original`) + fs.symlinkSync(other.cache, f.cache, 'junction') + assert.throws(() => prepareReleaseBundle(f.directory, f.names, 'next', f.inspect), /cache escapes repository/) + assert.deepEqual(fs.readdirSync(other.cache), before) +}) diff --git a/refactor/tasks.json b/refactor/tasks.json index 4ec095648..8a244b7b8 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -818,7 +818,8 @@ "DOC-10", "CI-01", "REL-08", - "REL-04" + "REL-04", + "CI-NPM-01" ], "status": "todo", "risk": "H", @@ -5786,6 +5787,27 @@ "changes/2026-09-15-PKG-DASH-CLEANUP-01-errors.md", "baselines/dash-cleanup-errors-validation.json" ] + }, + { + "id": "CI-NPM-01", + "phase": "2.2 GitHub CI/CD", + "title": "从已验收候选准备不可重建的npm交付包", + "scope": [ + "workspace" + ], + "dependsOn": [ + "DOC-10", + "REL-08", + "REL-04" + ], + "status": "done", + "risk": "H", + "deliverable": "复用严格准入台账、复制精确tarball、绑定源码/工具链/证据/摘要的本地准备命令及反向测试;供CI-03后续受信任artifact工作流使用", + "acceptance": "缺候选或任一准入缺口即拒绝;不构建、不安装、不联网或发布;阻止路径越界、脏源码、复制期间漂移及半成品冒充完成,声明远端信任/OIDC/registry预检仍未实现", + "evidence": [ + "changes/2026-09-15-CI-NPM-01-bundle.md", + "baselines/npm-bundle-validation.json" + ] } ] } diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 000000000..ea0172305 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,10 @@ +import process from 'node:process' +import { prepareRelease } from './release/prepare.ts' + +try { + prepareRelease() +} +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 new file mode 100644 index 000000000..579e0c08d --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,73 @@ +# Preparing exact npm candidate files + +Run from the repository root with the pinned Node and Yarn: + +```sh +yarn release:preflight --packages artplayer,artplayer-plugin-chapter +yarn release:bundle --packages artplayer,artplayer-plugin-chapter --tag next +yarn typecheck:release +yarn test:release-bundle +``` + +`release:bundle` does not build, pack, install, contact npm or publish. It is a +handoff step for candidates already recorded in `refactor/release-ledger.json`. +Both commands currently refuse release acceptance because the repository still +has unfinished candidate, review, device and other required gates. Synthetic test +reports are not evidence that an ArtPlayer package is ready to publish. + +## Responsibilities + +- `../prepare-release.mjs` is the Node entry and failure exit-code boundary. +- `prepare.ts` parses the explicit package batch/tag, requires the canonical + Node/Yarn, and checks clean Git state before both ledger reads. Ignored generated + files may exist; staged, unstaged and untracked source changes are rejected. +- `bundle.ts` consumes the repository ledger, checks the batch, copies exact + tarball bytes and writes a hash-bound manifest. Its injected inspector is an + internal test seam; the CLI never trusts a supplied JSON report as approval. +- `../../refactor/scripts/release-ledger.mjs` remains the source of candidate, + input fingerprint, review, risk, source/license, device, rollback and CI checks. + Do not introduce another manually maintained green-status list here. + +The narrow report interfaces describe the fields consumed by preparation. The +original report is preserved in full. The candidate assertion after batch +validation is the only non-null assertion; an absent or invalid candidate must +be rejected before any output directory is created. + +## Output and failure behavior + +An accepted run creates a fresh `refactor/.cache/npm-bundle-*/` directory with +the existing tarballs, `preflight.json` and `manifest.json`. No existing output is +overwritten. The manifest binds package names, versions, per-package source +commits, source fingerprints, SHA-256, SHA-512 integrity, batch source commit, +toolchain/lock information and the complete preflight report's hash. + +The second ledger read must equal the first; candidates and copied files are +checked again. The manifest completion marker is written last. On failure, only +the verified temporary directory is removed. Redirected paths are rejected; if +cleanup also fails, both errors and the retained directory are reported. A +leftover directory without a manifest is not a successful handoff. + +Allowed tags are explicitly `next`, `alpha`, `beta`, `rc` and `latest`; there is +no implicit default, and prereleases cannot use `latest`. Registry metadata is +fixed to the public npm registry. Site distributions cannot enter this npm bundle. +These are preparation-tool constraints, not changes to any player package API. + +## Trust boundary and remaining workflow work + +Every output retains `publicationAuthorized: false`. A manifest and its hashes +provide content binding, not authenticity or publishing permission. CI-03 still +needs trusted workflow/run artifact provenance, registry occupancy checks, exact +tarball publication without lifecycle rebuilding, permission/OIDC configuration, +partial-failure recovery and post-publication readback. Neither this script nor +its tests enables a GitHub workflow, creates a token, or authorizes publication. + +The future publisher must validate downloaded contents and fresh release evidence; +it must not trust an artifact solely because it has this manifest shape. Do not +add a rebuild fallback to preparation or publishing when an artifact is missing. + +`release-bundle.test.mjs` uses small real tar archives with explicitly synthetic +ledger reports for byte-copy/failure tests, and temporary real Git repositories +for the clean-source guard. It covers stale inputs, changed outputs, missing +gates, site confusion, selection/tag errors, outside paths/junctions, partial +cleanup and original exception retention. These tests run in `test:baseline`; +strict TypeScript checking runs in `ci:check`, and the TS source is in root lint. diff --git a/scripts/release/bundle.ts b/scripts/release/bundle.ts new file mode 100644 index 000000000..654e69376 --- /dev/null +++ b/scripts/release/bundle.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs' + +interface Candidate { + path: string + version: string + sourceCommit: string + inputFingerprint: string + integrity: string + errors: string[] +} + +interface PackageRow { + name: string + version: string + distribution: string + fingerprint: string + status: string + blockers: unknown[] + candidate: Candidate | null +} + +export interface LedgerSnapshot { + schemaVersion: number + sourceCommit: string + evidenceComplete: boolean + publicationAuthorized: boolean + toolchain: { node: string, canonicalNode: string, packageManager: string, lock: { sha256: string } } + packages: PackageRow[] +} + +const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') +const integrity = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}` +const encode = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`) + +function checkBatch(report: LedgerSnapshot, names: string[], tag: string): void { + assert.equal(report.schemaVersion, 1, 'Unsupported release ledger report') + assert.equal(report.publicationAuthorized, false, 'A ledger report cannot authorize publication') + assert(/^[a-f\d]{40}$/.test(report.sourceCommit), 'Missing source commit') + assert.deepEqual(report.packages.map(row => row.name), names, 'Ledger package selection differs') + assert(report.evidenceComplete, 'Release preflight is blocked; candidate bundle was not prepared') + for (const row of report.packages) { + assert.equal(row.status, 'evidence-complete', `${row.name}: incomplete evidence`) + assert.deepEqual(row.blockers, [], `${row.name}: blocking findings`) + assert(['npm', 'renamed-npm', 'recovered-npm'].includes(row.distribution), `${row.name}: site output must not become an npm publication`) + assert(/^artplayer(?:-[a-z0-9]+)*$/.test(row.name), 'Invalid workspace package name') + assert(/^\d+\.\d+\.\d+(?:-[a-z0-9]+(?:[.-][a-z0-9]+)*)?$/i.test(row.version), `${row.name}: invalid prepared version`) + assert(tag !== 'latest' || !row.version.includes('-'), 'Prerelease versions cannot use latest') + assert(row.candidate, `${row.name}: missing candidate`) + assert.deepEqual(row.candidate.errors, [], `${row.name}: invalid candidate`) + assert.equal(row.candidate.version, row.version, `${row.name}: candidate version drift`) + assert.equal(row.candidate.inputFingerprint, row.fingerprint, `${row.name}: stale candidate`) + assert(/^[a-f\d]{40}$/.test(row.candidate.sourceCommit), `${row.name}: missing candidate commit`) + } +} + +// inspect is an internal test seam. The CLI always recomputes the repository ledger; +// it does not accept a caller-supplied JSON report as evidence. +export function prepareReleaseBundle(directory: string, names: string[], tag: string, inspect: (directory: string, names: string[]) => LedgerSnapshot = buildLedger) { + assert(names.length && new Set(names).size === names.length && names.every(name => /^artplayer(?:-[a-z0-9]+)*$/.test(name)), 'Select explicit, unique workspace packages') + assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(tag), 'Select next, alpha, beta, rc or latest explicitly') + directory = fs.realpathSync(directory) + const initial = inspect(directory, names) + checkBatch(initial, names, tag) + const cache = fs.realpathSync(path.join(directory, 'refactor/.cache')) + assert(cache.startsWith(directory + path.sep), 'Bundle cache escapes repository') + const stage = fs.mkdtempSync(path.join(cache, 'npm-bundle-')) + try { + const packages = initial.packages.map((row) => { + const candidate = row.candidate! + const bytes = fs.readFileSync(localFile(directory, candidate.path)) + assert.equal(integrity(bytes), candidate.integrity, `${row.name}: candidate changed after preflight`) + const file = `${row.name}-${row.version}.tgz` + const output = path.join(stage, file) + fs.writeFileSync(output, bytes, { flag: 'wx' }) + assert.equal(sha256(fs.readFileSync(output)), sha256(bytes), `${row.name}: copied artifact differs`) + return { name: row.name, version: row.version, file, bytes: bytes.length, sha256: sha256(bytes), integrity: candidate.integrity, sourceCommit: candidate.sourceCommit, inputFingerprint: row.fingerprint } + }) + const final = inspect(directory, names) + checkBatch(final, names, tag) + assert.deepEqual(final, initial, 'Release inputs or evidence changed while preparing the bundle') + for (const item of packages) + assert.equal(sha256(fs.readFileSync(path.join(stage, item.file))), item.sha256, `${item.name}: staged artifact changed`) + const preflight = encode(final) + fs.writeFileSync(path.join(stage, 'preflight.json'), preflight, { flag: 'wx' }) + assert.equal(sha256(fs.readFileSync(path.join(stage, 'preflight.json'))), sha256(preflight), 'Written preflight report differs') + const manifest = { + schemaVersion: 1, + kind: 'artplayer-npm-bundle', + publicationAuthorized: false, + sourceCommit: final.sourceCommit, + registry: 'https://registry.npmjs.org/', + tag, + toolchain: final.toolchain, + preflight: { file: 'preflight.json', sha256: sha256(preflight) }, + packages, + limitations: ['This bundle is not publication authorization.', 'A future publisher must verify trusted workflow/run provenance, current registry occupancy, all bundle hashes and fresh release evidence before publishing these exact tarballs.'], + } + // Completion marker is written last; no directory is returned on failure. + fs.writeFileSync(path.join(stage, 'manifest.json'), encode(manifest), { flag: 'wx' }) + return { directory: stage, manifest } + } + catch (error) { + try { + const resolved = fs.realpathSync(stage) + assert.equal(path.dirname(resolved), cache, 'Refusing redirected bundle cleanup') + assert(path.basename(resolved).startsWith('npm-bundle-'), 'Refusing unrelated bundle cleanup') + fs.rmSync(resolved, { recursive: true }) + } + catch (cleanupError) { + throw new AggregateError([error, cleanupError], `Bundle preparation failed; inspect retained files at ${stage}`) + } + throw error + } +} diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts new file mode 100644 index 000000000..052326928 --- /dev/null +++ b/scripts/release/prepare.ts @@ -0,0 +1,27 @@ +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 { parseArgs } from 'node:util' +import { buildLedger, root } from '../../refactor/scripts/release-ledger.mjs' +import { prepareReleaseBundle } from './bundle.ts' + +export function assertCleanSource(directory: string): void { + const status = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=normal'], { cwd: directory, encoding: 'utf8' }) + assert(status.length === 0, 'Commit source and evidence changes before preparing a release bundle') +} + +export function prepareRelease(args = process.argv.slice(2)): void { + const { values } = parseArgs({ args, options: { packages: { type: 'string' }, tag: { type: 'string' } }, strict: true }) + assert(values.packages && values.tag, 'Explicit --packages and --tag are required') + const expectedNode = fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim() + assert.equal(process.version, `v${expectedNode}`, 'Use the canonical Node version') + assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:bundle') + function inspect(directory: string, names: string[]) { + assertCleanSource(directory) + return buildLedger(directory, names) + } + const result = prepareReleaseBundle(root, values.packages.split(','), values.tag, inspect) + console.log(JSON.stringify({ directory: result.directory, packages: result.manifest.packages.map(({ name, version, integrity }) => ({ name, version, integrity })), publicationAuthorized: false })) +} diff --git a/scripts/tsconfig.release.json b/scripts/tsconfig.release.json new file mode 100644 index 000000000..7bb21fed2 --- /dev/null +++ b/scripts/tsconfig.release.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"], + "allowImportingTsExtensions": true + }, + "include": ["release/**/*.ts"] +}