diff --git a/.changeset/bright-nodes-build.md b/.changeset/bright-nodes-build.md new file mode 100644 index 0000000..6f9a534 --- /dev/null +++ b/.changeset/bright-nodes-build.md @@ -0,0 +1,5 @@ +--- +"sandbox-image-node": patch +--- + +Pin exact Node.js versions during builds and publish exact-version image tags alongside the major-version tags. diff --git a/.github/scripts/update-node-version.mjs b/.github/scripts/update-node-version.mjs new file mode 100644 index 0000000..c1ebda0 --- /dev/null +++ b/.github/scripts/update-node-version.mjs @@ -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= 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; + }); +} diff --git a/.github/scripts/update-node-version.test.mjs b/.github/scripts/update-node-version.test.mjs new file mode 100644 index 0000000..e649d9d --- /dev/null +++ b/.github/scripts/update-node-version.test.mjs @@ -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" }); +}); diff --git a/.github/workflows/update-node-versions.yml b/.github/workflows/update-node-versions.yml new file mode 100644 index 0000000..f066c53 --- /dev/null +++ b/.github/workflows/update-node-versions.yml @@ -0,0 +1,256 @@ +name: Update Node.js Versions + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Check for updates without opening pull requests + type: boolean + default: false + pull_request: + paths: + - ".github/scripts/update-node-version.mjs" + - ".github/scripts/update-node-version.test.mjs" + - ".github/workflows/update-node-versions.yml" + - "images/docker-bake.hcl" + +concurrency: + group: update-node-versions-${{ github.event_name == 'pull_request' && github.ref || 'scheduled' }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + discover: + name: Discover Node.js majors + runs-on: ubuntu-latest + outputs: + majors: ${{ steps.majors.outputs.majors }} + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24.16.0" + + - name: Test updater + run: node --test .github/scripts/update-node-version.test.mjs + + - name: Validate Bake configuration + run: docker buildx bake -f images/docker-bake.hcl --print >/dev/null + + - name: Discover configured majors + id: majors + run: | + echo "majors=$(node .github/scripts/update-node-version.mjs --list-majors)" \ + >> "$GITHUB_OUTPUT" + + update: + name: Update Node.js ${{ matrix.major }} + if: github.event_name != 'pull_request' + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + major: ${{ fromJSON(needs.discover.outputs.majors) }} + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24.16.0" + + - name: Check Node.js ${{ matrix.major }} + env: + DRY_RUN: ${{ inputs.dry_run || false }} + MAJOR: ${{ matrix.major }} + run: | + args=("--major=$MAJOR") + if [[ "$DRY_RUN" == "true" ]]; then + args+=("--dry-run") + fi + node .github/scripts/update-node-version.mjs "${args[@]}" > update-result.json + cat update-result.json + + - name: Create or update pull request + uses: actions/github-script@v7 + env: + DRY_RUN: ${{ inputs.dry_run || false }} + MAJOR: ${{ matrix.major }} + with: + script: | + const fs = require('node:fs'); + const result = JSON.parse(fs.readFileSync('update-result.json', 'utf8')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const branch = `automation/update-node-${result.major}`; + const head = `${owner}:${branch}`; + const { data: repository } = await github.rest.repos.get({ owner, repo }); + + const { data: pullRequests } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + base: repository.default_branch, + head, + }); + + if (process.env.DRY_RUN === 'true') { + core.info( + result.updated + ? `Would update Node.js ${result.major} from ${result.current} to ${result.latest}` + : `Node.js ${result.major} is current at ${result.current}`, + ); + return; + } + + if (!result.updated) { + for (const pullRequest of pullRequests) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullRequest.number, + body: `Closing this automated update because \`${repository.default_branch}\` already contains Node.js ${result.current}, which is current for the ${result.major}.x release line.`, + }); + await github.rest.pulls.update({ + owner, + repo, + pull_number: pullRequest.number, + state: 'closed', + }); + } + + if (pullRequests.length > 0) { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${branch}`, + }).catch(error => { + if (error.status !== 422) throw error; + }); + } + core.info(`Node.js ${result.major} is current at ${result.current}`); + return; + } + + const { data: baseRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${repository.default_branch}`, + }); + + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + await github.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branch}`, + sha: baseRef.object.sha, + force: true, + }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branch}`, + sha: baseRef.object.sha, + }); + } + + const branchHead = baseRef.object.sha; + + const additions = [result.configPath, result.changesetPath].map(path => ({ + path, + contents: fs.readFileSync(path).toString('base64'), + })); + const headline = `chore(images): bump Node.js ${result.major} to ${result.latest}`; + const mutation = ` + mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { oid url } + } + } + `; + + const commitInput = { + branch: { + repositoryNameWithOwner: `${owner}/${repo}`, + branchName: branch, + }, + message: { + headline, + body: 'Automated by the nightly Node.js version updater.', + }, + fileChanges: { additions }, + expectedHeadOid: branchHead, + }; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await github.graphql(mutation, { input: commitInput }); + break; + } catch (error) { + if (attempt === 3 || !/expectedHeadOid|does not match|expected.*head/i.test(error.message)) { + throw error; + } + await new Promise(resolve => setTimeout(resolve, attempt * 1000)); + } + } + + const body = [ + `Updates the Node.js ${result.major}.x image from ${result.current} to ${result.latest}.`, + '', + `- Release: https://github.com/nodejs/node/releases/tag/v${result.latest}`, + `- Keeps both \`node:${result.major}\` and \`node:${result.latest}\` image tags`, + '- Includes a patch changeset for `sandbox-image-node`', + '', + '_This pull request is maintained by the nightly Node.js version updater._', + ].join('\n'); + + if (pullRequests.length > 0) { + const [pullRequest, ...duplicates] = pullRequests; + await github.rest.pulls.update({ + owner, + repo, + pull_number: pullRequest.number, + title: headline, + body, + }); + for (const duplicate of duplicates) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: duplicate.number, + state: 'closed', + }); + } + core.info(`Updated pull request #${pullRequest.number}`); + return; + } + + try { + const { data: pullRequest } = await github.rest.pulls.create({ + owner, + repo, + title: headline, + body, + head: branch, + base: repository.default_branch, + }); + core.info(`Created pull request #${pullRequest.number}`); + } catch (error) { + if (error.status !== 422) throw error; + core.info(`A pull request already exists for ${branch}`); + } diff --git a/images/docker-bake.hcl b/images/docker-bake.hcl index 9ce5b67..a02a068 100644 --- a/images/docker-bake.hcl +++ b/images/docker-bake.hcl @@ -21,20 +21,36 @@ target "ubuntu" { target "node" { matrix = { - major = ["22", "24", "26"] + node = [ + { + major = "22" + version = "22.23.2" + }, + { + major = "24" + version = "24.19.0" + }, + { + major = "26" + version = "26.7.0" + }, + ] } - name = "node-${major}" + name = "node-${node.major}" inherits = ["_common"] context = "node" - tags = ["${REGISTRY}/node:${major}"] + tags = [ + "${REGISTRY}/node:${node.major}", + "${REGISTRY}/node:${node.version}", + ] contexts = { base = "target:ubuntu" } args = { - NODE_MAJOR = major + NODE_VERSION = node.version } } diff --git a/images/node/Dockerfile b/images/node/Dockerfile index 32a6d63..d3d2080 100644 --- a/images/node/Dockerfile +++ b/images/node/Dockerfile @@ -7,11 +7,9 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends curl libarchive-tools && \ rm -rf /var/lib/apt/lists/* -ARG NODE_MAJOR -RUN tarball="$(curl -fsSL "https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/SHASUMS256.txt" \ - | grep -oE 'node-v[0-9]+\.[0-9]+\.[0-9]+-linux-x64\.tar\.xz' | head -n1)" && \ - mkdir -p /opt/node && \ - curl -fsSL "https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/${tarball}" \ +ARG NODE_VERSION +RUN mkdir -p /opt/node && \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ | bsdtar -xJ --strip-components=1 -C /opt/node && \ rm -f /opt/node/CHANGELOG.md /opt/node/LICENSE /opt/node/README.md diff --git a/images/node/README.md b/images/node/README.md index 6e1eeea..59f2532 100644 --- a/images/node/README.md +++ b/images/node/README.md @@ -2,16 +2,15 @@ `vercel/sandbox/node:22` | `vercel/sandbox/node:24` | `vercel/sandbox/node:26` -Node.js on top of the [ubuntu](../ubuntu) base image. Each tag pins a major version -and installs the latest release of that line at build time, so tags roll -forward with rebuilds. +Node.js on top of the [ubuntu](../ubuntu) base image. Each tag pins a major version, +with the exact release supplied to the build through `NODE_VERSION`. Runs as the default `ubuntu` user (uid 1000) with passwordless sudo. ## Packages -- Node.js (latest release of the tag's major line), with `npm`, `npx` and - `corepack` +- Node.js 22.23.2, 24.19.0, or 26.7.0 (depending on the image tag), with + `npm`, `npx` and `corepack` - `pnpm` 11 - `git` - `libatomic1`