From afcd182e9203b2cccf40b3d2418043107a4f8c94 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 17:52:37 +0200 Subject: [PATCH] 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. --- .github/scripts/set-release-version.mjs | 66 +++++++++++ .github/scripts/set-release-version.test.mjs | 115 +++++++++++++++++++ .github/workflows/prerelease.yml | 8 +- .github/workflows/promote.yml | 12 +- 4 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/set-release-version.mjs create mode 100644 .github/scripts/set-release-version.test.mjs diff --git a/.github/scripts/set-release-version.mjs b/.github/scripts/set-release-version.mjs new file mode 100644 index 0000000000..0559e611b8 --- /dev/null +++ b/.github/scripts/set-release-version.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Sets the release version in package.json AND package-lock.json. +// +// prerelease.yml and promote.yml used to `sed` 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 +// +// Nothing caught it 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 was eventually noticed. +// +// Both files are tab-indented JSON that JSON.stringify round-trips byte for +// byte, so rewriting them whole still produces a one-line-per-file diff. The +// test pins that: if npm ever changes how it formats a lockfile, a release +// commit would otherwise silently become a 40k-line reformat. + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { argv } from "node:process"; + +/** + * @param {string} version Version to write, e.g. "1.9.0" or "1.9.0-rc.2". + * @param {string} dir Directory holding package.json and package-lock.json. + */ +export function setReleaseVersion(version, dir) { + if (!version) throw new Error("a version is required"); + + const edit = (name, mutate) => { + const file = join(dir, name); + const json = JSON.parse(readFileSync(file, "utf8")); + mutate(json); + writeFileSync(file, `${JSON.stringify(json, null, "\t")}\n`); + }; + + edit("package.json", (pkg) => { + pkg.version = version; + }); + + edit("package-lock.json", (lock) => { + lock.version = version; + // lockfileVersion 3 repeats the root version inside packages[""]. Optional + // chaining would quietly skip it if the shape ever changed — the same + // silent half-bump this script exists to end — so demand it instead. + if (!lock.packages?.[""]) { + throw new Error( + 'package-lock.json has no packages[""] entry; the lockfile format changed and this script needs updating', + ); + } + lock.packages[""].version = version; + }); +} + +// Only run when invoked directly, so the test can import the function. +if (import.meta.filename === argv[1]) { + const version = argv[2]; + if (!version) { + console.error("usage: node .github/scripts/set-release-version.mjs "); + process.exit(1); + } + setReleaseVersion(version, process.cwd()); + console.log(`version ${version} set in package.json and package-lock.json`); +} diff --git a/.github/scripts/set-release-version.test.mjs b/.github/scripts/set-release-version.test.mjs new file mode 100644 index 0000000000..baf80a8cbf --- /dev/null +++ b/.github/scripts/set-release-version.test.mjs @@ -0,0 +1,115 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setReleaseVersion } from "./set-release-version.mjs"; + +let dir; + +// Tab-indented, like the real files, and shaped like lockfileVersion 3 — the +// point of most of these assertions is formatting, so the fixtures have to be +// byte-faithful rather than merely structurally right. +const pkg = [ + "{", + '\t"name": "openscreen",', + '\t"version": "1.8.0",', + '\t"private": true', + "}", + "", +].join("\n"); + +const lock = [ + "{", + '\t"name": "openscreen",', + '\t"version": "1.8.0",', + '\t"lockfileVersion": 3,', + '\t"requires": true,', + '\t"packages": {', + '\t\t"": {', + '\t\t\t"name": "openscreen",', + '\t\t\t"version": "1.8.0",', + '\t\t\t"dependencies": {', + '\t\t\t\t"zod": "^4.0.0"', + "\t\t\t}", + "\t\t},", + '\t\t"node_modules/zod": {', + '\t\t\t"version": "4.0.0"', + "\t\t}", + "\t}", + "}", + "", +].join("\n"); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "set-release-version-")); + writeFileSync(join(dir, "package.json"), pkg); + writeFileSync(join(dir, "package-lock.json"), lock); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const read = (name) => readFileSync(join(dir, name), "utf8"); + +describe("setReleaseVersion", () => { + it("sets the version in package.json and both lockfile roots", () => { + setReleaseVersion("1.9.0", dir); + + expect(JSON.parse(read("package.json")).version).toBe("1.9.0"); + const written = JSON.parse(read("package-lock.json")); + expect(written.version).toBe("1.9.0"); + expect(written.packages[""].version).toBe("1.9.0"); + }); + + it("accepts a prerelease version", () => { + setReleaseVersion("2.0.0-rc.3", dir); + + expect(JSON.parse(read("package.json")).version).toBe("2.0.0-rc.3"); + expect(JSON.parse(read("package-lock.json")).packages[""].version).toBe("2.0.0-rc.3"); + }); + + // The reason the script may rewrite these files wholesale: anything else in + // them must come back out byte for byte. If npm changes its lockfile + // formatting, this fails here rather than turning a release commit into a + // 40k-line reformat nobody reviews. + it("changes only the version lines, leaving formatting untouched", () => { + setReleaseVersion("1.9.0", dir); + + const diff = (before, after) => { + const a = before.split("\n"); + const b = after.split("\n"); + expect(b.length).toBe(a.length); + return a.map((line, i) => [line, b[i]]).filter(([x, y]) => x !== y); + }; + + expect(diff(pkg, read("package.json"))).toEqual([ + ['\t"version": "1.8.0",', '\t"version": "1.9.0",'], + ]); + expect(diff(lock, read("package-lock.json"))).toEqual([ + ['\t"version": "1.8.0",', '\t"version": "1.9.0",'], + ['\t\t\t"version": "1.8.0",', '\t\t\t"version": "1.9.0",'], + ]); + }); + + it("leaves dependency versions alone", () => { + setReleaseVersion("1.9.0", dir); + + const written = JSON.parse(read("package-lock.json")); + expect(written.packages["node_modules/zod"].version).toBe("4.0.0"); + }); + + // A lockfile format change must stop the release, not half-bump it. + it("throws rather than half-bumping when the lockfile shape is unknown", () => { + writeFileSync( + join(dir, "package-lock.json"), + `${JSON.stringify({ name: "openscreen", version: "1.8.0" }, null, "\t")}\n`, + ); + + expect(() => setReleaseVersion("1.9.0", dir)).toThrow(/packages/); + }); + + it("requires a version", () => { + expect(() => setReleaseVersion("", dir)).toThrow(/version is required/); + }); +}); diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 8723005fef..5a3e1c56d3 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -106,10 +106,10 @@ jobs: echo "Creating release branch ${BRANCH} from ${GITHUB_REF_NAME}" git checkout -b "$BRANCH" fi - sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json - echo "package.json version:" - grep '"version"' package.json - git add package.json + # Writes package-lock.json too. A sed over package.json alone left the + # lockfile behind on every release up to 1.9.0; see the script header. + node .github/scripts/set-release-version.mjs "${PRERELEASE}" + git add package.json package-lock.json git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" || echo "(version already at ${PRERELEASE})" git push "$REMOTE" "$BRANCH" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 3e8214b9ca..ce52a242e5 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -59,25 +59,25 @@ jobs: STABLE_VERSION: ${{ steps.version.outputs.stable_version }} run: node .github/scripts/release-milestone-close.mjs - - name: Bump package.json to stable version on the release branch + - name: Bump the version to stable on the release branch env: TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} STABLE_VERSION: ${{ steps.version.outputs.stable_version }} run: | set -euo pipefail # Promote checks out the FROZEN release branch (created by prerelease.yml) and - # rewrites package.json there. This guarantees the stable tag points at the + # rewrites the version there. This guarantees the stable tag points at the # same code that was tested as the RC plus any cherry-picked bugfixes. BRANCH="release/v${STABLE_VERSION}" git fetch origin "$BRANCH" git checkout "$BRANCH" git reset --hard "origin/${BRANCH}" - sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${STABLE_VERSION}\2|" package.json - echo "package.json version:" - grep '"version"' package.json + # Writes package-lock.json too. A sed over package.json alone left the + # lockfile behind on every release up to 1.9.0; see the script header. + node .github/scripts/set-release-version.mjs "${STABLE_VERSION}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json + git add package.json package-lock.json git commit --allow-empty -m "chore(release): bump to ${STABLE_VERSION} [skip ci]" || true git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"