diff --git a/.github/nsolid-release-lines.json b/.github/nsolid-release-lines.json new file mode 100644 index 0000000000..2e54fb0db0 --- /dev/null +++ b/.github/nsolid-release-lines.json @@ -0,0 +1,3 @@ +{ + "nodeReleaseLines": [22, 24] +} diff --git a/.github/workflows/create-nsolid-release-proposal.yml b/.github/workflows/create-nsolid-release-proposal.yml new file mode 100644 index 0000000000..24ee204d2d --- /dev/null +++ b/.github/workflows/create-nsolid-release-proposal.yml @@ -0,0 +1,48 @@ +name: Create N|Solid Release Proposal + +on: + workflow_dispatch: + inputs: + node-line: + required: true + type: choice + options: + - '22' + - '24' + description: Node.js release line + +concurrency: ${{ github.workflow }}-${{ inputs.node-line }} + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + create: + if: github.repository == 'nodesource/nsolid' + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: true + token: ${{ secrets.GH_USER_TOKEN || github.token }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: lts/* + + - name: Create release proposal + run: tools/actions/create-nsolid-release-proposal.sh + env: + GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN || github.token }} + GH_TOKEN: ${{ secrets.GH_USER_TOKEN || github.token }} + NODE_LINE: ${{ inputs.node-line }} + + - name: Upload conflict report + if: failure() && hashFiles('nsolid-release-conflicts.md') != '' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nsolid-release-conflicts + path: nsolid-release-conflicts.md diff --git a/.github/workflows/watch-node-releases.yml b/.github/workflows/watch-node-releases.yml new file mode 100644 index 0000000000..5b8e164218 --- /dev/null +++ b/.github/workflows/watch-node-releases.yml @@ -0,0 +1,29 @@ +name: Watch Node.js Releases + +on: + schedule: + - cron: '17 12 * * *' + workflow_dispatch: + +concurrency: ${{ github.workflow }} + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + watch: + if: github.repository == 'nodesource/nsolid' + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: lts/* + + - name: Watch Node.js release lines + run: node tools/actions/watch-node-releases.mjs + env: + GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN || github.token }} diff --git a/doc/contributing/nsolid-release-automation.md b/doc/contributing/nsolid-release-automation.md new file mode 100644 index 0000000000..663ffa16c0 --- /dev/null +++ b/doc/contributing/nsolid-release-automation.md @@ -0,0 +1,103 @@ +# N|Solid release automation + +This document describes the GitHub Actions automation that tracks Node.js +releases and prepares N|Solid release proposal pull requests. + +The automation is intentionally small. A releaser should be able to run it from +the GitHub Actions UI by selecting only the Node.js release line. + +## What it does + +The automation has two workflows: + +* `Watch Node.js Releases` + * runs on a schedule and can also be started manually; + * reads `.github/nsolid-release-lines.json`; + * checks the latest stable Node.js release for each configured line; + * skips the release if an N|Solid release branch or tag already exists; + * creates or reuses a tracking issue; + * attempts to create the release proposal. +* `Create N|Solid Release Proposal` + * is manually started with one input: `node-line`; + * finds the latest stable Node.js tag for that line; + * checks out `node-v.x-nsolid-v6.x`; + * reads the current N|Solid version from `src/node_version.h`; + * creates `node-vX.Y.Z-nsolid-vA.B.C-release`; + * merges the upstream Node.js tag; + * sets `NSOLID_VERSION_IS_RELEASE` to `1`; + * prepends the N|Solid changelog entry; + * pushes the release branch; + * opens a draft release PR. + +If the Node.js tag merge conflicts, the workflow stops, comments on the +tracking issue when one exists, and uploads `nsolid-release-conflicts.md` as an +artifact. + +The automation does not create a release tag. Release tags should still be +created by the releaser after the proposal is reviewed. + +## Supported release lines + +The supported lines are configured in `.github/nsolid-release-lines.json`: + +```json +{ + "nodeReleaseLines": [22, 24] +} +``` + +Only lines listed there are watched by the scheduled workflow and shown in the +manual workflow selector. + +## Add a release line + +When N|Solid starts supporting a new Node.js release line: + +1. Add the major version to `.github/nsolid-release-lines.json`. +2. Add the same value to the `node-line` options in + `.github/workflows/create-nsolid-release-proposal.yml`. +3. Ensure the base branch exists: + `node-v.x-nsolid-v6.x`. +4. Ensure the N|Solid changelog exists: + `doc/changelogs/NSOLID_CHANGELOG_V6_NODE_V.md`. +5. Run the workflow manually once for that line. + +For example, to add Node.js v26 support, add `26` to the JSON file and add +`'26'` to the workflow options. + +## Remove a release line + +When N|Solid stops supporting a Node.js release line: + +1. Remove the major version from `.github/nsolid-release-lines.json`. +2. Remove the same value from the `node-line` options in + `.github/workflows/create-nsolid-release-proposal.yml`. + +Do not delete existing release branches, tags, or changelog files as part of +removing the line from automation. + +## Manual use + +To prepare a release proposal manually: + +1. Open the `Create N|Solid Release Proposal` workflow. +2. Select the Node.js release line. +3. Run the workflow. + +The workflow always uses the latest stable Node.js release for the selected +line. If a different upstream tag is required, prepare the release manually. + +## Failure handling + +If the release branch already exists and has an open PR, the workflow reuses the +existing PR. If the release branch exists without an open PR, the workflow fails +so a releaser can inspect the branch before opening a PR manually. + +If the workflow fails with merge conflicts: + +1. Open the workflow artifact named `nsolid-release-conflicts`. +2. Resolve the listed conflicts locally from the generated release branch steps. +3. Continue the release manually. + +If the workflow fails while creating the PR, the release branch may already have +been pushed. Open the PR manually using the branch named in the workflow logs. diff --git a/tools/actions/create-nsolid-release-proposal.sh b/tools/actions/create-nsolid-release-proposal.sh new file mode 100755 index 0000000000..c89a37c6d1 --- /dev/null +++ b/tools/actions/create-nsolid-release-proposal.sh @@ -0,0 +1,224 @@ +#!/bin/sh + +set -eu + +CONFIG_PATH="${CONFIG_PATH:-.github/nsolid-release-lines.json}" +NODE_RELEASE_INDEX_URL="${NODE_RELEASE_INDEX_URL:-https://nodejs.org/download/release/index.json}" +NODE_LINE="${NODE_LINE:-}" +ISSUE_NUMBER="${ISSUE_NUMBER:-}" +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-}" +export NODE_RELEASE_INDEX_URL + +if [ -z "$NODE_LINE" ]; then + echo "Usage: NODE_LINE=22 $0" >&2 + exit 1 +fi + +node -e ' + const fs = require("node:fs"); + const [configPath, nodeLine] = process.argv.slice(1); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if (!config.nodeReleaseLines.map(String).includes(nodeLine)) { + throw new Error(`Node.js v${nodeLine} is not enabled in ${configPath}`); + } +' "$CONFIG_PATH" "$NODE_LINE" + +latest_node_tag() { + node -e ' + const line = Number(process.argv[1]); + const url = process.env.NODE_RELEASE_INDEX_URL; + const timeoutMs = Number(process.env.RELEASE_INDEX_TIMEOUT_MS || 30000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let releases; + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); + releases = await response.json(); + } catch (error) { + if (error.name === "AbortError") { + throw new Error(`Timed out fetching ${url} after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + const stable = releases + .map((release) => release.version) + .filter((version) => new RegExp(`^v${line}\\.\\d+\\.\\d+$`).test(version)) + .sort((a, b) => { + const left = a.slice(1).split(".").map(Number); + const right = b.slice(1).split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return right[i] - left[i]; + } + return 0; + }); + if (stable.length === 0) throw new Error(`No stable Node.js v${line} release found`); + console.log(stable[0]); + ' "$NODE_LINE" +} + +BASE_BRANCH="node-v${NODE_LINE}.x-nsolid-v6.x" +CHANGELOG_PATH="doc/changelogs/NSOLID_CHANGELOG_V6_NODE_V${NODE_LINE}.md" +NODE_TAG="${NODE_TAG:-$(latest_node_tag)}" +NODE_VERSION="${NODE_TAG#v}" +RELEASE_DATE="${RELEASE_DATE:-$(date -u +%F)}" +CONFLICT_REPORT="nsolid-release-conflicts.md" + +node -e ' + const [nodeLine, nodeTag] = process.argv.slice(1); + const pattern = new RegExp(`^v${nodeLine}\\.\\d+\\.\\d+$`); + if (!pattern.test(nodeTag)) { + throw new Error(`Latest Node.js tag for v${nodeLine} is invalid: ${nodeTag}`); + } +' "$NODE_LINE" "$NODE_TAG" + +comment_issue() { + body_file="$1" + if [ -n "$ISSUE_NUMBER" ] && [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then + gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$body_file" || true + fi +} + +write_conflict_report() { + { + echo "## Release proposal merge conflict" + echo + echo "- Base branch: \`$BASE_BRANCH\`" + echo "- Upstream Node.js tag: \`$NODE_TAG\`" + echo "- Release branch: \`$RELEASE_BRANCH\`" + echo + echo "The merge could not be completed automatically. No release PR was opened." + echo + echo "### Conflicted files" + echo + git diff --name-only --diff-filter=U | sed 's/^/- `/' | sed 's/$/`/' + echo + echo "### Git status" + echo + echo '```text' + git status --short + echo '```' + } > "$CONFLICT_REPORT" +} + +git config --local user.email "${GIT_AUTHOR_EMAIL:-nsolid-bot@nodesource.com}" +git config --local user.name "${GIT_AUTHOR_NAME:-N|Solid GitHub Bot}" + +git fetch --no-tags origin "$BASE_BRANCH" +git remote add nodejs https://github.com/nodejs/node.git 2>/dev/null || true +git fetch --no-tags nodejs "refs/tags/${NODE_TAG}:refs/tags/${NODE_TAG}" + +git switch --detach "origin/$BASE_BRANCH" +NSOLID_VERSION="$(python3 tools/getnsolidversion.py)" +RELEASE_BRANCH="node-${NODE_TAG}-nsolid-v${NSOLID_VERSION}-release" + +git fetch --no-tags origin "+refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}" 2>/dev/null || true +if git rev-parse --verify "origin/${RELEASE_BRANCH}" >/dev/null 2>&1; then + EXISTING_PR_URL="$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --head "$RELEASE_BRANCH" \ + --base "$BASE_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url' || true)" + if [ -n "$EXISTING_PR_URL" ]; then + COMMENT_BODY="nsolid-release-success.md" + { + echo "## Release proposal already exists" + echo + echo "- PR: $EXISTING_PR_URL" + echo "- Release branch: \`$RELEASE_BRANCH\`" + } > "$COMMENT_BODY" + comment_issue "$COMMENT_BODY" + echo "$EXISTING_PR_URL" + exit 0 + fi + echo "Release branch origin/${RELEASE_BRANCH} already exists, but no open PR was found." >&2 + exit 1 +fi + +git switch -c "$RELEASE_BRANCH" + +if ! git merge "$NODE_TAG" --no-edit; then + write_conflict_report + comment_issue "$CONFLICT_REPORT" + echo "::error::Merge conflict while merging $NODE_TAG into $BASE_BRANCH" + exit 2 +fi + +node -e ' + const fs = require("node:fs"); + const path = "src/node_version.h"; + const text = fs.readFileSync(path, "utf8"); + const next = text.replace( + /#define NSOLID_VERSION_IS_RELEASE [01]/, + "#define NSOLID_VERSION_IS_RELEASE 1", + ); + if (text === next) { + throw new Error("Could not update NSOLID_VERSION_IS_RELEASE"); + } + fs.writeFileSync(path, next); +' + +CODENAME="$(awk '/#define NODE_VERSION_LTS_CODENAME / { gsub(/"/, "", $3); print $3; exit }' src/node_version.h)" +MERGE_SHA="$(git rev-parse --short=10 HEAD)" +MERGE_SUBJECT="$(git log -1 --format=%s)" +MERGE_AUTHOR="$(git log -1 --format=%an)" + +node -e ' + const fs = require("node:fs"); + const [ + changelogPath, + releaseDate, + nodeVersion, + nsolidVersion, + codename, + mergeSha, + mergeSubject, + mergeAuthor, + ] = process.argv.slice(1); + const text = fs.readFileSync(changelogPath, "utf8"); + const displayCodename = codename ? " \x27" + codename + "\x27" : ""; + const entry = `## ${releaseDate}, Version ${nodeVersion}-nsolid-v${nsolidVersion}${displayCodename} + +### Commits + +* \\[[\`${mergeSha}\`](https://github.com/nodesource/nsolid/commit/${mergeSha})] - ${mergeSubject} (${mergeAuthor}) + +`; + fs.writeFileSync(changelogPath, entry + text); +' "$CHANGELOG_PATH" "$RELEASE_DATE" "$NODE_VERSION" "$NSOLID_VERSION" "$CODENAME" "$MERGE_SHA" "$MERGE_SUBJECT" "$MERGE_AUTHOR" + +git add src/node_version.h "$CHANGELOG_PATH" +git commit -m "${RELEASE_DATE}, Version ${NODE_VERSION}-nsolid-v${NSOLID_VERSION} '${CODENAME}'" + +git push origin "$RELEASE_BRANCH" + +PR_BODY="nsolid-release-pr-body.md" +{ + echo "Release PR for Node.js ${NODE_TAG} with N|Solid v${NSOLID_VERSION}." + echo + echo "Generated by the N|Solid release workflow." +} > "$PR_BODY" + +PR_URL="$(gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$BASE_BRANCH" \ + --head "$RELEASE_BRANCH" \ + --title "${RELEASE_DATE}, Version ${NODE_VERSION}-nsolid-v${NSOLID_VERSION} '${CODENAME}'" \ + --body-file "$PR_BODY" \ + --draft)" + +COMMENT_BODY="nsolid-release-success.md" +{ + echo "## Release proposal created" + echo + echo "- PR: $PR_URL" + echo "- Release branch: \`$RELEASE_BRANCH\`" + echo "- Release tag: not created by automation" +} > "$COMMENT_BODY" +comment_issue "$COMMENT_BODY" + +echo "$PR_URL" diff --git a/tools/actions/watch-node-releases.mjs b/tools/actions/watch-node-releases.mjs new file mode 100755 index 0000000000..b5fe946703 --- /dev/null +++ b/tools/actions/watch-node-releases.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; + +const CONFIG_PATH = '.github/nsolid-release-lines.json'; +const NODE_RELEASE_INDEX_URL = process.env.NODE_RELEASE_INDEX_URL || + 'https://nodejs.org/download/release/index.json'; + +const repository = process.env.GITHUB_REPOSITORY; +const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + +async function githubRequest(path, options = {}) { + if (!repository) throw new Error('GITHUB_REPOSITORY is required'); + if (!token) throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); + + const response = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'x-github-api-version': '2022-11-28', + ...(options.headers || {}), + }, + }); + + if (response.status === 204) return null; + + const text = await response.text(); + const body = text ? JSON.parse(text) : null; + if (!response.ok) { + const error = new Error(`GitHub API ${response.status} for ${path}`); + error.status = response.status; + error.body = body; + throw error; + } + return body; +} + +async function findIssue(title) { + const query = new URLSearchParams({ + q: `repo:${repository} is:issue in:title "${title.replaceAll('"', '\\"')}"`, + per_page: '100', + }); + const result = await githubRequest(`/search/issues?${query}`); + return result.items.find((issue) => !issue.pull_request && issue.title === title); +} + +async function createIssue(title, body, labels) { + try { + return await githubRequest(`/repos/${repository}/issues`, { + method: 'POST', + body: JSON.stringify({ title, body, labels }), + }); + } catch (error) { + if (error.status !== 422) throw error; + return githubRequest(`/repos/${repository}/issues`, { + method: 'POST', + body: JSON.stringify({ title, body }), + }); + } +} + +async function releaseAlreadyExists(nodeTag) { + const branches = await githubRequest( + `/repos/${repository}/git/matching-refs/heads/node-${nodeTag}-nsolid-v`, + ); + if (branches.length > 0) return true; + + const tags = await githubRequest( + `/repos/${repository}/git/matching-refs/tags/node-${nodeTag}-nsolid-v`, + ); + return tags.length > 0; +} + +function latestReleaseForLine(releases, line) { + return releases + .map((release) => release.version) + .filter((version) => new RegExp(`^v${line}\\.\\d+\\.\\d+$`).test(version)) + .sort((a, b) => { + const left = a.slice(1).split('.').map(Number); + const right = b.slice(1).split('.').map(Number); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return right[i] - left[i]; + } + return 0; + })[0]; +} + +function buildIssueBody(line, nodeTag) { + return `## Release candidate + +- Node.js tag: \`${nodeTag}\` +- Base branch: \`node-v${line}.x-nsolid-v6.x\` + +The workflow will try to create the release PR automatically. If the merge conflicts, it will report the conflicted files here. +`; +} + +async function fetchJsonWithTimeout(url) { + const timeoutMs = Number(process.env.RELEASE_INDEX_TIMEOUT_MS || 30000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return await response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Timed out fetching ${url} after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +const config = JSON.parse(await readFile(CONFIG_PATH, 'utf8')); +const lines = config.nodeReleaseLines; +if (!Array.isArray(lines) || lines.length === 0) { + throw new Error(`No release lines configured in ${CONFIG_PATH}`); +} + +const releases = await fetchJsonWithTimeout(NODE_RELEASE_INDEX_URL); + +for (const line of lines) { + const nodeTag = latestReleaseForLine(releases, line); + if (!nodeTag) { + console.log(`No stable Node.js release found for v${line}.`); + continue; + } + + const title = `N|Solid release: Node.js ${nodeTag}`; + if (await releaseAlreadyExists(nodeTag)) { + console.log(`N|Solid release branch or tag already exists for ${nodeTag}; skipping.`); + continue; + } + + const existingIssue = await findIssue(title); + const issue = existingIssue || await createIssue( + title, + buildIssueBody(line, nodeTag), + ['release', 'automation', `node-v${line}`], + ); + + console.log(`${existingIssue ? 'Found' : 'Created'} issue #${issue.number}: ${issue.html_url}`); + + const result = spawnSync('tools/actions/create-nsolid-release-proposal.sh', { + stdio: 'inherit', + env: { + ...process.env, + NODE_LINE: String(line), + NODE_TAG: nodeTag, + ISSUE_NUMBER: String(issue.number), + }, + }); + + if (result.error) { + throw result.error; + } + + if (result.signal) { + throw new Error(`Release proposal for ${nodeTag} was terminated by ${result.signal}`); + } + + if (result.status !== 0) { + process.exitCode = result.status; + } +}