diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d0d0aa..8b86fd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,19 +76,7 @@ jobs: run: | set -euo pipefail NOTES_FILE="$(mktemp)" - RELEASE_VERSION="${RELEASE_VERSION}" node <<'NODE' > "${NOTES_FILE}" - const fs = require("fs"); - const version = process.env.RELEASE_VERSION; - const lines = fs.readFileSync("CHANGELOG.md", "utf8").split(/\r?\n/); - const start = lines.findIndex((line) => line.startsWith(`## ${version} - `)); - if (start === -1) { - console.error(`CHANGELOG.md is missing a ${version} release section`); - process.exit(1); - } - const end = lines.findIndex((line, index) => index > start && line.startsWith("## ")); - const body = lines.slice(start + 1, end === -1 ? lines.length : end).join("\n").trim(); - process.stdout.write(`## plugin-inspector v${version}\n\n${body}\n`); - NODE + node scripts/release-notes.mjs "${RELEASE_VERSION}" > "${NOTES_FILE}" release_flags=(--title "plugin-inspector ${RELEASE_TAG}" --notes-file "${NOTES_FILE}" --verify-tag) if [[ "${RELEASE_TAG}" =~ (alpha|beta|rc) ]]; then diff --git a/docs/releasing.md b/docs/releasing.md index 2b0dd01..12015d3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -73,6 +73,12 @@ versioned section like `## 0.3.1 - 2026-04-28`, update `package.json` to the same version, and update Crabpot's `pluginInspectorRef` to the exact release commit. +Draft release notes from the current `Unreleased` section with: + +```bash +npm run release:notes +``` + ## Crabpot follow-through Before tagging a release, update Crabpot's `pluginInspectorRef` to the diff --git a/package.json b/package.json index 7069d82..4dc1011 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "release:crabpot": "node scripts/check-crabpot-followthrough.mjs", "release:readiness": "npm run release:local && npm run release:crabpot", "release:local": "npm run check", + "release:notes": "node scripts/release-notes.mjs --unreleased", "test": "node --test test/*.test.js" }, "keywords": [ diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..8bfe1e4 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const options = parseArgs(process.argv.slice(2)); + const changelogText = readFileSync(options.changelogPath, "utf8"); + process.stdout.write(extractReleaseNotes({ changelogText, version: options.version })); +} + +export function extractReleaseNotes({ changelogText, version }) { + const lines = changelogText.split(/\r?\n/); + const start = findReleaseStart(lines, version); + if (start === -1) { + throw new Error(`CHANGELOG.md is missing a ${version} release section`); + } + + const end = lines.findIndex((line, index) => index > start && line.startsWith("## ")); + const body = lines.slice(start + 1, end === -1 ? lines.length : end).join("\n").trim(); + const title = version === "Unreleased" ? "plugin-inspector unreleased" : `plugin-inspector v${version}`; + return `## ${title}\n\n${body}\n`; +} + +function findReleaseStart(lines, version) { + if (version === "Unreleased") { + return lines.findIndex((line) => line.trim() === "## Unreleased"); + } + return lines.findIndex((line) => line.startsWith(`## ${version} - `)); +} + +function parseArgs(argv) { + const options = { + changelogPath: path.resolve("CHANGELOG.md"), + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--changelog") { + options.changelogPath = path.resolve(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--unreleased") { + options.version = "Unreleased"; + continue; + } + if (arg === "--version") { + options.version = argv[index + 1]; + index += 1; + continue; + } + if (!options.version) { + options.version = arg; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + + if (!options.version) { + throw new Error("usage: release-notes.mjs |--unreleased [--changelog CHANGELOG.md]"); + } + + return options; +} diff --git a/test/release-notes.test.js b/test/release-notes.test.js new file mode 100644 index 0000000..619e429 --- /dev/null +++ b/test/release-notes.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { extractReleaseNotes } from "../scripts/release-notes.mjs"; + +const changelog = [ + "# Changelog", + "", + "## Unreleased", + "", + "### Added", + "", + "- Add grouped API helpers.", + "", + "## 0.3.0 - 2026-04-27", + "", + "### Changed", + "", + "- Improve setup.", + "", +].join("\n"); + +test("release notes can render unreleased draft notes", () => { + assert.equal( + extractReleaseNotes({ changelogText: changelog, version: "Unreleased" }), + ["## plugin-inspector unreleased", "", "### Added", "", "- Add grouped API helpers.", ""].join("\n"), + ); +}); + +test("release notes can render versioned changelog sections", () => { + assert.equal( + extractReleaseNotes({ changelogText: changelog, version: "0.3.0" }), + ["## plugin-inspector v0.3.0", "", "### Changed", "", "- Improve setup.", ""].join("\n"), + ); +}); + +test("release notes fail when a version section is missing", () => { + assert.throws(() => extractReleaseNotes({ changelogText: changelog, version: "9.9.9" }), /missing a 9\.9\.9/); +});