Skip to content

Commit 3b54d79

Browse files
fengmk2zkochan
andauthored
fix(deps-installer): keep catalog-referencing overrides in sync on update (#12158)
* fix(deps-installer): re-resolve catalog-referencing overrides on update When `pnpm.overrides` reference a catalog (e.g. `overrides: { foo: 'catalog:' }`), `pnpm update` bumped the catalog entry during resolution but left the resolved `overrides` in the lockfile pointing at the old version. The lockfile's `catalogs` advanced while `overrides` stayed stale, producing an internally inconsistent lockfile that fails a later `pnpm install --frozen-lockfile` with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. After resolution, re-resolve the overrides against the catalog merged with the update's `updatedCatalogs`, so the lockfile `overrides` track the bumped catalog just like `catalogs` and direct catalog dependencies do. * fix(deps-installer): re-resolve catalog overrides before afterAllResolved Address review feedback: - Run the catalog-override re-resolution before the `afterAllResolved` pnpmfile hook instead of after it, so a hook that edits `lockfile.overrides` still sees and can amend the final value (the block previously ran after the hook and would clobber its edits whenever a catalog entry was updated). - Drop the dead `opts.catalogs ?? {}` fallback; `opts.catalogs` is required on the install options and always defaulted to `{}`, so it is never nullish here. * test(pacquet): cover catalog-referencing override sync on update --latest Mirrors pnpm's regression test for keeping lockfile overrides that resolve through a catalog in sync when `update --latest` bumps that catalog. pacquet already behaves correctly (it threads the bumped catalogs through to override parsing), so this is a guard against a future refactor reintroducing the inconsistency that #12158 fixes on the TypeScript side. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
1 parent c7950e7 commit 3b54d79

7 files changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/installing.deps-installer": patch
3+
"pnpm": patch
4+
---
5+
6+
`pnpm update` now keeps lockfile `overrides` that resolve through a catalog in sync with the catalog. Previously, when an override referenced a catalog (e.g. `overrides: { foo: 'catalog:' }`) and `pnpm update` bumped that catalog entry, the lockfile's `catalogs` advanced while the resolved `overrides` kept the old version. The resulting lockfile was internally inconsistent, so a later `pnpm install --frozen-lockfile` failed with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`.

installing/deps-installer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@pnpm/building.after-install": "workspace:*",
6565
"@pnpm/building.during-install": "workspace:*",
6666
"@pnpm/building.policy": "workspace:*",
67+
"@pnpm/catalogs.config": "workspace:*",
6768
"@pnpm/catalogs.protocol-parser": "workspace:*",
6869
"@pnpm/catalogs.resolver": "workspace:*",
6970
"@pnpm/catalogs.types": "workspace:*",

installing/deps-installer/src/install/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker'
44
import { buildSelectedPkgs } from '@pnpm/building.after-install'
55
import { buildModules, type DepsStateCache, linkBinsOfDependencies } from '@pnpm/building.during-install'
66
import { createAllowBuildFunction, isBuildExplicitlyDisallowed } from '@pnpm/building.policy'
7+
import { mergeCatalogs } from '@pnpm/catalogs.config'
78
import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser'
89
import { type CatalogResultMatcher, matchCatalogResolveResult, resolveFromCatalog } from '@pnpm/catalogs.resolver'
910
import type { Catalogs } from '@pnpm/catalogs.types'
11+
import { parseOverrides } from '@pnpm/config.parse-overrides'
1012
import {
1113
LAYOUT_VERSION,
1214
LOCKFILE_MAJOR_VERSION,
@@ -1653,6 +1655,20 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
16531655
stage: 'resolution_done',
16541656
})
16551657

1658+
// `pnpm update` may bump catalog entries during resolution. Overrides that
1659+
// reference a catalog (e.g. `overrides: { foo: 'catalog:' }`) were resolved
1660+
// against the pre-update catalog when the install options were extended, so
1661+
// re-resolve them against the updated catalog. Done before `afterAllResolved`
1662+
// so that hook still sees (and can amend) the final overrides. Otherwise
1663+
// lockfile `overrides` keeps pointing at the old version while `catalogs`
1664+
// advances, and a later `--frozen-lockfile` install fails with
1665+
// ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
1666+
if (updatedCatalogs != null && opts.overrides != null && Object.keys(opts.overrides).length > 0) {
1667+
newLockfile.overrides = createOverridesMapFromParsed(
1668+
parseOverrides(opts.overrides, mergeCatalogs(opts.catalogs, updatedCatalogs))
1669+
)
1670+
}
1671+
16561672
newLockfile = ((opts.hooks?.afterAllResolved) != null)
16571673
? await pipeWith(async (f, res) => f(await res), opts.hooks.afterAllResolved as any)(newLockfile) as LockfileObject // eslint-disable-line
16581674
: newLockfile

installing/deps-installer/test/catalogs.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,74 @@ describe('update', () => {
16591659
expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@1.3.0'])
16601660
})
16611661

1662+
test('overrides that reference a catalog are updated in the lockfile when the catalog is updated', async () => {
1663+
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
1664+
name: 'project1',
1665+
dependencies: {
1666+
'@pnpm.e2e/foo': 'catalog:',
1667+
},
1668+
}])
1669+
1670+
const mutateOpts = {
1671+
...options,
1672+
lockfileOnly: true,
1673+
catalogs: {
1674+
default: { '@pnpm.e2e/foo': '1.0.0' },
1675+
},
1676+
// An override that resolves through the catalog. A scoped selector is
1677+
// used so the override does not shadow the direct `catalog:` dependency
1678+
// above (an unscoped override would replace its specifier and drop it
1679+
// from the catalog). The resolved value recorded in lockfile "overrides"
1680+
// must track the catalog as the catalog is updated.
1681+
overrides: {
1682+
'@pnpm.e2e/foobar>@pnpm.e2e/foo': 'catalog:',
1683+
},
1684+
}
1685+
1686+
await mutateModules(installProjects(projects), mutateOpts)
1687+
1688+
// Widen the catalog range so a later update can bump it, while 1.0.0 stays
1689+
// locked for now.
1690+
mutateOpts.catalogs.default['@pnpm.e2e/foo'] = '^1.0.0'
1691+
await mutateModules(installProjects(projects), mutateOpts)
1692+
1693+
expect(readLockfile().catalogs.default).toEqual({
1694+
'@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.0.0' },
1695+
})
1696+
expect(readLockfile().overrides).toEqual({ '@pnpm.e2e/foobar>@pnpm.e2e/foo': '^1.0.0' })
1697+
1698+
const { updatedCatalogs } = await addDependenciesToPackage(
1699+
projects['project1' as ProjectId],
1700+
['@pnpm.e2e/foo'],
1701+
{
1702+
...mutateOpts,
1703+
dir: path.join(options.lockfileDir, 'project1'),
1704+
update: true,
1705+
})
1706+
1707+
expect(updatedCatalogs).toEqual({
1708+
default: { '@pnpm.e2e/foo': '^1.3.0' },
1709+
})
1710+
1711+
const lockfile = readLockfile()
1712+
expect(lockfile.catalogs).toEqual({
1713+
default: { '@pnpm.e2e/foo': { specifier: '^1.3.0', version: '1.3.0' } },
1714+
})
1715+
1716+
// The override referencing the catalog must be updated to match the new
1717+
// catalog. Otherwise lockfile "overrides" points at the old version while
1718+
// "catalogs" points at the new one, and a later frozen install fails with
1719+
// ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
1720+
expect(lockfile.overrides).toEqual({ '@pnpm.e2e/foobar>@pnpm.e2e/foo': '^1.3.0' })
1721+
1722+
// The updated catalog is written back to pnpm-workspace.yaml, so a
1723+
// subsequent frozen install reads the bumped catalog. It must not fail.
1724+
mutateOpts.catalogs.default['@pnpm.e2e/foo'] = '^1.3.0'
1725+
await expect(
1726+
mutateModules(installProjects(projects), { ...mutateOpts, frozenLockfile: true })
1727+
).resolves.toBeDefined()
1728+
})
1729+
16621730
test('update works on named catalog', async () => {
16631731
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
16641732
name: 'project1',

installing/deps-installer/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
{
4040
"path": "../../building/policy"
4141
},
42+
{
43+
"path": "../../catalogs/config"
44+
},
4245
{
4346
"path": "../../catalogs/protocol-parser"
4447
},

pacquet/crates/cli/tests/catalog.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ fn catalog_snapshot(workspace: &Path, name: &str) -> (String, String) {
6868
(entry.specifier.clone(), entry.version.clone())
6969
}
7070

71+
fn lockfile_override(workspace: &Path, selector: &str) -> Option<String> {
72+
let lockfile: Lockfile =
73+
serde_saphyr::from_str(&read(workspace, "pnpm-lock.yaml")).expect("parse pnpm-lock.yaml");
74+
lockfile.overrides.as_ref().and_then(|overrides| overrides.get(selector).cloned())
75+
}
76+
7177
fn run_ok(workspace: &Path, args: &[&str]) {
7278
let output = pacquet(workspace, args).output().expect("run pacquet");
7379
assert!(
@@ -213,6 +219,55 @@ fn update_latest_named_catalog_bumps_the_entry() {
213219
drop((root, anchor));
214220
}
215221

222+
/// `update --latest` bumping a catalog that an override resolves through
223+
/// must keep `pnpm-lock.yaml`'s `overrides` in sync with the bumped
224+
/// catalog. A scoped selector is used so the override does not shadow the
225+
/// direct `catalog:` dependency. If the override is not re-resolved against
226+
/// the bumped catalog, lockfile `overrides` lags `catalogs` and the
227+
/// follow-up `--frozen-lockfile` install fails with an overrides/catalogs
228+
/// mismatch. Ported from pnpm's "overrides that reference a catalog are
229+
/// updated in the lockfile when the catalog is updated".
230+
#[test]
231+
fn update_latest_keeps_catalog_referencing_override_in_sync() {
232+
let (root, workspace, anchor) = setup();
233+
write_manifest(&workspace, &format!(r#"{{ "{FOO}": "catalog:" }}"#));
234+
let override_selector = format!("@pnpm.e2e/foobar>{FOO}");
235+
append_workspace_yaml(
236+
&workspace,
237+
&format!(
238+
"catalogMode: prefer\ncatalog:\n '{FOO}': '^1.0.0'\noverrides:\n '{override_selector}': 'catalog:'\n",
239+
),
240+
);
241+
242+
run_ok(&workspace, &["install", "--lockfile-only"]);
243+
244+
// The override resolves through the catalog, so it records the catalog's
245+
// specifier rather than a literal version.
246+
let (initial_spec, _) = catalog_snapshot(&workspace, FOO);
247+
assert_eq!(
248+
lockfile_override(&workspace, &override_selector).as_deref(),
249+
Some(initial_spec.as_str()),
250+
"override should track the catalog specifier before the update",
251+
);
252+
253+
run_ok(&workspace, &["update", "--latest", "--lockfile-only", FOO]);
254+
255+
let (bumped_spec, _) = catalog_snapshot(&workspace, FOO);
256+
assert_ne!(bumped_spec, initial_spec, "update --latest should bump the catalog entry");
257+
assert_eq!(
258+
lockfile_override(&workspace, &override_selector).as_deref(),
259+
Some(bumped_spec.as_str()),
260+
"lockfile override must be re-resolved against the bumped catalog",
261+
);
262+
263+
// The bumped catalog is written back to pnpm-workspace.yaml, so a
264+
// follow-up frozen install reads it and must not fail with an
265+
// overrides/catalogs mismatch.
266+
run_ok(&workspace, &["install", "--frozen-lockfile"]);
267+
268+
drop((root, anchor));
269+
}
270+
216271
/// `update --latest --no-save` must not persist catalog edits to
217272
/// `pnpm-workspace.yaml`, matching pnpm's `if (opts.save !== false)` guard.
218273
#[test]

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)