DSH / Atlas
2026-08-10implementedprocess

Private npm publication as three independent sequences

三条独立序列的私有 NPM 发布

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, chan

English

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.

SequenceMembersVersion baselineTagWorkflow
dshPublish set: non-experimental packages/*/* + apps/*; private experimental packages join only the shared version bumpone version for the publish set, private dsh packages, and workspace root, 0.0.xdsh-v<version>release.yml (pack) / release-publish.yml (publish)
vendored frameworkthe nine vendor/* packageseach package on its own version linevendor-<package>-v<version> (one per package)release-vendor.yml (pack) / release-vendor-publish.yml (publish)
nativenative/landlock-run/packages/*its own 0.0.xlandlock-run-v<version>landlock-run-release.yml

All three publish to the @deepseek-ai scope on npmjs.com, and access is per sequence rather than per scope: the vendored framework and the native packages are public, the dsh family is restricted (rationale). No publish path passes --access, because one flag cannot serve sequences that disagree and would override the manifest that owns the level.

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 publishable family, every private package under packages/*/*, and the workspace root. Private packages receive no release tag and remain outside pack and publish; they follow the version because the workspace constraint requires every dsh package's version to equal the root's. 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:

PackageUpstream versionFirst published version
@deepseek-ai/cordis4.0.0-rc.74.0.1
@deepseek-ai/cordis-plugin-loader1.0.0-rc.51.0.1
@deepseek-ai/cosmokit1.8.11.8.2
@deepseek-ai/schemastery3.18.03.18.1
@deepseek-ai/cordis-plugin-hmr1.0.151.0.16
@deepseek-ai/cordis-plugin-include1.0.41.0.5
@deepseek-ai/cordis-plugin-timer1.1.21.1.3
@deepseek-ai/cordis-plugin-group1.0.01.0.1
@deepseek-ai/cordis-plugin-logger-console1.0.01.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. --prerelease rc.1 publishes a rehearsal instead, which takes --tag next and leaves the release numbers free: a prerelease has lower precedence than the release it precedes, so 4.0.1 still follows 4.0.1-rc.1. That ordering is computed here rather than read from git tag --sort=v:refname, which places a prerelease above its release.

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-<package>-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:

StateAction
the registry does not have that versionpublish
the registry has it, and the tarball's sha512 equals the recorded dist.integrityskip: this is a re-run over one artifact
the registry has it, and the integrity differsfail, 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.

All three sequences decide this way, including the native one: it publishes through its own script rather than a shell loop, because a loop of bare npm publish calls cannot be retried — the registry answers a repeat of an existing version permanently, so one failure partway through left no way forward.

Two registry behaviours shape how a publish is attempted. Writes are spaced by at least two seconds and retried with a backoff, because publishing several packages back to back outruns the registry's own processing and earns E409 Failed to save packument. And every retry re-reads the registry first: a reported failure can answer a write that landed anyway, so a version that now exists with this tarball's integrity counts as published rather than as a version to place again.

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.

An optional dependency is never loaded at module scope

A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta.<name>.optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. The failure appears only in an installed tree missing that package, and no test here constructs one: a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer.

verify-optional-dependency-imports closes that hole. It reads each package's own manifest for what that package allows to be absent, then scans the files that ship — packages/*/*/src/ and apps/*/src/ — across both compiler faces. vendor/ is out of scope, as pinned upstream source under the vendoring policy. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so import type {}, import {}, an inline type specifier, and a named binding that resolves to a type all emit nothing and are allowed, while a bare import, a value binding, and a star re-export are kept and rejected. Only the type phase erases an import: import defer still resolves and links its module, deferring evaluation alone, so the gate counts it as a load.

A violation names the package, the declaration that made it optional, and the way out in order — import it as a type, which is all that declaration merging needs, or restructure so module scope does not need the package. A dynamic import() only moves the failure to first use, so it belongs to a caller that genuinely requires the package and handles its absence; reaching for it is a sign the dependency is not optional, and the gate does not offer it as the remedy.

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.

ObjectResponsibility
ReleaseFamilya family's identity: member discovery, version baseline, tag prefix, packed-payload rule, installed entry
ReleaseMemberone publishable package: directory, name, version, manifest
publishOrdertopological order over the sections npm installs plus peer declarations, ties broken by package name; a cycle among installed dependencies is reported rather than resolved arbitrarily, and a peer edge no order can honour is dropped and named
packpacks a whole family into one directory and records the upload order
verifythe family's version baseline, the publish order it prints in full, and — when publishing — that the run comes from that family's tag and its members are publishable
verify-packed-installinstalls the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable
publishthe three registry states above
process / tarballthe 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 on PR/push, publish from a manual dispatch workflow

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; it lives in release.yml / release-vendor.yml. 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. Publication lives in a separate release-publish.yml / release-vendor-publish.yml workflow that is workflow_dispatch-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the npm-publish environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global Release-publish 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 the dsh pack job packs the vendored family for verification while publishing only the dsh set. The publish workflow (release-publish.yml) repacks the current tree and publishes only the dsh set.

The verification also packs the Landlock entry, which dsh-sandbox-local declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency.

Repository changes this carried

ItemContent
release-set manifestsprivate: true removed; publishConfig.access per sequence and repository with each package's directory added
release-set boundaryevery member of packages/*/*, apps/*, and vendor/*
dependency protocolworkspace-internal references are workspace:^, with check-workspace-constraints.ts and the invariant-companion rule requiring it
root AGENTS.mdthe convention that vendored packages are private: true no longer holds
vendor/README.mdrecords src joining cordis's files as a local modification
the three native packagespublishConfig.access: public, and their workflow passes no --access

Relationship to the earlier proposal

This Agent Note replaces the version scheme and the release-set boundary in artifact-first npm baseline publication: its <base>-<timestamp>-<short SHA> prerelease versions and dev-<base> 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 <base>-<timestamp>-<short SHA> 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-web-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.

中文

问题

这个仓库有三组互不相干的可发布包,却没有任何发布通道把它们送上 registry。

packages/*/*apps/* 组成 @deepseek-ai/dsh 的运行面;vendor/* 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;native/landlock-run/packages/* 是 Linux 平台包,有自己的 workflow。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。

挡路的还有两处硬门。全部 217 个 workspace manifest 都是 private: truenpm 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。

序列成员版本基线tagworkflow
dsh发布集:非 experimental 的 packages/*/* + apps/*;私有实验性包仅加入共享版本 bump发布集、私有 dsh 包与 workspace 根共用一个 0.0.xdsh-v<版本>release.yml(pack)/ release-publish.yml(发布)
vendored frameworkvendor/* 九个包每包各自一条版本线vendor-<包名>-v<版本>(每包一个)release-vendor.yml(pack)/ release-vendor-publish.yml(发布)
nativenative/landlock-run/packages/*自己的 0.0.xlandlock-run-v<版本>landlock-run-release.yml

三组一律发到 npmjs.com 的 @deepseek-ai scope,且 access 按序列而非按 scope 区分:vendored 框架与 native 包是 public,dsh 族是 restricted理由)。没有任何发布路径传 --access——一个选项无法服务级别互不相同的序列,且会覆盖真正拥有该级别的 manifest。

版本由本地命令写进仓库,CI 只核对与上传

每条序列有一条 bump-and-commit 命令:算出目标版本,写进相关 manifest,跑 pnpm install --lockfile-only,再把 manifest 连 lockfile 一起 commit。发布版本因此在仓库里查得到。tag 由人工在 commit 合入 master 后打;CI 不写仓库,也不需要写权限。

release:dsh 接受 majorminorpatch 或显式版本号,把同一个版本写进可发布族、packages/*/* 下的每个私有包以及 workspace 根。私有包不会获得发布 tag,仍位于 pack 与 publish 之外;它们跟随版本是因为 workspace 约束要求每个 dsh 包的版本等于根版本。根的检查接受预发布段。像 0.0.1-rc.1 这样的预发布号先把 pack、已安装产物探针和一次真实私有发布跑通,数字版本随后。dist-tag 沿用 landlock-run-release.yml 已有的判定:版本带预发布段就 --tag next,否则进 latest

vendor:谁改了谁发版,tag 就是账本

vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本取「manifest 版本」与「上次发布版本」中较高的那个,再递增 patch——这一步同时去掉上游的预发布段。首发版本:

上游版本首发版本
@deepseek-ai/cordis4.0.0-rc.74.0.1
@deepseek-ai/cordis-plugin-loader1.0.0-rc.51.0.1
@deepseek-ai/cosmokit1.8.11.8.2
@deepseek-ai/schemastery3.18.03.18.1
@deepseek-ai/cordis-plugin-hmr1.0.151.0.16
@deepseek-ai/cordis-plugin-include1.0.41.0.5
@deepseek-ai/cordis-plugin-timer1.1.21.1.3
@deepseek-ai/cordis-plugin-group1.0.01.0.1
@deepseek-ai/cordis-plugin-logger-console1.0.01.0.1

以「上次发布版本」为基线才扛得住重同步:本仓发过 4.0.1 之后上游把版本恢复成 4.0.0-rc.8,只看 manifest 会再算出 4.0.1 并撞上已发版本。加 --prerelease rc.1 则发一次排练版:它进 --tag next,而且不占用那组数字——预发布的优先级低于它所先行的正式版,所以 4.0.1 仍然接在 4.0.1-rc.1 之后。这个次序由脚本自己算,不读 git tag --sort=v:refname——git 会把预发布排在正式版之前。

只发改动过的包,而变更判据不引入新的状态文件:每包一个 tag,tag 就是「上次发布到哪个 commit」的记录。bump 对每个包取最新的 vendor-<包名>-v* tag,拿包目录与它做 diff。一条路径算命中的条件是:manifest 的 files 选中它,或 npm 无论如何都会发布它(package.jsonREADME*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 去推断该发哪些包。

三条序列都按这套判定,native 也在内:它通过自己的脚本发布,而不是 shell 循环——一串裸 npm publish 无法重试,registry 对「重发已存在的版本」的回答是永久失败,因此中途失败一次就没有前路了。

registry 的两个行为决定了「怎么尝试一次发布」。写入之间至少间隔两秒并带退避重试,因为连续背靠背发多个包会超出 registry 自身的处理速度,换来 E409 Failed to save packument。而每次重试都先重查 registry:报出来的失败可能对应一次其实已经落地的写入,所以「该版本现在存在且 integrity 与本 tarball 相同」算作已发布,而不是又一个待放置的版本。

workspace 内部引用走 workspace: 协议

所有指向 workspace 成员的引用都用 workspace:^,由 pnpm pack 替换成匹配目标版本的范围:兄弟包的 peerDependencies 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 workspace:*(发布成精确版本),因为平台包与它的入口必须版本完全一致。

scripts/check-workspace-constraints.ts 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 @deepseek-ai/dsh-invariantsworkspace:^

optional 依赖绝不在模块作用域被加载

optionalDependencies 里的依赖,或带 peerDependenciesMeta.<name>.optional 的 peer,在安装出来的树里可以不存在——这份「可以不存在」正是 optional 的全部承诺。而静态 import 在引入方模块加载时就求值,于是一个缺失的包不再表现为「这个能力不可用」,而是变成所有能走到该模块的代码的加载失败。这种失败只在「缺了该包的安装树」里出现,而本仓没有任何测试构造这种树:workspace 安装总是把每个包都装上,所以单测、快照、打包安装探针全都会过,而那个拒绝了这个 optional peer 的消费者拿到的却是坏的包。

verify-optional-dependency-imports 堵掉这个洞。它从每个包自己的 manifest 读取「这个包允许谁缺失」,再扫描会发布出去的文件——packages/*/*/src/apps/*/src/——且两个编译门面各扫一遍。vendor/ 不在范围内,那是受 vendoring 政策管辖的固定上游源码。值与类型的判定对着绑定好的 Program 做,而不是看 import 写法,因为 verbatimModuleSyntax 是关的:编译器本来就会消除绑定解析为类型的 import,所以 import type {}import {}、内联 type 说明符、以及解析为类型的具名绑定都不产生产物、一律放行,而裸 import、值绑定、星号 re-export 会被保留、一律报错。只有 type 相位会消除 import:import defer 仍然解析并链接它的模块,只推迟求值,所以门禁把它算作一次加载。

报错会点名这个包、点名是哪条声明把它标成 optional 的,并按顺序给出出路——把它作为类型引入(声明合并需要的仅此而已),或者调整写法让模块作用域不再需要这个包。动态 import() 只是把失败推迟到首次使用,它属于那种确实需要这个包、并且自己处理缺失的调用方;会想到它,往往说明这个依赖并不 optional,所以门禁不把它作为解法给出。

发布族对象

这个领域里的实体是发布族:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。

对象职责
ReleaseFamily一族的身份:成员发现、版本基线、tag 前缀、打包 payload 规则、已安装入口
ReleaseMember一个可发布包:目录、包名、版本、manifest
publishOrder按 npm 会安装的依赖段加 peer 声明做拓扑序,同层按包名排;安装依赖成环是报错而不是随意定序,任何排不进去的 peer 边被丢弃并点名
pack把整族打进一个目录并记录上传顺序
verify族的版本基线、完整打印出来的发布顺序;发布时还要求本次运行来自该族的 tag、且成员可发布
verify-packed-install把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口
publish上面那三态
process / tarball启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import

dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 ./src/*,去掉 src 会发出一个导出映射指向不存在文件的包。

workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布

pack job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 release.yml / release-vendor.yml。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。

pack 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 release-publish.yml / release-vendor-publish.yml 工作流,仅 workflow_dispatch(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 npm-publish environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 Release-publish 分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。

dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 pack job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(release-publish.yml)重新打包当前树,只发布 dsh 族。

验证还会打一份 Landlock entry 的 tarball——dsh-sandbox-local 把它声明为普通 dependencies——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。

本次带出的仓库改动

内容
发布集 manifest去掉 private: true;按序列补 publishConfig.access 与带各自 directoryrepository
发布集边界packages/*/*apps/*vendor/* 的全部成员
依赖协议workspace 内部引用为 workspace:^,由 check-workspace-constraints.ts 与 invariant companion 规则强制
AGENTS.md「vendored 包是 private: true」这条约定不再成立
vendor/README.md记录「src 加入 cordisfiles」这条本地修改
native 三包publishConfig.access: public,且其 workflow 不传 --access

与先前提案的关系

本 Note 取代 以产物为先的 NPM 基线发布 中的版本方案与发布集边界:那篇的 <base>-<时间戳>-<短 SHA> 预发布版本与 dev-<base> dist-tag 不再采用,vendor 也不排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。

曾考虑的替代方案

<base>-<时间戳>-<短 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-r1vendor-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-web-frontend 沿 dependencies 爬得到 156 个包,比全量少 61 个。但本仓的插件是 cordis.yml 按名字挂载的、不是被 import 的:vendor/cordis-plugin-groupvendor/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 缺陷都会集中在同一次发布里暴露——这正是先用预发布版本把完整链路走一遍的理由。