diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51dea15..0a52853 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,25 +2,33 @@ name: Release # Manually triggered ("Run workflow"). On trigger it: # 1. reads the version from package.json, -# 2. builds a self-contained bundle for every platform (one runner — there's no +# 2. promotes `## [Unreleased]` content into `## []` in +# CHANGELOG.md (and commits + pushes that change back to main), so +# the published release notes are never sparse just because the +# maintainer didn't pre-stage the [] block by hand, +# 3. builds a self-contained bundle for every platform (one runner — there's no # native compilation, so cross-packaging is fine), -# 3. creates the GitHub Release (tag v) with all archives, using the +# 4. creates the GitHub Release (tag v) with all archives, using the # release notes from CHANGELOG.md, -# 4. publishes the npm thin-installer (shim + per-platform packages). +# 5. publishes the npm thin-installer (shim + per-platform packages). # -# Before triggering: bump package.json and make sure CHANGELOG.md has the matching -# section (## [], or ## [Unreleased]). Set the NPM_TOKEN repo secret. +# Before triggering: bump package.json. CHANGELOG.md entries can live under +# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret. on: workflow_dispatch: {} permissions: - contents: write # create the GitHub Release + tag + contents: write # create the GitHub Release + tag, push the CHANGELOG promote jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + # Default checkout is detached at a SHA; we need an actual branch + # so the CHANGELOG-promote commit knows where to push. + ref: ${{ github.ref }} - uses: actions/setup-node@v6 with: node-version: 22 @@ -29,6 +37,32 @@ jobs: - name: Ensure zip/unzip run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip + - name: Resolve version + id: ver + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Promote [Unreleased] → [] in CHANGELOG.md + # Idempotent: a no-op if [Unreleased] is empty OR if the previous + # run already moved everything. Auto-commit + push the change back + # so the version block on main is the source of truth going + # forward (and so subsequent extract-release-notes.mjs calls + # surface the full content even if this run is re-triggered). + run: | + set -euo pipefail + V="${{ steps.ver.outputs.version }}" + before=$(git rev-parse HEAD) + node scripts/prepare-release.mjs "$V" + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged — nothing to commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow." + # Push to the branch the workflow was triggered on (main). + git push origin "HEAD:${GITHUB_REF#refs/heads/}" + fi + - name: Build all platform bundles run: | for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do @@ -43,11 +77,10 @@ jobs: ( cd release && sha256sum codegraph-* > SHA256SUMS ) cat release/SHA256SUMS - - name: Resolve version - id: ver - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - - name: Release notes from CHANGELOG.md + # The [] block was guaranteed-populated by the + # "Promote" step above, so the [Unreleased] fallback should + # never be needed in practice. Kept for defense-in-depth. run: | V="${{ steps.ver.outputs.version }}" node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \ diff --git a/__tests__/prepare-release.test.ts b/__tests__/prepare-release.test.ts new file mode 100644 index 0000000..7e0af6c --- /dev/null +++ b/__tests__/prepare-release.test.ts @@ -0,0 +1,187 @@ +/** + * Unit tests for `scripts/prepare-release.mjs`. + * + * The script reads CHANGELOG.md and package.json from `process.cwd()`, + * so the tests run it via `node` in a temp directory after staging + * those files. Real script, real fs — keeps the test honest about what + * the workflow will actually do. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs'); + +function run(cwd: string, ...args: string[]) { + const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }); + return out.trim(); +} + +function setup(changelog: string, version = '1.2.3') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-release-')); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version })); + return dir; +} + +const HEADER = `# Changelog + +Some intro. + +`; + +describe('prepare-release.mjs', () => { + let dir: string; + afterEach(() => { + if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe('Case A: [version] block does not yet exist', () => { + it('renames [Unreleased] to [version] - and adds a fresh empty [Unreleased]', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- New feature foo\n- New feature bar\n\n### Fixed\n- Fixed thing\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old entry\n`, + ); + const out = run(dir); + expect(out).toMatch(/renamed \[Unreleased\] to \[1\.2\.3\]/); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // [Unreleased] is now empty and at the top. + expect(result).toMatch(/## \[Unreleased\]\n\n\n## \[1\.2\.3\]/); + // [1.2.3] gets a date. + expect(result).toMatch(/## \[1\.2\.3\] - \d{4}-\d{2}-\d{2}/); + // Promoted content lives under [1.2.3]. + const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0]; + expect(v123Section).toContain('### Added'); + expect(v123Section).toContain('- New feature foo'); + expect(v123Section).toContain('- New feature bar'); + expect(v123Section).toContain('### Fixed'); + expect(v123Section).toContain('- Fixed thing'); + // [1.2.2] is intact. + expect(result).toContain('## [1.2.2] - 2026-01-01'); + expect(result).toContain('- Old entry'); + }); + }); + + describe('Case B: [version] already exists AND [Unreleased] has content', () => { + it('merges Unreleased sub-sections into the matching [version] sub-sections', () => { + // The v0.9.5 scenario verbatim: sparse [0.9.5] with two Fixed + // entries, full [Unreleased] above it with Added + more Fixed. + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Big feature 1\n- Big feature 2\n\n### Fixed\n- Watcher fix\n- Worktree fix\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix A\n- Old fix B\n\n## [1.2.2] - 2026-01-01\n`, + ); + const out = run(dir); + expect(out).toMatch(/merged \d+ Unreleased entries/); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // [Unreleased] is emptied. + const unrelSection = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0]; + expect(unrelSection.trim()).toBe(''); + + // [1.2.3] now has BOTH the original Fixed entries AND the + // Unreleased Fixed entries, plus the new Added sub-section. + const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0]; + expect(v123Section).toContain('### Added'); + expect(v123Section).toContain('- Big feature 1'); + expect(v123Section).toContain('- Big feature 2'); + expect(v123Section).toContain('### Fixed'); + expect(v123Section).toContain('- Old fix A'); + expect(v123Section).toContain('- Old fix B'); + expect(v123Section).toContain('- Watcher fix'); + expect(v123Section).toContain('- Worktree fix'); + // Date on [1.2.3] is preserved (we don't re-stamp it). + expect(result).toContain('## [1.2.3] - 2026-02-02'); + }); + + it('appends sub-sections that exist only in [Unreleased] to the [version] block', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Security\n- CVE patch\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix\n`, + ); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const v123 = result.split('## [1.2.3]')[1]; + expect(v123).toContain('### Fixed'); + expect(v123).toContain('- Old fix'); + expect(v123).toContain('### Security'); + expect(v123).toContain('- CVE patch'); + }); + }); + + describe('Case C: [Unreleased] has no entries', () => { + it('is a no-op when [Unreleased] is empty', () => { + dir = setup(HEADER + `## [Unreleased]\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- thing\n`); + const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out = run(dir); + expect(out).toMatch(/nothing to do/); + const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(after).toBe(before); + }); + + it('is a no-op when [Unreleased] has only sub-section headings with no bullets', () => { + dir = setup( + HEADER + `## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.2.3] - 2026-02-02\n`, + ); + const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out = run(dir); + expect(out).toMatch(/nothing to do/); + const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(after).toBe(before); + }); + }); + + describe('idempotency', () => { + it('running twice produces the same output as running once', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Thing A\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old\n`, + ); + run(dir); // first run promotes + const afterFirst = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out2 = run(dir); // second run should be a no-op + const afterSecond = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(out2).toMatch(/nothing to do/); + expect(afterSecond).toBe(afterFirst); + }); + }); + + describe('version source', () => { + it('reads the target version from package.json by default', () => { + dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9'); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain('## [9.9.9]'); + }); + + it('accepts an explicit version argument that overrides package.json', () => { + dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9'); + run(dir, '5.5.5'); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain('## [5.5.5]'); + expect(result).not.toContain('## [9.9.9]'); + }); + }); + + describe('extractor integration', () => { + it('the resulting [version] block is what extract-release-notes.mjs would surface', () => { + // Run prepare, then extract — confirm the output contains all the + // promoted entries. + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Feature A\n- Feature B\n\n### Fixed\n- Bug fix\n\n## [1.2.2] - 2026-01-01\n`, + ); + run(dir); + + const extractor = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs'); + const notes = execFileSync('node', [extractor, '1.2.3'], { cwd: dir, encoding: 'utf8' }); + expect(notes).toContain('### Added'); + expect(notes).toContain('Feature A'); + expect(notes).toContain('Feature B'); + expect(notes).toContain('### Fixed'); + expect(notes).toContain('Bug fix'); + }); + }); +}); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..0d1655e --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Promote `## [Unreleased]` content into `## []` in CHANGELOG.md + * so the release.yml workflow's `extract-release-notes.mjs ` call + * picks up everything that landed since the last release. + * + * **Why this exists:** the release workflow used to do a literal + * `extract-release-notes.mjs ` lookup with an `[Unreleased]` + * fallback. The fallback only triggers if the `[]` block + * doesn't exist at all — and in practice maintainers sometimes had a + * sparse `[]` block pre-populated (e.g. one early fix + * documented before the rest of the work landed). The workflow then + * extracted that sparse block, ignoring the much larger `[Unreleased]` + * section above it — so the published release notes were missing most + * of what shipped. See v0.9.5 for the canonical post-mortem. + * + * **What it does**, idempotently: + * + * Case A — `[]` does not exist yet: + * Rename the `[Unreleased]` header to `[] - ` + * and add a fresh empty `## [Unreleased]` block above it. This is + * the common case. + * + * Case B — `[]` exists AND `[Unreleased]` has content: + * Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed / + * ### Changed / ### Removed / ### Deprecated / ### Security) into + * the corresponding sub-sections of `[]`. Unmatched + * sub-sections are appended to `[]`. The `[Unreleased]` + * block is then emptied. + * + * Case C — `[Unreleased]` has no content: + * No-op. Exit 0. Re-runs of the workflow are safe. + * + * **Where the date comes from:** for Case A, `` is the + * UTC date at run time. Matches the existing CHANGELOG convention. + * + * **Usage:** + * + * node scripts/prepare-release.mjs # reads version from package.json + * node scripts/prepare-release.mjs 1.2.3 # explicit version + * + * **Output:** + * + * Writes CHANGELOG.md in place. Prints a summary line to stdout + * like `prepare-release: 0.9.5 — promoted 6 Unreleased entries`. + * Exits non-zero on parse failures. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const CHANGELOG_PATH = resolve(process.cwd(), 'CHANGELOG.md'); + +function readPackageVersion() { + const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')); + if (!pkg.version) throw new Error('package.json has no "version" field'); + return pkg.version; +} + +function todayUtcIsoDate() { + // YYYY-MM-DD in UTC. Matches the CHANGELOG's existing convention + // (the existing dated entries don't disclose a timezone, but UTC is + // stable across runners and is what the workflow's runner produces + // by default anyway). + return new Date().toISOString().slice(0, 10); +} + +/** + * Split the CHANGELOG into a header preface + an ordered list of + * version blocks `{ header, body[] }`, preserving line content + * verbatim so we can re-join without surprises. + */ +function parseChangelog(text) { + const lines = text.split('\n'); + const versionHeaderRe = /^## \[([^\]]+)\](?:\s+-\s+(.+))?\s*$/; + const preface = []; + const blocks = []; // { header: string, name: string, body: string[] } + let cur = null; + for (const line of lines) { + const m = line.match(versionHeaderRe); + if (m) { + if (cur) blocks.push(cur); + cur = { header: line, name: m[1], date: m[2] ?? null, body: [] }; + } else if (cur) { + cur.body.push(line); + } else { + preface.push(line); + } + } + if (cur) blocks.push(cur); + return { preface, blocks }; +} + +function joinChangelog({ preface, blocks }) { + const parts = [preface.join('\n')]; + for (const b of blocks) { + // Reconstruct: header + body. The block body INCLUDES the blank + // line after the header (it was captured verbatim). + parts.push([b.header, ...b.body].join('\n')); + } + return parts.join('\n'); +} + +/** + * Split a block body into ordered sub-sections keyed by their + * `### Heading`. Lines before the first `### Heading` go in + * `leading`. Preserves the original (line-array) body inside each + * sub-section so we can splice cleanly when merging. + */ +function splitSubsections(body) { + const subsectionRe = /^### (\w+)\s*$/; + const leading = []; + const subs = []; // { heading: 'Added' | 'Fixed' | …, headerLine: string, body: string[] } + let cur = null; + for (const line of body) { + const m = line.match(subsectionRe); + if (m) { + if (cur) subs.push(cur); + cur = { heading: m[1], headerLine: line, body: [] }; + } else if (cur) { + cur.body.push(line); + } else { + leading.push(line); + } + } + if (cur) subs.push(cur); + return { leading, subs }; +} + +function rebuildBody({ leading, subs }) { + const parts = []; + if (leading.length) parts.push(leading.join('\n')); + for (const s of subs) { + parts.push([s.headerLine, ...s.body].join('\n')); + } + return parts.join('\n').split('\n'); +} + +/** + * Return true when the block has any meaningful entries (a bullet line + * starting with `-`, `*`, or a digit) — vs. being empty / just + * whitespace / just sub-section headers with nothing under them. + */ +function blockHasContent(body) { + for (const line of body) { + if (/^\s*([-*]|\d+\.)\s+/.test(line)) return true; + } + return false; +} + +/** + * Trim trailing blank lines from an array of lines, then return. + * Keeps the output tidy when merging. + */ +function trimTrailingBlank(arr) { + let i = arr.length; + while (i > 0 && /^\s*$/.test(arr[i - 1])) i--; + return arr.slice(0, i); +} + +function main() { + const versionArg = process.argv[2]; + const version = versionArg || readPackageVersion(); + + const text = readFileSync(CHANGELOG_PATH, 'utf8'); + const parsed = parseChangelog(text); + + const unrelIdx = parsed.blocks.findIndex((b) => b.name === 'Unreleased'); + const verIdx = parsed.blocks.findIndex((b) => b.name === version); + + if (unrelIdx === -1) { + console.log(`prepare-release: no [Unreleased] block — nothing to do`); + return; + } + + const unrel = parsed.blocks[unrelIdx]; + if (!blockHasContent(unrel.body)) { + console.log(`prepare-release: [Unreleased] is empty — nothing to do`); + return; + } + + if (verIdx === -1) { + // Case A — promote Unreleased → [version]. + const today = todayUtcIsoDate(); + const promoted = { + header: `## [${version}] - ${today}`, + name: version, + date: today, + body: trimTrailingBlank(unrel.body).concat(['']), // single trailing blank + }; + const emptied = { + header: `## [Unreleased]`, + name: 'Unreleased', + date: null, + body: ['', ''], // two blank lines for the next round of entries + }; + parsed.blocks.splice(unrelIdx, 1, emptied, promoted); + writeFileSync(CHANGELOG_PATH, joinChangelog(parsed)); + console.log(`prepare-release: ${version} — renamed [Unreleased] to [${version}] - ${today}`); + return; + } + + // Case B — merge Unreleased sub-sections into the existing + // [version] sub-sections. New sub-section headings encountered in + // Unreleased that don't exist in [version] get appended. + const ver = parsed.blocks[verIdx]; + const unrelSubs = splitSubsections(unrel.body); + const verSubs = splitSubsections(ver.body); + + let merged = 0; + for (const us of unrelSubs.subs) { + const target = verSubs.subs.find((s) => s.heading === us.heading); + const usBody = trimTrailingBlank(us.body); + if (usBody.length === 0) continue; + if (target) { + // Append Unreleased's entries to the end of the version's matching + // sub-section, keeping their original ordering. Insert a separating + // blank line if the existing sub-section doesn't already end in one. + const existing = trimTrailingBlank(target.body); + const sep = existing.length && !/^\s*$/.test(existing[existing.length - 1]) ? [''] : []; + target.body = existing.concat(sep, usBody, ['']); + } else { + // Append the whole sub-section to the end. + verSubs.subs.push({ + heading: us.heading, + headerLine: us.headerLine, + body: usBody.concat(['']), + }); + } + merged += usBody.filter((l) => /^\s*([-*]|\d+\.)\s+/.test(l)).length; + } + + ver.body = rebuildBody(verSubs); + // Empty out Unreleased. + unrel.body = ['', '']; + + writeFileSync(CHANGELOG_PATH, joinChangelog(parsed)); + console.log(`prepare-release: ${version} — merged ${merged} Unreleased entries into existing [${version}] block`); +} + +try { + main(); +} catch (err) { + console.error(`prepare-release: ${err?.message ?? err}`); + process.exit(1); +}