feat: per-package bumps via commit-driven-version-dispatch + version bumps input#82
Merged
Conversation
Adds a new `bumps` input to the `version` action that, when supplied,
runs `lerna version <bump> --scope <package>` once per entry instead of
the existing single `lerna version <strategy>` call.
Motivating use case: a squash-merged PR in a monorepo carries
per-commit bumps of *different* levels for different workspace
packages (one major commit + several patch / refactor commits). The
existing `conventional-commits` strategy attributes the squash commit's
subject (= PR title) to every package it touches, so any breaking
marker fans out to all of them. A caller that computed a
`{ pkg: bump }` map upstream can now hand it to this action and get
each package versioned independently with the correct level.
API
- New `bumps` input (JSON object). When set, `version-strategy` is
ignored. Every package in the map must also appear in `packages`.
- Invalid input fails fast: malformed JSON, non-object payloads, and
unknown bump levels throw at the top of the run rather than letting
lerna error mid-flight.
Implementation
- `parseBumps(raw)` returns `undefined` for empty input (preserves the
existing strategy flow) or a validated `{ pkg: bump }` map.
- `versionPackagesExplicit({ bumps, extraArgs })` loops the entries,
each call producing its own commit + tag, returning the count so the
caller can reset all of them.
- `resetLastCommit` gains an optional `count` parameter, defaulting to
1, so the version flow can collapse N commits at once.
- The static-strategy changelog path now also fires for explicit bumps,
since per-call `lerna version <bump>` without
`--conventional-commits` skips changelog generation.
Tests
- `parse-bumps.spec.ts` covers happy path, every valid bump level,
malformed JSON, non-object input, empty map (returns undefined), and
unknown / wrong-type bump rejection.
- `git.spec.ts` gains regression tests for the new `count` parameter on
`resetLastCommit`.
- 125/125 tests pass.
Consumer
`ExodusMovement/actions-playground#700` is the matching consumer; it
computes the bump map from a PR's pre-squash commits and dispatches
this action with the JSON.
Per-review feedback on PR #82. The explicit-bumps path produces N commits (one per lerna invocation), each with its own tag. The previous `getTags(packages)` then ran `git tag --contains HEAD`, which only lists tags whose commit is HEAD itself or a descendant — so every tag from earlier commits in the loop was silently dropped, leaving dangling tags and an incomplete combined commit body. Fix: capture `preLernaSha = getCommitSha()` before the lerna call and pass it to `getTags(packages, preLernaSha)`. `git tag --contains <preLernaSha>` returns every tag at `preLernaSha` or descendant — i.e. every tag created by every iteration of `versionPackagesExplicit`. The single-call strategy path is a degenerate case of the same logic (exactly one commit between `preLernaSha` and HEAD). `getTags(packages)` (no `fromSha`) keeps the prior HEAD-only behavior for callers that haven't adopted the new signature. Also synthesizes a combined-commit subject when explicit bumps were used — `chore(release): publish <pkg-a>, <pkg-b>` — rather than inheriting the last per-package lerna commit's subject, which would misleadingly label a multi-package release as if only the last package was published.
lerna v9's `version` command does not accept `--scope`, so passing
`--scope <pkg>` per call fails with `Unknown argument: scope`.
Switch the explicit-bumps path to drive bumps without lerna's version
command:
1. resolve each `bumps` entry's package name to a directory by
reading package.json from the supplied `packages` list,
2. run `npm version <bump> --no-git-tag-version` inside that dir so
only that package's package.json is mutated,
3. commit just that package.json,
4. create the lerna-style `<pkg-name>@<new-version>` annotated tag
at the commit.
Each entry still produces one commit + one tag, matching the
post-processing assumptions in version.ts (the `getTags(packages,
preLernaSha)` scan and the `resetLastCommit({ count })` collapse).
The signature changes from `{ bumps, extraArgs }` to `{ bumps,
packages }` — `extraArgs` was previously forwarded to lerna's
unsupported CLI flags and is no longer meaningful for this path.
A new sub-action that replaces `version-dispatch` for monorepos where
a single squash-merged PR may carry per-commit bumps of *different*
levels for different workspace packages.
Algorithm:
1. Iterate the merged PR's pre-squash commits via
GET /repos/{owner}/{repo}/pulls/{N}/commits.
2. Parse each commit subject/body for conventional-commit type plus
breaking marker (! after type/scope or BREAKING CHANGE: footer)
to derive a bump level.
3. Fetch each commit's files[] and attribute the bump to every
workspace package whose directory contains a touched file
(re-uses lerna-utils' getPathsByPackageNames).
4. Aggregate per package taking max bump across attributed commits.
5. If the per-commit pass yields nothing, fall back to parsing the PR
title and applying that bump to every workspace touched in the PR.
Preserves the long-standing PR-title-is-the-release-level workflow
for repos that do not write per-commit conventional subjects.
6. Dispatch the version workflow with `packages` and `bumps` JSON —
the version action then runs `versionPackagesExplicit` once per
`(pkg, bump)` entry using `npm version`.
Inputs: `github-token`, `version-workflow-id`, `ref`,
`exclude-labels`, `dry-run`, `pr-number` (ad-hoc / workflow_dispatch).
31 unit tests cover bump parsing, file→package attribution, the
end-to-end aggregation loop, the title-fallback path, max-bump-wins
semantics, and the cross-touching-commit case.
Pairs with the `bumps` input added to `version` in the same PR. End-
to-end verified against `actions-playground` test fixtures producing
a release PR with @exodus/gadgets@2.0.0 (major) + @exodus/batcave@4.1.1
(patch) from a single mixed PR.
bumps JSON inputbumps input
Per review feedback — there is no architectural reason for two
dispatchers. The new commit-driven flow becomes the sole behavior of
the existing `version-dispatch` sub-action; the separate
`commit-driven-version-dispatch` sub-action introduced earlier in this
PR is removed.
Changes to `version-dispatch`:
- Selects affected packages by file path attribution across each
pre-squash commit, not by PR labels.
- Emits a `{ pkg: bump }` JSON map in the dispatched workflow inputs,
in addition to `packages`.
- Falls back to parsing the PR title once and applying that bump to
every workspace touched in the PR, when no commit carries a
release-worthy type.
- Drops the `exclude-commit-types` input — per-commit attribution
makes it redundant; a PR with only `chore`/`docs` commits naturally
produces an empty bumps map and no dispatch.
- New optional inputs: `dry-run`, `pr-number` (the latter enables ad-
hoc / workflow_dispatch invocation against an unmerged PR).
Backwards compatibility:
- `version-strategy` is gone from the dispatched workflow inputs.
Consumers' `version.yml` should accept `bumps` and forward it to
`lerna-release-action/version`. The `version` action falls back to
the old `version-strategy` flow when `bumps` is absent, so consumers
that haven't updated their `version.yml` keep working — they just
won't get per-package bump differentiation.
- Consumers that previously relied on PR labels to select packages
must migrate. Their version-dispatch will now select by file paths
touched in each commit instead.
Files removed: `commit-driven-version-dispatch/action.yml`,
`src/commit-driven-version-dispatch.ts`,
`src/commit-driven-version-dispatch.spec.ts`,
`dist/commit-driven-version-dispatch/`. The helpers move from
`src/commit-driven-version-dispatch/` to `src/version-dispatch/`.
Tests: 139/139 passing (was 156 — the commit-driven specs are folded
in; the old label-selection specs are replaced with per-commit
attribution + title-fallback + abort-condition specs).
When the action runs against a still-open PR (e.g. pull_request:
synchronize), it now skips the workflow dispatch and instead rewrites
a sentinel block at the bottom of the PR description with the
computed { pkg: bump } map and the resulting current → next versions.
The block is delimited by paired HTML markers, so on every push the
prior block is stripped and a fresh one appended in-place — no
comment-spam, and reviewers always see the latest preview anchored
at the bottom of the PR body.
Empty bumps still triggers a body-update to clean up any stale block.
19 new unit tests (139 → 158 passing).
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
reviewed
May 12, 2026
exo-mv
approved these changes
May 12, 2026
Switch the preview-mode output back to a sticky comment, dropping the PR-description sentinel-block approach. On every run the action now lists comments on the PR, deletes every prior preview comment (matched by a hidden HTML marker), and posts a fresh one — so reviewers always see exactly one preview comment, re-anchored at the end of the conversation timeline after each push. Rationale: the body-update approach mutated the author's PR description and risked clobbering parallel edits. Comments stay out of the author's body entirely and survive merge cleanly. Updates the integration + unit tests to assert the new delete-then-create flow. 153/153 passing.
- Rename utils/git.ts \`resetLastCommit\` → \`resetCommits\` since it now
accepts a multi-commit \`count\`.
- parse-bumps reuses the existing \`VersionStrategy\` enum instead of a
hand-rolled bumps set — single source of truth.
- Replace the hand-rolled \`nextVersion\` in preview.ts with
\`semver.inc(current, bump)\`. Adds semver as an explicit dependency.
- Enhance \`utils/conventional-commits.parseMessage\` to return
\`{ type, scope?, breaking }\` and have \`bumpFromMessage\` delegate
to it — one parser, two consumers.
- Split \`computeBumpsForPr\` into the async I/O wrapper plus a pure
sync \`aggregateBumps\` helper for cheap unit-testing. \`getCommit\`
fetches now run in parallel via \`Promise.all\` instead of awaiting
one-at-a-time.
- Drop the redundant \`unknownPackages\` warning loop in \`version.ts\`
— \`versionPackagesExplicit\` already throws with a stricter check
based on each package.json's actual name.
158/158 tests passing (added 5 new \`aggregateBumps\` cases).
The CLI subproject symlinks cli/src/action → ../../src, so it now imports semver via the shared preview.ts. Without semver in cli/package.json, the CLI's tsc build fails with TS7016.
exo-mv
approved these changes
May 12, 2026
Cap the parallel getCommit fan-out with p-limit so PRs with many commits don't hit GitHub's secondary rate limit. 10 concurrent requests is well under the per-second ceiling while still ~10x faster than sequential awaits. Also adds p-limit to cli/package.json since cli/src/action is a symlink to ../../src.
exo-mv
approved these changes
May 12, 2026
exo-egor
added a commit
that referenced
this pull request
May 13, 2026
\`import * as semver from 'semver'\` pulled the entire semver
package into both the version and version-dispatch bundles —
parser, comparator, ranges, prerelease handlers, the lot — even
though we only call \`inc()\`.
Switch to \`import semverInc = require('semver/functions/inc')\`
and keep a type-only import for \`ReleaseType\`. Same behavior
(\`inc\` is the exact function this module exports), but ncc
inlines only the small inc → SemVer class subgraph instead of
all of semver.
Net effect on the bundles:
- dist/version-dispatch/index.js shrinks by ~3.6k lines (was already
paying the full-semver cost since #82's preview rendering)
- dist/version/index.js grows much less than it would have
No source changes other than the imports. 165/165 tests still passing.
exo-mv
pushed a commit
that referenced
this pull request
May 13, 2026
…#84) * fix(version): bump explicit-version package.json without invoking npm \`versionPackagesExplicit\` used to shell out to \`npm version <bump> --no-git-tag-version\` to mutate each target package.json. \`npm\` parses the whole file (incl. devDependencies) and rejects yarn's \`workspace:\` protocol with \`EUNSUPPORTEDPROTOCOL\` — even when the bump itself doesn't touch deps at all. This regressed exodus-hydra#16388: the release workflow blew up on \`libraries/multi-account-redux\` because the package's devDependencies include \`@exodus/assets-feature: workspace:*\` / \`@exodus/wallet-accounts: workspace:*\`. Replace the npm subprocess with a direct \`semver.inc\` + in-place regex rewrite of the \`"version"\` field, preserving every other byte (formatting, trailing newline, key order). This matches what \`lerna version\` does internally on the strategy path, which is why the strategy flow never hit this. Adds 5 regression tests under \`versionPackagesExplicit\` covering: - the basic version bump - a \`workspace:*\` devDep package (would have caught the original hydra release failure) - multi-package git commit + tag emission - unknown package name throws - invalid bump level throws 165/165 tests passing (was 160). * chore(deps): import only semver/functions/inc \`import * as semver from 'semver'\` pulled the entire semver package into both the version and version-dispatch bundles — parser, comparator, ranges, prerelease handlers, the lot — even though we only call \`inc()\`. Switch to \`import semverInc = require('semver/functions/inc')\` and keep a type-only import for \`ReleaseType\`. Same behavior (\`inc\` is the exact function this module exports), but ncc inlines only the small inc → SemVer class subgraph instead of all of semver. Net effect on the bundles: - dist/version-dispatch/index.js shrinks by ~3.6k lines (was already paying the full-semver cost since #82's preview rendering) - dist/version/index.js grows much less than it would have No source changes other than the imports. 165/165 tests still passing.
exo-mv
pushed a commit
that referenced
this pull request
May 14, 2026
When versionPackagesExplicit bumps a workspace package's major, walk every other workspace package.json and rewrite any \`dependencies\`/\`devDependencies\`/\`peerDependencies\` ref to the bumped package whose existing semver range no longer satisfies the new version. Preserves the existing range prefix (\`^\`, \`~\`, \`>=\`, …). Minor and patch bumps skip the walk entirely — the existing caret/tilde range already satisfies the new version, so yarn keeps resolving the workspace symlink and there's no need to touch any consumer's package.json. Why: lerna's \`version\` flow does this automatically. The explicit-bumps path in #82 lost the behavior, so a 2.x → 3.0.0 release on a workspace package would leave every consumer pinned to \`^2.0.1\`. yarn falls back to the published 2.x copy on the registry — still carrying the old pre-migration source — which broke optimistic-balances' build in exodus-hydra#16530. Skips refs that aren't semver ranges: - \`workspace:*\` / \`workspace:^\` - \`npm:@scope/alias@…\` - \`file:\` / \`link:\` / \`portal:\` - URLs (anything with \`://\`) - dist-tags (\`*\`, \`latest\`, \`next\`) Adds 13 new tests covering: basic rewrite, range-prefix preservation (^/~), staging into the release commit, the 8 skip protocols (each as its own .each row), cross-section rewrite (deps/devDeps/peerDeps), and the 5 non-major bump levels (minor/patch/preminor/prepatch/ prerelease) as a single .each — confirming non-major bumps leave consumers untouched. 181/181 tests passing (was 165, +16).
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.
Summary
Two changes to
lerna-release-action:version-dispatchrewritten as a commit-driven dispatcher — walks the merged PR's pre-squash commits, attributes each to workspace packages by file path, derives a per-package bump from the commit's conventional-commit type, and dispatches the version workflow with a{ pkg: bump }JSON map.bumpsJSON input onversion— when supplied,versionrunsnpm version <bump>once per(pkg, bump)entry instead of the existing singlelerna version <strategy>call. Same backward-compatible flow when unset.Together they let a single squash-merged PR release multiple packages at different bump levels — major for the package with the breaking commit, patch for the consumers updating their imports.
Motivation
A real PR in
exodus-hydracarries:refactor(multi-account-redux)!: drop /src/common.js subpath(breaking — major bump)fix: import setAccounts from package root(patch for three consumer packages)With the existing dispatcher the PR title's bump fans out to every workspace lerna sees a file change for — so all four packages become major, which is wrong. The new flow attributes each pre-squash commit to its target package(s) and gets the right per-package answer:
version-dispatchalgorithmGET /repos/{owner}/{repo}/pulls/{N}/commits.!orBREAKING CHANGE:footer).files[]and map files to workspace packages viagetPathsByPackageNames(already in lerna-utils).version.ymlwithpackages+bumpsJSON.Supports both
pull_request: closed(auto-dispatch on merge) andworkflow_dispatch(ad-hoc, viapr-numberinput) triggers.dry-run: trueskips the dispatch and just outputs the map.versionacceptingbumpsbumpsis an optional JSON map. When set,version-strategyis ignored. lerna v9'sversioncommand doesn't accept--scope, so the explicit-bumps path bypasseslerna versionentirely:bumpsentry's package name to a directory by reading package.json frompackages,npm version <bump> --no-git-tag-versioninside that dir,package.json,<pkg-name>@<new-version>annotated tag.Each entry produces one commit + one tag. The post-processing in
version.tscollapses the N commits viaresetLastCommit({ count })and recovers all tags viagetTags(packages, preLernaSha)—preLernaShais captured before the loop so the tag scan covers every iteration, not just the last.Tests
parseBumpsvalidation, and the newcountparameter onresetLastCommit.End-to-end verification
Validated against three live fixtures in
ExodusMovement/actions-playground:test(cdvd): mixed per-package bumpsfeat(gadgets)!,fix(batcave),refactor(gadgets). Tests max-bump-wins per package.{"@exodus/gadgets":"major","@exodus/batcave":"patch"}feat(gadgets)!: title-fallback testrefactor,chore), breaking type only in PR title. Tests title-fallback path.{"@exodus/gadgets":"major"}via the title-fallback branchversionjob dispatched from #701.@exodus/gadgets1.0.0 → 2.0.0,@exodus/batcave4.1.0 → 4.1.1, both CHANGELOGs updated, both@scope/name@versiontags in PR body, combined commit subjectchore: release @exodus/gadgets@2.0.0,@exodus/batcave@4.1.1Rollout
v4).exodus-hydra, switch.github/workflows/version-dispatch.yamlto:version.ymlworkflow to forwardbumps:Files
version-dispatch/action.yml— addsdry-runandpr-numberinputs; outputsbumps+packages.src/version-dispatch.ts— main entry (orchestration +computeBumpsForPr).src/version-dispatch/{bumps,files-to-packages}.ts— small pure helpers.src/version-dispatch.spec.ts— 9 tests covering the new dispatcher.version/action.yml— adds thebumpsinput.src/version.ts— branches toversionPackagesExplicitwhenbumpsis set.src/version/parse-bumps.{ts,spec.ts}— JSON validation.src/version/version-packages.ts— addsversionPackagesExplicit.src/version/get-tags.ts—fromShaparameter for multi-commit recovery.src/utils/git.ts—countparameter onresetLastCommit.