diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c3e35..efe6175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: needs: [quality, postgresql, mysql, browser, redis] runs-on: ubuntu-latest permissions: - contents: read + contents: write id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -164,3 +164,10 @@ jobs: fi - if: steps.registry.outputs.exists != 'true' run: npm publish --access public + - name: Write release notes + run: node scripts/release-notes.mjs "${GITHUB_REF_NAME#v}" > "${RUNNER_TEMP}/release-notes.md" + - name: Create GitHub release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + name: ${{ github.ref_name }} + body_path: ${{ runner.temp }}/release-notes.md diff --git a/docs/releasing.md b/docs/releasing.md index 5d37658..a4973d6 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -28,7 +28,8 @@ npm trust github solid-objects \ 1. Update the version in `package.json` and `src/version.ts`, refresh the lockfile when needed, and move the release notes out of the Unreleased - section in `CHANGELOG.md`. + section in `CHANGELOG.md` into a dated section for the new version. The + publish job reads that section, so a version without one fails the release. 2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`, `pnpm run build`, `pnpm run pack:check`, `pnpm run test:package`, `pnpm run test:recovery`, `pnpm run test:browser`, and @@ -46,3 +47,8 @@ The tag runs the complete CI matrix. The publish job starts only after every quality, database, Redis, and browser job succeeds. It rejects tags that do not match `package.json`, safely skips versions already present in npm, and publishes new versions with npm provenance. + +The job then builds the release notes with `scripts/release-notes.mjs`, which +prints the `CHANGELOG.md` section for the tagged version, and creates the GitHub +release for the tag. Re-running the job on a tag that npm already holds still +creates a missing release. diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..622d083 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs" +import process from "node:process" + +const version = process.argv[2] + +if (!version) { + process.stderr.write("usage: node scripts/release-notes.mjs \n") + process.exit(1) +} + +const entry = findEntry(fs.readFileSync("CHANGELOG.md", "utf8"), version) + +if (!entry) { + process.stderr.write(`CHANGELOG.md has no entry for ${version}\n`) + process.exit(1) +} + +const manifest = JSON.parse(fs.readFileSync("package.json", "utf8")) +const repository = manifest.repository.url.replace(/^git\+/, "").replace(/\.git$/, "") +const tag = `v${version}` +const changelogLink = `${repository}/blob/${tag}/CHANGELOG.md#${anchor(entry.heading)}` +const correctnessLink = `${repository}/blob/${tag}/docs/correctness.md` + +process.stdout.write( + [ + entry.body, + "", + `Install with \`npm install ${manifest.name}@${version}\`.`, + "", + `See the [full changelog](${changelogLink}) and [correctness boundaries](${correctnessLink}).`, + "", + ].join("\n"), +) + +function findEntry(changelog, releasedVersion) { + const lines = changelog.split("\n") + const start = lines.findIndex((line) => isHeadingFor(line, releasedVersion)) + if (start === -1) return undefined + + const remainder = lines.slice(start + 1) + const end = remainder.findIndex((line) => line.startsWith("## ")) + const body = end === -1 ? remainder : remainder.slice(0, end) + + return { heading: heading(lines[start]), body: body.join("\n").trim() } +} + +function isHeadingFor(line, releasedVersion) { + if (!line.startsWith("## ")) return false + + const text = heading(line) + + return text === releasedVersion || text.startsWith(`${releasedVersion} `) +} + +function heading(line) { + return line.replace(/^##\s+/, "").trim() +} + +function anchor(text) { + return text + .toLowerCase() + .replace(/[^\w\- ]+/g, "") + .replace(/ /g, "-") +} diff --git a/test/release-notes.test.ts b/test/release-notes.test.ts new file mode 100644 index 0000000..48a9db1 --- /dev/null +++ b/test/release-notes.test.ts @@ -0,0 +1,114 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +const scriptPath = path.resolve("scripts/release-notes.mjs") + +const changelog = `# Changelog + +## 0.14.0 - 2026-09-01 + +- Add the newest thing. + +## 0.13.2 - 2026-08-17 + +- Accept a \`key\` on \`schedule\`. +- Add a process administration query. + +## 0.13.0 - 2026-08-16 + +- Release the oldest thing. +` + +let workspace: string | undefined + +afterEach(() => { + if (workspace !== undefined) fs.rmSync(workspace, { recursive: true, force: true }) + workspace = undefined +}) + +describe("release notes", () => { + it("writes the changelog entry for the requested version", () => { + const notes = releaseNotes({ version: "0.13.2" }) + + expect(notes).toContain("- Accept a `key` on `schedule`.") + expect(notes).toContain("- Add a process administration query.") + }) + + it("leaves the neighbouring entries out", () => { + const notes = releaseNotes({ version: "0.13.2" }) + + expect(notes).not.toContain("Add the newest thing") + expect(notes).not.toContain("Release the oldest thing") + expect(notes).not.toContain("## 0.13.0") + }) + + it("reads the oldest entry to the end of the file", () => { + const notes = releaseNotes({ version: "0.13.0" }) + + expect(notes).toContain("- Release the oldest thing.") + }) + + it("names the package and the version to install", () => { + const notes = releaseNotes({ version: "0.13.2" }) + + expect(notes).toContain("npm install solid-objects@0.13.2") + }) + + it("links the changelog entry and the correctness boundaries at the tag", () => { + const notes = releaseNotes({ version: "0.13.2" }) + + expect(notes).toContain( + "https://github.com/cardmagic/solid-objects-js/blob/v0.13.2/CHANGELOG.md#0132---2026-08-17", + ) + expect(notes).toContain( + "https://github.com/cardmagic/solid-objects-js/blob/v0.13.2/docs/correctness.md", + ) + }) + + it("fails when the changelog has no entry for the version", () => { + const result = runScript({ version: "9.9.9" }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain("9.9.9") + }) + + it("fails when no version is given", () => { + const result = runScript({ version: undefined }) + + expect(result.status).not.toBe(0) + }) +}) + +function releaseNotes({ version }: { version: string }) { + const result = runScript({ version }) + if (result.status !== 0) throw new Error(`the script failed: ${result.stderr}`) + + return result.stdout +} + +function runScript({ version }: { version: string | undefined }) { + const directory = buildWorkspace() + + return spawnSync(process.execPath, version === undefined ? [scriptPath] : [scriptPath, version], { + cwd: directory, + encoding: "utf8", + }) +} + +function buildWorkspace() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-notes-")) + fs.writeFileSync(path.join(directory, "CHANGELOG.md"), changelog) + fs.writeFileSync( + path.join(directory, "package.json"), + JSON.stringify({ + name: "solid-objects", + repository: { url: "git+https://github.com/cardmagic/solid-objects-js.git" }, + }), + ) + workspace = directory + + return directory +}