Skip to content

Commit e62b61c

Browse files
committed
feat(scripts): add yarn-lockfile-surgeon for minimum-version lockfile bumps
Adds a CLI tool that surgically bumps packages in Yarn Berry lockfiles to their minimum satisfying versions, unlike `yarn up` which resolves to the latest. Useful for applying security patches on LTS branches with minimal lockfile impact.
1 parent 885b042 commit e62b61c

9 files changed

Lines changed: 2360 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# yarn-lockfile-surgeon
2+
3+
Surgically bump packages in a Yarn Berry (v3+) lockfile to their **minimum
4+
satisfying versions**, without re-resolving unrelated transitive dependencies.
5+
6+
## Why
7+
8+
Yarn's built-in `yarn up` and `yarn install` always resolve new dependency
9+
ranges to the **latest** matching version. On an LTS branch where lockfile
10+
stability matters (e.g. security patches), this pulls in far more changes than
11+
necessary.
12+
13+
This tool resolves to the **lowest** version that satisfies each range,
14+
keeping the lockfile diff as small as possible.
15+
16+
## How it works
17+
18+
1. Removes old lockfile entries for the target packages (including any `patch:` entries)
19+
2. Fetches the new version's metadata from the npm registry
20+
3. For each new dependency range not satisfied by an existing lockfile entry,
21+
resolves to the **minimum satisfying version**
22+
4. Walks transitive dependencies to catch cascading range bumps
23+
5. Writes the updated lockfile with empty checksums —
24+
`yarn install --mode=update-lockfile` fills these in
25+
26+
## Usage
27+
28+
The tool only modifies `yarn.lock`. Before running it, you need to:
29+
30+
1. Update direct dependency versions in your `package.json` files
31+
2. Remove any `patch:` resolutions from `package.json` for the packages being upgraded
32+
3. Delete the corresponding `.yarn/patches/` files
33+
34+
Then run:
35+
36+
```bash
37+
cd scripts/yarn-lockfile-surgeon
38+
npm install # first time only
39+
40+
yarn-lockfile-surgeon yarn.lock \
41+
@scope/package-a@1.2.3 \
42+
@scope/package-b@4.5.6
43+
44+
# Then, from the directory containing yarn.lock
45+
yarn install --mode=update-lockfile
46+
```
47+
48+
## Comparison with `yarn up`
49+
50+
Given `@backstage/backend-defaults@0.12.2` which declares
51+
`@backstage/config: ^1.3.4` (current lockfile has 1.3.3):
52+
53+
| Tool | Resolves `^1.3.4` to | Result |
54+
| ---------------------- | -------------------- | ------------------------------- |
55+
| `yarn up` | 1.3.7 (latest) | Cascading transitive upgrades |
56+
| `yarn-lockfile-surgeon` | 1.3.4 (minimum) | Only the required patch version |
57+
58+
## Known limitations
59+
60+
**Extra entries from `yarn install`**: When `yarn install --mode=update-lockfile`
61+
fills in checksums, it may also add new lockfile entries for dependency ranges
62+
introduced by second-order transitive dependencies. These resolve to the
63+
**latest** version since they go through Yarn's standard resolver. The extra
64+
entries are additive and harmless, but make the diff slightly larger than the
65+
theoretical minimum.
66+
67+
**Unresolvable ranges**: If no published version satisfies a transitive
68+
dependency range, the tool logs a warning and skips it. The range will be left
69+
for `yarn install` to resolve using its default (latest) strategy.
70+
71+
## Prior art
72+
73+
pnpm has a built-in [`resolution-mode: lowest-direct`](https://pnpm.io/settings#resolution-mode)
74+
setting that resolves direct dependencies to their lowest matching version.
75+
Yarn Berry has no equivalent — this tool fills that gap for lockfile-level
76+
bumps.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env node
2+
/**
3+
* yarn-lockfile-surgeon
4+
*
5+
* Surgically bumps packages in a Yarn Berry (v3+) lockfile to their minimum
6+
* satisfying versions, without re-resolving unrelated transitive dependencies.
7+
*
8+
* Unlike `yarn up` or `yarn install`, this tool resolves new ranges to the
9+
* LOWEST version that satisfies them — not the latest — keeping the lockfile
10+
* as close to the original as possible.
11+
*
12+
* Usage:
13+
* yarn dlx yarn-lockfile-surgeon <lockfile> <pkg@version> [<pkg@version> ...]
14+
*
15+
* Example:
16+
* yarn dlx yarn-lockfile-surgeon yarn.lock \
17+
* @scope/package-a@1.2.3 \
18+
* @scope/package-b@4.5.6
19+
*
20+
* After running, execute `yarn install --mode=update-lockfile` to fill in
21+
* checksums without re-resolving dependencies.
22+
*/
23+
24+
import { resolve } from "node:path";
25+
import { parseArgs } from "node:util";
26+
import { structUtils } from "@yarnpkg/core";
27+
import { bumpLockfile } from "./lib.ts";
28+
import { createNpmFetcher } from "./registry.ts";
29+
30+
const USAGE = `Usage: yarn-lockfile-surgeon [--help] <lockfile> <pkg@version> [<pkg@version> ...]
31+
32+
Example:
33+
yarn-lockfile-surgeon yarn.lock @scope/package@1.2.3`;
34+
35+
const { values, positionals } = parseArgs({
36+
options: {
37+
help: { type: "boolean", short: "h", default: false },
38+
},
39+
allowPositionals: true,
40+
});
41+
42+
const [lockfileArg, ...targetArgs] = positionals;
43+
44+
if (values.help || !lockfileArg || targetArgs.length === 0) {
45+
console.error(USAGE);
46+
process.exit(values.help ? 0 : 1);
47+
}
48+
49+
const lockfilePath = resolve(lockfileArg);
50+
const targets = targetArgs.map((arg) => structUtils.parseDescriptor(arg));
51+
52+
console.log("🔪 Yarn Lockfile Surgeon — minimum-version strategy\n");
53+
console.log(`Lockfile: ${lockfilePath}`);
54+
console.log(
55+
`Targets: ${targets.map((t) => structUtils.stringifyDescriptor(t)).join(", ")}`,
56+
);
57+
58+
await bumpLockfile(lockfilePath, targets, createNpmFetcher());

0 commit comments

Comments
 (0)