ADR-0011: Dependency Governance & Release Pipeline
Date: 2026-07-29 Status: Accepted Last revised: 2026-09-23 — Decision 2 与 Decision 3 被重写(单一 fixed 组 + pnpm 原生 release management + git-cliff)。 原决策的理由与历史集中收进下方唯一的 ## Alternatives considered 小节。
Context
After the monorepo merge and Phase 1-3 refactors, the project had excellent dependency consistency (248 catalog entries, all package.json using catalog: or workspace:*), but lacked mechanical enforcement, version management tooling, and a unified release strategy.
Three interdependent decisions needed to be made:
- Whether to enforce catalog-only dependencies mechanically
- How to handle version identity across apps and packages
- What release tooling to adopt
Decision 1: Enable catalogMode: strict
Chosen: catalogMode: strict in pnpm-workspace.yaml.
Rationale:
- pnpm 10.12+ native enforcement is stronger than ESLint rules (runs at install time)
- Zero breakage — all existing deps already use
catalog:protocol - One-line change with no maintenance burden
- Prevents future version drift from
pnpm addwithout--save-catalog
Decision 2: One Fixed Group — every workspace package
Chosen: a single versioning.fixed group containing all workspace packages (originally 3 apps + 9 packages; 14 since 2026-09-23, when packages/tooling/ was split into 5 packages — see ADR 0019). Every package always shares one version number, so the release tag vX.Y.Z always has exactly one source (the group version, baselined on apps/admin).
Rationale:
- Two groups left a structural trap. pnpm's (and changesets') fixed groups are mutually independent. A release that only touched a shared package (e.g.
feat(utils): …) bumped the packages group whileapps/admin's version stayed put ⇒ the orchestrator hit "版本号未变更,跳过发版" and exited 0, leaving a workspace that had been bumped but would never get a tag. One group removes that path structurally rather than by convention. - It matches the ADR-0008 title (Unified Versioning) — unified versioning was already the intent; two tracks were an accident of the original package layout.
- Nothing was lost. All packages sat at
0.0.1with zero tags; the separate package track was never exercised. private: trueremoval signals the code is publicly visible (not secret/internal). Does NOT imply intent to publish to npm.- Unified
0.0.1as a clean starting point after the repo merge (previous versions were 0.0.1, 0.0.0, 1.0.0, 1.18.0 — inconsistent artifacts of separate repos).
Consequences for attribution: with one group, "which package does this commit belong to" no longer affects the version number — it only decides whether a commit produces a change intent. Attribution is path-first, scope-as-fallback, and infrastructure scopes (docker, deploy, pnpm, release) plus unknown scopes produce no intent. This is deliberate: infrastructure changes must not drive the product version.
Decision 3: pnpm native release management + git-cliff
Chosen: version + intent management by pnpm's built-in release management (pnpm change writes intents, pnpm version -r consumes them, .changeset/ledger.yaml is the consumption ledger), configured by the versioning: block in pnpm-workspace.yaml. Per-package CHANGELOG.md is rendered by git-cliff and written by packages/tooling/release/src/release/changelog.ts (@walnut/release) — the sole writer.
Rationale:
- The ledger is the thing that was missing.
pnpm version -rrecords every consumed intent in a committed, append-only.changeset/ledger.yaml. "Has this intent been consumed?" becomes an unambiguous fact instead of a heuristic over "is the file still on disk?" — which is exactly what the oldhandleFailedPushResume()heuristic got wrong. pnpm change checkis a real gate. It fails when a package's committed version drifts out of itsversioning.fixedgroup, so it runs in CI and inprepush— the drift is caught on the PR that introduced it, not at release time.- Version policy has one source.
pnpm-workspace.yamlholds both the workspace layout and the version policy;.changeset/config.json(a second config file that described the same thing) is gone. - git-cliff renders from commits, not from intent prose. The old pipeline's changelog entries were the changeset summaries; git-cliff renders grouped, linked entries directly from git history with PR numbers and authors (via git-cliff's native GitHub provider).
versioning.changelog.storage: registrykeeps pnpm from writing its own changelog files (repositorymode has no "off" switch, so both writers would emit a second## X.Y.Zsection for the same version — the classic footgun when using git-cliff for changelogs).
Tool responsibilities:
| Tool | Role |
|---|---|
pnpm change | Write a change intent (.changeset/*.md) — invoked per commit by the orchestrator |
pnpm version -r | Bump versions across the fixed group, write ledger.yaml |
pnpm change check | Validate committed versions against versioning.fixed (CI + prepush gate) |
| git-cliff | Render changelog sections (--unreleased --tag vX.Y.Z, per-package include/exclude paths) |
walnut-release | Orchestrate the six steps: intents → bump confirm → consume → changelog → summary → commit/gates/tag/push(bin of @walnut/release, in packages/tooling/release/) |
Key configuration:
pnpm-workspace.yaml#versioning: singlefixedgroup (14 packages since 2026-09-23);changelog.storage: registrycliff.toml(repo root): group names,tag_pattern,commit_parserskept in step withcommit-intent.ts'sBUMP_MAP, and the[remote.github]providerpnpm release --status/--plan/--dry-run/--jsonare the read-only and machine-readable surfaces; the resume ladder is a pure function of observable facts (release/plan.ts)lint:root/types:check:rootare not turbo tasks (they are rootpackage.jsonscripts), so the battery runs each as its own step —turbo run lint+pnpm lint:root+pnpm types:check:root. Folding them into the turbo argv fails withCould not find task 'lint:root' in project(fixed 2026-09-23; seemonorepo/release.md).
Alternatives considered
Decision 1: 启用 catalogMode: strict
- ESLint
pnpm/json-enforce-catalog—— 双重保险,但与 pnpm 的原生检查重复。 - 不做任何强制、只靠约定 —— 否决;约定会随时间退化。
Decision 2: 单一 fixed 组(覆盖全部 workspace 包)
- 两个 fixed 组(Apps + Packages) —— 即原设计;因上述结构性陷阱被否决。
- 全部包一组、apps 各自独立 —— 否决;tag 需要唯一来源,而只发包的版本仍然产生不了 tag。
- 全部 Full Independent —— 否决;9 个包紧耦合(
@walnut/contract的改动会级联到每个消费方),同步版本更有利。
Decision 3: pnpm 原生 release management + git-cliff
- 保留
@changesets/cli+@changesets/changelog-github—— 即原设计;开箱的 changelog 格式更丰富(能从 changeset 正文解析pr:/commit:/author:指令),但没有 ledger、没有change check,还要多一个配置文件;此处 ledger + 门禁比该插件更值,故否决。 versioning.changelog.storage: repository(由 pnpm 写入提交进仓库的 changelog)—— 否决;它会自己写CHANGELOG.md,无法只保留 intent 消费,git-cliff 会为同一版本产出重复章节。- 手写读取
git log的 changelog writer —— 否决;git-cliff 已解决分组、链接生成与按包路径筛选,且其配置可测试。 offline = true+ 在模板里写死仓库 URL —— 否决;会丢掉 PR 号与作者,而这两者正是选用「从提交渲染 changelog」的全部理由。
Consequences
pnpm addwithout--save-catalogwill now fail — developers must usepnpm add <pkg> --save-catalogpnpm release(=walnut-release,mainbranch only) executes the full pipeline; there is no rootchangeset/changeset:auto/changelogscript- All workspace packages are mechanically kept in one version via the single
fixedgroup — no manual version editing, andpnpm change checkcatches drift - A workspace package that is not listed in
versioning.fixedblocks the release (the fixed-group audit inrelease/attribution.tsfails the run with exit 1) - Changelog writing is a single-writer property: git-cliff renders,
changelog.tswrites, and pnpm is configured not to write — a duplicate## X.Y.Zsection would mean that property broke - The release does not create the GitHub Release:
pnpm releaseregenerates and commits the rootchangelog-latest.md, and.github/workflows/release.ymlpublishes it as the Release body. The release machine therefore needs no credential that can modify remote content
Related
- Supersedes the versioning aspect of ADR 0008 (unified versioning confirmed, deploy separation unchanged)
- Enables ADR 0009 quality gates (the version management foundation for CI test requirements)
- ADR 0018 records the git-hook migration (
simple-git-hooks→ lefthook) that this pipeline assumes - ADR 0019 records the tooling split (
@walnut/tooling→@walnut/scripts+@walnut/release) and thefixedgroup growing from 12 to 14 packages - References: release orchestration lives in
packages/tooling/release/src/release/(module map in that package'sREADME.md); operator guide inmonorepo/release.md