fix(ci): merge driver no longer reverts release version bumps - #2958
Merged
Conversation
`merge=package-json` resolved the `version` field to "ours", which is right for the forward-merge cascade it was written for (ADR 0004 §3) but wrong in the far more common direction: `main` merged INTO a branch off it. There "ours" is the fork point, so merging a release commit put the manifest back on the version the branch forked at. It is silent. The driver only runs when both sides changed the file, it emits no conflict markers, and the package's CHANGELOG.md merges cleanly and keeps the new entry — so the manifest and the changelog disagree with nothing to show for it. Reproduced twice on #2942, once per release. The blast radius is every developer, not just CI: `prepare` registers the driver in every local git config, and git config is per-repository, not per-branch. Resolve to the HIGHER semver instead. A version only ever moves forward on any line, so the higher side is correct in every direction: main -> next next's X.(Y+1).0-next.N > main's X.Y.Z -> ours main -> feature main's release bump > the fork point -> theirs next -> feature next's newer prerelease > the fork point -> theirs main -> next-based the next line > main's stable -> ours For the cascade this is not a behaviour change: whenever the driver runs there, `next` is the higher side, so it still wins and the empty- forward-merge property (ADR 0004 §6/§7) holds. The fix reaches existing checkouts without a reinstall — git config stores the script's path, not its contents. Back it up with a version-consistency guard in the required `test.yml` job: under fixed versioning every package matched by the `lerna.json` globs must carry `lerna.json`'s version, and a manifest left behind is otherwise invisible until the wrong version reaches npm. Tests drive real `git merge` calls through the real driver — the pure comparison helpers alone could not have caught this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Coverage Report for ./packages/components/
File CoverageNo changed files found. |
There was a problem hiding this comment.
Pull request overview
This PR fixes the repo-wide merge=package-json merge driver so it no longer silently reverts package.json/lerna.json version bumps when merging main into feature branches, and adds a CI guard to detect any version drift between lerna.json and Lerna-managed package manifests.
Changes:
- Update the
package-jsonmerge driver to resolveversionto the higher semver (direction-agnostic), while still 3-way-merging all other fields. - Add a version-consistency guard (lib + CLI wrapper) and run it in the required
test.ymljob. - Document the repo-wide implications of locally registered merge drivers (ADR + contribute docs +
.gitattributes/init script notes) and add end-to-end driver tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| docs/adr/0004-forward-merge-main-into-next.md | Updates ADR 0004 to reflect repo-wide merge driver behavior and the “higher semver wins” rule; documents the new guard. |
| CONTRIBUTE.md | Notes that merge drivers affect local merges and documents the version consistency invariant and CI enforcement. |
| .github/workflows/test.yml | Adds a CI step to run the new version-consistency guard on every PR. |
| .github/scripts/version-consistency-lib.test.mjs | Adds unit tests for glob parsing, package-dir classification, and mismatch collection. |
| .github/scripts/version-consistency-lib.mjs | Introduces pure helper functions to compute version mismatches against lerna.json. |
| .github/scripts/version-consistency-guard.mjs | Adds a guard script that enumerates managed package manifests and fails CI on version drift. |
| .github/scripts/merge-package-json.test.mjs | Adds unit + end-to-end git merge tests to validate the driver’s behavior in both merge directions. |
| .github/scripts/merge-package-json.cjs | Changes driver logic to pick the higher semver and exports helper functions for tests. |
| .github/scripts/init-merge-drivers.cjs | Documents that locally registered drivers become repo-wide merge rules and must be correct in all directions. |
| .gitattributes | Clarifies merge-driver behavior and ensures lerna.json is also handled by the same merge driver. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`git ls-files "*package.json"` reads as root-only, and was reported as such in review. It is not: a pathspec wildcard matches across `/` (fnmatch without FNM_PATHNAME), so it finds all 20 manifests at any depth. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
🚀 Preview DeploymentPreview environments are ready:
Images:
|
# Conflicts: # docs/adr/0004-forward-merge-main-into-next.md
maaaathis
approved these changes
Aug 27, 2026
mfal
added a commit
that referenced
this pull request
Aug 27, 2026
Conflict in `.github/workflows/test.yml`: #2958 inserted the version-consistency guard step directly above the step this branch renamed. Kept both — the new guard step, followed by "Run tests" instead of "Run unit tests", which no longer tells the truth now that `pnpm affected:test` also runs `test:links`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The
merge=package-jsondriver silently reverted release version bumps whenevermainwas merged into a branch off it.The driver (
.github/scripts/merge-package-json.cjs) resolved theversionfield to ours and 3-way-merged everything else. That is correct for the
forward-merge cascade it was written for (
main → next,next → major line,ADR 0004 §3), where the higher line must keep its own version. It is wrong in
the far more common direction —
mainmerged into a branch off it — where"ours" is the fork point, so merging a release commit put the manifest back on
the version the branch forked at.
It is silent by construction. The driver only runs when both sides changed
the file, it emits no conflict markers, and the package's
CHANGELOG.mdmergescleanly and keeps the new entry — so the manifest and the changelog end up
disagreeing with nothing to show for it.
Reproduced twice on #2942, which edits
packages/codemods/package.json: mergingthe
1.0.1release left that package at1.0.0(fixed in57fe92817), merging1.0.2left it at1.0.1(fixed inac6a5963d). Both times every other packagewas on the new version and only the edited one lagged.
The blast radius is every developer, not just CI:
preparerunsinit-merge-drivers.cjson install, and git config is per-repository, notper-branch — so the driver runs on every merge anyone performs here. For a
published package a stale version in
package.jsonis worse than cosmetic:lerna publish from-packageships what the manifest says.The fix
Resolve to the higher semver, not to "ours". A version only ever moves
forward on any line, so the higher side is the right answer in every direction:
main→nextX.(Y+1).0-next.N>X.Y.Znext→ major linemain→ featuremain's release bumpmain→ next-basednextlineThe cascade behaviour is unchanged — whenever the driver actually runs there,
nextis the higher side, so it still wins and the empty-forward-merge property(ADR 0004 §6/§7) holds. The comparison is dependency-free and implements semver
prerelease precedence (
1.1.0-next.0 > 1.0.99is the property the cascade restson); unparseable values sort below everything, so a
workspace:*can never win.The fix reaches every existing checkout without a reinstall: git config
stores the driver's script path, not its contents.
A version-consistency guard (
version-consistency-guard.mjs+ lib + tests)runs in the required
mainjob oftest.yml, on every PR regardless of base.Under fixed versioning every package matched by the
lerna.jsonglobs must carrylerna.json's version; a manifest left behind by a merge is otherwise invisibleuntil the wrong version reaches npm. It reads the globs from
lerna.jsonratherthan hardcoding
packages/*.Options weighed and not taken
forward-merge.yml(both workflows already dogit configthemselves).git -c merge.package-json.driver=…does workper-command, so
sync:resolvecould scope it — but local registration isload-bearing beyond the cascade:
/prepare-releasedepends on it for itsStep-2 guard probe and its promotion merge, and a manual cascade merge would
face 35 conflicts instead of one. It also would not reach existing checkouts
until the next install. Scoping treats the symptom (the driver runs where it
was not designed to) rather than the cause (it was not correct there).
fixed by hand each time — which is exactly what already happened twice. Worth
having as a net, not as the fix.
Notes for a reviewer
**/CHANGELOG.md merge=oursis left as it is. It is the same bluntrepo-wide instrument with no "higher wins" equivalent, but it only fires when
both sides changed a generated changelog, which on an ordinary branch never
happens; the one direction where it is wrong (the promotion) is already
compensated for by
/prepare-release. Documented as a caution in.gitattributesinstead of changed. Happy to close that hole too if you wantit closed here.
next (ours)/main (theirs)toneutral
ours/theirs. git tells a driver nothing about the branches, andthe driver is no longer direction-specific, so the old labels were a guess.
now states plainly that registering a driver locally makes it a repo-wide merge
rule that must be correct in both directions.
Verification
node --test .github/scripts/*.test.mjs→ 38 pass, 0 fail. Three of the newtests drive real
git mergecalls through the real driver — the pure comparisonhelpers alone could never have caught this bug.
The reported scenario was also replayed against a real clone of this repo (branch
edits
packages/codemods/package.json, then merges a synthetic1.0.3releasecommit):
codemodsstuck at1.0.2, the new guard exits 1and names the file;
1.0.3, the branch's owndependency change intact, guard passes.
prettier --checkacross the repo andeslint .github/scripts/are clean.Checklist
pnpm lintis clean andpnpm affected:testpasses (browser tests ifbehavior changed)
git diffis empty after the relevantbuild:*targets) — n/a, no generators touchedde-DEanden-USlocale files —n/a, no UI strings
updated snapshots / the
update-screenshotslabel — ADR 0004 andCONTRIBUTE.md updated; no visual change
🤖 Generated with Claude Code