mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
ci(release): turn the GitHub release body into a readable page
changesets/action copies a CHANGELOG.md section into the release as it stands, so a whole release rendered as one bullet behind its commit hash, with `### Thanks` as literal text and "Minor Changes" as the only real heading. The Release workflow now rebuilds the body from that section: Thanks first, then What's new with a heading per change, Fixes, Also in this release and a compare link, with "Behaviour change:" and "Breaking:" in bold. The release is titled `Codeman <x.y.z>`; the tag stays `codeman@<x.y.z>`, which is what the updater and the update toast read. This cannot be written into CHANGELOG.md instead: `### <Type> Changes` is hardcoded upstream, and a `## ` line would end the section for changesets/action and versionSection(). So the changelog keeps its format and the toast is unaffected. npm has already published when the step runs, so a formatter failure leaves the changesets text with a warning and the retag still happens. SECTION_LABELS is now shared with whats-new.mjs so the toast and the page agree on what counts as a headline. docs/releasing.md records the changeset shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66a9a574d
commit
e9b2ecd552
6 files changed
+455
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 <x.y.z>`). 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@<x.y.z> --notes-file <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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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, `- <hash>: <first line>`, 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: `### <Type> 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 `### <Type> 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 = /(?<![*\w])(Behaviou?r changes?|Breaking):/g;
|
||||
|
||||
/** The section's changesets, each as its own lines with the prefix and indent removed. */
|
||||
function changesets(section) {
|
||||
const items = [];
|
||||
for (const line of section.split('\n')) {
|
||||
if (TYPE_HEADING.test(line)) continue;
|
||||
const start = ITEM_START.exec(line);
|
||||
if (start) items.push([start[1]]);
|
||||
else if (items.length > 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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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<string, number>();
|
||||
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[@]}"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user