diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22a12caa..56a1a08c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,12 +62,26 @@ jobs: # 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package # releases already exist by the time this step runs, so setting it here # is deterministic. + # + # The title and body are the page people land on from the update toast. + # changesets copied the body from CHANGELOG.md as one long bullet, so it is + # reshaped here (scripts/lib/release-notes.mjs). npm has already published by + # now, so a failure in that must not stop the retag below: without the body + # field the release keeps the text changesets wrote. RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true) if [ -n "$RELEASE_ID" ]; then + NOTES_FILE="$RUNNER_TEMP/release-notes.md" + BODY_ARGS=() + if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then + BODY_ARGS=(-F "body=@$NOTES_FILE") + else + echo "::warning::Keeping the changesets release notes for $VERSION" + fi gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -f tag_name="$NEW_TAG" \ - -f name="$NEW_TAG" \ - -f make_latest=true + -f name="Codeman ${VERSION}" \ + -f make_latest=true \ + "${BODY_ARGS[@]}" fi # Retag diff --git a/docs/releasing.md b/docs/releasing.md index cb960dab..3cab92b9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,6 +19,24 @@ When user says "COM": Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too. + **Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman `). The in-app update toast reads the same bold lead-ins: + + ```markdown + ### Thanks + - @someone for what they did (#123). + + **What the change does for the user (#123).** Two or three plain sentences. + + **Fixes.** + - **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently. + + **Fixes applied while landing.** One sentence each. + + **Docs and tests.** One sentence. + ``` + + `### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@ --notes-file `. + 3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast) 4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json` 5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing: diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs new file mode 100644 index 00000000..478f7300 --- /dev/null +++ b/scripts/generate-release-notes.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +// Prints the GitHub release notes for one version, built from its section of CHANGELOG.md. +// The Release workflow sends this to the release it just published. Run it by hand to preview: +// +// node scripts/generate-release-notes.mjs [version] default: the version in package.json +// +// Exits 1 with nothing on stdout when the changelog has no section for that version, so the +// workflow keeps the text changesets wrote rather than blanking a published release. +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildReleaseNotes } from './lib/release-notes.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const version = process.argv[2] ?? JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version; +const notes = buildReleaseNotes(readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'), version); +if (!notes) { + console.error(`release-notes: CHANGELOG.md has no notes for ${version}`); + process.exit(1); +} +process.stdout.write(notes); diff --git a/scripts/lib/release-notes.mjs b/scripts/lib/release-notes.mjs new file mode 100644 index 00000000..fb8e1151 --- /dev/null +++ b/scripts/lib/release-notes.mjs @@ -0,0 +1,144 @@ +/** + * Turns one version's section of CHANGELOG.md into the body of its GitHub release. + * + * changesets writes a changeset as one list item, `- : `, with the rest + * indented under it, and changesets/action copies that section into the release as it stands. + * So a whole release rendered as a single bullet, and the `### Thanks` that opens a changeset + * sat behind the hash as literal text. The shape below cannot be written into CHANGELOG.md + * instead: `### Changes` is hardcoded upstream, and a `## ` line would end the section + * for both changesets/action and versionSection(). So the changelog keeps changesets' format, + * and the Release workflow applies this to what comes out of it. + * + * Only the commit prefix and the `### Changes` lines are removed. A block this does not + * recognise stays as text; test/release-notes.test.ts checks that over every past release. + */ +import { SECTION_LABELS, cleanLead, versionSection } from './whats-new.mjs'; + +const REPO_URL = 'https://github.com/Ark0N/Codeman'; + +const TYPE_HEADING = /^### (?:Major|Minor|Patch) Changes\s*$/; +const ITEM_START = /^- (?:[0-9a-f]{7,40}: )?(.*)$/; +const HEADING = /^#{1,6}\s+(.+?)\s*$/; +const BOLD_START = /^\*\*(.+?)\*\*(?:\s+(.*))?$/; +const LIST_ITEM = /^\s*[-*] /; +const VERSION_HEADING = /^## (\d+\.\d+\.\d+\S*)\s*$/; +const BEHAVIOUR_CHANGE = /(? 0) items[items.length - 1].push(line.replace(/^ {2}/, '')); + else if (line.trim()) items.push([line]); + } + return items; +} + +/** Splits lines on blank ones, keeping a fenced code block whole. */ +function splitBlocks(lines) { + const blocks = []; + let current = []; + let inFence = false; + for (const line of lines) { + if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence; + if (inFence || line.trim()) { + current.push(line); + } else if (current.length > 0) { + blocks.push(current); + current = []; + } + } + if (current.length > 0) blocks.push(current); + return blocks; +} + +/** + * The title a block opens with, or null. Bold counts only as a lead-in when it ends in + * punctuation or stands alone: "**Allow network access** is a new setting" is a sentence. + */ +function blockTitle(firstLine) { + const heading = HEADING.exec(firstLine); + if (heading) return { title: heading[1], text: '', isHeading: true }; + const bold = BOLD_START.exec(firstLine); + if (bold && (!bold[2] || /[.:?!]$/.test(bold[1]))) return { title: bold[1], text: bold[2] ?? '', isHeading: false }; + return null; +} + +/** Which part of the page a group label heads, or null for a change's own headline. */ +function groupFor(label) { + if (!SECTION_LABELS.test(label)) return null; + if (/^thanks\b/i.test(label)) return 'thanks'; + if (/^fixes$/i.test(label)) return 'fixes'; + return /^new features$/i.test(label) ? null : 'also'; +} + +/** Sorts every block of the section into the part of the page it belongs to. */ +function sortBlocks(section) { + const parts = { thanks: [], intro: [], news: [], fixes: [], also: [] }; + const items = changesets(section); + for (const item of items) { + const blocks = splitBlocks(item); + // Text with no headline leads What's new, or it would read as part of the last feature. + let target = 'intro'; + let thanksIsEmpty = false; + for (const block of blocks) { + const [first, ...rest] = block; + const opened = blockTitle(first); + if (opened) { + const label = cleanLead(opened.title); + const group = groupFor(label); + const body = [opened.text, ...rest].filter(Boolean).join('\n'); + target = group ?? 'news'; + thanksIsEmpty = group === 'thanks' && !body; + if (group === 'also') { + // These two are a sentence or two each, so they keep their bold lead-in: a heading + // over every one of them weighs more than the text under it. + parts.also.push(opened.isHeading ? [`**${label}.**`, ...rest].join('\n') : block.join('\n')); + } else if (group) { + if (body) parts[group].push(body); + } else { + parts.news.push(`### ${label}`); + if (body) parts.news.push(body); + } + continue; + } + // Only credit stays under Thanks: what opens it, its list, and a line ending in a colon + // that leads into one. A paragraph after that is the release talking again. + if (target === 'thanks' && !thanksIsEmpty && !LIST_ITEM.test(first) && !first.endsWith(':')) target = 'intro'; + thanksIsEmpty = false; + const text = block.join('\n'); + // A one-line changeset was a bullet in the changelog; beside others it stays one. + parts[target].push(blocks.length === 1 && items.length > 1 ? `- ${text}` : text); + } + } + return parts; +} + +/** The release before `version`, or null for the first one or a version not in the changelog. */ +export function previousVersion(changelog, version) { + const versions = changelog.split(/\r?\n/).flatMap((line) => VERSION_HEADING.exec(line)?.[1] ?? []); + const index = versions.indexOf(version); + return index === -1 ? null : (versions[index + 1] ?? null); +} + +/** The markdown for the release page, or '' when the changelog has nothing for `version`. */ +export function buildReleaseNotes(changelog, version) { + const { thanks, intro, news, fixes, also } = sortBlocks(versionSection(changelog, version)); + const page = []; + // Thanks stays first on purpose: contributors are the first thing a visitor to Latest sees. + if (thanks.length > 0) page.push('## Thanks', ...thanks); + if (intro.length + news.length > 0) page.push("## What's new", ...intro, ...news); + if (fixes.length > 0) page.push('## Fixes', ...fixes); + if (also.length > 0) page.push('## Also in this release', ...also); + if (page.length === 0) return ''; + + const blocks = page.map((block) => (block.startsWith('#') ? block : block.replace(BEHAVIOUR_CHANGE, '**$1:**'))); + const previous = previousVersion(changelog, version); + if (previous) { + blocks.push('---', `**Full Changelog**: ${REPO_URL}/compare/codeman@${previous}...codeman@${version}`); + } + return `${blocks.join('\n\n')}\n`; +} diff --git a/scripts/lib/whats-new.mjs b/scripts/lib/whats-new.mjs index 81d01182..9ac44ed9 100644 --- a/scripts/lib/whats-new.mjs +++ b/scripts/lib/whats-new.mjs @@ -19,9 +19,13 @@ export function versionSection(changelog, version) { // Bold phrases the release notes use as group labels rather than as the headline of a change: // "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes. -const SECTION_LABELS = +// Shared with release-notes.mjs, so the toast and the release page agree on what is a headline. +export const SECTION_LABELS = /^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i; +/** A bold lead-in without its closing punctuation. */ +export const cleanLead = (text) => text.replace(/[\s.:]+$/, '').trim(); + /** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */ export function extractHighlights(section) { const out = []; @@ -30,7 +34,7 @@ export function extractHighlights(section) { // ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has. const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line); if (!m) continue; - const text = m[1].replace(/[\s.:]+$/, '').trim(); + const text = cleanLead(m[1]); if (!text || SECTION_LABELS.test(text)) continue; if (!out.includes(text)) out.push(text); } diff --git a/test/release-notes.test.ts b/test/release-notes.test.ts new file mode 100644 index 00000000..5feeea86 --- /dev/null +++ b/test/release-notes.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +// @ts-expect-error plain .mjs helper shared with scripts/generate-release-notes.mjs +import { buildReleaseNotes, previousVersion } from '../scripts/lib/release-notes.mjs'; +// @ts-expect-error plain .mjs helper shared with scripts/generate-whats-new.mjs +import { versionSection, extractHighlights } from '../scripts/lib/whats-new.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const realChangelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'); + +/** A release the way changesets writes it: one hashed bullet, the rest nested under it. */ +const CHANGELOG = `# aicodeman + +## 2.1.0 + +### Minor Changes + +- 12024cc: ### Thanks + - @someone for the tile grid (#10). + - @other for reporting the wheel bug (#11). + + **The tile grid (#10).** Six sessions side by side. Behaviour change: \`Ctrl+G\` opens it now. + + It remembers your layout. + + **Install without admin rights.** Nothing lands outside your home folder. + + **Fixes.** + - **The wheel works (#11).** It survives a reload. + - **A second fix.** Text. + + **Fixes applied while landing.** One review fix (#10). + + **Docs and tests.** A new guide. + +## 2.0.0 + +### Patch Changes + +- abcdef1: One line. +- A bullet with no hash. + +## 1.9.0 + +- **The oldest entry.** Text. +`; + +const notes = (version: string, changelog = CHANGELOG): string => buildReleaseNotes(changelog, version); + +describe('release notes', () => { + it('drops what only changesets needs: the commit prefix, the type heading and the nesting', () => { + const body = notes('2.1.0'); + expect(body).not.toMatch(/12024cc/); + expect(body).not.toMatch(/Minor Changes/); + expect(body.split('\n').filter((line) => line.startsWith(' '))).toEqual([]); + }); + + it('lays the page out in order, with Thanks first', () => { + const headings = notes('2.1.0') + .split('\n') + .filter((line) => line.startsWith('#')); + expect(headings).toEqual([ + '## Thanks', + "## What's new", + '### The tile grid (#10)', + '### Install without admin rights', + '## Fixes', + '## Also in this release', + ]); + }); + + it('keeps a paragraph with the feature it follows', () => { + const body = notes('2.1.0'); + expect(body.indexOf('It remembers your layout.')).toBeLessThan(body.indexOf('### Install without admin rights')); + expect(body.indexOf('It remembers your layout.')).toBeGreaterThan(body.indexOf('### The tile grid')); + }); + + it('keeps the fixes as a list and the two short sections as run-ins', () => { + const body = notes('2.1.0'); + expect(body).toContain('## Fixes\n\n- **The wheel works (#11).** It survives a reload.\n- **A second fix.** Text.'); + expect(body).toContain('**Fixes applied while landing.** One review fix (#10).'); + expect(body).toContain('**Docs and tests.** A new guide.'); + }); + + it('makes a behaviour change stand out, without doubling bold that is already there', () => { + expect(notes('2.1.0')).toContain('**Behaviour change:** `Ctrl+G` opens it now.'); + const bold = '## 1.0.0\n\n- **Fixes.**\n - **Clone.** Text. Behaviour changes: a. **Breaking:** b.\n'; + const body = notes('1.0.0', bold); + expect(body).toContain('**Behaviour changes:** a. **Breaking:** b.'); + expect(body).not.toContain('****'); + }); + + it('links the comparison with the previous release, and only when there is one', () => { + expect(previousVersion(CHANGELOG, '2.1.0')).toBe('2.0.0'); + expect(notes('2.1.0')).toContain('compare/codeman@2.0.0...codeman@2.1.0'); + expect(previousVersion(CHANGELOG, '1.9.0')).toBeNull(); + expect(notes('1.9.0')).not.toContain('Full Changelog'); + }); + + it('keeps one-line changesets as a list, hashed or not', () => { + expect(notes('2.0.0')).toContain("## What's new\n\n- One line.\n\n- A bullet with no hash."); + }); + + it('merges several changesets into one page instead of repeating sections', () => { + const changelog = [ + '## 3.0.0', + '### Minor Changes', + '- aaaaaaa: ### Thanks\n - @a for one.\n\n **Feature A.** Text.\n\n **Fixes.**\n - **Fix A.** Text.', + '### Patch Changes', + '- bbbbbbb: ### Thanks\n - @b for two.\n\n **Feature B.** Text.\n\n **Fixes.**\n - **Fix B.** Text.', + ].join('\n\n'); + const body = notes('3.0.0', changelog); + expect(body.match(/^## Thanks$/gm)).toHaveLength(1); + expect(body.match(/^## Fixes$/gm)).toHaveLength(1); + expect(body).toContain('### Feature A'); + expect(body).toContain('### Feature B'); + expect(body).toContain('- @b for two.'); + expect(body).toContain('- **Fix B.** Text.'); + }); + + it('does not take a sentence that opens in bold for a headline', () => { + const changelog = '## 1.0.0\n\n- **Allow network access** is a new setting, off by default.\n'; + const body = notes('1.0.0', changelog); + expect(body).toContain('**Allow network access** is a new setting, off by default.'); + expect(body).not.toContain('###'); + }); + + it('keeps text after the Thanks list out of Thanks, and a lead-in line in it', () => { + const changelog = [ + '## 1.0.0', + '- ### Thanks', + ' 1.0.0 follows 0.9.0, so the thanks belong here too:', + '', + ' - @a for a fix.', + '', + ' ![a demo](https://example.com/demo.png)', + '', + ' **A feature.** Text.', + ].join('\n'); + const body = notes('1.0.0', changelog); + expect(body).toMatch(/## Thanks\n\n1\.0\.0 follows 0\.9\.0.*:\n\n- @a for a fix\.\n\n## What's new\n\n!\[a demo\]/); + }); + + it('keeps credit written as a paragraph under Thanks, where it opened (1.21.0 does this)', () => { + const changelog = + '## 1.0.0\n\n- ### Thanks\n\n Seven rounds of beta testing by **@someone**.\n\n **A feature.** Text.\n'; + expect(notes('1.0.0', changelog)).toMatch( + /^## Thanks\n\nSeven rounds of beta testing by \*\*@someone\*\*\.\n\n## What's new/ + ); + }); + + it('leaves a fenced code block alone', () => { + const changelog = '## 1.0.0\n\n- **A feature.** Text.\n\n ```md\n ### Not a heading\n\n **Fixes.**\n ```\n'; + const body = notes('1.0.0', changelog); + expect(body).toContain('```md\n### Not a heading\n\n**Fixes.**\n```'); + expect(body).not.toContain('## Fixes'); + }); + + it('returns nothing for a version the changelog does not have', () => { + expect(notes('9.9.9')).toBe(''); + expect(notes('1.0.0', '## 1.0.0\n\n### Patch Changes\n')).toBe(''); + }); +}); + +describe('release notes over every release in CHANGELOG.md', () => { + const versions: string[] = [...realChangelog.matchAll(/^## (\d+\.\d+\.\d+\S*)\s*$/gm)].map((m) => m[1]); + + /** The words of a text, ignoring only what the page legitimately changes around them. */ + const words = (text: string): string[] => + text + .replace(/^### (?:Major|Minor|Patch) Changes\s*$/gm, '') + .replace(/^- [0-9a-f]{7,40}: /gm, '') + .replace(/[*#]/g, '') + .split(/\s+/) + .map((word) => word.replace(/[.:]+$/, '')) + .filter((word) => word && word !== '-'); + + it('reads the whole changelog', () => { + expect(versions.length).toBeGreaterThan(200); + }); + + it('never loses a word', () => { + const lost: string[] = []; + for (const version of versions) { + const available = new Map(); + for (const word of words(buildReleaseNotes(realChangelog, version))) { + available.set(word, (available.get(word) ?? 0) + 1); + } + for (const word of words(versionSection(realChangelog, version))) { + const left = available.get(word) ?? 0; + if (left === 0) lost.push(`${version}: ${word}`); + available.set(word, left - 1); + } + } + expect(lost).toEqual([]); + }); + + it('leaves no changesets scaffolding on any page, and opens with Thanks wherever there is one', () => { + for (const version of versions) { + const body: string = buildReleaseNotes(realChangelog, version); + expect(body, version).not.toMatch(/^- [0-9a-f]{7,40}: /m); + expect(body, version).not.toMatch(/^#+ (?:Major|Minor|Patch) Changes/m); + if (/^ *### Thanks|: ### Thanks/m.test(versionSection(realChangelog, version))) { + expect(body.startsWith('## Thanks'), version).toBe(true); + } + } + }); + + it('gives a page every headline the in-app toast announces for the same release', () => { + const body: string = buildReleaseNotes(realChangelog, '1.49.0'); + const highlights: string[] = extractHighlights(versionSection(realChangelog, '1.49.0')); + expect(highlights.length).toBeGreaterThan(0); + for (const highlight of highlights) expect(body).toContain(highlight); + }); +}); + +describe('generate-release-notes.mjs', () => { + const script = resolve(root, 'scripts/generate-release-notes.mjs'); + + it('prints the page for a version', () => { + const out = execFileSync(process.execPath, [script, '1.49.0'], { encoding: 'utf8' }); + expect(out).toBe(buildReleaseNotes(realChangelog, '1.49.0')); + }); + + it('prints nothing and fails for an unknown version, so the workflow keeps the changesets text', () => { + const run = spawnSync(process.execPath, [script, '0.0.1-nope'], { encoding: 'utf8' }); + expect(run.status).toBe(1); + expect(run.stdout).toBe(''); + }); +}); + +describe('Release workflow', () => { + const workflow = readFileSync(resolve(root, '.github/workflows/release.yml'), 'utf8'); + + it('titles the release after the product and keeps the tag the updater parses', () => { + expect(workflow).toContain('-f name="Codeman ${VERSION}"'); + expect(workflow).toContain('-f tag_name="$NEW_TAG"'); + }); + + it('cannot lose the retag to a failed format: the script is guarded and the PATCH is not', () => { + const guard = workflow.indexOf('if node scripts/generate-release-notes.mjs "$VERSION"'); + const patch = workflow.indexOf('gh api -X PATCH'); + expect(guard).toBeGreaterThan(-1); + expect(guard).toBeLessThan(patch); + expect(workflow.slice(guard, patch)).toContain('fi\n'); + expect(workflow.slice(patch)).toContain('"${BODY_ARGS[@]}"'); + }); +});