Skip to content

feat: per-package bumps via commit-driven-version-dispatch + version bumps input#82

Merged
exo-egor merged 10 commits into
masterfrom
egor/version-explicit-bumps
May 12, 2026
Merged

feat: per-package bumps via commit-driven-version-dispatch + version bumps input#82
exo-egor merged 10 commits into
masterfrom
egor/version-explicit-bumps

Conversation

@exo-egor

@exo-egor exo-egor commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Two changes to lerna-release-action:

  1. version-dispatch rewritten 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.
  2. bumps JSON input on version — when supplied, version runs npm version <bump> once per (pkg, bump) entry instead of the existing single lerna 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-hydra carries:

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

{
  "@exodus/multi-account-redux": "major",
  "@exodus/balances": "patch",
  "@exodus/optimistic-balances": "patch",
  "@exodus/activity-txs": "patch"
}

version-dispatch 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 + breaking marker (! or BREAKING CHANGE: footer).
  3. Fetch the commit's files[] and map files to workspace packages via getPathsByPackageNames (already in lerna-utils).
  4. Aggregate per package taking max bump across attributed commits.
  5. Title fallback — if step 4 produces an empty map, parse the PR title once and apply that bump to every workspace touched. Preserves the existing PR-title-is-the-release-level flow for repos whose individual commits aren't conventional.
  6. Dispatch version.yml with packages + bumps JSON.

Supports both pull_request: closed (auto-dispatch on merge) and workflow_dispatch (ad-hoc, via pr-number input) triggers. dry-run: true skips the dispatch and just outputs the map.

version accepting bumps

bumps is an optional JSON map. When set, version-strategy is ignored. lerna v9's version command doesn't accept --scope, so the explicit-bumps path bypasses lerna version entirely:

  1. resolve each bumps entry's package name to a directory by reading package.json from packages,
  2. run npm version <bump> --no-git-tag-version inside that dir,
  3. commit just that package.json,
  4. create the lerna-style <pkg-name>@<new-version> annotated tag.

Each entry produces one commit + one tag. The post-processing in version.ts collapses the N commits via resetLastCommit({ count }) and recovers all tags via getTags(packages, preLernaSha)preLernaSha is captured before the loop so the tag scan covers every iteration, not just the last.

Tests

  • 139/139 passing.
  • New tests across bump parsing, file→package attribution, end-to-end aggregation, title fallback, max-bump-wins, cross-touching commits, the 250-commit REST cap warning, parseBumps validation, and the new count parameter on resetLastCommit.

End-to-end verification

Validated against three live fixtures in ExodusMovement/actions-playground:

Fixture PR What it exercises Result
#701 — test(cdvd): mixed per-package bumps Multi-commit PR: feat(gadgets)!, fix(batcave), refactor(gadgets). Tests max-bump-wins per package. ✅ Dispatched {"@exodus/gadgets":"major","@exodus/batcave":"patch"}
#702 — feat(gadgets)!: title-fallback test Two commits both non-bumping (refactor, chore), breaking type only in PR title. Tests title-fallback path. ✅ Dispatched {"@exodus/gadgets":"major"} via the title-fallback branch
#703 — release PR auto-produced Output of the version job dispatched from #701. @exodus/gadgets 1.0.0 → 2.0.0, @exodus/batcave 4.1.0 → 4.1.1, both CHANGELOGs updated, both @scope/name@version tags in PR body, combined commit subject chore: release @exodus/gadgets@2.0.0,@exodus/batcave@4.1.1

Rollout

  1. Merge this PR, tag a release (e.g. v4).
  2. In exodus-hydra, switch .github/workflows/version-dispatch.yaml to:
    uses: ExodusMovement/lerna-release-action/version-dispatch@v4
  3. Update hydra's version.yml workflow to forward bumps:
    bumps:
      description: 'JSON map of `{ pkg: bump }`'
      type: string
      required: false
    - uses: ExodusMovement/lerna-release-action/version@v4
      with:
        packages: ${{ inputs.packages }}
        bumps: ${{ inputs.bumps }}

Files

  • version-dispatch/action.yml — adds dry-run and pr-number inputs; outputs bumps + 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 the bumps input.
  • src/version.ts — branches to versionPackagesExplicit when bumps is set.
  • src/version/parse-bumps.{ts,spec.ts} — JSON validation.
  • src/version/version-packages.ts — adds versionPackagesExplicit.
  • src/version/get-tags.tsfromSha parameter for multi-commit recovery.
  • src/utils/git.tscount parameter on resetLastCommit.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

exo-egor added 3 commits May 11, 2026 20:43
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.
@exo-egor exo-egor changed the title feat(version): per-package explicit bumps via bumps JSON input feat: per-package bumps via commit-driven-version-dispatch + version bumps input May 11, 2026
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).
@exo-egor exo-egor requested review from exo-mv and sparten11740 May 12, 2026 07:42
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 exo-mv requested a review from guten-exodus May 12, 2026 12:46
Comment thread src/version-dispatch.ts
Comment thread src/version-dispatch/bumps.ts
Comment thread src/version-dispatch/preview.ts Outdated
Comment thread src/utils/git.ts Outdated
Comment thread src/version/parse-bumps.ts Outdated
Comment thread src/version-dispatch/preview.ts
Comment thread src/version-dispatch.ts Outdated
Comment thread src/version.ts Outdated
Comment thread src/version-dispatch.ts
exo-egor added 3 commits May 12, 2026 16:33
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.
Comment thread src/version-dispatch.ts Outdated
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-egor exo-egor merged commit 96c8400 into master May 12, 2026
2 checks passed
@exo-egor exo-egor deleted the egor/version-explicit-bumps branch May 12, 2026 16:14
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants