forked from siddharthvaddem/openscreen
-
Notifications
You must be signed in to change notification settings - Fork 80
fix(release): bump package-lock.json alongside package.json #290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| #!/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. | ||
| */ | ||
| // MAJOR.MINOR.PATCH with the optional prerelease suffix promote.yml and | ||
| // prerelease.yml actually produce ("1.9.0", "2.0.0-rc.3"). Deliberately not full | ||
| // semver: this is a gate on what may be written into a published manifest, and | ||
| // build metadata or a leading "v" would be a caller bug, not a version to honour. | ||
| const RELEASE_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; | ||
|
|
||
| export function setReleaseVersion(version, dir) { | ||
| if (!version) throw new Error("a version is required"); | ||
| // Truthiness alone let 123, " " and "not-a-version" through into both | ||
| // manifests. The callers compute this from a validated tag, so it is a | ||
| // defence-in-depth check — but a script whose whole purpose is to stop bad | ||
| // version metadata should not be the thing that writes it. | ||
| if (typeof version !== "string" || !RELEASE_VERSION.test(version)) { | ||
| throw new Error( | ||
| `invalid version ${JSON.stringify(version)}; expected MAJOR.MINOR.PATCH with an optional prerelease suffix`, | ||
| ); | ||
| } | ||
|
|
||
| // Both manifests are read and validated before a single byte is written. The | ||
| // obvious order — write package.json, then validate the lockfile — left | ||
| // package.json bumped and the lockfile untouched whenever validation failed, | ||
| // which is precisely the half-bump this script exists to end. Its own error | ||
| // path must not reproduce the bug it fixes. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const load = (name) => { | ||
| const file = join(dir, name); | ||
| return { file, json: JSON.parse(readFileSync(file, "utf8")) }; | ||
| }; | ||
|
|
||
| const pkg = load("package.json"); | ||
| const lock = load("package-lock.json"); | ||
|
|
||
| // 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.json.packages?.[""]) { | ||
| throw new Error( | ||
| 'package-lock.json has no packages[""] entry; the lockfile format changed and this script needs updating', | ||
| ); | ||
| } | ||
|
|
||
| pkg.json.version = version; | ||
| lock.json.version = version; | ||
| lock.json.packages[""].version = version; | ||
|
|
||
| for (const { file, json } of [pkg, lock]) { | ||
| writeFileSync(file, `${JSON.stringify(json, null, "\t")}\n`); | ||
| } | ||
| } | ||
|
|
||
| // Only run when invoked directly, so the test can import the function. | ||
| if (import.meta.filename === argv[1]) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const version = argv[2]; | ||
| if (!version) { | ||
| console.error("usage: node .github/scripts/set-release-version.mjs <version>"); | ||
| process.exit(1); | ||
| } | ||
| setReleaseVersion(version, process.cwd()); | ||
| console.log(`version ${version} set in package.json and package-lock.json`); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { copyFileSync, mkdirSync, 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/); | ||
|
|
||
| // The throw alone was never the property this test claims. package.json | ||
| // used to be written before the lockfile was validated, so this case left | ||
| // exactly the half-bump the name promises it prevents. | ||
| expect(JSON.parse(read("package.json")).version).toBe("1.8.0"); | ||
| expect(read("package-lock.json")).not.toContain("1.9.0"); | ||
| }); | ||
|
|
||
| it("requires a version", () => { | ||
| expect(() => setReleaseVersion("", dir)).toThrow(/version is required/); | ||
| }); | ||
|
|
||
| // Truthiness alone let all of these reach both manifests. A number in | ||
| // particular writes `"version": 123`, which is not even a legal package.json. | ||
| it.each([ | ||
| [123, "a number"], | ||
| [" ", "whitespace"], | ||
| ["not-a-version", "a malformed version"], | ||
| ["v1.9.0", "a leading v"], | ||
| ])("rejects %o (%s)", (bad) => { | ||
| expect(() => setReleaseVersion(bad, dir)).toThrow(/invalid version/); | ||
| expect(JSON.parse(read("package.json")).version).toBe("1.8.0"); | ||
| expect(read("package-lock.json")).not.toContain("1.9.0"); | ||
| }); | ||
|
|
||
| // The workflows invoke this with a repository-relative path, and the | ||
| // direct-invocation guard compares against `import.meta.filename`, which is | ||
| // absolute. Node resolves argv[1] before exposing it, so the two match — but | ||
| // nothing pinned that, and the whole script is dead code if it ever stops | ||
| // being true. Invoked here the way promote.yml and prerelease.yml do. | ||
| it("runs when invoked directly through a relative path", () => { | ||
| const scripts = join(dir, "scripts"); | ||
| mkdirSync(scripts); | ||
| copyFileSync( | ||
| join(import.meta.dirname, "set-release-version.mjs"), | ||
| join(scripts, "set-release-version.mjs"), | ||
| ); | ||
|
|
||
| execFileSync("node", ["scripts/set-release-version.mjs", "1.9.0"], { cwd: dir }); | ||
|
|
||
| expect(JSON.parse(read("package.json")).version).toBe("1.9.0"); | ||
| expect(JSON.parse(read("package-lock.json")).packages[""].version).toBe("1.9.0"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.