Skip to content

Commit 9d0a300

Browse files
fix(version): honor workspace selection for recursive version bumps (#11425)
## Problem `pnpm version --recursive` did not bump the workspace packages the user selected. In recursive mode the command re-derived the workspace selection itself (via `filterProjectsFromDir`) using an incomplete set of options, so it could resolve a different set of packages than the CLI's actual `--filter`/`--recursive` resolution. Fixes #11348. ## Change - In recursive mode, bump the projects in `selectedProjectsGraph` — the selection the pnpm CLI (`main.ts`) already computes from the workspace filter, exactly the way `pnpm publish --recursive` works. - Remove the in-handler `filterProjectsFromDir` fallback. It duplicated the CLI's filtering logic and was unreachable in production (`main.ts` always passes `selectedProjectsGraph` for a recursive run). `@pnpm/workspace.projects-filter` becomes a dev-only dependency of `@pnpm/releasing.commands`. - No global/config plumbing is needed for `--recursive`: `@pnpm/cli.parse-cli-args` already recognizes `recursive` (and the `-r` shorthand) for every command, the config reader passes it through to the handler, and the `version` command already declares `recursive` in its own `cliOptionsTypes`. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
1 parent c1f9cdf commit 9d0a300

6 files changed

Lines changed: 62 additions & 71 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/releasing.commands": patch
3+
"pnpm": patch
4+
---
5+
6+
Fixed `pnpm version --recursive` so it honors the workspace selection. In recursive mode the version bump now applies to the packages resolved from the workspace filter (`selectedProjectsGraph`), matching the behavior of `pnpm publish --recursive`, instead of always bumping every workspace package [#11348](https://github.com/pnpm/pnpm/issues/11348).

pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm/test/version.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect, test } from '@jest/globals'
2+
import { preparePackages } from '@pnpm/prepare'
3+
import { loadJsonFileSync } from 'load-json-file'
4+
import { writeYamlFileSync } from 'write-yaml-file'
5+
6+
import { execPnpm } from './utils/index.js'
7+
8+
test('version --recursive bumps every workspace package', async () => {
9+
preparePackages([
10+
{ name: 'project-1', version: '1.0.0' },
11+
{ name: 'project-2', version: '2.3.0' },
12+
])
13+
writeYamlFileSync('pnpm-workspace.yaml', { packages: ['project-*'] })
14+
15+
await execPnpm(['version', '--recursive', '--no-git-checks', 'minor'])
16+
17+
expect(loadJsonFileSync<{ version: string }>('project-1/package.json').version).toBe('1.1.0')
18+
expect(loadJsonFileSync<{ version: string }>('project-2/package.json').version).toBe('2.4.0')
19+
})
20+
21+
test('version --recursive --filter bumps only the selected package', async () => {
22+
preparePackages([
23+
{ name: 'project-1', version: '1.0.0' },
24+
{ name: 'project-2', version: '2.3.0' },
25+
])
26+
writeYamlFileSync('pnpm-workspace.yaml', { packages: ['project-*'] })
27+
28+
await execPnpm(['version', '--recursive', '--filter', 'project-2', '--no-git-checks', 'patch'])
29+
30+
expect(loadJsonFileSync<{ version: string }>('project-1/package.json').version).toBe('1.0.0')
31+
expect(loadJsonFileSync<{ version: string }>('project-2/package.json').version).toBe('2.3.1')
32+
})

releasing/commands/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
"@pnpm/releasing.exportable-manifest": "workspace:*",
6363
"@pnpm/resolving.resolver-base": "workspace:*",
6464
"@pnpm/types": "workspace:*",
65-
"@pnpm/workspace.projects-filter": "workspace:*",
6665
"@pnpm/workspace.projects-sorter": "workspace:*",
6766
"@types/normalize-path": "catalog:",
6867
"@zkochan/rimraf": "catalog:",
@@ -102,6 +101,7 @@
102101
"@pnpm/test-ipc-server": "workspace:*",
103102
"@pnpm/testing.command-defaults": "workspace:*",
104103
"@pnpm/testing.registry-mock": "workspace:*",
104+
"@pnpm/workspace.projects-filter": "workspace:*",
105105
"@types/cross-spawn": "catalog:",
106106
"@types/is-windows": "catalog:",
107107
"@types/libnpmpublish": "catalog:",

releasing/commands/src/version/index.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { type Config, types as allTypes } from '@pnpm/config.reader'
55
import { PnpmError } from '@pnpm/error'
66
import { runLifecycleHook, type RunLifecycleHookOptions } from '@pnpm/exec.lifecycle'
77
import { isGitRepo, isWorkingTreeClean } from '@pnpm/network.git-utils'
8-
import { filterProjectsFromDir, type WorkspaceFilter } from '@pnpm/workspace.projects-filter'
8+
import type { ProjectsGraph } from '@pnpm/types'
99
import { safeExeca as execa } from 'execa'
1010
import { pick } from 'ramda'
1111
import { renderHelp } from 'render-help'
@@ -119,6 +119,7 @@ interface VersionHandlerOptions extends Config {
119119
message?: string
120120
preid?: string
121121
recursive?: boolean
122+
selectedProjectsGraph?: ProjectsGraph
122123
signGitTag?: boolean
123124
tagVersionPrefix?: string
124125
}
@@ -149,28 +150,7 @@ export async function handler (
149150
const changes: VersionChange[] = []
150151

151152
if (opts.recursive) {
152-
const workspaceDir = opts.workspaceDir || opts.dir
153-
const filters: WorkspaceFilter[] = []
154-
155-
if (opts.filter && opts.filter.length > 0) {
156-
opts.filter.forEach(filterPattern => {
157-
filters.push({
158-
filter: filterPattern,
159-
followProdDepsOnly: !!opts.filterProd && opts.filterProd.length > 0,
160-
})
161-
})
162-
}
163-
164-
const result = await filterProjectsFromDir(
165-
workspaceDir,
166-
filters,
167-
{
168-
workspaceDir,
169-
prefix: opts.dir,
170-
}
171-
)
172-
173-
const pkgDirs = Object.keys(result.selectedProjectsGraph)
153+
const pkgDirs = Object.keys(opts.selectedProjectsGraph ?? {})
174154
const bumpResults = await Promise.all(
175155
pkgDirs.map(pkgDir => bumpPackageVersion(pkgDir, rawBump, explicitVersion, opts))
176156
)

releasing/commands/test/version/index.test.ts

Lines changed: 17 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ fs.appendFileSync(process.argv[2], process.argv[3] + ':' + manifest.version + '\
383383
dir: tempDir,
384384
workspaceDir: tempDir,
385385
recursive: true,
386+
selectedProjectsGraph: {
387+
[pkgADir]: { dependencies: [], package: {} },
388+
[pkgBDir]: { dependencies: [], package: {} },
389+
},
386390
} as any, ['patch']) // eslint-disable-line @typescript-eslint/no-explicit-any
387391

388392
const { stdout: tags } = await execa('git', ['tag', '--list'], { cwd: tempDir })
@@ -394,63 +398,28 @@ fs.appendFileSync(process.argv[2], process.argv[3] + ':' + manifest.version + '\
394398
})
395399

396400
describe('recursive mode', () => {
397-
it('should bump versions of all workspace packages with --recursive', async () => {
398-
// Create workspace structure
401+
// The happy path (which packages a recursive run selects) is covered end to
402+
// end in pnpm/test/version.ts against the real CLI. The cases below exercise
403+
// handler branches that do not depend on the CLI selection wiring.
404+
it('should honor an empty selectedProjectsGraph and bump nothing', async () => {
399405
const pkgADir = path.join(tempDir, 'packages', 'pkg-a')
400-
const pkgBDir = path.join(tempDir, 'packages', 'pkg-b')
401406
fs.mkdirSync(pkgADir, { recursive: true })
402-
fs.mkdirSync(pkgBDir, { recursive: true })
403407

404408
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'my-workspace', version: '1.0.0' }))
405409
fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n')
406410
fs.writeFileSync(path.join(pkgADir, 'package.json'), JSON.stringify({ name: 'pkg-a', version: '1.0.0' }))
407-
fs.writeFileSync(path.join(pkgBDir, 'package.json'), JSON.stringify({ name: 'pkg-b', version: '2.3.0' }))
408-
409-
const result = await handler({
410-
dir: tempDir,
411-
workspaceDir: tempDir,
412-
gitChecks: false,
413-
gitTagVersion: false,
414-
recursive: true,
415-
} as any, ['minor']) // eslint-disable-line @typescript-eslint/no-explicit-any
416-
417-
const resultStr = result as string
418-
expect(resultStr).toContain('pkg-a')
419-
expect(resultStr).toContain('pkg-b')
420-
421-
const manifestA = JSON.parse(fs.readFileSync(path.join(pkgADir, 'package.json'), 'utf-8'))
422-
const manifestB = JSON.parse(fs.readFileSync(path.join(pkgBDir, 'package.json'), 'utf-8'))
423-
expect(manifestA.version).toBe('1.1.0')
424-
expect(manifestB.version).toBe('2.4.0')
425-
})
426-
427-
it('should return JSON output in recursive mode with --json', async () => {
428-
const pkgDir = path.join(tempDir, 'packages', 'pkg-a')
429-
fs.mkdirSync(pkgDir, { recursive: true })
430411

431-
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'my-workspace', version: '1.0.0' }))
432-
fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n')
433-
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'pkg-a', version: '1.0.0' }))
434-
435-
const result = await handler({
412+
await expect(handler({
436413
dir: tempDir,
437414
workspaceDir: tempDir,
438415
gitChecks: false,
439416
gitTagVersion: false,
440417
recursive: true,
441-
json: true,
442-
} as any, ['patch']) // eslint-disable-line @typescript-eslint/no-explicit-any
418+
selectedProjectsGraph: {},
419+
} as any, ['minor'])).rejects.toThrow('No packages to version') // eslint-disable-line @typescript-eslint/no-explicit-any
443420

444-
const parsed = JSON.parse(result as string)
445-
expect(parsed).toEqual(
446-
expect.arrayContaining([
447-
expect.objectContaining({
448-
name: 'pkg-a',
449-
currentVersion: '1.0.0',
450-
newVersion: '1.0.1',
451-
}),
452-
])
453-
)
421+
expect(JSON.parse(fs.readFileSync(path.join(pkgADir, 'package.json'), 'utf-8')).version).toBe('1.0.0')
422+
expect(JSON.parse(fs.readFileSync(path.join(tempDir, 'package.json'), 'utf-8')).version).toBe('1.0.0')
454423
})
455424

456425
it('should skip workspace packages without name or version', async () => {
@@ -470,6 +439,10 @@ fs.appendFileSync(process.argv[2], process.argv[3] + ':' + manifest.version + '\
470439
gitChecks: false,
471440
gitTagVersion: false,
472441
recursive: true,
442+
selectedProjectsGraph: {
443+
[pkgADir]: { dependencies: [], package: {} },
444+
[pkgBDir]: { dependencies: [], package: {} },
445+
},
473446
} as any, ['patch']) // eslint-disable-line @typescript-eslint/no-explicit-any
474447

475448
const resultStr = result as string

0 commit comments

Comments
 (0)