Skip to content

Commit afcd182

Browse files
committed
fix(release): bump package-lock.json alongside package.json
prerelease.yml and promote.yml rewrote the version with a sed over package.json alone, so every release shipped a lockfile whose root version disagreed with the package it locks: v1.7.0 package.json=1.7.0 lock=1.6.0 v1.8.0 package.json=1.8.0 lock=1.8.0-rc.4 v1.9.0 package.json=1.9.0 lock=1.8.0 It went unnoticed for three releases because npm ci only fails on dependency drift, never on this field. The mismatch is inert until someone reads the diff, which is how it finally surfaced. Both workflows now call one script that writes package.json and both root version fields of the lockfile (lockfileVersion 3 repeats it under packages[""]). A plain sed cannot do this: the lockfile has a "version" key per dependency, so a naive substitution would rewrite the whole tree. The files are tab-indented JSON that JSON.stringify round-trips byte for byte, so rewriting them whole still yields a three-line diff. That is load-bearing rather than incidental, and the test pins it: if npm ever changes its lockfile formatting, the test fails instead of a release commit silently becoming a 40k-line reformat. The script refuses to write when packages[""] is absent rather than skipping it through optional chaining, since a silent half-bump is the exact failure being fixed.
1 parent af1ff35 commit afcd182

4 files changed

Lines changed: 191 additions & 10 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env node
2+
// Sets the release version in package.json AND package-lock.json.
3+
//
4+
// prerelease.yml and promote.yml used to `sed` package.json alone, so every
5+
// release shipped a lockfile whose root version disagreed with the package it
6+
// locks:
7+
//
8+
// v1.7.0 package.json=1.7.0 lock=1.6.0
9+
// v1.8.0 package.json=1.8.0 lock=1.8.0-rc.4
10+
// v1.9.0 package.json=1.9.0 lock=1.8.0
11+
//
12+
// Nothing caught it for three releases because `npm ci` only fails on
13+
// dependency drift, never on this field — the mismatch is inert until someone
14+
// reads the diff, which is how it was eventually noticed.
15+
//
16+
// Both files are tab-indented JSON that JSON.stringify round-trips byte for
17+
// byte, so rewriting them whole still produces a one-line-per-file diff. The
18+
// test pins that: if npm ever changes how it formats a lockfile, a release
19+
// commit would otherwise silently become a 40k-line reformat.
20+
21+
import { readFileSync, writeFileSync } from "node:fs";
22+
import { join } from "node:path";
23+
import { argv } from "node:process";
24+
25+
/**
26+
* @param {string} version Version to write, e.g. "1.9.0" or "1.9.0-rc.2".
27+
* @param {string} dir Directory holding package.json and package-lock.json.
28+
*/
29+
export function setReleaseVersion(version, dir) {
30+
if (!version) throw new Error("a version is required");
31+
32+
const edit = (name, mutate) => {
33+
const file = join(dir, name);
34+
const json = JSON.parse(readFileSync(file, "utf8"));
35+
mutate(json);
36+
writeFileSync(file, `${JSON.stringify(json, null, "\t")}\n`);
37+
};
38+
39+
edit("package.json", (pkg) => {
40+
pkg.version = version;
41+
});
42+
43+
edit("package-lock.json", (lock) => {
44+
lock.version = version;
45+
// lockfileVersion 3 repeats the root version inside packages[""]. Optional
46+
// chaining would quietly skip it if the shape ever changed — the same
47+
// silent half-bump this script exists to end — so demand it instead.
48+
if (!lock.packages?.[""]) {
49+
throw new Error(
50+
'package-lock.json has no packages[""] entry; the lockfile format changed and this script needs updating',
51+
);
52+
}
53+
lock.packages[""].version = version;
54+
});
55+
}
56+
57+
// Only run when invoked directly, so the test can import the function.
58+
if (import.meta.filename === argv[1]) {
59+
const version = argv[2];
60+
if (!version) {
61+
console.error("usage: node .github/scripts/set-release-version.mjs <version>");
62+
process.exit(1);
63+
}
64+
setReleaseVersion(version, process.cwd());
65+
console.log(`version ${version} set in package.json and package-lock.json`);
66+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { setReleaseVersion } from "./set-release-version.mjs";
6+
7+
let dir;
8+
9+
// Tab-indented, like the real files, and shaped like lockfileVersion 3 — the
10+
// point of most of these assertions is formatting, so the fixtures have to be
11+
// byte-faithful rather than merely structurally right.
12+
const pkg = [
13+
"{",
14+
'\t"name": "openscreen",',
15+
'\t"version": "1.8.0",',
16+
'\t"private": true',
17+
"}",
18+
"",
19+
].join("\n");
20+
21+
const lock = [
22+
"{",
23+
'\t"name": "openscreen",',
24+
'\t"version": "1.8.0",',
25+
'\t"lockfileVersion": 3,',
26+
'\t"requires": true,',
27+
'\t"packages": {',
28+
'\t\t"": {',
29+
'\t\t\t"name": "openscreen",',
30+
'\t\t\t"version": "1.8.0",',
31+
'\t\t\t"dependencies": {',
32+
'\t\t\t\t"zod": "^4.0.0"',
33+
"\t\t\t}",
34+
"\t\t},",
35+
'\t\t"node_modules/zod": {',
36+
'\t\t\t"version": "4.0.0"',
37+
"\t\t}",
38+
"\t}",
39+
"}",
40+
"",
41+
].join("\n");
42+
43+
beforeEach(() => {
44+
dir = mkdtempSync(join(tmpdir(), "set-release-version-"));
45+
writeFileSync(join(dir, "package.json"), pkg);
46+
writeFileSync(join(dir, "package-lock.json"), lock);
47+
});
48+
49+
afterEach(() => {
50+
rmSync(dir, { recursive: true, force: true });
51+
});
52+
53+
const read = (name) => readFileSync(join(dir, name), "utf8");
54+
55+
describe("setReleaseVersion", () => {
56+
it("sets the version in package.json and both lockfile roots", () => {
57+
setReleaseVersion("1.9.0", dir);
58+
59+
expect(JSON.parse(read("package.json")).version).toBe("1.9.0");
60+
const written = JSON.parse(read("package-lock.json"));
61+
expect(written.version).toBe("1.9.0");
62+
expect(written.packages[""].version).toBe("1.9.0");
63+
});
64+
65+
it("accepts a prerelease version", () => {
66+
setReleaseVersion("2.0.0-rc.3", dir);
67+
68+
expect(JSON.parse(read("package.json")).version).toBe("2.0.0-rc.3");
69+
expect(JSON.parse(read("package-lock.json")).packages[""].version).toBe("2.0.0-rc.3");
70+
});
71+
72+
// The reason the script may rewrite these files wholesale: anything else in
73+
// them must come back out byte for byte. If npm changes its lockfile
74+
// formatting, this fails here rather than turning a release commit into a
75+
// 40k-line reformat nobody reviews.
76+
it("changes only the version lines, leaving formatting untouched", () => {
77+
setReleaseVersion("1.9.0", dir);
78+
79+
const diff = (before, after) => {
80+
const a = before.split("\n");
81+
const b = after.split("\n");
82+
expect(b.length).toBe(a.length);
83+
return a.map((line, i) => [line, b[i]]).filter(([x, y]) => x !== y);
84+
};
85+
86+
expect(diff(pkg, read("package.json"))).toEqual([
87+
['\t"version": "1.8.0",', '\t"version": "1.9.0",'],
88+
]);
89+
expect(diff(lock, read("package-lock.json"))).toEqual([
90+
['\t"version": "1.8.0",', '\t"version": "1.9.0",'],
91+
['\t\t\t"version": "1.8.0",', '\t\t\t"version": "1.9.0",'],
92+
]);
93+
});
94+
95+
it("leaves dependency versions alone", () => {
96+
setReleaseVersion("1.9.0", dir);
97+
98+
const written = JSON.parse(read("package-lock.json"));
99+
expect(written.packages["node_modules/zod"].version).toBe("4.0.0");
100+
});
101+
102+
// A lockfile format change must stop the release, not half-bump it.
103+
it("throws rather than half-bumping when the lockfile shape is unknown", () => {
104+
writeFileSync(
105+
join(dir, "package-lock.json"),
106+
`${JSON.stringify({ name: "openscreen", version: "1.8.0" }, null, "\t")}\n`,
107+
);
108+
109+
expect(() => setReleaseVersion("1.9.0", dir)).toThrow(/packages/);
110+
});
111+
112+
it("requires a version", () => {
113+
expect(() => setReleaseVersion("", dir)).toThrow(/version is required/);
114+
});
115+
});

.github/workflows/prerelease.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ jobs:
106106
echo "Creating release branch ${BRANCH} from ${GITHUB_REF_NAME}"
107107
git checkout -b "$BRANCH"
108108
fi
109-
sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json
110-
echo "package.json version:"
111-
grep '"version"' package.json
112-
git add package.json
109+
# Writes package-lock.json too. A sed over package.json alone left the
110+
# lockfile behind on every release up to 1.9.0; see the script header.
111+
node .github/scripts/set-release-version.mjs "${PRERELEASE}"
112+
git add package.json package-lock.json
113113
git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" || echo "(version already at ${PRERELEASE})"
114114
git push "$REMOTE" "$BRANCH"
115115

.github/workflows/promote.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,25 @@ jobs:
5959
STABLE_VERSION: ${{ steps.version.outputs.stable_version }}
6060
run: node .github/scripts/release-milestone-close.mjs
6161

62-
- name: Bump package.json to stable version on the release branch
62+
- name: Bump the version to stable on the release branch
6363
env:
6464
TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
6565
STABLE_VERSION: ${{ steps.version.outputs.stable_version }}
6666
run: |
6767
set -euo pipefail
6868
# Promote checks out the FROZEN release branch (created by prerelease.yml) and
69-
# rewrites package.json there. This guarantees the stable tag points at the
69+
# rewrites the version there. This guarantees the stable tag points at the
7070
# same code that was tested as the RC plus any cherry-picked bugfixes.
7171
BRANCH="release/v${STABLE_VERSION}"
7272
git fetch origin "$BRANCH"
7373
git checkout "$BRANCH"
7474
git reset --hard "origin/${BRANCH}"
75-
sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${STABLE_VERSION}\2|" package.json
76-
echo "package.json version:"
77-
grep '"version"' package.json
75+
# Writes package-lock.json too. A sed over package.json alone left the
76+
# lockfile behind on every release up to 1.9.0; see the script header.
77+
node .github/scripts/set-release-version.mjs "${STABLE_VERSION}"
7878
git config user.name "github-actions[bot]"
7979
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
80-
git add package.json
80+
git add package.json package-lock.json
8181
git commit --allow-empty -m "chore(release): bump to ${STABLE_VERSION} [skip ci]" || true
8282
git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"
8383

0 commit comments

Comments
 (0)