Skip to content
Draft
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
218 changes: 218 additions & 0 deletions .github/scripts/update-node-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";

const DEFAULT_CONFIG = "images/docker-bake.hcl";
const NODE_RELEASES_URL = "https://nodejs.org/dist/index.json";
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;

export function parseNodeVersions(config) {
const versions = [];
const pattern = /major\s*=\s*"(\d+)"\s*\n\s*version\s*=\s*"([^"]+)"/g;

for (const match of config.matchAll(pattern)) {
if (!VERSION_PATTERN.test(match[2])) {
throw new Error(
`Invalid Node.js version for major ${match[1]}: ${match[2]}`,
);
}

versions.push({ major: match[1], version: match[2] });
}

if (versions.length === 0) {
throw new Error("No Node.js versions found in the Bake configuration");
}

const majors = new Set();
for (const { major, version } of versions) {
if (majors.has(major)) {
throw new Error(
`Duplicate Node.js major in Bake configuration: ${major}`,
);
}
if (!version.startsWith(`${major}.`)) {
throw new Error(`Node.js ${version} does not belong to major ${major}`);
}
majors.add(major);
}

return versions;
}

export function compareVersions(left, right) {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);

for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}

return 0;
}

export function findLatestVersion(releases, major) {
if (!Array.isArray(releases)) {
throw new Error("Node.js release index must be an array");
}

const versions = releases
.map((release) => release?.version)
.filter((version) => typeof version === "string" && version.startsWith("v"))
.map((version) => version.slice(1))
.filter((version) => VERSION_PATTERN.test(version))
.filter((version) => version.startsWith(`${major}.`));

if (versions.length === 0) {
throw new Error(`No stable Node.js releases found for major ${major}`);
}

return versions.reduce((latest, version) =>
compareVersions(version, latest) > 0 ? version : latest,
);
}

export function updateNodeVersion(config, major, nextVersion) {
parseVersion(nextVersion);
if (!nextVersion.startsWith(`${major}.`)) {
throw new Error(`Node.js ${nextVersion} does not belong to major ${major}`);
}

const entries = parseNodeVersions(config);
const current = entries.find((entry) => entry.major === major);
if (!current) {
throw new Error(`Node.js major ${major} is not configured`);
}

const entryPattern = new RegExp(
`(major\\s*=\\s*"${escapeRegExp(major)}"\\s*\\n\\s*version\\s*=\\s*")${escapeRegExp(current.version)}(")`,
);
const updated = config.replace(entryPattern, `$1${nextVersion}$2`);

if (updated === config) {
throw new Error(`Failed to update Node.js major ${major}`);
}

return updated;
}

export function createChangeset(major, currentVersion, nextVersion) {
return `---\n"sandbox-image-node": patch\n---\n\nUpdate Node.js ${major} from ${currentVersion} to ${nextVersion}.\n`;
}

export async function checkNodeVersion({
major,
configPath = DEFAULT_CONFIG,
changesetPath = `.changeset/update-node-${major}.md`,
fetchReleases = fetchNodeReleases,
write = true,
}) {
const config = await readFile(configPath, "utf8");
const entries = parseNodeVersions(config);
const current = entries.find((entry) => entry.major === major);

if (!current) {
throw new Error(`Node.js major ${major} is not configured`);
}

const latest = findLatestVersion(await fetchReleases(), major);
const updated = compareVersions(latest, current.version) > 0;

if (updated && write) {
await writeFile(configPath, updateNodeVersion(config, major, latest));
await writeFile(
changesetPath,
createChangeset(major, current.version, latest),
);
}

return {
major,
current: current.version,
latest,
updated,
configPath,
changesetPath,
};
}

async function fetchNodeReleases() {
const response = await fetch(NODE_RELEASES_URL, {
headers: { "user-agent": "vercel-sandbox-node-version-updater" },
});

if (!response.ok) {
throw new Error(
`Failed to fetch Node.js releases: ${response.status} ${response.statusText}`,
);
}

return response.json();
}

function parseVersion(version) {
const match = VERSION_PATTERN.exec(version);
if (!match) {
throw new Error(`Invalid stable Node.js version: ${version}`);
}
return match.slice(1).map(Number);
}

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function parseArguments(args) {
const options = {};

for (const argument of args) {
if (argument === "--list-majors") {
options.listMajors = true;
} else if (argument === "--dry-run") {
options.dryRun = true;
} else if (argument.startsWith("--major=")) {
options.major = argument.slice("--major=".length);
} else if (argument.startsWith("--config=")) {
options.configPath = argument.slice("--config=".length);
} else if (argument.startsWith("--changeset=")) {
options.changesetPath = argument.slice("--changeset=".length);
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}

return options;
}

async function main() {
const options = parseArguments(process.argv.slice(2));
const configPath = options.configPath ?? DEFAULT_CONFIG;

if (options.listMajors) {
const config = await readFile(configPath, "utf8");
console.log(
JSON.stringify(parseNodeVersions(config).map(({ major }) => major)),
);
return;
}

if (!options.major) {
throw new Error("Pass --major=<major> or --list-majors");
}

const result = await checkNodeVersion({
major: options.major,
configPath,
changesetPath: options.changesetPath,
write: !options.dryRun,
});
console.log(JSON.stringify(result));
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
143 changes: 143 additions & 0 deletions .github/scripts/update-node-version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import {
checkNodeVersion,
compareVersions,
createChangeset,
findLatestVersion,
parseNodeVersions,
updateNodeVersion,
} from "./update-node-version.mjs";

const CONFIG = `target "node" {
matrix = {
node = [
{
major = "22"
version = "22.23.2"
},
{
major = "24"
version = "24.19.0"
},
{
major = "26"
version = "26.7.0"
},
]
}
}
`;

test("parses configured Node.js majors and versions", () => {
assert.deepEqual(parseNodeVersions(CONFIG), [
{ major: "22", version: "22.23.2" },
{ major: "24", version: "24.19.0" },
{ major: "26", version: "26.7.0" },
]);
});

test("rejects duplicate and mismatched majors", () => {
assert.throws(
() => parseNodeVersions(`${CONFIG}\n${CONFIG}`),
/Duplicate Node\.js major/,
);
assert.throws(
() => parseNodeVersions(CONFIG.replace("22.23.2", "24.23.2")),
/does not belong to major 22/,
);
});

test("compares numeric versions", () => {
assert.ok(compareVersions("24.19.1", "24.19.0") > 0);
assert.ok(compareVersions("24.20.0", "24.19.9") > 0);
assert.equal(compareVersions("26.7.0", "26.7.0"), 0);
assert.throws(() => compareVersions("24.20.0-rc.1", "24.19.0"));
});

test("finds the newest stable release for one major", () => {
const releases = [
{ version: "v26.8.0" },
{ version: "v24.20.0-rc.1" },
{ version: "v24.19.1" },
{ version: "v24.20.0" },
{ version: "v22.24.0" },
];

assert.equal(findLatestVersion(releases, "24"), "24.20.0");
});

test("updates only the selected major", () => {
const updated = updateNodeVersion(CONFIG, "24", "24.20.0");

assert.deepEqual(parseNodeVersions(updated), [
{ major: "22", version: "22.23.2" },
{ major: "24", version: "24.20.0" },
{ major: "26", version: "26.7.0" },
]);
assert.equal(
updated,
CONFIG.replace('version = "24.19.0"', 'version = "24.20.0"'),
);
assert.throws(() => updateNodeVersion(CONFIG, "20", "20.20.0"));
});

test("creates a patch changeset for the Node image", () => {
assert.equal(
createChangeset("24", "24.19.0", "24.20.0"),
`---
"sandbox-image-node": patch
---

Update Node.js 24 from 24.19.0 to 24.20.0.
`,
);
});

test("writes an update and changeset when a newer release exists", async () => {
const directory = await mkdtemp(join(tmpdir(), "node-version-updater-"));
const configPath = join(directory, "docker-bake.hcl");
const changesetPath = join(directory, "update-node-24.md");
await writeFile(configPath, CONFIG);

const result = await checkNodeVersion({
major: "24",
configPath,
changesetPath,
fetchReleases: async () => [
{ version: "v24.20.0" },
{ version: "v22.24.0" },
],
});

assert.equal(result.updated, true);
assert.equal(result.current, "24.19.0");
assert.equal(result.latest, "24.20.0");
assert.equal(
await readFile(configPath, "utf8"),
CONFIG.replace('version = "24.19.0"', 'version = "24.20.0"'),
);
assert.match(await readFile(changesetPath, "utf8"), /sandbox-image-node/);
});

test("leaves files untouched when the configured release is current", async () => {
const directory = await mkdtemp(join(tmpdir(), "node-version-updater-"));
const configPath = join(directory, "docker-bake.hcl");
const changesetPath = join(directory, "update-node-24.md");
await writeFile(configPath, CONFIG);

const result = await checkNodeVersion({
major: "24",
configPath,
changesetPath,
fetchReleases: async () => [{ version: "v24.19.0" }],
});

assert.equal(result.updated, false);
assert.equal(await readFile(configPath, "utf8"), CONFIG);
await assert.rejects(readFile(changesetPath, "utf8"), { code: "ENOENT" });
});
Loading
Loading