diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml new file mode 100644 index 0000000000..4246b4c62a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -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/implemented/process/2026-08-10-npm-release-sequences.md +2026-08-10-npm-release-sequences.md: 23c5f26c7be2b87d1ef2edffdd3f79a87cf1f9a1 +2026-08-10-npm-release-sequences.zh.md: 77b772460d0bf6b66ae6924c7bab1dfe0860b401 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md new file mode 100644 index 0000000000..23c5f26c7b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -0,0 +1,160 @@ +# Agent Note: Private npm publication as three independent sequences + +Status: implemented + +English | [中文](2026-08-10-npm-release-sequences.zh.md) + +## Problem + +This repository held three unrelated groups of publishable packages and no channel that sent any of them to a registry. + +`packages/*/*` and `apps/*` form the runtime surface of `@deepseek-ai/dsh`; `vendor/*` holds nine rescoped Cordis framework packages, each carrying its upstream version; `native/landlock-run/packages/*` holds Linux platform packages with their own workflow. The three differ in version baseline, change rate, and build requirements: dsh moves with the product, vendor moves only when upstream is re-synced or a local modification changes, and native needs a musl toolchain and one build per architecture. Forcing them through one pipeline means every product release republishes the framework and the native binaries. + +Two hard blockers sat in the way. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one was 933 hand-written `peerDependencies: "^0.0.1"` entries between sibling dsh packages: `pnpm pack` substitutes the `workspace:` protocol but leaves semver ranges alone, and `^0.0.1` means `>=0.0.1 <0.0.2` — it excludes `0.0.2`, and semver excludes prereleases from a range without a prerelease of its own, so it excluded `0.0.1-rc.1` too. Those entries never failed only because the version never left `0.0.1`. + +`scripts/publish-npm-baseline.ts` is a local publication script: it packs and publishes in one process, needs a human to authenticate and retry on their own machine, and excludes vendor from its release set. It cannot be the basis for CI publication, though its tarball payload validation and installed-artifact probes are verified parts. + +## Decision + +### Three independent sequences + +`packages/`, `vendor/`, and `native/` each have one bump sequence and one publication, sharing no version, no trigger, and no waiting. Releasing dsh does not republish vendor; releasing vendor does not republish native. + +| Sequence | Members | Version baseline | Tag | Workflow | +|---|---|---|---|---| +| dsh | `packages/*/*` + `apps/*` (`@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend`) | one version for the family and the workspace root, `0.0.x` | `dsh-v` | `release.yml` | +| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` | +| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` | + +All three publish privately to the `@deepseek-ai` scope on npmjs.com. `publishConfig.access` in each manifest is `restricted` and no workflow passes `--access`, because a command-line flag overrides the manifest. + +### Versions land in the repository from a local command; CI only checks and uploads + +Each sequence has one bump-and-commit command: it derives the target version, writes it into the relevant manifests, runs `pnpm install --lockfile-only`, and commits the manifests with the lockfile. The published version is therefore readable from the repository. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission. + +`release:dsh` accepts `major`, `minor`, `patch`, or an explicit version, and writes one version across the family **and the workspace root** — the workspace constraint requires every member's version to equal the root's, so the root carries the family version, and the root check accepts a prerelease segment. A prerelease such as `0.0.1-rc.1` drives pack, the installed-artifact probe, and one real private publication before numbered versions follow. The dist-tag decision is the one `landlock-run-release.yml` already made: a version with a prerelease segment publishes under `--tag next`, anything else takes `latest`. + +### vendor: publish what changed, and let tags be the ledger + +The vendored packages are decoupled from upstream by their scope but keep their own version lines. The published version is the higher of the manifest version and the last published version, with the patch incremented — which also drops an upstream prerelease segment. The first published versions: + +| Package | Upstream version | First published version | +|---|---|---| +| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | +| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | +| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | +| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | +| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | +| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | +| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | +| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | +| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | + +Taking the last published version as the baseline is what survives a re-sync: upstream restoring `4.0.0-rc.8` after this repository published `4.0.1` would otherwise compute `4.0.1` again and collide. + +Only changed packages publish, and the change judgement adds no state file: **each package has its own tag, and that tag records the commit it last published from**. For each package, bump reads the newest `vendor--v*` tag and diffs the package directory against it. A path counts when the manifest's `files` selects it, when npm publishes it regardless (`package.json`, `README*`, `LICENSE*`), or — for a package whose `files` selects `lib/` — when it is a build input (`src/**`, `tsconfig*.json`, a build config). That last rule exists because a built payload is not tracked by git: without it, a real source change reads as "nothing changed" and the next publication fails on a version whose bytes moved. + +A tag is a commit pointer, not proof of publication. Bump asks the registry whether the version its newest tag names exists and fails for a human to resolve when it does not, because a tag pushed for a publication that then failed would otherwise read as "already published" and skip the package indefinitely. Querying a private package needs credentials, so an unauthenticated machine reports the gap instead of failing. + +`vendor/cordis` publishes `src` as well. Its export map declares `"./src/*"`, so a tarball without those files points consumers at absent paths, and `files` selecting only build output left the change judgement with no tracked path to match. + +### Publication runs only on GitHub, and the registry decides what goes out + +Publication runs only from GitHub Actions; there is no local publication path. Publish reads no tag and no manifest of "what this release includes". For each packed tarball it compares the version against the registry, in three states: + +| State | Action | +|---|---| +| the registry does not have that version | publish | +| the registry has it, and the tarball's sha512 equals the recorded `dist.integrity` | skip: this is a re-run over one artifact | +| the registry has it, and the integrity differs | fail, reporting content changed without a version bump | + +The third state catches code that changed without a version bump. The first two provide idempotence — re-running publish over one artifact republishes nothing and needs no manual selection of packages. The same rule resolves the tension between one vendor release carrying several tags and a workflow that can only run from one ref: the workflow never infers which packages to publish from the tag it ran from. + +### Workspace-internal references use the `workspace:` protocol + +Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substitutes a range matching the target version: sibling `peerDependencies` follow the family version, and a reference to a vendored package follows that package's own line. The Landlock platform packages keep `workspace:*`, which publishes the exact version, because a platform package and its entry must agree exactly. + +`scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason. + +### Release family objects + +The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core. + +| Object | Responsibility | +|---|---| +| `ReleaseFamily` | a family's identity: member discovery, version baseline, tag prefix, packed-payload rule, installed entry | +| `ReleaseMember` | one publishable package: directory, name, version, manifest | +| `publishOrder` | topological order over runtime dependencies, ties broken by package name; a cycle is reported rather than resolved arbitrarily | +| `pack` | packs a whole family into one directory and records the upload order | +| `verify` | the family's version baseline, and — when publishing — that the run comes from that family's tag and its members are publishable | +| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable | +| `publish` | the three registry states above | +| `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | + +The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files. + +### Workflow shape: pack everything at once, then publish as one set + +The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building. + +`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state. + +A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set. + +### Repository changes this carried + +| Item | Content | +|---|---| +| release-set manifests | `private: true` removed; `publishConfig.access: restricted` and `repository` with each package's `directory` added | +| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*` | +| dependency protocol | workspace-internal references are `workspace:^`, with `check-workspace-constraints.ts` and the invariant-companion rule requiring it | +| root `AGENTS.md` | the convention that vendored packages are `private: true` no longer holds | +| `vendor/README.md` | records `src` joining `cordis`'s `files` as a local modification | +| the three native packages | `publishConfig.access: restricted`, and their workflow no longer passes `--access` | + +### Relationship to the earlier proposal + +This Agent Note replaces the version scheme and the release-set boundary in [artifact-first npm baseline publication](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md): its `--` prerelease versions and `dev-` dist-tag are not adopted, and vendor is not excluded from the release set. What both agree on stands: pack and publish are separate, publish consumes only verified tarballs, and the payload and installed-artifact probes are release gates. + +## Alternatives considered + +**A `--` version.** Planned for continuous dev publication. It conflicts with keeping the published version in the repository: the version embeds a commit SHA, and writing the version back produces a new commit, so the SHA can only name the parent commit that was published and the link needs a convention to explain it. With numbered versions, a prerelease such as `0.0.1-rc.1` already covers "verify first, then release". + +**A `vendor/published.json` ledger recording each package's published version and commit.** This preceded the tag design. It adds a state file that must not drift from the registry. A per-package tag gives the same commit pointer, and the tag has to exist anyway, so it introduces no second copy of the state. + +**Event-level tags (`vendor-r1`, `vendor-r2`).** Prepared for one release event carrying several package versions. Once the registry decides what publishes, the workflow no longer infers the set from the tag, so per-package tags suffice — and each one names its own package's real version. + +**Putting the nine vendored packages on one `4.0.x` line.** It removes change detection, but cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests. + +**Incrementing every vendored package on every vendor release, with no change detection.** The least machinery, at the cost of new version numbers for packages whose content is byte-identical to the previous release. Tags reduce change detection to reading one tag and running one diff, which is not worth trading for inflated version numbers. + +**Deciding "already published" from the version alone, without comparing content.** The reference flow queries no registry: publish uploads each tarball and npm rejects a duplicate version. Skipping on the version alone misses code that changed without a bump, which is the only failure that quietly leaves stale bytes on the registry. The cost is a registry query and a dependency on reproducible builds. + +**Verifying only the packed install, with no local registry.** The reference flow unpacks tarballs into a tree and drives it with plain Node, which bypasses version-range resolution. Running a local registry in CI to cover that layer was rejected: artifact correctness is covered by existing tests, the publication path is exercised by the master rehearsal, and a pull request only needs to prove the release set packs. Installing from `file:` specifiers still exercises range resolution for every internal dependency. + +**Selecting a subset by entry closure.** Crawling `dependencies` from `@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend` yields 156 packages, 61 fewer than the whole set. But this repository's plugins are mounted by name from `cordis.yml` rather than imported: `vendor/cordis-plugin-group` and `vendor/cordis-plugin-logger-console` fall outside the dependency closure while being required at runtime. Selecting by code dependency fails as "the consumer installs it and it will not start", and it would need a standing proof that no mounted package was missed. Under a private scope the extra packages are invisible outside the organization. `python/`, the root `examples/`, `docs/`, and `website/` are not members. + +**Extending `scripts/publish-npm-baseline.ts`.** It is a local publication script that packs and publishes in one process, the opposite of separating credential-free packing from protected publication. Its verified parts — payload validation and installed-artifact probes — are reused so `pnpm run duplication` does not report clones. + +**One workflow with a `family` input.** Two version models in one file forks the concurrency group, the tag prefix, and the rehearsal triggers into conditional expressions. One file per family is both shorter and easier to read. + +**Rewriting dependency ranges at publication time.** Compared with the protocol, the rewrite runs only in CI, a local `pnpm install` cannot show whether it is correct, and it repeats on every release. + +**Running bump in CI and pushing the version back.** It needs repository write permission for the workflow, and a version commit on the release branch races human commits. Bump and commit stay local; CI checks and uploads. + +## Consequences + +The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, the payload change judgement, and each family's payload policy. Two defects the first draft carried — a publish command that ran the pack command on import, and a change judgement blind to `vendor/cordis` source edits — are exactly what a test at that seam catches. + +A pull request runs the full pack for both sequences without credentials and installs the packed dsh tarballs into a throwaway consumer, where plain Node drives `dsh --version`. That probe is deliberately one command: it proves `files` selected a complete payload and that the published ranges resolve, and says nothing about interactive behavior. + +What this costs: + +- **Tags can drift from the registry.** A tag pushed for a publication that then failed is caught by bump's registry check, but only where credentials exist; an unauthenticated machine reports the gap and continues. +- **The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, degrades the vendored judgement to "publish everything for the first time". `fetch-depth: 0` is a precondition, not an optimization. +- **The protocol rewrite touched 1504 dependency declarations.** It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out. +- **Private packages need credentials to install.** Every consumer — CI, sandbox e2e, outside users — needs scope credentials, including for the Landlock packages, which have never been published and so cut off no existing anonymous path. +- **`repository` names a different organization than the one running the workflows.** Token-based publication is unaffected; npm provenance (OIDC) requires the two to agree, so adopting it means either repointing `repository` or publishing from the organization it names. +- **Byte reproducibility is assumed, not measured.** The skip-on-identical-integrity state rests on packing the same commit twice producing the same bytes. Nothing measures that yet: if the build embeds absolute paths or timestamps, a re-run reports a false failure. Measure it before the first publication a re-run might follow, and fall back to comparing per-file content hashes if it does not hold. +- **Re-running publish over an older artifact can move `latest` backwards.** Publication is decided per version, so an older set republished after a newer one takes the stable dist-tag again. The rehearsals run from a prerelease version, which never takes `latest`. +- **The first publication is one large step.** Nine vendored packages and the whole dsh set publish at once, so any payload defect surfaces in a single release, which is why a prerelease version drives the complete path first. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md new file mode 100644 index 0000000000..77b772460d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -0,0 +1,160 @@ +# Agent Note: 三条独立序列的私有 NPM 发布 + +Status: implemented + +[English](2026-08-10-npm-release-sequences.md) | 中文 + +## 问题 + +这个仓库有三组互不相干的可发布包,却没有任何发布通道把它们送上 registry。 + +`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行面;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,有自己的 workflow。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。 + +挡路的还有两处硬门。全部 217 个 workspace manifest 都是 `private: true`,`npm publish` 直接拒绝。更隐蔽的是 933 条 dsh 兄弟包之间硬写的 `peerDependencies: "^0.0.1"`:`pnpm pack` 只替换 `workspace:` 协议,不动语义范围,而 `^0.0.1` 等于 `>=0.0.1 <0.0.2`——发 `0.0.2` 落不进去,发 `0.0.1-rc.1` 也落不进去(semver 规定不带预发布段的范围排除预发布版本)。这些条目至今没出事,只因为版本一直停在 `0.0.1`。 + +`scripts/publish-npm-baseline.ts` 是本机发布脚本:它把 pack 与 publish 放进同一个进程,需要人工在本机完成认证与重试,且把 vendor 排除在发布集之外。它不能作为 CI 发布的基础,但其中的 tarball payload 校验与已安装产物探针是验证过的零件。 + +## 决策 + +### 三条独立序列 + +`packages/`、`vendor/`、`native/` 各自一条 bump 序列、各自一次发布,不共享版本号、不共享触发、不互相等待。发 dsh 不重发 vendor,发 vendor 不重发 native。 + +| 序列 | 成员 | 版本基线 | tag | workflow | +|---|---|---|---|---| +| dsh | `packages/*/*` + `apps/*`(`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend`) | 全族与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml` | +| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml` | +| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml` | + +三组一律发到 npmjs.com 的 `@deepseek-ai` scope 下的私有包。每个 manifest 的 `publishConfig.access` 都是 `restricted`,且没有任何 workflow 传 `--access`——命令行选项会覆盖 manifest。 + +### 版本由本地命令写进仓库,CI 只核对与上传 + +每条序列有一条 bump-and-commit 命令:算出目标版本,写进相关 manifest,跑 `pnpm install --lockfile-only`,再把 manifest 连 lockfile 一起 commit。发布版本因此在仓库里查得到。tag 由人工在 commit 合入 master 后打;CI 不写仓库,也不需要写权限。 + +`release:dsh` 接受 `major`、`minor`、`patch` 或显式版本号,把同一个版本写进全族**以及 workspace 根**——workspace 约束要求每个成员的版本等于根版本,所以根承载族版本,而根的检查接受预发布段。像 `0.0.1-rc.1` 这样的预发布号先把 pack、已安装产物探针和一次真实私有发布跑通,数字版本随后。dist-tag 沿用 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。 + +### vendor:谁改了谁发版,tag 就是账本 + +vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本取「manifest 版本」与「上次发布版本」中较高的那个,再递增 patch——这一步同时去掉上游的预发布段。首发版本: + +| 包 | 上游版本 | 首发版本 | +|---|---|---| +| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | +| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | +| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | +| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | +| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | +| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | +| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | +| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | +| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | + +以「上次发布版本」为基线才扛得住重同步:本仓发过 `4.0.1` 之后上游把版本恢复成 `4.0.0-rc.8`,只看 manifest 会再算出 `4.0.1` 并撞上已发版本。 + +只发改动过的包,而变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,拿包目录与它做 diff。一条路径算命中的条件是:manifest 的 `files` 选中它,或 npm 无论如何都会发布它(`package.json`、`README*`、`LICENSE*`),或者——当该包的 `files` 选中 `lib/` 时——它是构建输入(`src/**`、`tsconfig*.json`、构建配置)。最后那条规则的存在理由是构建产物不在 git 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。 + +tag 只是 commit 指针,不是发布成功的证明。bump 会向 registry 核对「最新 tag 指向的版本是否真的存在」,不存在就明确失败交人处理——否则一个为失败发布而推的 tag 会被读成「已发布」,从此永远跳过该包。查询私有包需要凭据,因此未鉴权的机器只报告这道核对被跳过,不失败。 + +`vendor/cordis` 现在也发布 `src`。它的 exports 声明了 `"./src/*"`,tarball 里没有这些文件就等于把消费方指向不存在的路径;而 `files` 只选构建产物,也让变更判据没有任何受 git 跟踪的路径可匹配。 + +### 发布只在 GitHub 执行,由 registry 状态决定发什么 + +发布只从 GitHub Actions 执行,没有本机发布路径。publish 不读 tag、不读任何「本次发布包含什么」的清单,而是对每个打包好的 tarball 拿版本与 registry 比对,分三态: + +| 状态 | 处置 | +|---|---| +| registry 上没有该版本 | 发布 | +| 已有该版本,且 tarball 的 sha512 等于记录的 `dist.integrity` | 跳过:这是同一批产物的重跑 | +| 已有该版本,但 integrity 不同 | 失败退出,报「内容已变但版本未 bump」 | + +第三态拦住「改了代码却没 bump 版本」。前两态给出幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。同一条规则还解决了「一次 vendor 发布携带多个 tag,而 workflow 只能从一个 ref 触发」的矛盾:workflow 从不从触发它的 tag 去推断该发哪些包。 + +### workspace 内部引用走 `workspace:` 协议 + +所有指向 workspace 成员的引用都用 `workspace:^`,由 `pnpm pack` 替换成匹配目标版本的范围:兄弟包的 `peerDependencies` 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 `workspace:*`(发布成精确版本),因为平台包与它的入口必须版本完全一致。 + +`scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。 + +### 发布族对象 + +这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。 + +| 对象 | 职责 | +|---|---| +| `ReleaseFamily` | 一族的身份:成员发现、版本基线、tag 前缀、打包 payload 规则、已安装入口 | +| `ReleaseMember` | 一个可发布包:目录、包名、版本、manifest | +| `publishOrder` | 按运行时依赖的拓扑序,同层按包名排;遇到环是报错而不是随意定序 | +| `pack` | 把整族打进一个目录并记录上传顺序 | +| `verify` | 族的版本基线;发布时还要求本次运行来自该族的 tag、且成员可发布 | +| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 | +| `publish` | 上面那三态 | +| `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | + +dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。 + +### workflow 形状:一次性 pack 全部,再统一 publish + +`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 + +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。 + +dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。 + +### 本次带出的仓库改动 + +| 项 | 内容 | +|---|---| +| 发布集 manifest | 去掉 `private: true`;补 `publishConfig.access: restricted` 与带各自 `directory` 的 `repository` | +| 发布集边界 | `packages/*/*`、`apps/*`、`vendor/*` 的全部成员 | +| 依赖协议 | workspace 内部引用为 `workspace:^`,由 `check-workspace-constraints.ts` 与 invariant companion 规则强制 | +| 根 `AGENTS.md` | 「vendored 包是 `private: true`」这条约定不再成立 | +| `vendor/README.md` | 记录「`src` 加入 `cordis` 的 `files`」这条本地修改 | +| native 三包 | `publishConfig.access: restricted`,且其 workflow 不再传 `--access` | + +### 与先前提案的关系 + +本 Note 取代 [以产物为先的 NPM 基线发布](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 + +## 曾考虑的替代方案 + +**`-<时间戳>-<短 SHA>` 版本号。** 曾计划用于持续 dev 发布。它与「把发布版本留在仓库里」冲突:版本内嵌 commit SHA,而把版本写回会产生新的 commit,于是 SHA 只能指向被发布的父 commit,这条链要靠约定解释。改用数字版本后,`0.0.1-rc.1` 这类预发布号已经覆盖「先验证再正式发」。 + +**用 `vendor/published.json` 账本记录每包的已发版本与 commit。** 这是 tag 方案之前的设计。它新增一份必须与 registry 不漂移的状态文件;per-package tag 提供同样的 commit 指针,而 tag 本来就要打,不引入第二处状态。 + +**事件级 tag(`vendor-r1`、`vendor-r2`)。** 为「一次发布事件携带多个包版本」准备。既然由 registry 决定发什么,workflow 就不再从 tag 推断集合,per-package tag 够用,而且每个 tag 携带的是它自己那个包的真实版本。 + +**把九个 vendored 包统一到一条 `4.0.x` 版本线。** 省掉变更检测,但 cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。 + +**每次 vendor 发布把九包全部 patch+1,不做变更检测。** 机制最少,代价是内容与上一版逐字节相同的包也拿到新版本号。tag 把变更检测的成本压到「读一个 tag、跑一次 diff」,不值得为省这点让版本号虚涨。 + +**只按版本号判断「是否已发布」,不比对内容。** 参照流程根本不查 registry:publish 逐个上传,重复版本由 npm 拒绝。只按版本号跳过会漏掉「改了代码没 bump」,而这是唯一会安静地把旧字节留在 registry 上的错误。代价是引入一次 registry 查询和对构建可复现性的依赖。 + +**只做打包后安装验证,不起本地 registry。** 参照流程是把 tarball 解包成一棵树、用普通 Node 驱动,这绕过了版本范围解析。曾提议在 CI 里起本地 registry 补这一层,被否:产物正确性已由既有测试覆盖,发布路径由 master 的排练覆盖,而 pull request 只需证明发布集能打出来。用 `file:` 说明符安装依然会对每个内部依赖走一遍范围解析。 + +**按入口闭包挑一部分包发。** 从 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend` 沿 `dependencies` 爬得到 156 个包,比全量少 61 个。但本仓的插件是 `cordis.yml` 按名字挂载的、不是被 import 的:`vendor/cordis-plugin-group` 与 `vendor/cordis-plugin-logger-console` 落在依赖闭包之外,却是运行时必需。照代码依赖挑的失败形态是「消费方装完起不来」,而且要额外持续证明「没漏任何挂载项」。私有 scope 下多出来的包对组织外不可见。`python/`、根 `examples/`、`docs/` 与 `website/` 不是成员。 + +**在 `scripts/publish-npm-baseline.ts` 上扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件——payload 校验与已安装产物探针——被搬运复用,以免 `pnpm run duplication` 判重复。 + +**一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件,会让 concurrency 组、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 + +**在发布期改写依赖范围。** 与协议相比,改写逻辑只在 CI 执行过,本机 `pnpm install` 看不出它是否正确,而且每次发布都要重来一遍。 + +**在 CI 里执行 bump 并把版本推回仓库。** 需要给 workflow 仓库写权限,且发布分支上的版本 commit 会与人的 commit 竞争。bump 与 commit 留在本地,CI 只核对与上传。 + +## 后果 + +发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算、payload 变更判据,以及各族的 payload 策略。第一版带过的两个缺陷——publish 命令在 import 时执行了 pack 命令、变更判据对 `vendor/cordis` 的源码改动失明——正是这类测试在对应接缝上能抓住的。 + +一个 pull request 会为两条序列跑完整的 pack(无凭据),并把打包好的 dsh tarball 装进一次性 consumer,用普通 Node 驱动 `dsh --version`。这个探针刻意只有一条命令:它证明 `files` 选出了完整 payload、发布出去的范围可解析,不涉及任何交互行为。 + +代价: + +- **tag 可能与 registry 漂移。** 为失败发布而推的 tag 由 bump 的 registry 核对拦下,但只在有凭据的地方;未鉴权的机器只报告这道核对被跳过。 +- **变更判据依赖 tag 可见。** shallow clone 或未拉 tag 会把 vendored 族的判据退化成「全部首发」。`fetch-depth: 0` 是前提,不是优化。 +- **协议改写触及 1504 处依赖声明。** 它不改变本机解析(pnpm 本来就从 workspace 解析),但改变了发布出去的范围写法。 +- **私有包需要凭据才能安装。** 任何消费方——CI、沙箱 e2e、外部使用者——都要持有 scope 凭据,Landlock 三包也在其中;它们从未发布过,所以没有切断既有的匿名安装路径。 +- **`repository` 指向的组织与运行 workflow 的组织不同。** 用 token 发布不受影响;npm provenance(OIDC)要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。 +- **字节可复现性是假定的,没有实测。** 「integrity 相同则跳过」这一态建立在「同一 commit 两次 pack 得到相同字节」之上。目前没有任何东西测量过它:若构建嵌入了绝对路径或时间,重跑会误报失败。在第一次可能被重跑的发布之前实测,若不成立就退到比对 tarball 内逐文件内容哈希。 +- **用较旧的 artifact 重跑 publish 会把 `latest` 拉回旧版。** 发布是按版本决定的,所以在较新版本之后重发较旧的一批,会让稳定 dist-tag 再次指向旧版。排练用的是预发布版本,它永远不占 `latest`。 +- **首发是一次大步。** 九个 vendored 包与整个 dsh 集一次发出,任何 payload 缺陷都会集中在同一次发布里暴露——这正是先用预发布版本把完整链路走一遍的理由。 diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml deleted file mode 100644 index 76798fc636..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: bfe4ccaeddee3713445a3ffb77d575e4af207ae2 -2026-08-10-npm-release-sequences.zh.md: 3df42748429ac62e8131bfe22e4ebaafb4943b32 diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md deleted file mode 100644 index bfe4ccaedd..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md +++ /dev/null @@ -1,185 +0,0 @@ -# Agent Note: Private npm publication as three independent sequences - -Status: proposed - -English | [中文](2026-08-10-npm-release-sequences.zh.md) - -## Problem - -This repository holds three unrelated groups of publishable packages and no channel that sends any of them to a registry. - -`packages/*/*` and `apps/*` form the runtime surface of `@deepseek-ai/dsh`; `vendor/*` holds nine rescoped Cordis framework packages, each carrying its upstream version; `native/landlock-run/packages/*` holds Linux platform packages that already have `landlock-run-release.yml`. The three differ in version baseline, change rate, and build requirements: dsh moves with the product, vendor moves only when upstream is re-synced or a local modification changes, and native needs a musl toolchain and one build per architecture. Forcing them through one pipeline means every product release republishes the framework and the native binaries. - -Two hard blockers sit in the current state. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one is 933 hard-written `peerDependencies: "^0.0.1"` entries between sibling dsh packages: `pnpm pack` substitutes the `workspace:` protocol but leaves semver ranges alone, and `^0.0.1` means `>=0.0.1 <0.0.2` — it excludes `0.0.2`, and semver excludes prereleases from a range without a prerelease of its own, so it excludes `0.0.1-rc.1` too. Those 933 entries have never failed only because the version has never left `0.0.1`. - -The existing `scripts/publish-npm-baseline.ts` is a local publication script: it packs and publishes in one process, needs a human to authenticate and retry on their own machine, and excludes vendor from its release set. It cannot be the basis for CI publication, but its tarball payload validation and installed-artifact probes are verified parts. - -## Proposal - -### Three independent sequences - -`packages/`, `vendor/`, and `native/` each get one bump sequence and one publication, sharing no version, no trigger, and no waiting. Releasing dsh does not republish vendor; releasing vendor does not republish native. - -| Sequence | Members | Version baseline | Tag | Workflow | -|---|---|---|---|---| -| dsh | `packages/*/*` + `apps/*` (`@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend`) | one version for the whole family, `0.0.x` | `dsh-v` | `release.yml` (new) | -| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` (new) | -| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` (unchanged) | - -All three publish privately to the `@deepseek-ai` scope on npmjs.com (`npm publish --access restricted`). - -### Versions land in the repository from a local command; CI only checks and uploads - -Each sequence has one `bump and commit` command: derive the target version, write it into the relevant manifests, run `pnpm install --lockfile-only`, self-check immediately, then `git add` the manifests and the lockfile and commit. The published version is therefore readable from the repository, and "which version went out" is never a question. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission. - -The dsh sequence shares one version across the family and accepts `major | minor | patch | x.y.z`. A prerelease version such as `0.0.1-rc.1` drives pack, the installed-artifact probes, and one real private publication end to end first; numbered versions like `0.0.1` and `0.0.2` follow once that passes. The dist-tag decision is the one this repository already makes in `landlock-run-release.yml`: a version with a prerelease segment publishes under `--tag next`, anything else takes `latest`. - -### vendor: publish what changed, and let tags be the ledger - -The vendored packages are decoupled from upstream by their scope but keep their own version lines. The published version is the upstream version with its prerelease segment dropped and its patch incremented: - -| Package | Upstream version | First published version | -|---|---|---| -| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | -| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | -| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | -| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | -| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | -| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | -| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | -| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | -| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | - -Only changed packages publish, and the change judgement adds no state file: **each package has its own tag, and that tag records the commit it last published from**. For each package, bump reads the newest `vendor--v*` tag and runs `git diff ..HEAD -- vendor/`; a difference means patch+1, no difference means skip, and a missing tag means the first publication from the table above. The diff considers only paths that reach the tarball (the `files` rules in `scripts/publication-payload.ts`), so editing a comment inside a vendored package does not trigger a release. - -A tag is a commit pointer, not proof of publication — a tag created for a publication that then failed must be recognizable. So bump also asks the registry whether the version that tag names exists, and fails for a human to resolve when it does not, rather than guessing. Querying a private package needs authentication; the check is skipped on a machine that is not logged in and enforced in CI. - -The dependency ranges *inside* the nine vendored packages need no rewrite: `^1.8.1` admits `1.8.2` and `^1.0.0-rc.5` admits `1.0.1`, so patch+1 always lands inside the range. - -### Publication runs only on GitHub, and the registry decides what goes out - -Publication runs only from GitHub Actions; there is no local publication path. That makes the registry check a mandatory CI step instead of something with a bypass for an unauthenticated machine. - -Publish reads no tag and no manifest of "what this release includes". For each member it compares the manifest version against the registry, in three states: - -| State | Action | -|---|---| -| the registry does not have that version | publish | -| the registry has it, and the tarball's sha512 equals the recorded `dist.integrity` | skip: this is a re-run over one artifact | -| the registry has it, and the integrity differs | fail, reporting content changed without a version bump | - -The third state is the point of the rule: it catches code that changed without a version bump. The first two provide idempotence — re-running publish over one artifact republishes nothing and needs no manual selection of packages. - -The same rule resolves the tension between one vendor release carrying several tags and a workflow that can only run from one ref: the workflow never infers which packages to publish from the tag it ran from. The dsh sequence behaves identically with one version: the difference is either the whole set or nothing. - -The third state depends on a reproducible build — packing the same commit twice must produce the same bytes. That must be measured, not assumed: if `pnpm run build` embeds absolute paths or timestamps, integrity drifts while content is unchanged and the third state reports a false failure. Before this lands, pack the same commit twice in CI and compare integrity; if it is not reproducible, compare per-file content hashes inside the tarball instead and exclude the fields that drift. - -### Rewrite workspace-internal references to `workspace:^`, once - -Every reference to a workspace member becomes `workspace:^`, so `pnpm pack` substitutes a range that matches the target version: - -| Surface | Count | Effect | -|---|---|---| -| sibling dsh `peerDependencies` | 933 | `0.0.2` and `0.0.1-rc.1` both get a matching range | -| dep / peer / devDep pointing at vendor | 105 + 221 + 218 | no dsh-side rewrite after a vendor patch+1, and no range that goes stale as vendor increments | - -`scripts/check-workspace-constraints.ts` currently asserts that the vendor peer and dev ranges are equal; both become `workspace:^`, so the assertion still holds but its wording changes with it. - -This is what makes "no dependency rewriting at publication time" possible: publication does one thing, which is packing bytes. - -### Release family objects - -The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a family description and one workflow lane, not changing the core. - -| Object | Responsibility | -|---|---| -| `ReleaseFamily` | a family's identity: member discovery, version policy, tag naming, publish target. A new release family lands here | -| `ReleaseMember` | one publishable package: directory, manifest, family, position in publish order | -| `VersionPolicy` | where the version comes from. `SharedSemver` (dsh: one version for the family) and `PerPackageChanged` (vendor: change judged by tag, prerelease dropped, patch incremented) | -| `ReleaseSet` | a family's members in topological order over `dependencies`, ties broken by package name for determinism | -| `PackedBundle` | the tarballs plus `publish-order.txt` and its metadata: the only handoff between pack and publish | -| `PublishTarget` | registry, access, dist-tag, credential source. The dist-tag derives from the version's shape | -| `VersionInvariant` | the family's versions agree; publication runs from the family's tag; the tag version equals the package version; the target version is absent from the registry | -| `PayloadInvariant` | tarball content validation, reusing `scripts/publication-payload.ts` | -| `InstalledProbe` | a throwaway consumer outside the repository installs from the tarballs and drives the installed entry with plain Node: `dsh --version`, `dsh --dump-default-config`, and one TUI startup to ready and exit. Moved over from `scripts/publish-npm-baseline.ts` | - -### Workflow shape: pack everything at once, then publish as one set - -The shape comes from the reference flow (`release.yml` and `scripts/pack-release.mjs` in node-addon-require-builtin): the `pack` job walks the whole release set once, running `pnpm --dir pack --pack-destination ` per member, writes `publish-order.txt`, and uploads that directory as **one** artifact; the `publish` job downloads that artifact and runs `npm publish` per entry in `publish-order.txt`. The release set is one unit — half the packages can never reach the registry while the other half is still building. - -The `pack` job carries no credentials: install, verify, build, pack, installed-artifact verification, upload-artifact. The `publish` job sits behind the `npm-publish` environment for human approval, runs `setup-node` and `download-artifact` only, and **neither checks out nor builds** — it uploads the bytes pack produced. Checkout uses `fetch-depth: 0`, because the vendor change judgement reads history and tags. - -The environment is the only brake in the flow: pack has no credentials and can be rehearsed freely, and only publish stops for approval. GitHub needs an `NPM_TOKEN` secret (an automation token with publish rights on the scope) and an `npm-publish` environment (required reviewers, allowed tags limited to `dsh-v*`, `vendor-*`, and `landlock-run-v*`). - -### Pull requests run as far as pack - -The reference flow only has `workflow_dispatch`, so it verifies nothing on a pull request. Here `pull_request` runs the full pack: install, verify, build, pack per member, upload the tarball artifact. What it proves is that this release set still packs completely; it uses no credentials, touches no registry, and runs for pull requests from forks. The artifacts' own correctness is covered by existing tests and is not repeated at this layer. - -The publication path is exercised from master: `push: master` runs the same pack rehearsal as a post-merge regression, and `workflow_dispatch` with `publish: true` performs a real publication from a tag. - -### Repository changes - -| Item | Content | -|---|---| -| release-set manifests | drop `private: true`; add `publishConfig.access: restricted` and `repository` (`git+https://github.com/deepseek-ai/deepseek-harness.git` plus each package's `directory`) | -| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*`; no smaller selection | -| dependency protocol | workspace-internal references become `workspace:^`, with `check-workspace-constraints.ts` updated | -| root `AGENTS.md` | it states that vendored packages are rescoped and `private: true`; vendor now publishes, so that convention changes | -| `vendor/README.md` | its manifest table records the upstream version, kept distinct from the version we publish | -| the three native packages | `publishConfig.access` moves from `public` to `restricted`; they have never been published, so no anonymous install path exists to preserve | - -### Relationship to the existing proposal - -This Agent Note replaces the version scheme and the release-set boundary in [artifact-first npm baseline publication](2026-08-04-artifact-first-npm-baseline-publication.md): its `--` prerelease versions and `dev-` dist-tag are not adopted, and vendor is no longer excluded from the release set. What both agree on stays: pack and publish are separate, publish consumes only verified tarballs, and the payload and installed-artifact probes are release gates. - -## Alternatives considered - -**A `--` version.** Planned for continuous dev publication. It conflicts with keeping the published version in the repository: the version embeds a commit SHA, and writing the version back produces a new commit, so the SHA can only name the parent commit that was published and the link needs a convention to explain it. With numbered versions, a prerelease such as `0.0.1-rc.1` already covers "verify first, then release". - -**A `vendor/published.json` ledger recording each package's published version and commit.** This preceded the tag design. It adds a state file that must not drift from the registry. A per-package tag gives the same commit pointer, and the tag has to exist anyway, so it introduces no second copy of the state. - -**Event-level tags (`vendor-r1`, `vendor-r2`).** Prepared for one release event carrying several package versions. Once the registry decides what publishes, the workflow no longer infers the set from the tag, so per-package tags suffice — and each one names its own package's real version. - -**Putting the nine vendored packages on one `4.0.x` line.** It removes change detection, but cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests. - -**Incrementing every vendored package on every vendor release, with no change detection.** The least machinery, at the cost of new version numbers for packages whose content is byte-identical to the previous release. Tags reduce change detection to reading one tag and running one diff, which is not worth trading for inflated version numbers. - -**Deciding "already published" from the version alone, without comparing content.** The reference flow queries no registry at all: publish uploads each tarball and npm rejects a duplicate version. Skipping on the version alone misses code that changed without a bump, which is the only failure that quietly leaves stale bytes on the registry. The cost is a registry query and a dependency on reproducible builds. - -**Verifying only the packed install, with no local registry.** This is what the reference flow does: unpack the tarballs into a tree and drive it with plain Node. It bypasses version-range resolution, so in principle it cannot prove that 200-odd interdependent packages install from a registry. Running a local registry in CI to cover that layer was proposed and rejected: artifact correctness is already covered by existing tests, the publication path is exercised by the master rehearsal, and a pull request only needs to prove the release set packs. - -**Selecting a subset by entry closure.** Crawling `dependencies` from `@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend` yields 156 packages, 61 fewer than the whole set. But this repository's plugins are mounted by name from `cordis.yml` rather than imported: `vendor/cordis-plugin-group` and `vendor/cordis-plugin-logger-console` fall outside the dependency closure while being required at runtime. Selecting by code dependency fails as "the consumer installs it and it will not start", and it would need a standing proof that no mounted package was missed. The release set is therefore all of `packages/*/*`, `apps/*`, and `vendor/*`; under a private scope the extra packages are invisible outside the organization. `python/`, the root `examples/`, `docs/`, and `website/` are not members. - -**Extending `scripts/publish-npm-baseline.ts`.** It is a local publication script that packs and publishes in one process, the opposite of separating credential-free packing from protected publication. Its verified parts — payload validation and installed-artifact probes — are reused so `pnpm run duplication` does not report clones. - -**One workflow with a `family` input.** Two version models in one file forks the concurrency group, the tag prefix, and the rehearsal triggers into conditional expressions. One file per family is both shorter and easier to read. - -**Rewriting dependency ranges at publication time.** Compared with rewriting them to `workspace:^` once, the rewrite runs only in CI, a local `pnpm install` cannot show whether it is correct, and it repeats on every release. - -**Running bump in CI and pushing the version back.** It needs repository write permission for the workflow, and a version commit on the release branch races human commits. The reference flow leaves bump and commit to local commands and lets CI check and upload. - -## Acceptance criteria - -1. The three sequences release independently: releasing dsh modifies no vendor or native manifest, and the converse holds. -2. `pnpm release:dsh ` performs bump and commit in one command, and the resulting commit carries the family's manifests and the lockfile and self-checks immediately. -3. `pnpm release:vendor` increments the patch only for packages whose tarball content changed since their `vendor--v*` tag, and leaves the manifests of unchanged packages alone. -4. `pull_request` runs the full pack and produces the tarball artifact, with no credentials and no access to a real registry, including for pull requests from forks. -5. `push: master` runs the same pack rehearsal; a real publication can only come from `workflow_dispatch` with `publish: true` from that family's tag. -6. Re-running publish over one artifact republishes no existing version, and when a version exists whose tarball integrity differs, publish fails and names the package. -7. A throwaway consumer outside the repository installs `@deepseek-ai/dsh@0.0.1-rc.1` and drives `--version`, `--dump-default-config`, and one TUI startup with plain Node. -8. Every workspace-internal reference is `workspace:^`, the packed tarballs carry no `workspace:` remnant, and no range points at a version that does not exist. -9. No release-set member sets `private: true`, and each one sets `publishConfig.access: restricted`. - -## Risks - -**Tags drifting from the registry.** A tag created for a publication that then failed makes the next bump treat the package as published. Bump asks the registry whether the version the tag names exists and fails when it does not; on a machine that is not logged in to the private registry that check is skipped, and only the same check in CI catches it. - -**The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, breaks the vendor judgement and degrades it to "publish everything for the first time". `fetch-depth: 0` is a precondition of the judgement, not an optimization. - -**`workspace:^` touches a large surface.** It rewrites 1477 dependency declarations at once. It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out, and the workspace constraint gate changes with it. - -**The visibility cost of private packages.** After `--access restricted`, every consumer — CI, sandbox e2e, and outside users — needs scope credentials to install. The three native packages move to `restricted` as well; they have never been published, so no existing anonymous install path is cut off. - -**The `repository` field names a different organization than the one running the workflow.** The release set points consumers at `github.com/deepseek-ai/deepseek-harness`, which is not where these workflows run. Token-based publication is unaffected; npm provenance (OIDC) requires the two to agree, so adopting it means either repointing `repository` or publishing from the organization it names. - -**The first publication is one large step.** Nine vendored packages and the whole dsh set publish at once, so any payload defect surfaces in a single release. Driving the complete path with `0.0.1-rc.1` first is the only mitigation, which is why numbered versions wait for that to pass. diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md deleted file mode 100644 index 3df4274842..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md +++ /dev/null @@ -1,188 +0,0 @@ -# Agent Note: 三条独立序列的私有 NPM 发布 - -Status: proposed - -[English](2026-08-10-npm-release-sequences.md) | 中文 - -## 问题 - -这个仓库有三组互不相干的可发布包,但没有任何发布通道把它们送上 registry。 - -`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行时闭包;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,已有自己的 `landlock-run-release.yml`。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。 - -当前状态还有两处硬门。全部 217 个 workspace manifest 都是 `private: true`,直接 `npm publish` 发不出去。更隐蔽的是 933 条 dsh 兄弟包之间硬写的 `peerDependencies: "^0.0.1"`:`pnpm pack` 只替换 `workspace:` 协议,不动语义范围,而 `^0.0.1` 等于 `>=0.0.1 <0.0.2`——发 `0.0.2` 落不进去,发 `0.0.1-rc.1` 也落不进去(semver 规定不带预发布段的范围排除预发布版本)。这 933 条至今没出事,只因为版本一直停在 `0.0.1`。 - -本仓已有的 `scripts/publish-npm-baseline.ts` 是本机发布脚本:它把 pack 与 publish 放进同一个进程,需要人工在本机完成认证与重试,且把 vendor 排除在发布集之外。它不能作为 CI 发布的基础,但其中的 tarball payload 校验与已安装产物探针是验证过的零件。 - -## 提案 - -### 三条独立序列 - -`packages/`、`vendor/`、`native/` 各自一条 bump 序列、各自一次发布,不共享版本号、不共享触发、不互相等待。发 dsh 不重发 vendor,发 vendor 不重发 native。 - -| 序列 | 成员 | 版本基线 | tag | workflow | -|---|---|---|---|---| -| dsh | `packages/*/*` + `apps/*`(`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend`) | 全族一个 `0.0.x` | `dsh-v<版本>` | `release.yml`(新增) | -| vendored framework | `vendor/*` 九个包 | 每包各自的上游版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml`(新增) | -| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml`(现状不动) | - -三组一律发到 npmjs.com 的 `@deepseek-ai` scope 下的**私有包**(`npm publish --access restricted`)。native 三个包现在写的是 `access: public`,要改成 `restricted`。 - -### 版本由本地命令写进仓库,CI 只核对与上传 - -每条序列有一条 `bump and commit` 命令:算出目标版本 → 写进相关 manifest → `pnpm install --lockfile-only` → 立刻自检 → `git add` manifest 与 lockfile → commit。发布版本因此在仓库里查得到,不存在「发出去的是哪个版本说不清楚」。tag 由人工在合入 master 后打,CI 不写仓库、不需要写权限。 - -dsh 序列全族共用一个版本,接受 `major | minor | patch | x.y.z` 三种入参与显式版本号。先用 `0.0.1-rc.1` 这类预发布号把 pack、仓外安装探针、真实私有发布跑通一遍,验证通过后再发 `0.0.1`、`0.0.2` 这样的数字版本。dist-tag 沿用本仓 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。 - -### vendor:谁改了谁发版,tag 就是账本 - -vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本 = 去掉预发布段后 patch+1,首发目标: - -| 包 | 上游版本 | 首发版本 | -|---|---|---| -| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | -| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | -| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | -| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | -| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | -| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | -| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | -| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | -| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | - -只发改动过的包。变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,`git diff <该 tag>..HEAD -- vendor/<目录>` 有差异就 patch+1,没差异就跳过;查不到 tag 就按上表首发。差异只看会进 tarball 的路径(复用 `scripts/publication-payload.ts` 的 `files` 规则),改动 vendor 内的注释不触发发版。 - -tag 只是 commit 指针,不是「已发布」的证明——打了 tag 而 publish 失败的情况必须能识别。所以 bump 还要向 registry 核对「tag 所指版本确实存在」,不一致时明确失败交人处理,不让脚本猜。私有包查询需要鉴权,本机未登录时跳过这条核对,CI 里强制执行。 - -vendor 九包**内部**的依赖范围不用改:`^1.8.1` 容纳 `1.8.2`、`^1.0.0-rc.5` 容纳 `1.0.1`,patch+1 永远落在范围内。 - -### publish 只在 GitHub 执行,用 registry 状态决定发什么 - -发布只从 GitHub Actions 执行,没有本机发布路径。这让「向 registry 核对」成为 CI 里的强制步骤,不需要为本机未鉴权的情况留旁路。 - -publish 不读 tag、不读任何清单,对发布集里每个包比较 manifest 版本与 registry 上的已发布状态,按三态处置: - -| 状态 | 处置 | -|---|---| -| registry 上没有该版本 | 发布 | -| 已有该版本,且 tarball 的 sha512 与 registry 记录的 `dist.integrity` 相同 | 跳过,属于同一批产物的重跑 | -| 已有该版本,但 integrity 不同 | 失败退出,报「内容已变但版本未 bump」 | - -第三态是这条规则的目的:它拦住「改了代码却没 bump 版本」。前两态给出的是幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。 - -这条规则同时解决了一次发布事件产生多个 vendor tag、而 workflow 只能从一个 ref 触发的矛盾:workflow 不需要从 tag 推断本次该发哪些包。dsh 序列同构处理,它只有一个版本,差集要么全发要么全跳。 - -第三态依赖同输入构建可复现(同一 commit 两次 pack 得到相同字节)。这一点必须实测确认,不能假定:`pnpm run build` 的产物若嵌入绝对路径或时间,integrity 就会在内容未变时漂移,第三态会误报。落地前先在 CI 上对同一 commit 连跑两次 pack 比对 integrity;若不可复现,则把比较下沉到 tarball 内的逐文件内容哈希,并明确排除导致漂移的字段。 - - -### 一次性把 workspace 内部引用改成 `workspace:^` - -仓库里所有指向 workspace 成员的引用统一成 `workspace:^`,由 `pnpm pack` 在发布时替换成匹配目标版本的范围。 - -| 面 | 数量 | 效果 | -|---|---|---| -| dsh 兄弟包 `peerDependencies` | 933 | 发 `0.0.2` 或 `0.0.1-rc.1` 都自动得到匹配的范围 | -| 指向 vendor 的 dep / peer / devDep | 105 + 221 + 218 | vendor patch+1 后不需要改写 dsh 侧引用,范围也不会随 vendor 递增而过期 | - -`scripts/check-workspace-constraints.ts` 现在断言 vendor peer 与 dev 的范围相等,改后两边都是 `workspace:^`,断言仍成立但语义要随之更新。 - -这条是「发布期不做任何依赖改写」的前提:发布期只做一件事——pack 出字节。 - -### 发布族对象 - -领域里的实体是**发布族**:一组共享版本基线与 tag 前缀、可整体发布的包。新增一族等于新增一份族描述加一条 workflow lane,不改核心。 - -| 对象 | 职责 | -|---|---| -| `ReleaseFamily` | 一族的身份:成员发现规则、版本策略、tag 命名、publish 目标。新增发布族在此落地 | -| `ReleaseMember` | 一个可发布包:目录、manifest、族归属、发布顺序位次 | -| `VersionPolicy` | 版本从哪来。`SharedSemver`(dsh:全族一个版本)与 `PerPackageChanged`(vendor:按 tag 判变更、去预发布段后 patch+1) | -| `ReleaseSet` | 一族成员的拓扑序,按 `dependencies` 排、同层按包名排,保证确定性 | -| `PackedBundle` | tarball 集合 + `publish-order.txt` + 元数据清单,是 pack 与 publish 之间唯一的交接物 | -| `PublishTarget` | registry、access、dist-tag、凭据来源。dist-tag 由版本形态派生 | -| `VersionInvariant` | 族内版本自洽;publish 必须从对应 tag 跑;tag 版本等于包版本;待发版本不得已存在于 registry | -| `PayloadInvariant` | tarball 内容校验,复用 `scripts/publication-payload.ts` | -| `InstalledProbe` | 仓外临时 consumer 从 tarball 安装后,用普通 Node 驱动已安装入口:`dsh --version`、`dsh --dump-default-config`、起一次 TUI 到就绪后退出。实现从 `scripts/publish-npm-baseline.ts` 搬运复用 | - -### workflow 形状:一次性 pack 全部,再统一 publish - -照参照流程(node-addon-require-builtin 的 `release.yml` 与 `scripts/pack-release.mjs`)的形状:`pack` job 一趟遍历整个发布集,逐包 `pnpm --dir <目录> pack --pack-destination <同一个目录>`,写出 `publish-order.txt`,整个目录作为**一份** artifact 上传;`publish` job 下载那一份 artifact,按 `publish-order.txt` 逐个 `npm publish`。发布集是一个整体,不存在「一半的包发出去了、另一半还在构建」。 - -`pack` job 无凭据:install → verify → build → pack → 打包后安装验证 → upload-artifact。`publish` job 挂 `environment: npm-publish` 人工审批,只 `setup-node` 加 `download-artifact`,**不 checkout、不 build**,上传的就是 pack 出来的同一份字节。checkout 用 `fetch-depth: 0`,vendor 的变更判据需要历史与 tag。 - -`environment` 是整条流程唯一的刹车:pack 无凭据、可随意排练;只有 publish 会停在审批上。GitHub 侧需要 `NPM_TOKEN` secret(对该 scope 有发布权限的 automation token)与 `npm-publish` environment(required reviewers,允许的 tag 限制为 `dsh-v*`、`vendor-*`、`landlock-run-v*`)。 - -### PR 阶段跑到 pack 为止 - -参照流程只有 `workflow_dispatch`,PR 上什么都验证不了。本仓在 `pull_request` 上跑完整的 pack:install → verify → build → 逐包 pack → 上传 tarball artifact。它证明的是「这个发布集现在能完整打出来」,无凭据、不碰任何 registry,fork 发来的 PR 也能跑。产物本身的正确性由既有测试覆盖,不在 PR 这一层重复。 - -发布路径的测试走 master:`push: master` 跑同一套 pack 排练作为合入后回归,`workflow_dispatch` 带 `publish: true` 从 tag 走真实发布。 - - - -### 仓库改造项 - -| 项 | 内容 | -|---|---| -| 发布集 manifest | 去掉 `private: true`,补 `publishConfig.access: restricted` 与 `repository`(`git+https://github.com/deepseek-ai/deepseek-harness.git` + 各自 `directory`) | -| 发布集边界 | `packages/*/*` + `apps/*` + `vendor/*` 全部成员,不另挑子集 | -| 依赖协议 | workspace 内部引用统一 `workspace:^`,并更新 `check-workspace-constraints.ts` | -| 根 `AGENTS.md` | 现在写着 vendored 包是 rescope 过且 `private: true`,vendor 要发布,这条约定要改 | -| `vendor/README.md` | manifest 表补记上游版本,与我们发布的版本区分开 | -| native 三包 | `publishConfig.access` 从 `public` 改 `restricted`;它们尚未发布过,所以没有匿名安装路径要保 | - -### 与既有提案的关系 - -本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界两部分:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 - -## 考虑过的替代方案 - -**`-<时间戳>-<短 SHA>` 版本号。** 曾计划用它做持续 dev 发布。它与「版本必须落进代码库」冲突:版本内嵌 commit SHA,而把版本写回 manifest 会产生新的 commit,SHA 只能指向被发布的父 commit,链条要靠约定解释。改用数字版本递增后,`0.0.1-rc.1` 这类预发布号已经足够覆盖「先验证再正式发」的需求。 - -**用 `vendor/published.json` 账本记录每包的已发版本与 commit。** 这是 tag 方案之前的设计,需要新增一份状态文件并保证它与 registry 不漂移。per-package tag 提供同样的 commit 指针,且 tag 本来就要打,不引入第二处状态。 - -**事件级 tag(`vendor-r1`、`vendor-r2`)。** 为「一次发布事件多个包版本」准备的。改用 registry 差集决定发布集之后,workflow 不再需要从 tag 推断本次发布哪些包,per-package tag 就够用,而且每个 tag 携带的是它自己那个包的真实版本。 - -**vendor 九包统一到 `4.0.x` 一条线。** 省掉变更检测,但 cosmokit 会从 `1.8.1` 跳到 `4.0.1`,上游血缘全部丢失;且九包内部的上游依赖范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。 - -**vendor 每次全部 patch+1,不做变更检测。** 最省事,代价是没有改动的包也拿到新版本号、内容与上一版逐字节相同。tag 方案让变更检测的成本降到「取一个 tag 加一次 diff」,不值得为省这点而让版本号虚涨。 - -**只按版本号判断是否已发布,不比对内容。** 参照流程根本不查 registry,publish 直接逐个上传,重复版本由 npm 报错拦下。只按版本号跳过则会漏掉「改了代码没 bump」这一类,而这是唯一会安静地把旧字节留在 registry 上的错误。代价是引入对 registry 的查询与对构建可复现性的依赖。 - -**只做打包后安装验证,不起本地 registry。** 参照流程就是这样:解包 tarball 组树、普通 Node 驱动。它绕过版本范围解析,理论上验证不了「200 多个互相依赖的包能不能从 registry 装起来」。曾提议在 CI 里起本地 registry 补这一层,被否:产物验证已由既有测试覆盖,发布路径的验证放在 master workflow 的排练里,PR 只需证明发布集能完整打出来。 - -**以 `scripts/publish-npm-baseline.ts` 为基础扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件(payload 校验、已安装产物探针)搬运复用,避免 `pnpm run duplication` 判重复。 - -**按入口闭包挑一部分包发。** 从 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend` 沿 `dependencies` 爬得到 156 个包,比全量少 61 个。但本仓的插件是 cordis.yml 按名字挂载的,不是被 import 的:`vendor/cordis-plugin-group` 与 `vendor/cordis-plugin-logger-console` 就落在依赖闭包之外,而它们是运行时必需。照代码依赖挑,漏掉的表现是消费方装完起不来,且要额外证明「没漏任何挂载项」。发布集因此取 `packages/*/*` + `apps/*` + `vendor/*` 全部;私有 scope 下多几个包不对外可见。`python/`、根 `examples/`、`docs/` 与 `website/` 不是发布集成员。 - -**一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件会让 concurrency group、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 - -**在发布期改写依赖范围。** 与一次性改成 `workspace:^` 相比,改写逻辑只在 CI 执行过,本机 `pnpm install` 看不见它是否正确,且每次发布都要重跑一遍。 - -**CI 里执行 bump 并把版本推回仓库。** 需要给 workflow 仓库写权限,且发布分支上的版本提交会与人的提交竞争。参照流程把 bump 与 commit 留在本地命令,CI 只核对与上传。 - -## 验收标准 - -1. 三条序列各自可独立发布:发 dsh 不改动 vendor 与 native 的任何 manifest,反之亦然。 -2. `pnpm release:dsh <版本>` 一条命令完成 bump 与 commit,产出的 commit 含全族 manifest 与 lockfile,且立刻自检通过。 -3. `pnpm release:vendor` 只对「自其 `vendor-<包名>-v*` tag 以来 tarball 内容有变化」的包 patch+1,无变化的包 manifest 不被改动。 -4. `pull_request` 上跑完整 pack 并产出 tarball artifact,无凭据、不访问真实 registry,fork 的 PR 也能跑。 -5. `push: master` 跑同一套 pack 排练;真实发布只能由 `workflow_dispatch` 带 `publish: true` 从对应 tag 触发。 -6. publish 重跑同一 artifact 不重复发布已存在的版本;当某个版本已存在而 tarball integrity 不同时,publish 失败并指明是哪个包。 -7. 仓外临时 consumer 安装 `@deepseek-ai/dsh@0.0.1-rc.1` 后,用普通 Node 能跑通 `--version`、`--dump-default-config` 与一次 TUI 启动。 -8. 所有 workspace 内部引用为 `workspace:^`,且 pack 出的 tarball 里没有任何 `workspace:` 残留、没有指向不存在版本的范围。 -9. 发布集内没有 `private: true`,每个成员都有 `publishConfig.access: restricted`。 - -## 风险 - -**tag 与 registry 漂移。** 打了 tag 但 publish 失败,会让下一次 bump 误判该包已发布。缓解手段是 bump 向 registry 核对 tag 所指版本,不一致就失败退出;但本机未登录私有 registry 时这条核对被跳过,此时误判只能由 CI 的同一条核对拦下。 - -**变更判据依赖 tag 可见。** shallow clone 或未拉取 tag 会让 vendor 的判据失效并退化成「全部首发」。`fetch-depth: 0` 是这条判据的前提,不是优化。 - -**`workspace:^` 改动面大。** 一次触及 1477 处依赖声明。它不改变本机解析行为(pnpm 本来就从 workspace 解析),但会改变发布出去的范围写法,且要同步更新 workspace 约束门。 - -**私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包一并转 `restricted`;它们尚未发布过,因此没有既有的匿名安装路径被切断。 - -**`repository` 指向的组织与运行 workflow 的组织不一致。** 发布集让消费方指向 `github.com/deepseek-ai/deepseek-harness`,而这些 workflow 并不跑在那里。用 token 发布不受影响;一旦改用 npm provenance(OIDC),npm 会要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。 - -**首发一次性放大。** vendor 首发九包、dsh 首发全闭包,任何 payload 缺陷都会在同一次发布里暴露。用 `0.0.1-rc.1` 先跑一遍完整链路是唯一的缓解手段,正式版本号留给验证通过之后。 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index 7d78e98bc6..8448af1ad1 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -172,5 +172,7 @@ jobs: tag_args=() case "$version" in *-*) tag_args=(--tag next);; esac while IFS= read -r tarball; do - npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" + # No --access: publishConfig.access in each manifest decides, and a + # command-line flag would override it. + npm publish "dist/npm/${tarball}" "${tag_args[@]}" done < dist/npm/publish-order.txt diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index dc778f3d21..c8af47251e 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -23,7 +23,9 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }} + # Pack runs per ref so concurrent pull requests never displace each + # other; the publish job below serializes the shared dist-tag state. + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: @@ -96,9 +98,11 @@ jobs: needs: pack runs-on: ubuntu-24.04 environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false permissions: contents: read - id-token: write steps: # Checkout and install carry the release scripts only; no build step. - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c7a7489f3..7659b3b5a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,9 @@ permissions: contents: read concurrency: - # dist-tags are shared registry state; never race two release runs. - group: ${{ github.workflow }} + # Pack runs per ref so concurrent pull requests never displace each + # other; the publish job below serializes the shared dist-tag state. + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: @@ -103,9 +104,11 @@ jobs: # Required reviewers and the allowed tags live on the environment; this is # the only step in the sequence that can write to the registry. environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false permissions: contents: read - id-token: write steps: # Checkout and install carry the release scripts only. There is no build # step: publication uploads the bytes the pack job produced. diff --git a/apps/cli/package.json b/apps/cli/package.json index 71f64a0f41..b8f0887ac6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,10 +20,10 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/cordis-plugin-hmr": "workspace:*", - "@deepseek-ai/cordis-plugin-include": "workspace:*", - "@deepseek-ai/cordis-plugin-loader": "workspace:*", - "@deepseek-ai/cordis-plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index dc54c75012..9c5fc2a5a8 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,8 +41,8 @@ } }, "dependencies": { - "@deepseek-ai/cordis-plugin-hmr": "workspace:*", - "@deepseek-ai/cordis-plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16602cc946..646244da69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,16 +130,16 @@ importers: specifier: workspace:^ version: link:../../vendor/cordis '@deepseek-ai/cordis-plugin-hmr': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/hmr '@deepseek-ai/cordis-plugin-include': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/include '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/loader '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/timer '@deepseek-ai/dsh-agent-tool-mode': specifier: workspace:^ @@ -1205,10 +1205,10 @@ importers: packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': - specifier: workspace:* + specifier: workspace:^ version: link:../../../vendor/hmr '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:* + specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': specifier: workspace:^ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 30afce1e9f..cfc10fda49 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -378,8 +378,10 @@ function checkHierarchyShape(): string[] { } function checkRepositoryVersion(): string[] { - if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return [] - return ['package.json: version must be stable X.Y.Z'] + // The root carries the dsh release family's version, so a prerelease such as + // 0.0.1-rc.1 is a valid state between `release:dsh` and its publication. + if (repositoryVersion && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(repositoryVersion)) return [] + return ['package.json: version must be X.Y.Z with an optional prerelease segment'] } /** Dependency sections whose ranges reach a published tarball or a local install. */ diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 9755fd00c4..0f50ea6a9c 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -1,13 +1,13 @@ /** * Bump one release family's version and commit it, so the published version is * readable from the repository rather than derived inside CI - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * - * The dsh family shares one version: `major`, `minor`, `patch`, or an explicit - * `x.y.z` (including a prerelease such as `0.0.1-rc.1`). The vendored family - * has one version line per package and publishes only what changed since that - * package's own `vendor--v*` tag, which is the record of the commit it - * last published from. + * The dsh family shares one version across its members and the workspace root: + * `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such + * as `0.0.1-rc.1`). The vendored family has one version line per package and + * publishes only what changed since that package's own `vendor--v*` + * tag, which is the record of the commit it last published from. * * The version lands in the manifests, the lockfile follows, and a human creates * the tag after the commit merges. CI never writes to the repository. @@ -17,14 +17,39 @@ import { readFileSync, writeFileSync } from 'node:fs' import { join, matchesGlob } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' -import { capture } from './process.ts' +import { attempt, capture, isEntry } from './process.ts' /** Files npm publishes whether or not `files` lists them. */ const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const +/** + * Inputs that decide what a built payload contains. A package whose `files` + * selects `lib/` publishes build output that git does not track, so a change to + * the sources or the build configuration changes the tarball while no published + * path appears in the diff. + */ +const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const + /** Release types the dsh family accepts besides an explicit version. */ const RELEASE_TYPES = ['major', 'minor', 'patch'] as const +/** The workspace root manifest, which carries the dsh family's version. */ +const ROOT_MANIFEST = 'package.json' + +/** One manifest the bump rewrites, and the tag its new version will carry. */ +interface PlannedVersion { + /** Repository-relative manifest path. */ + readonly manifestPath: string + /** Label for the log line. */ + readonly label: string + /** The version the manifest currently carries. */ + readonly from: string + /** The version to write. */ + readonly to: string + /** The tag this version publishes from, or undefined for the workspace root. */ + readonly tag: string | undefined +} + /** * Split a version into its release numbers, discarding any prerelease segment. * @param version - the current version. @@ -36,6 +61,18 @@ function releaseNumbers(version: string): [number, number, number] { return [Number(match[1]), Number(match[2]), Number(match[3])] } +/** + * Order two versions by their release numbers alone. + * @param left - one version. + * @param right - the other version. + * @returns Negative when `left` is lower, positive when higher, zero when equal. + */ +function compareReleaseNumbers(left: string, right: string): number { + const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left) + const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right) + return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch +} + /** * The next dsh version. * @param current - the family's current shared version. @@ -56,13 +93,19 @@ function nextSharedVersion(current: string, request: string): string { } /** - * The version a vendored package publishes next: its release numbers with the - * patch incremented, which also drops an upstream prerelease segment. - * @param current - the package's current version. + * The version a vendored package publishes next: the higher of its manifest + * version and its last published version, with the patch incremented. + * + * The manifest alone is not the baseline. A vendor re-sync restores upstream's + * version, which is lower than what this repository already published, and + * incrementing that would name a version the registry already carries. + * @param current - the package's manifest version. + * @param published - the version its newest tag names, when it has one. * @returns The target version. */ -function nextVendorVersion(current: string): string { - const [major, minor, patch] = releaseNumbers(current) +export function nextVendorVersion(current: string, published: string | undefined): string { + const baseline = published !== undefined && compareReleaseNumbers(published, current) > 0 ? published : current + const [major, minor, patch] = releaseNumbers(baseline) return `${String(major)}.${String(minor)}.${String(patch + 1)}` } @@ -70,57 +113,147 @@ function nextVendorVersion(current: string): string { * Whether a repository-relative path reaches the member's published payload. * @param member - the member the path belongs to. * @param path - repository-relative path. - * @returns True when `files` (or npm's always-published set) selects it. + * @returns True when `files`, npm's always-published set, or a build input selects it. */ -function reachesPayload(member: ReleaseMember, path: string): boolean { +export function reachesPayload(member: ReleaseMember, path: string): boolean { const relative = path.slice(member.directory.length + 1) const files = member.manifest.files - const patterns = [ - ...ALWAYS_PUBLISHED, - ...Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [], - ] + const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [] + const built = selected.some(pattern => pattern.startsWith('lib')) + const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []] return patterns.some(pattern => matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern) } /** - * The newest tag a member published from, or undefined when it never published. + * The newest version a member published, read from its tags. * @param family - the member's family. * @param member - the member. - * @returns The tag name. + * @returns The version, or undefined when the member never published. */ -function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined { - const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v') - const tags = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') - return tags[0] -} - -/** - * Whether a member's published payload changed since it last published. - * @param family - the member's family. - * @param member - the member. - * @returns True when the member needs a new version. - */ -function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean { - const tag = lastPublishedTag(family, member) - if (tag === undefined) return true - const changed = capture('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) +function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined { + const prefix = family.tagPrefixFor(member) + const [newest] = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']) .split('\n').filter(line => line !== '') - return changed.some(path => reachesPayload(member, path)) + return newest === undefined ? undefined : newest.slice(prefix.length) } /** - * Write a version into a member's manifest, preserving formatting and key order. - * @param root - repository root. - * @param member - the member to rewrite. - * @param version - the target version. + * Confirm the registry carries the version a tag names. + * + * A tag is a commit pointer, not proof of publication: a tag pushed for a + * publication that then failed would otherwise read as "already published" and + * skip the package indefinitely. Querying a private package needs credentials, + * so an unauthenticated machine reports the gap instead of failing. + * @param name - package name. + * @param version - the version the tag names. */ -function writeVersion(root: string, member: ReleaseMember, version: string): void { - const path = join(root, member.directory, 'package.json') +function confirmPublished(name: string, version: string): void { + const result = attempt('npm', ['view', `${name}@${version}`, 'version']) + if (result.status === 0) return + const output = `${result.stdout}${result.stderr}` + if (output.includes('ENEEDAUTH') || output.includes('E401') || output.includes('E403')) { + console.log(`release bump: cannot reach the registry for ${name}@${version}; skipping the tag check`) + return + } + if (output.includes('E404') || output.includes('404 Not Found')) { + throw new Error( + `${name}@${version} is tagged but absent from the registry.` + + '\nThe tag was pushed for a publication that did not complete: re-run that publish, or delete the tag.', + ) + } + throw new Error(`npm view ${name}@${version} failed:\n${output}`) +} + +/** + * Write a version into a manifest, preserving formatting and key order. + * @param root - repository root. + * @param manifestPath - repository-relative manifest path. + * @param from - the version the manifest currently carries. + * @param to - the target version. + */ +function writeVersion(root: string, manifestPath: string, from: string, to: string): void { + const path = join(root, manifestPath) const text = readFileSync(path, 'utf8') - const line = `"version": "${member.version}"` - if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`) - writeFileSync(path, text.replace(line, `"version": "${version}"`)) + const line = `"version": "${from}"` + if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`) + writeFileSync(path, text.replace(line, `"version": "${to}"`)) +} + +/** + * Read the workspace root version. + * @param root - repository root. + * @returns The root manifest version. + */ +function rootVersion(root: string): string { + const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8')) + const version = (manifest as Record).version + if (typeof version !== 'string') throw new Error('package.json must declare a string version') + return version +} + +/** + * Plan the dsh family's rewrite: one version for every member and the root. + * @param family - the dsh family. + * @param root - repository root. + * @param members - the family's members. + * @param request - `major`, `minor`, `patch`, or an explicit version. + * @returns The manifests to rewrite and the shared target version. + */ +function planShared( + family: ReleaseFamily, + root: string, + members: readonly ReleaseMember[], + request: string, +): { planned: PlannedVersion[]; version: string } { + const [first] = members + if (first === undefined) throw new Error(`release family ${family.id} has no members`) + const version = nextSharedVersion(first.version, request) + // The workspace root carries the family version too: the workspace constraint + // requires every member's version to equal the root's. + const planned: PlannedVersion[] = [ + { manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined }, + ] + for (const member of members) { + planned.push({ + manifestPath: join(member.directory, 'package.json'), + label: member.directory, + from: member.version, + to: version, + tag: family.tagFor({ ...member, version }), + }) + } + return { planned, version } +} + +/** + * Plan the vendored family's rewrite: every package whose payload changed since + * it last published. + * @param family - the vendored family. + * @param members - the family's members. + * @returns The manifests to rewrite. + */ +function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]): PlannedVersion[] { + const planned: PlannedVersion[] = [] + for (const member of members) { + const published = lastPublishedVersion(family, member) + if (published !== undefined) { + confirmPublished(member.name, published) + const since = `${family.tagPrefixFor(member)}${published}` + const changed = capture('git', ['diff', '--name-only', `${since}..HEAD`, '--', member.directory]) + .split('\n').filter(line => line !== '') + if (!changed.some(path => reachesPayload(member, path))) continue + } + const to = nextVendorVersion(member.version, published) + planned.push({ + manifestPath: join(member.directory, 'package.json'), + label: member.directory, + from: member.version, + to, + tag: family.tagFor({ ...member, version: to }), + }) + } + return planned } /** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */ @@ -136,21 +269,17 @@ function main(): void { const members = family.members(root) family.verifyVersions(members) - const planned: { member: ReleaseMember; version: string }[] = [] + let planned: PlannedVersion[] let sharedVersion: string | undefined if (family.id === 'dsh') { const request = positionals[0] if (request === undefined) throw new Error('usage: release:dsh ') - const [first] = members - if (first === undefined) throw new Error(`release family ${family.id} has no members`) - sharedVersion = nextSharedVersion(first.version, request) - for (const member of members) planned.push({ member, version: sharedVersion }) + const shared = planShared(family, root, members, request) + planned = shared.planned + sharedVersion = shared.version } else { if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch') - for (const member of members) { - if (!changedSincePublication(family, member)) continue - planned.push({ member, version: nextVendorVersion(member.version) }) - } + planned = planPerPackage(family, members) } if (planned.length === 0) { @@ -160,25 +289,25 @@ function main(): void { const dryRun = values['dry-run'] if (!dryRun) { - for (const { member, version } of planned) writeVersion(root, member, version) + for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to) capture('pnpm', ['install', '--lockfile-only']) } const summary = sharedVersion - ?? planned.map(entry => `${entry.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ') + ?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ') console.log(`release bump: family ${family.id} -> ${summary}`) - for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`) + for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`) if (dryRun) { console.log('release bump: dry run, nothing written') return } - capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))]) + capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)]) capture('git', ['commit', '-m', `release(${family.id}): ${summary}`]) - // The dsh family tags once for its shared version; vendor tags each package. - const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))] console.log('release bump: committed. After this merges to master, tag it:') - for (const tag of tags) console.log(` git tag ${tag} && git push origin ${tag}`) + for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) { + console.log(` git tag ${tag} && git push origin ${tag}`) + } } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts new file mode 100644 index 0000000000..34faf36ddc --- /dev/null +++ b/scripts/release/families.spec.ts @@ -0,0 +1,146 @@ +/** Release family discovery, publish order, tag naming, and the bump judgements. */ + +import { describe, expect, it } from 'vitest' +import { releaseFamily, type ReleaseMember } from './families.ts' +import { nextVendorVersion, reachesPayload } from './bump.ts' + +/** + * A release member standing in for a manifest on disk. + * @param directory - repository-relative package directory. + * @param name - package name. + * @param manifest - manifest fields the subject reads. + * @returns The member. + */ +function member(directory: string, name: string, manifest: Record = {}): ReleaseMember { + return { directory, name, version: '0.0.1', manifest } +} + +describe('release families', () => { + it('names one tag for the whole dsh family and one per vendored package', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const cli = member('apps/cli', '@deepseek-ai/dsh') + const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' } + + expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1') + expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1') + // The prefix is constructed, not recovered from a tag: a version with a + // hyphen would defeat any suffix-stripping. + expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v') + expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7') + }) + + it('rejects a family whose members disagree on the shared version', () => { + const dsh = releaseFamily('dsh') + const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-frontend'), version: '0.0.2' }] + + expect(() => dsh.verifyVersions(members)).toThrow(/must share one version/) + expect(() => dsh.verifyVersions([members[0]!])).not.toThrow() + }) + + it('accepts independent vendored versions and rejects an unpublishable one', () => { + const vendor = releaseFamily('vendor') + const members = [ + { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }, + { ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' }, + ] + + expect(() => vendor.verifyVersions(members)).not.toThrow() + expect(() => vendor.verifyVersions([{ ...members[0]!, version: 'latest' }])).toThrow(/unpublishable version/) + }) + + it('publishes a dependency before its consumer, and orders ties by name', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }), + member('packages/a/library', '@deepseek-ai/dsh-library'), + member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), + ] + + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-library', + '@deepseek-ai/dsh-consumer', + '@deepseek-ai/dsh-zebra', + ]) + }) + + it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }), + member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }), + ] + + expect(() => dsh.publishOrder(members)).toThrow(/dependency cycle/) + }) + + it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const harness = member('packages/a/library', '@deepseek-ai/dsh-library') + const vendored = member('vendor/cordis', '@deepseek-ai/cordis') + + expect(() => dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts'])) + .toThrow(/publishes source file/) + expect(() => vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts'])).not.toThrow() + expect(() => vendor.validatePayload(vendored, [])).toThrow(/empty tarball/) + }) + + it('drives the installed entry only for the family that publishes one', () => { + expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }) + expect(releaseFamily('vendor').installedEntry).toBeUndefined() + }) + + it('rejects an unknown family identifier', () => { + expect(() => releaseFamily('native')).toThrow(/unknown release family/) + }) +}) + +describe('vendored version baseline', () => { + it('drops an upstream prerelease segment and increments the patch', () => { + expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1') + expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1') + expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2') + }) + + it('increments from the last published version when a re-sync restored a lower one', () => { + // Upstream moved rc.7 -> rc.8 after this repository published 4.0.1; + // incrementing the manifest alone would name 4.0.1 a second time. + expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2') + expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1') + }) +}) + +describe('payload change judgement', () => { + const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', { + files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'], + }) + const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', { + files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'], + }) + + it('counts the manifest and the files npm always publishes', () => { + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true) + }) + + it('counts build inputs for a package whose payload is build output', () => { + // cordis publishes lib/ only, and lib/ is not tracked: without this, a real + // source change reads as "nothing changed" and the next publish fails on a + // version whose bytes moved. + expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true) + expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true) + }) + + it('ignores paths no tarball carries', () => { + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false) + // The README pattern is deliberately loose: over-reporting a change costs one + // unnecessary patch bump, while under-reporting fails the next publish on a + // version whose bytes moved. + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true) + expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }), + 'packages/a/library/tests/library.spec.ts')).toBe(false) + }) +}) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 78f5de1951..e4c5fda5be 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -3,7 +3,7 @@ * (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module * owns: `dsh` and `vendor`. Each family carries its own version baseline, tag * naming, and publish set, so releasing one never republishes another - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * * The family dimension lives here only. A new sequence adds a subclass and a * `releaseFamilies()` entry; nothing else in the release scripts branches on it. @@ -162,12 +162,22 @@ export abstract class ReleaseFamily { */ abstract verifyVersions(members: readonly ReleaseMember[]): void + /** + * The tag prefix a member's versions are tagged under. Every tag for that + * member starts with it, which is how the last published version is found. + * @param member - the member being published. + * @returns The prefix, ending in `-v`. + */ + abstract tagPrefixFor(member: ReleaseMember): string + /** * The tag a member publishes from. * @param member - the member being published. * @returns The full tag name, without `refs/tags/`. */ - abstract tagFor(member: ReleaseMember): string + tagFor(member: ReleaseMember): string { + return `${this.tagPrefixFor(member)}${member.version}` + } /** * Check what a member's packed tarball carries. @@ -202,12 +212,11 @@ class DshFamily extends ReleaseFamily { } /** - * The single family tag. - * @param member - any family member; all carry the same version. - * @returns `dsh-v`. + * The single family prefix: every member shares one version, so one tag names it. + * @returns `dsh-v`. */ - tagFor(member: ReleaseMember): string { - return `${this.tagPrefix}${member.version}` + tagPrefixFor(): string { + return this.tagPrefix } /** @@ -243,12 +252,12 @@ class VendorFamily extends ReleaseFamily { } /** - * The member's own tag, because one vendor release can carry several versions. + * A prefix per member, because one vendor release can carry several versions. * @param member - the member being published. - * @returns `vendor--v`. + * @returns `vendor--v`. */ - tagFor(member: ReleaseMember): string { - return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}` + tagPrefixFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v` } /** diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 5c50ebca4b..47a33a26ac 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -4,14 +4,14 @@ * * The pack step is the release boundary: it runs without credentials, produces * every tarball from one commit, and hands the publish step exactly those bytes - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). */ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' -import { run } from './process.ts' +import { isEntry, run } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' /** Where pack output lands when `--out` is omitted. */ @@ -58,4 +58,4 @@ function main(): void { console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/process.ts b/scripts/release/process.ts index 3e8c6943bd..746f24ac36 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -4,6 +4,8 @@ */ import { spawnSync } from 'node:child_process' +import { realpathSync } from 'node:fs' +import { fileURLToPath } from 'node:url' /** Where and with what environment a release step runs a command. */ export interface RunOptions { @@ -63,3 +65,18 @@ export function run(command: string, args: readonly string[], options: RunOption if (result.error !== undefined) throw result.error if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) } + +/** + * Whether this module is the process entry point. + * + * The release scripts are both commands and modules: a test imports their pure + * logic, and importing a module runs its body, so an unguarded `main()` would + * run the wrong command with the wrong arguments. + * @param moduleUrl - the caller's `import.meta.url`. + * @returns True when Node started this module. + */ +export function isEntry(moduleUrl: string): boolean { + const invoked = process.argv[1] + if (invoked === undefined) return false + return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl)) +} diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index bd0b2d8552..b180ce1aba 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -6,7 +6,7 @@ * version whose published tarball has the same integrity is skipped, and a * version whose published tarball differs fails the run — that last case means * the content changed without a version bump - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * * Skipping on identical integrity is what makes re-running the publish step over * the same artifact safe. @@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { attempt, run } from './process.ts' +import { attempt, isEntry, run } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** npm access level for every package this repository publishes. */ @@ -98,4 +98,4 @@ function main(): void { console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 7b970212ab..10fb824147 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -8,7 +8,7 @@ * and those packages live in another release sequence that this credential-free * job cannot fetch from a private registry — so a dsh verification passes the * vendored family's pack output too, while publishing only its own - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * * What this proves is that `files` selected a complete payload and that the * published dependency ranges resolve. A workspace link or a stale `lib/` in the @@ -21,7 +21,7 @@ import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { capture } from './process.ts' +import { capture, isEntry } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** @@ -105,4 +105,4 @@ function main(): void { } } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts index 98988de18d..1bd74c84d6 100644 --- a/scripts/release/verify.ts +++ b/scripts/release/verify.ts @@ -4,10 +4,11 @@ * * Publication happens only from GitHub Actions, so the tag and publishability * checks are gates on the workflow, not advisory local warnings - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). */ import { parseArgs } from 'node:util' +import { isEntry } from './process.ts' import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' /** @@ -66,4 +67,4 @@ function main(): void { console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/vendor/README.md b/vendor/README.md index e83f9b4140..0d889e87fd 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +18. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. ## Sync procedure diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 576ea209a7..ef4df3eb1d 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -27,7 +27,8 @@ "lib/index.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", - "bin.js" + "bin.js", + "src" ], "author": "Shigma ", "license": "MIT",