Date: 2026-08-04 Status: Accepted
Decision Makers: Maintainer
Tags: developer-workflow, tooling, release, publishing, versioning, pnpm
Supersedes ADR-027.
ADR-027 adopted Changesets as the source of truth for versions and changelogs,
wrapped in a bespoke release.yaml that satisfies this repo's two hard CI
constraints (required_signatures on every ref, and GITHUB_TOKEN pushes not
triggering workflows). That flow works and has shipped several releases.
pnpm 11.13.0 then added native workspace release management, and this repo
already pins pnpm 11.20.0 in packageManager. The relevant property is that it
is not a competing format: intent files are the same .changeset/*.md
markdown-with-frontmatter files, in the same directory, so the tooling can be
swapped without rewriting the pending intents or the committed changelogs.
The question this ADR answers is whether to keep two dev dependencies and a dedicated catalog for a job the pinned package manager now does natively.
The nature of the change is unchanged from ADR-027 — a release tool and a mandatory developer policy — so it is ADR-gated on the same two counts under ADR-006.
Two capability gaps drove the analysis, and both are real:
- No pluggable changelog generator. pnpm has no equivalent of
@changesets/changelog-github. Theversioning.changelogsettings expose onlystorage. (VersioningChangelogSettingscarries an undocumentedformat?: stringthat the validator accepts and nothing consumes — treat it as inert.) - No
changeset status --since.pnpm change statusreports the pending intents and the plan they produce. It has no--since, no--json, and exits 0 unconditionally, so it cannot answer "this branch touched a published package and recorded no intent". Apnpm change checkwas specified in RFC 0006 but did not ship.
A third gap — no linked groups, only fixed — forces a deliberate behaviour
change and is treated as its own section below.
Everything else ADR-027 relies on carries over unchanged: every internal edge
already uses workspace:* (which pnpm requires and hard-fails without), both
published packages already carry publishConfig.access: public, and no snapshot
or prerelease flow is in use.
Replace @changesets/cli and @changesets/changelog-github with pnpm's
built-in pnpm change / pnpm version -r / pnpm publish -r. Keep ADR-027's
release topology — signed bot commit, human-gated ci: version packages PR,
OIDC trusted publishing, tag-triggered docs deploy — and swap only the tool that
implements each step.
.changeset/config.json is deleted; pnpm reads none of it. Config lives under a
versioning key:
| Changesets config | pnpm equivalent |
|---|---|
linked: [[core, ocale]] |
versioning.fixed: [[core, ocale]] |
privatePackages: { version: false } |
versioning.ignore: [...] (explicit list) |
access: public |
publishConfig.access (already set) |
updateInternalDependencies: patch |
automatic, and range-accurate |
baseBranch: main |
not configurable; hardcoded main/master |
changelog: @changesets/changelog-github |
none — changelog.storage: repository |
| — | versioning.maxBump: minor (new) |
Three of these need explaining:
changelog.storage: repositorymust be set explicitly. The default isregistry, which commits noCHANGELOG.mdand instead parks composed sections in.changeset/changelogs/until the registry confirms publication. That would change our published artifacts and break the docs site's changelog pages.repositorykeeps today's committed-changelog behaviour, and has the secondary benefit that intent cleanup no longer depends on a registry round-trip.ignoremust list the private packages. pnpm only auto-excludes private packages with noversionfield; all six of ours carry one. Without the list,apps/e2eappears in the release plan for depending oncore/ocale. This is the one place pnpm needs more configuration than Changesets did.maxBump: minoris new. CLAUDE.md's pre-1.0 rule ("never writemajor;majoron 0.x jumps straight to 1.0.0") was documentation only.maxBumpis enforced on the assembled plan, after propagation and fixed-group resolution, so it also catches amajorthat arrives through a dependency. It must be removed deliberately for the 1.0.0 release.
pnpm implements fixed groups only. RFC 0006 states linked was deliberately
left out of v1 as "rarely used" with "jump-to-highest semantics [that]
surprise". The shipped validator has no linked case at all, so a stray
versioning.linked key would be silently ignored rather than rejected.
ADR-027 chose linked precisely to avoid fixed's no-op releases. That trade
is no longer on the table, and of the two remaining options fixed is the one
that preserves ADR-027's actual goal — a single shared version number for a
matched pair:
fixedpublishes an otherwise-untouchedocalewhen onlycorechanged. The cost is one no-op release with an empty changelog section, on the less common direction of change:coredepends onocale, soocalechanges already cascade intocoretoday.- No group lets the versions diverge (
core@0.1.4+ocale@0.1.3), which is the outcome ADR-027 set out to prevent and which ADR-027 rejected under "independent".
This is a genuine regression against ADR-027's Decision, accepted knowingly.
@changesets/changelog-github rendered each entry as a bullet carrying a PR
link, a commit link, and a Thanks [@author]! attribution. pnpm renders the
intent summary as plain markdown.
The surrounding structure is byte-compatible — # <pkg> title, ## <version>,
### Major|Minor|Patch Changes — so existing CHANGELOG.md files append
cleanly and nothing has to be rewritten. Dependency cascades render as a
- Updated dependencies: bullet without commit hashes.
For a solo-maintained repo the author attribution is noise; the PR backlink is
the real loss, and it remains recoverable from git log and the release tag.
Post-processing the generated changelog to re-add links was rejected as bespoke,
untested code standing between the release job and npm.
ADR-027's mandatory-intent policy is unchanged; only its implementation moves.
changeset-check.yaml now derives the touched packages from
pnpm list -r --depth -1 --json --filter "[origin/<base>]" and asserts that
every published package in that set is named by a frontmatter line in an intent
the PR itself adds or modifies.
Both halves of that last clause matter, and neither is free —
changeset status --since enforced them implicitly:
- PR-scoped, not repo-wide. Intents accumulate on
mainuntil a Version PR consumes them, so at any timemainmay already carry an intent namingcore. Scanning every.changeset/*.mdwould let a latercorechange pass on the strength of an unrelated pending intent and ship with no changelog entry of its own. The candidate set is thereforegit diff --name-only --diff-filter=AM "origin/<base>" HEAD -- .changeset, minusREADME.md. - Frontmatter, not the whole file.
pnpm version -rbuilds its plan from the frontmatter alone, so a body line reading@bedrock-rbx/core: ...would satisfy a whole-filegrepwhile producing no version bump and no changelog entry. The scan stops at the closing---.
Both comparisons are two-dot, matching how pnpm list --filter "[<ref>]"
compares, so the two halves of the gate cannot disagree about what the PR
changed. On a pull_request run HEAD is the merge commit, which already
contains the base, so the two-dot and three-dot forms agree anyway.
The ... dependents prefix is deliberately omitted from the filter: an
ocale-only change marks core as changed through the workspace:* edge, and
that cascade is applied automatically by pnpm version -r. Demanding an intent
for it would be a false failure.
pnpm change --bump none <pkg> replaces pnpm changeset add --empty as the
escape hatch, and is a better fit for this gate: it names the declined package
explicitly instead of recording an anonymous empty changeset.
pnpm version -r never commits and never tags — a recursive run can bump
packages to different versions, so there is no single version to tag. Both were
already explicit steps in release.yaml for the commit; the tag now becomes
explicit too, because changeset publish used to create them and pnpm publish
does not.
release.yaml therefore derives <name>@<version> tags from the public
workspace manifests (pnpm list -r --depth -1 --json, minus the private
packages), creates the ones that do not already exist, and pushes them. This
also retires the "New tag:" stdout grep that detected whether anything
published: a newly created @bedrock-rbx/core@* tag is now the signal that
gates the docs dispatch. website-release.yaml's trigger and ADR-004 are
untouched.
pnpm publish -r skips versions already on the registry, so a rerun after a
partial failure resumes rather than double-publishing — the same property
changeset publish provided. OIDC trusted publishing is unchanged: the same
pnpm publish is what exchanged the token before, one layer down.
That resume property is exactly why the tags come from the manifests and not
from --report-summary. The summary lists only what this invocation put on
the registry. If pnpm publish -r gets ocale out and then fails on core,
the job fails before tagging; the retry publishes core alone, and a
summary-derived tag step would tag core and never tag ocale. Reading the
manifests instead makes the step converge: a pnpm publish -r that exits 0
leaves every public package's current version on the registry regardless of
which run put it there, so tagging every public manifest version — skipping
those already tagged — is correct after a clean run and self-healing after a
partial one. The same idempotence makes the step a harmless no-op on a push to
main that releases nothing.
pnpm/release is the changesets/action counterpart, and its README states the
parallel directly. It is not usable: main is empty, the action lives on an
open PR branch, and there are no tags — pnpm/release@v0 does not resolve.
This costs nothing here. ADR-027 already rejected changesets/action because
its unsigned GITHUB_TOKEN push is refused by required_signatures, and
pnpm/release pushes the same way. The bespoke workflow was required either
way.
- Two dev dependencies and the
releasecatalog are gone, along with an upstream to track. ADR-027 listed the deps as a known cost. - The release tool is the pinned package manager, so it cannot drift out of
step with the workspace features it has to understand (catalogs,
workspace:*, patched dependencies). - The pre-1.0 bump policy is now enforced by
maxBump: minorrather than documented in CLAUDE.md and CONTRIBUTING.md. - Dependency propagation is range-accurate. pnpm computes the range the
dependent's modifier produced at its previous release and republishes when the
new version falls outside it, rather than applying a fixed
updateInternalDependenciesbump class. .changeset/ledger.yamlrecords which intents each released version consumed, which makes cherry-picks and merge-backs between release branches safe. Not needed today; free insurance if a maintenance branch ever appears.
- Changelog entries lose PR, commit, and author links (see Decision). Not recoverable without bespoke post-processing.
ocalegets no-op releases when onlycorechanges, becausefixedreplaceslinked(see Decision).- The blocking gate is hand-written, so it is ours to maintain and it
hardcodes the two published package names. It replaces a supported upstream
flag with shell, and a future third published package must be added to the
loop.
pnpm change checkmay make it deletable later. - The private-package list is manual. ADR-027 needed no
ignoreentries; this needs six, and a new private package must be added or it enters the release plan. versioninghas no schema published for editor validation, unlike.changeset/config.json's$schemaline. A typo'd key is silent — notablyversioning.linked, which the validator does not reject.
- The release topology is unchanged. Signed ghcommit,
ci: version packagesPR, human-gated merge, OIDC publish, tag-dispatched docs build: all identical. The blast radius is the tool, not the pipeline. .changeset/keeps its name even though the Changesets CLI is gone. pnpm uses that directory by design; renaming is not an option, and the format there genuinely is the changesets format.- The
changeset-release/mainbranch name and theChangeset Checkworkflow name are kept. Both are load-bearing for branch protection and for the head-ref guards inlint-pr-title.yamlandsmoke.yaml; renaming them buys tidiness at the cost of churn across four workflows. pnpm update --changesetstops working. It is the one feature still reading.changeset/config.json, degrading to a warning once that file is gone. Nothing in this repo uses it.
Rejected, narrowly. The status quo works, and it keeps GitHub-linked
changelogs and a supported status --since. What tips it is that both costs are
small (a solo repo does not need author attribution; the gate is fifteen lines)
while the benefit compounds: the release tool stops being a second thing that
has to understand pnpm catalogs and workspace:*. Had linked been the only
sticking point, or had the changelog links been consumer-facing on a
multi-contributor project, staying would be the right call.
Rejected. Letting core and ocale diverge avoids the no-op releases but
discards the shared version number, which is the property ADR-027 was protecting
and which it rejected explicitly under "independent". Trading a cosmetic cost
for a semantic one is the wrong direction.
Rejected. It would restore the links, but as untested shell or script code
running inside the release job, between version bump and publish, on a path with
no local test coverage. The links are recoverable from git log; the failure
mode is a broken release.
Rejected. The only thing that would preserve is pnpm update --changeset,
which this repo does not use. A config file that no tool reads except one unused
command is a trap for the next reader.
Files modified:
pnpm-workspace.yaml— add theversioningblock; delete thereleasecatalog (cleanupUnusedCatalogs: trueflags it otherwise).package.json(root) — drop both@changesets/*devDependencies; drop thechangesetscript;releasebecomespnpm build && pnpm publish -r. Theversionscript is deleted rather than ported:versionis an npm lifecycle name, and pointing it atpnpm version -rrisks re-entrant execution underenablePrePostScripts: true. CI calls the command directly..changeset/config.json— deleted..changeset/README.md— rewritten for the pnpm commands..github/workflows/changeset-check.yaml— the gate per Decision..github/workflows/release.yaml—pnpm version -r --no-git-checks;pnpm publish -r --no-git-checks; new tag-creation step.--no-git-checksis required on both:pnpm version -rrefuses a dirty tree, andpnpm publishdefaults its expected branch tomaster.CONTRIBUTING.md,CLAUDE.md,docs/adr/004-documentation-site.md— command and terminology updates.
Unchanged: hk.pkl, ci.yaml, commitlint.config.ts, knip.ts,
release-actions.yaml, website-release.yaml, and both CHANGELOG.md files.
knip's Changesets plugin de-activates with .changeset/config.json, in the same
change that removes the dependencies it accounted for.
New committed artifact: .changeset/ledger.yaml, written by
pnpm version -r. It is covered by the existing ghcommit file_pattern
(.changeset packages pnpm-lock.yaml). No .changeset/changelogs/ directory
appears under storage: repository.
External prerequisites: unchanged from ADR-027 — the npmjs.com Trusted Publisher per package, and "Allow GitHub Actions to create and approve pull requests".
Verification: pnpm change status and pnpm version -r --dry-run produce
the expected plan with both packages at one version; a real
pnpm version -r --no-git-checks on a scratch branch produces equal versions in
both manifests, prepends both changelogs, writes ledger.yaml, consumes the
intents, and creates no .changeset/changelogs/; the gate fails a PR whose
published-package change carries no intent.
- ADR-027: Changesets Release Flow — superseded by this ADR. Its Context
(the three defects that motivated automated releases) and its analysis of the
signing and workflow-trigger constraints remain the reason
release.yamlis shaped the way it is. - ADR-006: ADR Enforcement — gated on the same two counts as ADR-027: a release tool choice, and a mandatory developer policy.
- ADR-004: Documentation Site — the
@bedrock-rbx/core@*tag trigger is preserved, but the tags are now created byrelease.yamlrather than bychangeset publish. - ADR-013: hk — the intent gate stays a CI check, not a local hook tier.
- ADR-014: Vite+ — release orchestration remains outside
vp, as withknip(ADR-016) andmutate:changed(ADR-015). - ADR-016: Knip — the Changesets plugin and the dependencies it accounted for are removed together.
ADR-030 introduces plugin packages that extend core through its public
contracts, the first being @bedrock-rbx/state-s3.
The fixed group under versioning grows to include first-party plugin
packages alongside @bedrock-rbx/core and @bedrock-rbx/ocale. A plugin
implements contracts core owns, so the compatible pairing is the one released
together, and the workspace:* edge already required expresses that exactly,
with no range to reason about and no compatibility matrix to maintain.
The cost is that a first-party plugin releases whenever core does, including when nothing in the plugin changed.
Third-party plugins have no access to this mechanism and express compatibility
as a peer range on @bedrock-rbx/core instead. The two paths differ, and a
third-party author does not inherit the first-party guarantee.
- pnpm release management
pnpm change·pnpm version·pnpm publishversioningsettings reference- RFC 0006 — Native monorepo versioning
pnpm/release— the unreleasedchangesets/actioncounterpart.