Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/scripts/set-release-version.mjs
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +39 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the lockfile before writing either manifest.

When package-lock.json lacks packages[""], Line 40 writes package.json and Lines 48-51 then throw. This leaves the manifests out of sync on the error path that must prevent partial updates.

Parse and validate both files first. Write either file only after validation succeeds. Extend the invalid-lockfile test to assert that package.json remains at 1.8.0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/set-release-version.mjs around lines 39 - 53, Update the
release-version flow around the manifest edits to parse and validate both
package.json and package-lock.json, including lock.packages[""], before writing
either file. Preserve the existing error for an invalid lockfile, then apply the
version updates only after validation succeeds. Extend the invalid-lockfile test
to verify package.json remains at 1.8.0.

});
}

// Only run when invoked directly, so the test can import the function.
if (import.meta.filename === argv[1]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files matching script:"
fd -a 'set-release-version\.mjs$' . || true

echo
echo "Script size:"
wc -l .github/scripts/set-release-version.mjs 2>/dev/null || true

echo
echo "Relevant script lines:"
sed -n '1,120p' .github/scripts/set-release-version.mjs 2>/dev/null || true

echo
echo "Usages of script:"
rg -n "set-release-version\.mjs|release-version|releaseVersion|manifest" .github package.json . 2>/dev/null | head -200

Repository: getopenscreen/openscreen

Length of output: 9633


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import json, sys, pathlib, tempfile, os

script = pathlib.Path(".github/scripts/set-release-version.mjs")
if not script.exists():
    print("missing script")
    sys.exit(0)

source = script.read_text()
for needle in ["import.meta.filename", "argv[1]", "resolve(", "join("]:
    print(f"{needle!r}: {source.count(needle)}")

print("contains current comparison:")
print("if (import.meta.filename === argv[1])" in source)

with tempfile.TemporaryDirectory() as d:
    dpath = pathlib.Path(d)
    (dpath / "package.json").write_text("{}")
    os.chdir(dpath)
    os.environ["GITHUB_REPOSITORY"] = "owner/repo"
    os.environ["GITHUB_WORKSPACE"] = str(dpath)
    os.environ["GITHUB_REF_NAME"] = "main"
    os.environ["GITHUB_REF"] = "refs/heads/main"

    full = script.resolve()
    rel = pathlib.Path(".github/scripts/set-release-version.mjs")

    print("\nPaths in temporary repo at root:")
    print("GITHUB_WORKSPACE:", dpath)
    print("script resolve():", full)
    print("cli argv[1] =", str(rel))
    print("absolute argv:", os.path.abspath(str(rel)))
    print("current equality:", str(full) == str(rel))
    print("current equality with resolved argv:", str(full) == os.path.abspath(str(rel)))
PY

Repository: getopenscreen/openscreen

Length of output: 596


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Lines 95-117 of prerelease.yml:"
sed -n '95,117p' .github/workflows/prerelease.yml 2>/dev/null || true

echo
echo "Lines 65-83 of promote.yml:"
sed -n '65,83p' .github/workflows/promote.yml 2>/dev/null || true

echo
echo "Relevant tests:"
sed -n '1,140p' .github/scripts/set-release-version.test.mjs 2>/dev/null || true

Repository: getopenscreen/openscreen

Length of output: 6268


Normalize the direct-invocation path check.

import.meta.filename is absolute, but the workflow commands pass .github/scripts/set-release-version.mjs. The current equality is false, so node .github/scripts/set-release-version.mjs exits without writing package.json or package-lock.json.

Proposed fix
-import { join } from "node:path";
+import { join, resolve } from "node:path";
 
-if (import.meta.filename === argv[1]) {
+if (argv[1] && import.meta.filename === resolve(argv[1])) {

Add a CLI test that invokes node .github/scripts/set-release-version.mjs 1.9.0 from the repository root.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (import.meta.filename === argv[1]) {
if (argv[1] && import.meta.filename === resolve(argv[1])) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/set-release-version.mjs at line 58, Normalize the
direct-invocation comparison around import.meta.filename and argv[1] in the
script’s entry-point guard so equivalent absolute and repository-relative paths
match and the version-update flow runs when invoked from the repository root.
Add a CLI test that executes the script with version 1.9.0 from the repository
root and verifies both package.json and package-lock.json are updated.

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`);
}
115 changes: 115 additions & 0 deletions .github/scripts/set-release-version.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
8 changes: 4 additions & 4 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading