docs: rfc

This commit is contained in:
imccyu
2026-08-05 01:11:49 +08:00
parent 81c63bbf4d
commit 6a5f0e169f
4 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md
2026-08-04-artifact-first-npm-baseline-publication.md: 47a65ef574231cdf4ba36fc7a70349fa952aa513
2026-08-04-artifact-first-npm-baseline-publication.zh.md: 713007445cf8578b241f23d0733cf06522ebd3b2
@@ -0,0 +1,108 @@
# Agent Note: Artifact-first NPM baseline publication
Status: proposed
English | [中文](2026-08-04-artifact-first-npm-baseline-publication.zh.md)
## Problem
Runnable source in the monorepo does not prove that published packages are runnable. Workspace links, TypeScript paths, tsx source loading, and residual `lib/` files in the working tree can supply files or dependencies that are absent from a published tarball. Existing built-artifact tests still read `lib/` directly from the working tree, so they do not verify what `package.json#files` selects or the layout produced by package-manager installation. An execution that succeeds in development mode can therefore be published without a required bundle chunk, declaration, configuration file, or asset.
Publishing many mutually dependent `@deepseek-ai` packages also creates a set-consistency problem. If a script publishes each package immediately after packing it, a later pack or validation failure leaves the first part of an unusable baseline in the registry. The npm registry has no cross-package transaction, so “publish once” cannot promise an atomic commit. It can promise that the complete publication set is packed and validated before any remote write, then published from that immutable set by one resumable orchestration command.
The current baseline also requires a person to derive versions, authenticate, pack, publish, and retry from a local machine. A later GitHub Actions workflow must reuse the same release bundle and validation logic. It must not rebuild a different, untested set of tarballs after publication is approved.
## Proposal
The publication flow uses an immutable release bundle as its boundary. The pack phase builds every target package from one fixed Git commit, creates every tarball, validates tarball contents, and passes an installed-artifact integration test. The publish phase reads only those tarballs and their manifest and is forbidden from rebuilding or repacking.
The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list.
The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
The pack phase runs in this order:
1. Resolve the ref to an immutable commit, capture the UTC timestamp, derive the version from that commit's root manifest, and display the commit, timestamp, version, tag, registry, and output path. Both `pack` and `release` wait for Enter at this point before expensive work; `--yes` skips this confirmation for automation.
2. Install the frozen lockfile in an isolated detached worktree. Uncommitted files and old build output from the caller's working tree must not affect publication.
3. Stage every target manifest with the derived version, remove its publication-time `private` marker, and rewrite internal workspace dependencies in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies` to the same exact version.
4. Build the target commit completely, then run publint, built-package invariants, and publication-payload gates.
5. Pack every package in the target set without performing a registry write.
6. Inspect each tarball's package manifest, file inventory, internal dependency versions, name, and version, rejecting missing, duplicate, or extra tarballs.
7. Generate a release manifest and checksums containing the commit, version, tag, registry, and each package's tarball path, SHA-256, and npm integrity.
8. Install isolated consumers from the local tarballs and run the artifact-plane integration tests defined below.
9. Print one directly executable publish command only after the complete set passes. The pack command itself always remains free of remote writes.
The local `release` command composes pack and publish. It first uses the pack confirmation above to fix the expected timestamp and version, then waits for Enter again after a successful pack before publishing the same manifest; `release --yes` skips both confirmations. Separate `pack` and `publish --manifest` operations remain the primitives used by split CI jobs and recovery.
## Publication payload contract
Published packages contain only build artifacts required by consumers. `package.json#files` must not include `src` or `lib/types/**/*.d.ts.map`; an independent tarball-content gate must also confirm that no `package/src/**` or `package/**/*.d.ts.map` entry exists, preventing manifest patterns or pack behavior from bypassing the static constraint. Runtime JavaScript, `.d.ts` declarations, configuration, assets, worker files, and dynamic bundle chunks must cover the actual entrypoint closure.
Source manifests may retain `exports["./src/*"]` for this repository's source-plane resolution. That export does not place source in the publication payload and is not a consumer contract of the published package. Static gates must check the source plane and publication payload separately: deleting the source export must not hide broken workspace resolution, and publishing `src` must not repair missing build artifacts.
Every tarball must be free of `workspace:` specifiers, and every internal dependency and peer dependency that points into the publication set must equal the exact derived version; `^`, `~`, and other semver ranges must not cross commit baselines. Except for `exports["./src/*"]`, which is explicitly source-plane-only, every consumer entry declared by the package manifest must point to a file present in the tarball. Dynamic imports, runtime-computed paths, and non-exported assets cannot be validated solely from the manifest and require installed execution.
## Artifact-plane integration test
The integration test runs after all tarballs exist and before any publish. It creates a fresh temporary project outside the monorepo, installs the declared dependency closure through local `.tgz` files from the release manifest, and executes from that installation. The test must use plain Node and package-manager-produced `node_modules`; tsx, tsconfig paths, workspace links, repository source paths, working-tree `lib/`, and the same version from the published registry are forbidden resolution inputs. The test also asserts that critical modules and bins resolve to real paths inside the temporary consumer.
Installation uses the client behavior selected for this publication. Registry uploads must use the `npm` CLI because the private registry accepts only the npm client; pnpm may still orchestrate builds. Tarball tests must neither publish these packages to the real registry first nor repack after testing.
The test covers at least these execution surfaces:
- Installed `@deepseek-ai/dsh` runs `dsh --version` and `dsh --dump-default-config` successfully under plain Node, covering the static CLI entry and one dynamic mode entry.
- Installed default `dsh` completes one keyless TUI startup in a PTY and exits under test control after reaching a defined ready signal. This path must load the real TUI dynamic chunk, so a missing publication file such as `lib/tui-*.js` fails the gate.
- Every other published `bin` defines a package-owned smoke command that neither reaches a real service nor modifies user state. Different CLIs are not forced to share `--help`; the test runs the actual installed entry and checks its agreed exit or ready signal.
- Node-compatible public runtime entries load from the installation. Browser, worker, or host-protocol-only entries use matching isolated fixtures, but their inputs must still be the current tarballs exclusively.
These tests prove executability; they do not replace unit tests, snapshots, real-API e2e, or publint. Test fixtures should reuse behavior assertions from existing built-bin and PTY scenarios while changing the entry to the tarball installation. A test that runs working-tree `lib/bin.js` directly does not satisfy this gate.
## Publication and recovery
The publish command validates the release manifest, every local checksum, the target registry, `npm ping`, and `npm whoami` before uploading tarballs in a deterministic order. It accepts only a manifest created by the pack phase, never workspace directories. The default registry is `https://registry.npm.harnessment.com/`; every publish passes the registry and derived tag explicitly so a user-level `.npmrc` cannot redirect the operation.
npm provides no multi-package atomic transaction, so uploads still occur package by package. The orchestrator reduces the failure surface through idempotent recovery: upload when remote `<name>@<version>` does not exist; skip when it exists with the release manifest's integrity; fail immediately when it exists with different content. Dist-tag inspection reads tag assignments without resolving a default tag's target, so an unrelated dangling tag cannot block recovery. At completion it must confirm every package version's integrity and every dist-tag against the release version. The workflow reports success only when the complete set passes final verification.
If pack, tarball inspection, or installed-artifact integration fails, the registry must receive zero writes. If publish fails after a partial upload, the operator reruns publish with the same release manifest and must not repack into another timestamped version instead of recovering. Only a code or build-input change that requires different tarballs reruns the complete pack and test flow.
## GitHub Actions integration
GitHub Actions separates a credential-free pack-and-test job from a protected publish job. The first checks out the exact commit, invokes the same pack entry used locally, runs tarball-consumer tests, and uploads the complete release bundle as a workflow artifact. The second depends on the first, downloads that workflow artifact, revalidates the manifest and checksums, and invokes the same publish entry. It cannot rebuild after checkout.
Pull requests and ordinary pushes may run the credential-free pack-and-test signal so payload regressions surface before merge. Actual private-registry publication starts as `workflow_dispatch` with only a target ref as input; the pack job creates the UTC timestamp, while the base version, short SHA, tag, registry, and package inventory derive from repository state or version-controlled configuration. Stable-release triggering is outside this baseline proposal.
The registry token is injected only into the publish job, which uses a protected GitHub Environment to govern human approval, allowed branches or tags, and concurrency. The pack-and-test job cannot read publication credentials. The workflow artifact may have a short retention period, but the publish job must use the bundle produced by the same workflow run instead of locating tarballs from an untrusted source by version.
## Alternatives considered
**Publish recursively from the workspace.** Rejected because it interleaves packing and registry writes, cannot prove the complete set before the first write, and allows workspace resolution and caller working-tree state to influence publication.
**Test only built `lib/` in the working tree.** Rejected because that validates the build tree rather than the tarball selected by `package.json#files`. A dynamic chunk present in the working tree but omitted from the tarball is exactly the failure this proposal must catch.
**Run only `dsh --help`.** Rejected because Commander can print help and exit before loading the TUI, Web, or headless dynamic entry. It does not prove the default production startup path is complete.
**Publish `src` and declaration maps to reduce missing-file risk.** Rejected because the source plane is not a production-runtime fallback. Expanding the payload hides bundle-closure errors and turns local debugging outputs into accidental publication contracts.
**Require truly atomic cross-package publication.** Rejected because the npm registry has no such transaction. An immutable release bundle, complete pre-publication validation, integrity comparison, and idempotent recovery provide an implementable boundary while retaining the explicit limitation that partial uploads can be briefly visible.
**Rebuild in the publish job after approval.** Rejected because the tested and uploaded tarballs would lose content identity. The workflow artifact and checksums must carry the test inputs directly into publication.
## Acceptance criteria
- One pack entry discovers every target under `packages/*/*` and `apps/*` from a fixed commit, derives and displays a version from the UTC second and short commit before waiting for Enter, generates the complete release bundle before any registry write, and prints one copyable publish command; `release` waits again after packing, while `--yes` skips both confirmations.
- Static manifest and tarball-content gates both reject published `src` and `.d.ts.map` while source manifests retain `exports["./src/*"]`.
- The release bundle records the complete package set, commit, derived version, tag, registry, and per-tarball integrity; every internal dependency is pinned exactly to that version, and publish consumes only that bundle without rebuilding.
- An isolated integration test installs from local tarballs and starts the installed default `dsh` TUI under plain Node; deleting any required dynamic chunk makes the test fail deterministically.
- Every published bin and applicable public runtime entry has post-tarball-install execution coverage, with resolution paths proving that no monorepo fallback occurred.
- Publish safely resumes from the same manifest after partial success: matching integrity is skipped, conflicting integrity is rejected, and final verification requires every version and tag to agree.
- A credential-free GitHub Actions job creates and tests the bundle, a protected job uploads the identical bundle, and the publication token exists only in the latter.
## Risks
Full packing, installation, and startup add CI time and workflow-artifact volume. The implementation should cache external dependencies and the pnpm store, but it must not cache or reuse installed workspace output for target packages. Safe consumer probes can run in parallel to reduce latency.
Installing every tarball as a temporary project's top-level dependency can hide an undeclared internal dependency. The test generator should install each tested application's declared recursive closure and retain existing dependency gates. For `@deepseek-ai/dsh`, whose dependency surface approaches the full set, package-manifest and static graph checks remain necessary to detect undeclared edges.
Platform-specific optional dependencies, native addons, PTYs, and browser entries may require platform-owned probes. The first phase must cover primary `dsh` startup on the publication Linux runner and one local macOS path, then expand the matrix with the actual publication platforms. Skipping an unstable probe must not move a production path outside the gate.
Recovery cannot remove npm's partial visibility. During a failed publication, the registry may briefly contain only some package versions from the bundle. Operators and automation must treat the final bundle verification, not one successful `npm publish`, as the baseline-availability signal.
@@ -0,0 +1,108 @@
# Agent Note: 以产物为先的 NPM 基线发布
Status: proposed
[English](2026-08-04-artifact-first-npm-baseline-publication.md) | 中文
## 问题
monorepo 中可运行的源码并不能证明发布后的包可运行。workspace link、TypeScript paths、tsx 源码加载和工作树里残留的 `lib/` 都可能补上发布 tarball 中缺失的文件或依赖。即使现有构建产物测试使用普通 Node,它仍直接读取工作树中的 `lib/`,没有验证 `package.json#files` 最终选中了什么,也没有验证包管理器安装后的文件布局。一次开发模式正常的执行因此可能发布为缺少 bundle chunk、声明文件、配置或资源的包。
发布多个互相依赖的 `@deepseek-ai` 包还会产生集合一致性问题。如果脚本每 pack 一个包就立即 publish,那么后续 pack 或验证失败时,注册表中已经存在无法作为完整基线使用的前半组版本。npm 注册表没有跨包事务,因此这里的「一次性发布」不能承诺原子提交,只能承诺在任何远端写入前完整生成并验证发布集合,再由一个可恢复的编排命令发布这个不可变集合。
当前基线还需要人工在本机完成版本派生、认证、pack、发布和重试。后续 GitHub Actions 工作流必须复用同一套发布包与验证逻辑,不能在批准发布后重新构建另一组未经消费方测试的 tarball。
## 提案
发布流程以一个不可变的 release bundle(发布包集合)为边界。pack 阶段从一个确定的 Git commit 构建全部目标包、生成全部 tarball、检查 tarball 内容,并通过安装后集成测试;publish 阶段只读取这组 tarball 及其 manifest(元数据清单),禁止重建或重新 pack。
目标集合只包含 `packages/*/*/package.json``apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。
预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
pack 阶段按以下顺序执行:
1. 将 ref 解析成不可变 commit,采集 UTC 时间戳,从该 commit 的根 manifest 派生版本,并显示 commit、时间戳、版本、tag、注册表和输出路径。`pack``release` 此时都会在昂贵操作开始前等待 Enter;自动化可用 `--yes` 跳过该确认。
2. 在隔离的 detached worktree 中安装 frozen lockfile;调用方工作树中的未提交文件和旧构建输出不得参与发布。
3. 将所有目标 manifest 暂存为派生版本,移除发布时的 `private` 标记,并把 `dependencies``devDependencies``optionalDependencies``peerDependencies` 中的内部 workspace 依赖全部改写为同一精确版本。
4. 完整构建目标 commit,再运行 publint、已构建包不变式和发布 payload 门禁。
5. 为目标集合中的每个包执行 pack,但不执行任何注册表写入。
6. 检查 tarball 内的 package manifest、文件清单、内部依赖版本、包名和版本,并拒绝缺失、重复或额外的 tarball。
7. 生成包含 commit、版本、tag、注册表、每个包的 tarball 路径、SHA-256 与 npm integrity 的 release manifest 和校验和文件。
8. 从本地 tarball 安装隔离消费方并运行下文定义的产物平面集成测试。
9. 仅当整个集合通过时输出一个可直接执行的 publish 命令;pack 命令本身始终保持无远端写入。
本地 `release` 命令组合 pack 与 publish。它先通过上述 pack 确认确定预期时间戳和版本,再在 pack 成功后等待第二次 Enter,随后发布同一 manifest`release --yes` 跳过两次确认。独立的 `pack``publish --manifest` 仍是 CI 分 job 和断点恢复使用的基础操作。
## 发布 payload 契约
发布包只携带消费方需要的构建产物。`package.json#files` 禁止包含 `src``lib/types/**/*.d.ts.map`;tarball 内容门禁还要独立确认不存在任何 `package/src/**``package/**/*.d.ts.map`,避免 manifest pattern 或 pack 行为绕过静态约束。运行时 JS、声明文件 `.d.ts`、配置、资源、worker 文件和 bundle 动态 chunk 必须按实际入口闭包收齐。
源码 manifest 可以保留 `exports["./src/*"]`,供本仓库的源码平面解析使用;该 export 不代表源码会进入发布 payload,也不属于已发布包的消费方契约。静态门禁必须分别检查源码平面与发布 payload,不能通过删除 source export 来掩盖错误的 workspace 解析,也不能通过发布 `src` 来修补缺失的构建产物。
每个 tarball 必须不含 `workspace:` specifier,并且所有指向本次发布集合的内部依赖与对等依赖(peer dependency)都必须精确等于本次派生版本,禁止使用 `^``~` 或其他 semver 范围跨越 commit 基线。除了明确仅供源码平面使用的 `exports["./src/*"]`package manifest 中声明的每个消费方入口都必须指向 tarball 内存在的文件;动态 import、运行时拼接路径和非 export 资源不能只靠 manifest 检查,必须由安装后执行覆盖。
## 产物平面集成测试
集成测试在全部 tarball 生成后、任何 publish 之前运行。它在 monorepo 外创建一个全新临时项目,通过本次 release manifest 中的本地 `.tgz` 文件安装声明依赖闭包,并从安装目录执行。测试必须使用普通 Node 与包管理器生成的 `node_modules`;禁止 tsx、tsconfig paths、workspace link、仓库源码路径、工作树 `lib/` 和已发布注册表中的同版本包参与解析。测试还要断言关键模块与 bin 的真实路径位于临时消费方内。
安装使用本次发布选择的客户端行为。注册表上传必须使用 `npm` CLI,以满足私有注册表只接受 npm 客户端的策略;构建编排仍可使用 pnpm。tarball 测试不得先把这些包发布到真实注册表,也不得在测试后重新 pack。
测试至少覆盖以下执行面:
- `@deepseek-ai/dsh` 安装后的 `dsh --version``dsh --dump-default-config` 在普通 Node 下成功,分别覆盖静态 CLI 入口和一个动态模式入口。
- 安装后的默认 `dsh` 在 PTY 中完成一次无密钥 TUI 启动,到达既定 ready 信号后由测试受控退出。这条路径必须加载真实 TUI 动态 chunk,因此缺少类似 `lib/tui-*.js` 的发布文件会使门禁失败。
- 每个其他已发布 `bin` 都定义一个不会访问真实服务或修改用户状态的包级冒烟命令。不同 CLI 不强制共用 `--help`;测试必须运行其真实安装入口并检查约定的退出或 ready 信号。
- Node 兼容的公开运行时入口从安装目录加载;浏览器、worker 或必须由宿主协议驱动的入口使用对应的隔离 fixture(测试前置数据),但输入仍只能是本次 tarball。
这些测试验证可执行性,不替代单元测试、快照、真实 API e2e 或 publint。测试 fixture 应复用现有 built-bin 和 PTY 场景的行为断言,但必须把入口改为 tarball 安装结果;直接运行工作树 `lib/bin.js` 的测试不能算作本门禁。
## 发布与恢复
publish 命令先验证 release manifest、所有本地校验和、目标注册表、`npm ping``npm whoami`,再按确定顺序上传 tarball。命令只接受 pack 阶段生成的 manifest,不接受 workspace 目录作为发布输入。默认注册表为 `https://registry.npm.harnessment.com/`,每次 publish 都显式传入注册表和派生 tag,避免用户级 `.npmrc` 改变目标。
npm 不提供多包原子事务,上传仍会逐包发生。编排器通过幂等恢复缩小失败面:若远端不存在 `<name>@<version>` 就上传;若已存在且 integrity 与 release manifest 相同就跳过;若已存在但内容不同就立即失败。dist-tag 检查只读取 tag 映射,不解析默认 tag 指向的版本,因此即使无关 tag 指向的版本已不存在,也不会阻断恢复。完成后必须逐包确认版本 integrity 和 dist-tag 都指向本次版本,只有整个集合通过最终验证,工作流才报告发布成功。
如果 pack、tarball 检查或安装后集成测试失败,注册表必须保持零写入。如果 publish 在部分上传后失败,操作者使用同一 release manifest 重跑 publish 命令来恢复,不得重新 pack 并生成另一个时间戳版本来代替恢复。修复代码或改变构建输入后需要不同 tarball 时,才重新执行完整 pack 与测试。
## GitHub Actions 集成
GitHub Actions 分为无凭据的 pack-and-test job 与受保护的 publish job。前者检出精确 commit,调用与本地相同的 pack 入口,运行 tarball 消费方测试,并上传完整 release bundle 作为 workflow artifact。后者依赖前者成功,从 workflow artifact 下载 bundle、重新校验 manifest 和校验和,再调用同一个 publish 入口;它不能检出后重新构建。
PR 与普通 push 可以运行无凭据的 pack-and-test 信号,从而在合并前发现 payload 回归。实际私有注册表发布先通过 `workflow_dispatch` 提供,输入只包括目标 ref;UTC 时间戳由 pack job 生成,基础版本、短 SHA、tag、注册表和包清单都由仓库状态或受版本控制的配置派生。稳定发布触发方式不在本基线提案范围内。
注册表 token 只注入 publish job,并由受保护 GitHub Environment 控制人工批准、允许的分支或 tag 以及并发。pack-and-test job 不得读取发布凭据。workflow artifact 的保留期可以较短,但 publish job 必须使用同一 workflow run 生成的 bundle,不能按版本号从不受信任的位置寻找 tarball。
## Alternatives considered
**从 workspace 直接递归 publish。** 不采用,因为命令会把 pack 与注册表写入交错,无法在第一次写入前证明整个集合完整,也容易让 workspace 解析与调用方工作树状态影响发布结果。
**只测试工作树中构建后的 `lib/`。** 不采用,因为这验证的是构建树,不是 `package.json#files` 选出的 tarball。工作树中存在而 tarball 中漏掉的动态 chunk 正是本提案必须捕获的失败。
**只运行 `dsh --help`。** 不采用,因为 Commander 可以在加载 TUI、Web 或 headless 动态入口之前输出帮助并退出。它无法证明默认生产启动路径完整。
**把 `src` 和声明映射一起发布以降低漏文件风险。** 不采用,因为源码平面不是生产运行时的后备路径;扩大 payload 会掩盖 bundle 闭包错误,并把本地调试产物变成无意的发布契约。
**要求真正的跨包原子发布。** 不采用,因为 npm 注册表没有相应事务。不可变 release bundle、发布前全量验证、integrity 比对与幂等恢复提供可实现的边界,同时明确保留部分上传短暂可见的限制。
**在批准后由发布 job 重新构建。** 不采用,因为测试通过的 tarball 与实际上传的 tarball 将不再具有内容身份。workflow artifact 与校验和必须把测试输入直接传给发布步骤。
## Acceptance criteria
- 一个 pack 入口从确定 commit 发现 `packages/*/*``apps/*` 的全部目标包,以 UTC 秒级时间戳与短 commit 生成并显示版本,再等待 Enter;它在任何注册表写入前生成完整 release bundle,并输出一个可复制的 publish 命令;`release` 在 pack 后再次等待,`--yes` 跳过两次确认。
- 静态 manifest 门禁和 tarball 内容门禁都拒绝发布 `src``.d.ts.map`,同时保留源码 manifest 中的 `exports["./src/*"]`
- release bundle 记录完整包集合、commit、派生版本、tag、注册表和逐 tarball integrity;所有内部依赖都精确固化到该版本,publish 只消费该 bundle,绝不重建。
- 一个隔离集成测试从本地 tarball 安装消费方,并用普通 Node 启动安装后的默认 `dsh` TUI;删除任一所需动态 chunk 会使该测试稳定失败。
- 所有已发布 bin 和适用的公开运行时入口都有 tarball 安装后的执行覆盖,且解析路径证明没有回退到 monorepo。
- publish 可在部分成功后用同一 manifest 安全重跑;相同 integrity 被跳过,不同 integrity 被拒绝,最终验证要求所有版本与 tag 一致。
- GitHub Actions 的无凭据 job 生成并测试 bundle,受保护 job 上传完全相同的 bundle,发布 token 只存在于后者。
## Risks
全量 pack、安装和启动会增加 CI 时间与 workflow artifact 体积。实现应缓存外部依赖和 pnpm store,但不得缓存或复用目标包的已安装 workspace 输出;并行执行安全的消费方 probe 可以降低时延。
把所有 tarball 都安装为临时项目的顶层依赖可能掩盖未声明的内部依赖。测试生成器应按被测应用的声明式递归闭包安装,并结合现有依赖门禁;对依赖面接近全集的 `@deepseek-ai/dsh`,仍需依靠 package manifest 与静态图检查发现未声明边。
不同平台的 optional dependency、native addon、PTY 与浏览器入口可能需要平台专属 probe。第一阶段至少在发布所用 Linux runner 和一个本地 macOS 路径上覆盖主 `dsh` 启动,后续矩阵按实际发布平台扩展;不能用跳过不稳定 probe 的方式把生产路径移出门禁。
恢复机制不能消除 npm 的部分可见性。发布失败期间,注册表可能短暂含有本次版本的一部分包;操作者与自动化必须以最终 bundle 验证结果而非单个 `npm publish` 的成功作为基线可用信号。
+863
View File
@@ -0,0 +1,863 @@
/** Build, publish, and verify one commit-addressed npm workspace baseline. */
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
existsSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { parseArgs } from 'node:util'
const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com'
const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline'
const PACKAGE_PATTERNS = ['packages/*/*/package.json', 'apps/*/package.json'] as const
const DEPENDENCY_SECTIONS = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
] as const
const RELEASE_MANIFEST_NAME = 'manifest.json'
interface CommandResult {
status: number
stdout: string
stderr: string
}
interface PackageTarget {
name: string
directory: string
}
interface PackedPackage {
name: string
tarball: string
sha256: string
integrity: string
}
interface ReleaseManifest {
schemaVersion: 1
commit: string
version: string
distTag: string
registry: string
packages: PackedPackage[]
}
interface PackOptions {
ref: string
registry: string
outputDirectory: string
}
/** Fixes the identity of one pack attempt before any expensive work begins. */
class BaselinePackPlan {
constructor(
readonly commit: string,
readonly shortCommit: string,
readonly timestamp: string,
readonly baseVersion: string,
readonly version: string,
readonly distTag: string,
readonly registry: string,
readonly artifactDirectory: string,
) {}
async confirm(assumeYes: boolean): Promise<void> {
console.log('publish-npm-baseline: planned pack')
console.log(` commit: ${this.commit}`)
console.log(` timestamp: ${this.timestamp} UTC`)
console.log(` version: ${this.version}`)
console.log(` dist-tag: ${this.distTag}`)
console.log(` registry: ${this.registry}`)
console.log(` output: ${this.artifactDirectory}`)
if (assumeYes) return
await confirmEnter(
'Press Enter to start packing or type anything to cancel: ',
'pack requires an interactive terminal or --yes',
'pack cancelled',
)
}
}
/** Runs child processes without involving a command shell. */
class CommandRunner {
run(
command: string,
args: string[],
cwd: string,
environment: NodeJS.ProcessEnv = process.env,
): void {
const result = spawnSync(command, args, { cwd, env: environment, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`${formatCommand(command, args)} exited with status ${String(result.status)}`)
}
}
capture(
command: string,
args: string[],
cwd: string,
environment: NodeJS.ProcessEnv = process.env,
): string {
const result = this.result(command, args, cwd, environment)
if (result.status !== 0) throw commandFailure(command, args, result)
return result.stdout.trim()
}
result(
command: string,
args: string[],
cwd: string,
environment: NodeJS.ProcessEnv = process.env,
): CommandResult {
const result: SpawnSyncReturns<string> = spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: environment,
maxBuffer: 16 * 1024 * 1024,
})
if (result.error !== undefined) throw result.error
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
}
/** Owns a temporary detached worktree and removes it after staging. */
class DetachedWorktree {
private constructor(
readonly path: string,
private readonly temporaryRoot: string,
private readonly repositoryRoot: string,
private readonly runner: CommandRunner,
) {}
static create(repositoryRoot: string, commit: string, runner: CommandRunner): DetachedWorktree {
const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-baseline-'))
const path = join(temporaryRoot, 'worktree')
try {
runner.run('git', ['worktree', 'add', '--detach', path, commit], repositoryRoot)
return new DetachedWorktree(path, temporaryRoot, repositoryRoot, runner)
} catch (error: unknown) {
rmSync(temporaryRoot, { recursive: true, force: true })
throw error
}
}
dispose(): void {
const result = this.runner.result(
'git',
['worktree', 'remove', '--force', this.path],
this.repositoryRoot,
)
if (result.status !== 0) {
console.error(`publish-npm-baseline: could not remove worktree ${this.path}`)
if (result.stderr.trim() !== '') console.error(result.stderr.trim())
}
rmSync(this.temporaryRoot, { recursive: true, force: true })
}
}
/** Discovers and stages only two-level package entries and application entries. */
class WorkspacePackageSet {
private constructor(
readonly packages: PackageTarget[],
readonly baseVersion: string,
) {}
static discover(root: string): WorkspacePackageSet {
const manifestPaths = globSync(PACKAGE_PATTERNS, { cwd: root }).sort()
if (manifestPaths.length === 0) throw new Error('no package manifests found under packages/ or apps/')
const packages: PackageTarget[] = []
const names = new Set<string>()
let baseVersion: string | undefined
for (const manifestPath of manifestPaths) {
const manifest = readObject(resolve(root, manifestPath))
const name = expectString(manifest, 'name', manifestPath)
const version = expectString(manifest, 'version', manifestPath)
if (!name.startsWith('@deepseek-ai/')) {
throw new Error(`${manifestPath} must name an @deepseek-ai package`)
}
if (name === '@deepseek-ai/dsh-root') {
throw new Error(`${manifestPath} unexpectedly selected the workspace root`)
}
if (names.has(name)) throw new Error(`duplicate package name: ${name}`)
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`${manifestPath} must have a stable X.Y.Z version, got ${version}`)
}
baseVersion ??= version
if (version !== baseVersion) {
throw new Error(`${manifestPath} has version ${version}; expected ${baseVersion}`)
}
names.add(name)
packages.push({ name, directory: dirname(manifestPath) })
}
if (baseVersion === undefined) throw new Error('could not determine the workspace version')
packages.sort((left, right) => left.name.localeCompare(right.name))
return new WorkspacePackageSet(packages, baseVersion)
}
stage(root: string, releaseVersion: string): void {
const internalNames = new Set(this.packages.map(pkg => pkg.name))
for (const target of this.packages) {
const manifestPath = resolve(root, target.directory, 'package.json')
const manifest = readObject(manifestPath)
manifest.version = releaseVersion
delete manifest.private
stageInternalDependencies(manifest, internalNames, releaseVersion, manifestPath)
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}
}
}
/** Immutable local release bundle consumed by publish and verify. */
class ReleaseBundle {
private constructor(
readonly directory: string,
readonly manifest: ReleaseManifest,
) {}
static create(
directory: string,
expectedPackages: PackageTarget[],
commit: string,
version: string,
distTag: string,
registry: string,
runner: CommandRunner,
): ReleaseBundle {
const internalNames = new Set(expectedPackages.map(pkg => pkg.name))
const missingNames = new Set(internalNames)
const packages = readdirSync(directory)
.filter(name => name.endsWith('.tgz'))
.sort()
.map((tarball) => {
const artifact = inspectTarball(resolve(directory, tarball), runner)
if (!missingNames.delete(artifact.name)) {
throw new Error(`unexpected or duplicate packed package: ${artifact.name}`)
}
if (artifact.version !== version) {
throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`)
}
if (artifact.private === true) throw new Error(`${tarball} is still private`)
if (containsWorkspaceProtocol(artifact.manifest)) {
throw new Error(`${tarball} still contains a workspace: dependency`)
}
validateInternalDependencyPins(artifact.manifest, internalNames, version, tarball)
return packedPackage(artifact.name, resolve(directory, tarball))
})
.sort((left, right) => left.name.localeCompare(right.name))
if (missingNames.size !== 0) {
throw new Error(`missing tarballs for: ${[...missingNames].sort().join(', ')}`)
}
const manifest: ReleaseManifest = {
schemaVersion: 1,
commit,
version,
distTag,
registry,
packages,
}
writeFileSync(resolve(directory, RELEASE_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(
resolve(directory, 'SHA256SUMS'),
`${packages.map(pkg => `${pkg.sha256} ${pkg.tarball}`).join('\n')}\n`,
)
return new ReleaseBundle(directory, manifest)
}
static load(manifestPath: string, runner: CommandRunner): ReleaseBundle {
const absoluteManifestPath = resolve(manifestPath)
const raw = readObject(absoluteManifestPath)
if (raw.schemaVersion !== 1) {
throw new Error(`unsupported release manifest schema: ${String(raw.schemaVersion)}`)
}
const directory = dirname(absoluteManifestPath)
const packageValues = raw.packages
if (!Array.isArray(packageValues) || packageValues.length === 0) {
throw new Error('release manifest contains no packages')
}
const packages = packageValues.map((value, index) => parsePackedPackage(value, index))
const names = new Set<string>()
for (const pkg of packages) {
if (names.has(pkg.name)) throw new Error(`duplicate package in release manifest: ${pkg.name}`)
names.add(pkg.name)
}
const manifest: ReleaseManifest = {
schemaVersion: 1,
commit: expectString(raw, 'commit', RELEASE_MANIFEST_NAME),
version: expectString(raw, 'version', RELEASE_MANIFEST_NAME),
distTag: expectString(raw, 'distTag', RELEASE_MANIFEST_NAME),
registry: normalizeRegistry(expectString(raw, 'registry', RELEASE_MANIFEST_NAME)),
packages,
}
const bundle = new ReleaseBundle(directory, manifest)
bundle.verifyLocal(runner)
return bundle
}
private verifyLocal(runner: CommandRunner): void {
const internalNames = new Set(this.manifest.packages.map(pkg => pkg.name))
for (const pkg of this.manifest.packages) {
if (isAbsolute(pkg.tarball) || dirname(pkg.tarball) !== '.' || normalize(pkg.tarball) !== pkg.tarball) {
throw new Error(`invalid tarball path for ${pkg.name}: ${pkg.tarball}`)
}
const path = resolve(this.directory, pkg.tarball)
const actual = packedPackage(pkg.name, path)
if (actual.sha256 !== pkg.sha256 || actual.integrity !== pkg.integrity) {
throw new Error(`tarball checksum mismatch: ${pkg.tarball}`)
}
const artifact = inspectTarball(path, runner)
if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) {
throw new Error(`tarball identity mismatch: ${pkg.tarball}`)
}
if (artifact.private === true) throw new Error(`${pkg.tarball} is still private`)
if (containsWorkspaceProtocol(artifact.manifest)) {
throw new Error(`${pkg.tarball} still contains a workspace: dependency`)
}
validateInternalDependencyPins(
artifact.manifest,
internalNames,
this.manifest.version,
pkg.tarball,
)
}
}
tarballPath(pkg: PackedPackage): string {
return resolve(this.directory, pkg.tarball)
}
}
/** Builds a release bundle without mutating the caller's checkout. */
class BaselinePackager {
constructor(
private readonly repositoryRoot: string,
private readonly runner: CommandRunner,
private readonly now: () => Date = () => new Date(),
) {}
plan(options: PackOptions): BaselinePackPlan {
const timestamp = formatUtcTimestamp(this.now())
const registry = normalizeRegistry(options.registry)
const commit = this.runner.capture(
'git',
['rev-parse', '--verify', `${options.ref}^{commit}`],
this.repositoryRoot,
)
const shortCommit = this.runner.capture(
'git',
['rev-parse', '--short=10', commit],
this.repositoryRoot,
)
const rootManifest = parseObject(
this.runner.capture('git', ['show', `${commit}:package.json`], this.repositoryRoot),
`${commit}:package.json`,
)
const baseVersion = expectString(rootManifest, 'version', `${commit}:package.json`)
validateBaseVersion(baseVersion, `${commit}:package.json`)
const version = `${baseVersion}-${timestamp}-${shortCommit}`
const distTag = `dev-${baseVersion}`
validateDistTag(distTag)
const artifactDirectory = resolve(options.outputDirectory, version)
if (existsSync(artifactDirectory)) {
throw new Error(`output already exists: ${artifactDirectory}`)
}
return new BaselinePackPlan(
commit,
shortCommit,
timestamp,
baseVersion,
version,
distTag,
registry,
artifactDirectory,
)
}
pack(plan: BaselinePackPlan): ReleaseBundle {
const { artifactDirectory } = plan
if (existsSync(artifactDirectory)) {
throw new Error(`output already exists: ${artifactDirectory}`)
}
const worktree = DetachedWorktree.create(this.repositoryRoot, plan.commit, this.runner)
let createdArtifactDirectory = false
try {
const packageSet = WorkspacePackageSet.discover(worktree.path)
if (packageSet.baseVersion !== plan.baseVersion) {
throw new Error(
`workspace package version ${packageSet.baseVersion} does not match root version `
+ `${plan.baseVersion} at ${plan.commit}`,
)
}
console.log(`publish-npm-baseline: installing detached worktree ${plan.shortCommit}`)
this.runner.run('pnpm', ['install', '--frozen-lockfile'], worktree.path)
packageSet.stage(worktree.path, plan.version)
mkdirSync(artifactDirectory, { recursive: true })
createdArtifactDirectory = true
console.log(
`publish-npm-baseline: building ${packageSet.packages.length} packages as ${plan.version}`,
)
this.runner.run('pnpm', ['run', 'build'], worktree.path)
this.runner.run('pnpm', ['run', 'publint'], worktree.path)
this.runner.run('pnpm', ['run', 'verify-built-package-invariants'], worktree.path)
this.runner.run('pnpm', [
'--filter', './packages/**',
'--filter', './apps/**',
'--recursive',
'pack',
'--pack-destination', artifactDirectory,
], worktree.path)
const bundle = ReleaseBundle.create(
artifactDirectory,
packageSet.packages,
plan.commit,
plan.version,
plan.distTag,
plan.registry,
this.runner,
)
createdArtifactDirectory = false
console.log(`publish-npm-baseline: packed ${bundle.manifest.packages.length} packages`)
console.log(` version: ${bundle.manifest.version}`)
console.log(` dist-tag: ${bundle.manifest.distTag}`)
console.log(` manifest: ${resolve(bundle.directory, RELEASE_MANIFEST_NAME)}`)
console.log(' publish: ' + formatCopyableCommand('pnpm', [
'--dir',
this.repositoryRoot,
'exec',
'tsx',
resolve(this.repositoryRoot, 'scripts/publish-npm-baseline.ts'),
'publish',
'--manifest',
resolve(bundle.directory, RELEASE_MANIFEST_NAME),
'--yes',
]))
return bundle
} finally {
worktree.dispose()
if (createdArtifactDirectory) {
rmSync(artifactDirectory, { recursive: true, force: true })
}
}
}
}
/** Publishes and verifies a release bundle against its recorded registry. */
class RegistryPublication {
private readonly npmEnvironment = npmClientEnvironment()
private readonly npmWorkingDirectory = tmpdir()
constructor(
private readonly bundle: ReleaseBundle,
private readonly runner: CommandRunner,
) {}
async publish(assumeYes: boolean): Promise<void> {
this.preflight()
if (!assumeYes) await this.confirm()
for (const pkg of this.bundle.manifest.packages) {
const existingIntegrity = this.remoteIntegrity(pkg.name)
if (existingIntegrity === undefined) {
this.runner.run('npm', [
'publish',
this.bundle.tarballPath(pkg),
`--registry=${this.bundle.manifest.registry}`,
`--tag=${this.bundle.manifest.distTag}`,
], this.npmWorkingDirectory, this.npmEnvironment)
} else {
if (existingIntegrity !== pkg.integrity) {
throw new Error(
`${pkg.name}@${this.bundle.manifest.version} already exists with different integrity`,
)
}
console.log(
`publish-npm-baseline: already published ${pkg.name}@${this.bundle.manifest.version}`,
)
}
this.ensureDistTag(pkg.name)
}
console.log(
`publish-npm-baseline: published or confirmed ${this.bundle.manifest.packages.length} packages`,
)
}
verify(): void {
this.preflight()
for (const pkg of this.bundle.manifest.packages) {
const integrity = this.remoteIntegrity(pkg.name)
if (integrity === undefined) {
throw new Error(`package is missing: ${pkg.name}@${this.bundle.manifest.version}`)
}
if (integrity !== pkg.integrity) {
throw new Error(`integrity mismatch: ${pkg.name}@${this.bundle.manifest.version}`)
}
const tagVersion = this.remoteDistTag(pkg.name)
if (tagVersion !== this.bundle.manifest.version) {
throw new Error(
`${pkg.name}@${this.bundle.manifest.distTag} points to ${tagVersion ?? '<missing>'}; `
+ `expected ${this.bundle.manifest.version}`,
)
}
console.log(`publish-npm-baseline: verified ${pkg.name}@${this.bundle.manifest.version}`)
}
console.log(
`publish-npm-baseline: verified ${this.bundle.manifest.packages.length} packages and `
+ `dist-tag ${this.bundle.manifest.distTag}`,
)
}
private preflight(): void {
const { registry } = this.bundle.manifest
this.runner.capture(
'npm', ['ping', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment,
)
const identity = this.runner.capture(
'npm', ['whoami', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment,
)
console.log(`publish-npm-baseline: registry identity ${identity} at ${registry}`)
}
private async confirm(): Promise<void> {
await confirmEnter(
`Publish ${this.bundle.manifest.packages.length} packages as `
+ `${this.bundle.manifest.version} to ${this.bundle.manifest.registry}? `
+ 'Press Enter to continue or type anything to cancel: ',
'publish requires an interactive terminal or --yes',
'publication cancelled',
)
}
private remoteIntegrity(name: string): string | undefined {
const { registry, version } = this.bundle.manifest
const result = this.runner.result(
'npm',
['view', `${name}@${version}`, 'dist.integrity', '--json', `--registry=${registry}`],
this.npmWorkingDirectory,
this.npmEnvironment,
)
if (result.status !== 0) {
if (/E404|NOT_FOUND|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) return undefined
throw commandFailure('npm', ['view', `${name}@${version}`], result)
}
const value: unknown = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout)
if (typeof value !== 'string' || !value.startsWith('sha512-')) {
throw new Error(`registry returned no integrity for ${name}@${version}`)
}
return value
}
private remoteDistTag(name: string): string | undefined {
const { registry, distTag } = this.bundle.manifest
const raw = this.runner.capture(
'npm',
['dist-tag', 'ls', name, `--registry=${registry}`],
this.npmWorkingDirectory,
this.npmEnvironment,
)
return parseDistTagListing(raw, name).get(distTag)
}
private ensureDistTag(name: string): void {
if (this.remoteDistTag(name) === this.bundle.manifest.version) return
const { registry, version, distTag } = this.bundle.manifest
this.runner.run(
'npm',
['dist-tag', 'add', `${name}@${version}`, distTag, `--registry=${registry}`],
this.npmWorkingDirectory,
this.npmEnvironment,
)
}
}
interface InspectedTarball {
name: string
version: string
private: unknown
manifest: Record<string, unknown>
}
function inspectTarball(path: string, runner: CommandRunner): InspectedTarball {
const manifest = JSON.parse(
runner.capture('tar', ['-xOf', path, 'package/package.json'], dirname(path)),
) as unknown
if (!isRecord(manifest)) throw new Error(`${path} contains an invalid package.json`)
return {
name: expectString(manifest, 'name', path),
version: expectString(manifest, 'version', path),
private: manifest.private,
manifest,
}
}
function packedPackage(name: string, path: string): PackedPackage {
const bytes = readFileSync(path)
return {
name,
tarball: basename(path),
sha256: createHash('sha256').update(bytes).digest('hex'),
integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
}
}
function parsePackedPackage(value: unknown, index: number): PackedPackage {
if (!isRecord(value)) throw new Error(`invalid release manifest package at index ${index}`)
const context = `release manifest package at index ${index}`
const name = expectString(value, 'name', context)
if (!name.startsWith('@deepseek-ai/') || name === '@deepseek-ai/dsh-root') {
throw new Error(`invalid package name in release manifest: ${name}`)
}
return {
name,
tarball: expectString(value, 'tarball', context),
sha256: expectString(value, 'sha256', context),
integrity: expectString(value, 'integrity', context),
}
}
function containsWorkspaceProtocol(value: unknown): boolean {
if (typeof value === 'string') return value.startsWith('workspace:')
if (Array.isArray(value)) return value.some(containsWorkspaceProtocol)
return isRecord(value) && Object.values(value).some(containsWorkspaceProtocol)
}
function stageInternalDependencies(
manifest: Record<string, unknown>,
internalNames: ReadonlySet<string>,
releaseVersion: string,
context: string,
): void {
for (const section of DEPENDENCY_SECTIONS) {
const dependencies = manifest[section]
if (dependencies === undefined) continue
if (!isRecord(dependencies)) throw new Error(`${context} ${section} must be an object`)
for (const name of Object.keys(dependencies)) {
if (internalNames.has(name)) dependencies[name] = releaseVersion
}
}
}
function validateInternalDependencyPins(
manifest: Record<string, unknown>,
internalNames: ReadonlySet<string>,
releaseVersion: string,
context: string,
): void {
for (const section of DEPENDENCY_SECTIONS) {
const dependencies = manifest[section]
if (dependencies === undefined) continue
if (!isRecord(dependencies)) throw new Error(`${context} ${section} must be an object`)
for (const [name, range] of Object.entries(dependencies)) {
if (!internalNames.has(name)) continue
if (range !== releaseVersion) {
throw new Error(
`${context} has internal ${section} ${name}@${String(range)}; `
+ `expected exact version ${releaseVersion}`,
)
}
}
}
}
function readObject(path: string): Record<string, unknown> {
return parseObject(readFileSync(path, 'utf8'), path)
}
function parseObject(source: string, context: string): Record<string, unknown> {
const value: unknown = JSON.parse(source)
if (!isRecord(value)) throw new Error(`${context} must contain a JSON object`)
return value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function expectString(value: Record<string, unknown>, key: string, context: string): string {
const result = value[key]
if (typeof result !== 'string' || result === '') {
throw new Error(`${context} must contain a non-empty ${key}`)
}
return result
}
function normalizeRegistry(value: string): string {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`registry must use HTTP or HTTPS: ${value}`)
}
return value.replace(/\/+$/, '')
}
function npmClientEnvironment(): NodeJS.ProcessEnv {
const environment = { ...process.env }
delete environment.npm_config_user_agent
delete environment.NPM_CONFIG_USER_AGENT
return environment
}
function validateDistTag(value: string): void {
if (value === '' || /\s/.test(value)) throw new Error(`invalid dist-tag: ${JSON.stringify(value)}`)
}
function validateBaseVersion(value: string, context: string): void {
if (!/^\d+\.\d+\.\d+$/.test(value)) {
throw new Error(`${context} must have a stable X.Y.Z version, got ${value}`)
}
}
function parseDistTagListing(raw: string, name: string): Map<string, string> {
const tags = new Map<string, string>()
for (const line of raw.split(/\r?\n/)) {
if (line === '') continue
const separator = line.indexOf(': ')
if (separator <= 0 || separator + 2 === line.length) {
throw new Error(`registry returned an invalid dist-tag for ${name}: ${line}`)
}
const tag = line.slice(0, separator)
if (tags.has(tag)) throw new Error(`registry returned duplicate dist-tag ${tag} for ${name}`)
tags.set(tag, line.slice(separator + 2))
}
return tags
}
async function confirmEnter(
prompt: string,
nonInteractiveError: string,
cancellationError: string,
): Promise<void> {
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(nonInteractiveError)
const readline = createInterface({ input: process.stdin, output: process.stdout })
try {
const answer = await readline.question(prompt)
if (answer !== '') throw new Error(cancellationError)
} finally {
readline.close()
}
}
function formatUtcTimestamp(value: Date): string {
if (!Number.isFinite(value.getTime())) throw new Error('pack timestamp must be a valid date')
return value.toISOString().replaceAll(/[-:TZ.]/g, '').slice(0, 14)
}
function commandFailure(command: string, args: string[], result: CommandResult): Error {
const detail = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n')
return new Error(
`${formatCommand(command, args)} exited with status ${result.status}${detail === '' ? '' : `\n${detail}`}`,
)
}
function formatCommand(command: string, args: string[]): string {
return [command, ...args].map(value => JSON.stringify(value)).join(' ')
}
function formatCopyableCommand(command: string, args: string[]): string {
return [command, ...args].map(quoteShellArgument).join(' ')
}
function quoteShellArgument(value: string): string {
if (/^[\w./:@=+-]+$/.test(value)) return value
return `'${value.replaceAll("'", `'"'"'`)}'`
}
function printUsage(): void {
console.log(`Usage:
pnpm exec tsx scripts/publish-npm-baseline.ts pack [options]
pnpm exec tsx scripts/publish-npm-baseline.ts release [options] [--yes]
pnpm exec tsx scripts/publish-npm-baseline.ts publish --manifest <path> [--yes]
pnpm exec tsx scripts/publish-npm-baseline.ts verify --manifest <path>
Pack/release options:
--ref <git-ref> Git commit to stage (default: HEAD)
--registry <url> npm registry (default: ${DEFAULT_REGISTRY})
--output-dir <path> Artifact root (default: ${DEFAULT_OUTPUT_DIRECTORY})
--yes pack/release without waiting for Enter`)
}
async function main(): Promise<void> {
const command = process.argv[2]
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
printUsage()
return
}
if (process.argv.slice(3).some(value => value === '--help' || value === '-h')) {
printUsage()
return
}
const runner = new CommandRunner()
const repositoryRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], process.cwd())
if (command === 'pack' || command === 'release') {
const { values } = parseArgs({
args: process.argv.slice(3),
options: {
ref: { type: 'string', default: 'HEAD' },
registry: { type: 'string', default: DEFAULT_REGISTRY },
'output-dir': { type: 'string', default: resolve(repositoryRoot, DEFAULT_OUTPUT_DIRECTORY) },
yes: { type: 'boolean', default: false },
},
strict: true,
})
const packager = new BaselinePackager(repositoryRoot, runner)
const plan = packager.plan({
ref: values.ref,
registry: values.registry,
outputDirectory: resolve(values['output-dir']),
})
await plan.confirm(values.yes)
const bundle = packager.pack(plan)
if (command === 'release') {
await new RegistryPublication(bundle, runner).publish(values.yes)
}
return
}
if (command === 'publish' || command === 'verify') {
const { values } = parseArgs({
args: process.argv.slice(3),
options: {
manifest: { type: 'string' },
yes: { type: 'boolean', default: false },
},
strict: true,
})
if (values.manifest === undefined) throw new Error(`${command} requires --manifest`)
if (command === 'verify' && values.yes) throw new Error('verify does not accept --yes')
const bundle = ReleaseBundle.load(values.manifest, runner)
const publication = new RegistryPublication(bundle, runner)
if (command === 'publish') await publication.publish(values.yes)
else publication.verify()
return
}
throw new Error(`unknown command: ${command}`)
}
try {
await main()
} catch (error: unknown) {
console.error(`publish-npm-baseline: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}