@hyperfrontend/versioningHow to publish release notes from your CHANGELOG.md
You will turn a version number and a CHANGELOG.md into the body of a release, in a script your CI job can run, that fails when the version is not in the file rather than publishing an empty release.
The file already says what shipped. Getting one version out of it is where release jobs go wrong: a regex over ## headings works until an entry has a compare link in its heading, a scope in bold, or a breaking marker, and sed between two headings quietly takes the wrong section when the latest release is a rerun. @hyperfrontend/versioning parses Keep a Changelog into a document you can query.
1. Install it
npm install --save-dev @hyperfrontend/versioning
2. Read the file into a document
parseChangelog takes the markdown and returns the whole changelog as data: a header, and one ChangelogEntry per version, each holding its date, compare URL, and sections.
import { readFileSync } from 'node:fs'
import { parseChangelog } from '@hyperfrontend/versioning/changelog'
const changelog = parseChangelog(readFileSync('CHANGELOG.md', 'utf8'))
3. Take the version you shipped, or stop
getEntryByVersion returns that entry or nothing. Nothing means the release was tagged without its notes ever being written, which is worth failing on:
import { getEntryByVersion } from '@hyperfrontend/versioning/changelog'
const version = process.argv[2]
const entry = getEntryByVersion(changelog, version)
if (!entry) {
console.error(`CHANGELOG.md has no entry for ${version}`)
process.exit(1)
}
4. Render that entry on its own
A changelog holding one entry and an empty header serialises to exactly that entry, so serializeChangelog writes the release body with the same formatting rules that wrote the file:
import { createChangelog, serializeChangelog } from '@hyperfrontend/versioning/changelog'
const body = serializeChangelog(createChangelog({ header: { title: '', description: [], links: [] }, entries: [entry] }), {
includeCompareUrls: false,
}).trim()
Turning off compare URLs keeps the version heading plain text, since the release page is already named after the tag. The rest of SerializeOptions tunes the same output: section order, headings, list markers, and whether commit and issue references come along.
5. Lead with what a reader needs to see first
Every ChangelogItem carries breaking, lifted off the item text during parsing, so an upgrade warning is one pass over the sections. The entry's compareUrl is the diff against the previous release:
const breaking = entry.sections.some((section) => section.items.some((item) => item.breaking))
process.stdout.write(
[
breaking ? '> **This release contains breaking changes.**\n\n' : '',
body,
entry.compareUrl ? `\n\n**Full changelog**: ${entry.compareUrl}` : '',
].join('')
)
Check it worked
Run the script against a version you have already released and pipe it where the release goes:
node release-notes.mjs 1.4.0 > notes.md
gh release create v1.4.0 --notes-file notes.md
notes.md holds that version's sections and nothing from its neighbours, with the breaking marker still on the items that had it. Ask for a version the file does not mention and the script exits 1 with the version named, before anything is published. Ask for the same version twice and you get byte-identical notes, which is what makes the job safe to re-run.
Reference and background
Next steps
How to replace commitizen and commitlint with one package
Guided commit authoring and commit-message linting take four packages and two config files that have to be kept agreeing with each other, and the prompt still offers types the linter rejects.
Publish a TypeScript library to npm
Shipping a TypeScript library means an exports map, ESM and CJS builds, and a declaration pass that all have to agree, and every one of them is hand-maintained until it silently stops matching what was emitted.