-
-
Notifications
You must be signed in to change notification settings - Fork 3
feat: create GitHub releases from the changelog during npm publish #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| --- | ||
| id: dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d | ||
| effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve | ||
| title: 'Create changelog-backed GitHub releases in publish:ci' | ||
| state: accepted | ||
| created_at: '2026-08-22T19:28:52.332Z' | ||
| derives_from: | ||
| - con-public-npm-releases-use-one-lockstep-version--0c4eg8frxys4fv2s | ||
| --- | ||
|
|
||
| ## Context | ||
|
|
||
| The 1.0.1 packages reached npm without a GitHub release or release notes. A manual second step can be missed, can draft notes from a different source, and can leave npm and GitHub with different release records. The repository already keeps release notes in `CHANGELOG.md` and publishes all public packages at one lockstep version. | ||
|
|
||
| ## Decision | ||
|
|
||
| Treat npm publication and its GitHub release as one operator step in `pnpm publish:ci`. Before npm publication, require a non-empty lockstep version section in `CHANGELOG.md`, verify GitHub access and the remote tag state, and format GitHub notes from that section. Publish every public package first. Then create and push the annotated `v<version>` tag and create the GitHub release from the prepared notes. A retry skips packages and a GitHub release only when the existing release body matches those notes, and it rejects a tag at another commit. | ||
|
|
||
| ## Alternatives | ||
|
|
||
| We rejected manual `gh release create`, notes drafted in the GitHub UI, and a separate release job. Each option splits one release across two sources or two triggers and preserves the failure mode that left 1.0.1 without notes. We also rejected creating the remote tag or GitHub release before npm because that could announce a release whose packages did not publish. | ||
|
|
||
| ## Consequences | ||
|
|
||
| `publish:ci` needs npm, git, and GitHub credentials. Its dry run must test the same hard gates without writing. A failure after npm may still need a retry, so the tag and release steps must be idempotent. `CHANGELOG.md` is the source for public release notes. | ||
|
|
||
| ## Reversal criteria | ||
|
|
||
| Split the GitHub release into a separate job only if the release system can prove it runs once for every successful lockstep npm publication, consumes the same committed changelog section, checks the exact release commit, and exposes a failed or missing GitHub release as a blocking release error. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import test from 'ava'; | ||
| import { parseChangelogArgs } from './changelog'; | ||
|
|
||
| test('parseChangelogArgs accepts a dry run and optional version', (t) => { | ||
| t.deepEqual(parseChangelogArgs(['--dry-run']), { | ||
| dryRun: true, | ||
| version: undefined, | ||
| }); | ||
| t.deepEqual(parseChangelogArgs(['--dry-run', '--version', '1.2.3']), { | ||
| dryRun: true, | ||
| version: '1.2.3', | ||
| }); | ||
| t.deepEqual(parseChangelogArgs(['--version=1.2.3']), { | ||
| dryRun: false, | ||
| version: '1.2.3', | ||
| }); | ||
| t.deepEqual(parseChangelogArgs(['--', '--dry-run']), { | ||
| dryRun: true, | ||
| version: undefined, | ||
| }); | ||
| }); | ||
|
|
||
| test('parseChangelogArgs rejects a missing version value', (t) => { | ||
| const error = t.throws(() => parseChangelogArgs(['--version'])); | ||
| t.regex(error?.message ?? '', /requires a semver value/); | ||
| }); | ||
|
|
||
| test('parseChangelogArgs rejects unknown flags', (t) => { | ||
| const error = t.throws(() => parseChangelogArgs(['--oops'])); | ||
| t.regex(error?.message ?? '', /Unknown changelog flag/); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { promises as fs } from 'node:fs'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import colors from 'kleur'; | ||
| import { | ||
| formatGithubReleaseNotes, | ||
| prepareReleaseChangelog, | ||
| } from './utils/changelog'; | ||
|
|
||
| export type ChangelogCliOptions = { | ||
| readonly dryRun: boolean; | ||
| readonly version?: string; | ||
| }; | ||
|
|
||
| export function parseChangelogArgs( | ||
| argv: readonly string[] | ||
| ): ChangelogCliOptions { | ||
| let dryRun = false; | ||
| let version: string | undefined; | ||
|
|
||
| for (let index = 0; index < argv.length; index += 1) { | ||
| const arg = argv[index]; | ||
| if (arg === '--') continue; | ||
| if (arg === '--dry-run') { | ||
| dryRun = true; | ||
| continue; | ||
| } | ||
| if (arg === '--version') { | ||
| version = argv[index + 1]; | ||
| if (!version || version.startsWith('-')) { | ||
| throw new Error('--version requires a semver value'); | ||
| } | ||
| index += 1; | ||
| continue; | ||
| } | ||
| if (arg.startsWith('--version=')) { | ||
| version = arg.slice('--version='.length); | ||
| if (!version) throw new Error('--version requires a semver value'); | ||
| continue; | ||
| } | ||
| throw new Error(`Unknown changelog flag: ${arg}`); | ||
| } | ||
|
|
||
| return { dryRun, version }; | ||
| } | ||
|
|
||
| export async function readLockstepVersion(): Promise<string> { | ||
| const manifest = JSON.parse( | ||
| await fs.readFile('packages/flatbread/package.json', 'utf8') | ||
| ) as { version?: string }; | ||
| if (!manifest.version) { | ||
| throw new Error('packages/flatbread/package.json is missing a version'); | ||
| } | ||
| return manifest.version; | ||
| } | ||
|
|
||
| export async function shiftChangelog( | ||
| options: ChangelogCliOptions | ||
| ): Promise<void> { | ||
| const version = options.version ?? (await readLockstepVersion()); | ||
| const path = 'CHANGELOG.md'; | ||
| const markdown = await fs.readFile(path, 'utf8'); | ||
| const prepared = prepareReleaseChangelog(markdown, version); | ||
|
|
||
| if (options.dryRun) { | ||
| console.log( | ||
| colors.bold().yellow(`Dry run: changelog for ${version} (no file write)`) | ||
| ); | ||
| } | ||
|
|
||
| if (!prepared.didShift) { | ||
| console.log( | ||
| colors | ||
| .bold() | ||
| .green( | ||
| `CHANGELOG.md already has ## ${version}; Unreleased has no items to move` | ||
| ) | ||
| ); | ||
| printNotes(prepared.notes, version); | ||
|
Comment on lines
+70
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW, not blocking: when
Use the same yellow empty-section warning as the new-heading path ( |
||
| return; | ||
| } | ||
|
|
||
| if (!prepared.didMoveItems) { | ||
| if (options.dryRun) { | ||
| console.log( | ||
| colors | ||
| .bold() | ||
| .yellow( | ||
| `Would create empty heading ## ${version}. Add release notes before publishing.` | ||
| ) | ||
| ); | ||
| printPreview(prepared.markdown); | ||
| return; | ||
| } | ||
|
|
||
| await fs.writeFile(path, prepared.markdown); | ||
| console.log( | ||
| colors | ||
| .bold() | ||
| .yellow( | ||
| `Created empty heading ## ${version}. Add release notes before publishing.` | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| if (options.dryRun) { | ||
| printNotes(prepared.notes, version); | ||
| printPreview(prepared.markdown); | ||
| return; | ||
| } | ||
|
|
||
| await fs.writeFile(path, prepared.markdown); | ||
| console.log( | ||
| colors.bold().green(`Moved Unreleased items under ## ${version}`) | ||
| ); | ||
| printNotes(prepared.notes, version); | ||
| } | ||
|
|
||
| function printNotes(notes: string, version: string): void { | ||
| console.log(colors.bold('\nGitHub release notes\n')); | ||
| console.log(formatGithubReleaseNotes(notes, version)); | ||
| } | ||
|
|
||
| function printPreview(markdown: string): void { | ||
| const lines = markdown.split('\n'); | ||
| const preview = lines.slice(0, 40).join('\n'); | ||
| const omitted = | ||
| lines.length > 40 ? `\n… ${lines.length - 40} more lines` : ''; | ||
| console.log(colors.bold('\nCHANGELOG.md preview\n')); | ||
| console.log(`${preview}${omitted}`); | ||
| } | ||
|
|
||
| if (process.argv[1] === fileURLToPath(import.meta.url)) { | ||
| await shiftChangelog(parseChangelogArgs(process.argv.slice(2))); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.