From 95b85cabe19107ed72fdde78a495219723b4e2e9 Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Sat, 22 Aug 2026 12:10:14 -0700 Subject: [PATCH] feat: create GitHub releases from the changelog during npm publish The 1.0.1 packages reached npm without a GitHub release or release notes. `pnpm publish:ci` now owns both halves of the release so that step cannot be missed. Before npm publication, the script checks the clean tree, a non-empty committed changelog section, GitHub access, the pushed commit, and the remote tag. After every public package is on npm, it creates and pushes the annotated `v` tag and creates the GitHub release from that version's `CHANGELOG.md` section. Retries skip an existing GitHub release only when its body matches those filed notes. `pnpm publish:dry` runs the same readiness gates without writing. Bump and changelog commands distinguish moved notes from an empty version placeholder. The release policy and its reversal criteria are recorded in Proof. ## Test plan - [x] Focused AVA tests for bump, changelog, and publish behavior - [x] `pnpm typecheck` - [x] `pnpm verify` Change-Id: I88ddb9fb57904be037d03501f77f3d161d20c5f3 Co-authored-by: Cursor --- ...hub-releases-in-publi--fh6bsn7jr296q93d.md | 29 ++ CONTRIBUTING.md | 85 +++- package.json | 2 + scripts/bumpVersions.ts | 27 ++ scripts/changelog.test.ts | 31 ++ scripts/changelog.ts | 135 ++++++ scripts/publish.test.ts | 254 ++++++++++ scripts/publish.ts | 439 +++++++++++++++++- scripts/utils/changelog.test.ts | 201 ++++++++ scripts/utils/changelog.ts | 203 ++++++++ 10 files changed, 1375 insertions(+), 31 deletions(-) create mode 100644 .flatbread-proof/decisions/dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d.md create mode 100644 scripts/changelog.test.ts create mode 100644 scripts/changelog.ts create mode 100644 scripts/utils/changelog.test.ts create mode 100644 scripts/utils/changelog.ts diff --git a/.flatbread-proof/decisions/dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d.md b/.flatbread-proof/decisions/dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d.md new file mode 100644 index 00000000..d6beeaab --- /dev/null +++ b/.flatbread-proof/decisions/dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d.md @@ -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` 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ef90c87..70cd0aee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,8 +90,8 @@ https://github.com/FlatbreadLabs/oven. There are two steps: -1. Bump every public package to one version -2. Publish the release +1. Bump every public package to one version and file `CHANGELOG.md` +2. Publish the release to npm and GitHub together ### Lockstep versions @@ -123,17 +123,35 @@ What the script does: - Skipping packages that are not yet published on npm - Passes every public package manifest to one `bumpp` command so one chosen version is written across the set +- Moves `CHANGELOG.md` Unreleased list items under a new `## ` + heading and leaves the empty Unreleased section (plus any trailing file + note) in place. Those filed items become the GitHub release notes, after + the centered Flatbread mark and a `Flatbread - v Release Notes` + title. - Stops before publishing if any public package version differs Notes: -- Commit the version bumps after the script completes. For example: +- Commit the version bumps and changelog after the script completes. For + example: ```bash - git add packages/**/package.json + git add packages/**/package.json packages/proof/skills/proof/release.json .agents/skills/proof/release.json CHANGELOG.md git commit -m "release: bump public packages" ``` +- If Unreleased has no list items, the bump creates an empty version heading + and warns instead of claiming that it moved notes. Add release notes under + that heading before publishing. + +- If versions are already bumped but Unreleased still has items, file them + without bumping again: + + ```bash + pnpm changelog:shift --dry-run + pnpm changelog:shift + ``` + - Debugging: set `FLATBREAD_BUMP_DEBUG=1` to see detection details ```bash @@ -142,9 +160,11 @@ Notes: - New public packages join the same version as the rest of the release set. -### 2) Publish packages +### 2) Publish packages and the GitHub release -> Note: you must have access permissions on NPM +> Note: you must have access permissions on NPM and a logged-in GitHub CLI +> (`gh auth status`). Push the release commit before publishing so GitHub +> can see the SHA. When changing the Proof skill, edit the source files under `packages/proof/skills/proof/`, then run these checks in order: @@ -159,10 +179,25 @@ Bump and publish `@flatbread/proof` and `flatbread` together when the skill and runtime need matching versions. The publish script checks the copied skill files and package contents first. It then publishes ordinary packages, `@flatbread/proof`, and finally `flatbread`, stopping at the first -failure. +failure. After every package is on npm, it creates the annotated +`v` tag, pushes that tag to `origin`, and opens a GitHub +release. The notes start with the centered Flatbread mark and a +`Flatbread - v Release Notes` title, then the filed changelog +section. -Publish all public packages (the script checks for one shared version, builds, -then attempts to publish each package): +Preview both sides without publishing: + +```bash +pnpm publish:dry +``` + +The dry run uses the same clean-tree, changelog, GitHub CLI, pushed-commit, +remote-tag, and npm registry gates as a real publish. It exits with an error +if the release is not ready. + +Publish all public packages and the matching GitHub release (the script +checks for one shared version, builds, then attempts to publish each +package): ```bash pnpm publish:ci @@ -170,6 +205,11 @@ pnpm publish:ci Details: +- Requires a clean working tree, a `CHANGELOG.md` section for the release + version, and `gh` authenticated against this repository +- Before publishing any package, checks `origin` for the release tag. An absent + tag or a tag on the release commit is safe. A tag on another commit stops the + release. - Builds the repo: `pnpm run build` - Iterates public packages in dependency-safe deterministic order and runs: @@ -183,24 +223,16 @@ Details: network, and other errors abort before that package is published. - If a release stops after some packages publish, rerun `pnpm publish:ci` safely. Exact versions already published are skipped, and the script resumes - with the first package that still needs publishing. + with the first package that still needs publishing. The GitHub release is + created only after every package is on npm. The script writes the annotated + tag locally, pushes `refs/tags/v` to `origin`, then runs + `gh release create --verify-tag`. An existing GitHub release is skipped only + when its body matches the filed changelog notes; a mismatch stops the release. - Unpublished packages will be published for the first time - Dist-tags (alpha/beta) are currently disabled in the script. If you need them, bump with a pre-release version (`x.y.z-alpha.n`) and add tagging logic in `scripts/publish.ts` -### Post-publish - -- Only after every package publishes successfully, create an annotated, - immutable `v` Git tag at the exact release commit SHA - printed by `pnpm publish:ci`, then push the release commit and tag: - - ```bash - git tag -a v -m "Release v" - git push - git push origin v - ``` - - Protect release tags in the repository settings so they cannot be moved or - deleted after publication. +Protect release tags in the repository settings so they cannot be moved or +deleted after publication. End users install the skill from that release tag and install the matching `flatbread` version. Replace `X` with the released version — `1.0.0` for the @@ -226,7 +258,12 @@ install a newer release tag and its matching `flatbread` version. - Unpublished packages are skipped during bump but will be published during `publish:ci` - First-time publish of a new package + - Set an appropriate initial version in `packages//package.json` - Run `pnpm publish:ci` (the script will publish it) +- Publish stops because Unreleased still has items + - Versions were bumped without filing the changelog. Run + `pnpm changelog:shift`, commit `CHANGELOG.md`, then publish again. + If something’s unclear or you hit an issue, please open an issue or ask in Slack. diff --git a/package.json b/package.json index fb138048..e138d069 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "play:build": "pnpm build && cd examples/nextjs && pnpm build", "prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types", "publish:ci": "esno scripts/publish.ts", + "publish:dry": "esno scripts/publish.ts --dry-run", + "changelog:shift": "esno scripts/changelog.ts", "bump": "esno scripts/bumpVersions.ts", "test:ava": "ava", "test:vitest": "pnpm --filter @flatbread/codegen --filter @flatbread/utils test", diff --git a/scripts/bumpVersions.ts b/scripts/bumpVersions.ts index e1526a9a..ed7ece4c 100644 --- a/scripts/bumpVersions.ts +++ b/scripts/bumpVersions.ts @@ -7,6 +7,7 @@ import { getMonorepoPublicPackages, PathedFlatbreadPackage, } from './utils/packageManifest'; +import { prepareReleaseChangelog } from './utils/changelog'; type PackageChangeInfo = PathedFlatbreadPackage & { changedSinceLastPublish: boolean; @@ -362,6 +363,32 @@ async function main(): Promise { )}\n` ); execSync('pnpm skills:sync', { stdio: 'inherit' }); + + const changelogMarkdown = await fs.readFile('CHANGELOG.md', 'utf8'); + const preparedChangelog = prepareReleaseChangelog( + changelogMarkdown, + flatbreadManifest.version + ); + if (preparedChangelog.didShift) { + await fs.writeFile('CHANGELOG.md', preparedChangelog.markdown); + if (preparedChangelog.didMoveItems) { + console.log( + colors + .bold() + .green( + `Moved CHANGELOG.md Unreleased items under ## ${flatbreadManifest.version}` + ) + ); + } else { + console.log( + colors + .bold() + .yellow( + `Created empty CHANGELOG.md heading ## ${flatbreadManifest.version}. Add release notes before publishing.` + ) + ); + } + } } const invokedScript = process.argv[1] diff --git a/scripts/changelog.test.ts b/scripts/changelog.test.ts new file mode 100644 index 00000000..d1a94ac3 --- /dev/null +++ b/scripts/changelog.test.ts @@ -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/); +}); diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 00000000..d49d6e29 --- /dev/null +++ b/scripts/changelog.ts @@ -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 { + 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 { + 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); + 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))); +} diff --git a/scripts/publish.test.ts b/scripts/publish.test.ts index e0d8a285..f562bbbd 100644 --- a/scripts/publish.test.ts +++ b/scripts/publish.test.ts @@ -1,8 +1,13 @@ import test from 'ava'; import { assertLockstepVersions, + classifyGhReleaseView, classifyNpmViewResult, + classifyRemoteReleaseTag, parseNpmViewVersion, + parsePublishArgs, + preflightGithubRelease, + prepareReadyReleaseChangelog, sortPackages, } from './publish'; @@ -173,3 +178,252 @@ test('npm view preflight aborts on ambiguous not-found text', (t) => { test('npm view preflight accepts numeric 404 status', (t) => { t.is(classifyNpmViewResult({ error: { status: 404 } }, '1.0.0'), 'publish'); }); + +test('parsePublishArgs accepts a dry run', (t) => { + t.deepEqual(parsePublishArgs(['--dry-run']), { dryRun: true }); + t.deepEqual(parsePublishArgs(['--', '--dry-run']), { dryRun: true }); + t.deepEqual(parsePublishArgs([]), { dryRun: false }); +}); + +test('parsePublishArgs rejects unknown flags', (t) => { + const error = t.throws(() => parsePublishArgs(['--oops'])); + t.regex(error?.message ?? '', /Unknown publish flag/); +}); + +test('gh release view recognizes an existing release', (t) => { + t.is( + classifyGhReleaseView( + { + stdout: '{"tagName":"v1.0.1","body":"Filed release notes.\\r\\n"}\n', + }, + 'v1.0.1', + 'Filed release notes.' + ), + 'already-exists' + ); +}); + +test('gh release view rejects existing notes that differ from the changelog', (t) => { + t.throws( + () => + classifyGhReleaseView( + { + stdout: '{"tagName":"v1.0.1","body":"Hand-written notes."}\n', + }, + 'v1.0.1', + 'Filed release notes.' + ), + { message: /does not match its CHANGELOG\.md notes/ } + ); + t.throws( + () => + classifyGhReleaseView( + { stdout: '{"tagName":"v1.0.1","body":""}\n' }, + 'v1.0.1', + 'Filed release notes.' + ), + { message: /does not match its CHANGELOG\.md notes/ } + ); +}); + +test('gh release view treats a missing release as needing create', (t) => { + t.is( + classifyGhReleaseView( + { + error: { + status: 1, + stderr: 'release not found', + }, + }, + 'v1.0.1', + 'Filed release notes.' + ), + 'create' + ); + t.is( + classifyGhReleaseView( + { + error: { status: 1, stderr: Buffer.from('HTTP 404: Not Found') }, + }, + 'v1.0.1', + 'Filed release notes.' + ), + 'create' + ); +}); + +test('gh release view aborts on unexpected failures', (t) => { + t.throws( + () => + classifyGhReleaseView( + { + error: { + status: 1, + stderr: 'HTTP 401: Requires authentication', + }, + }, + 'v1.0.1', + 'Filed release notes.' + ), + { message: /gh release view failed/ } + ); + t.throws( + () => + classifyGhReleaseView( + { + error: { + status: 1, + stderr: 'GraphQL: Not Found (repository)', + }, + }, + 'v1.0.1', + 'Filed release notes.' + ), + { message: /gh release view failed/ } + ); +}); + +test('publish changelog gate distinguishes leftover items from a missing heading', (t) => { + const withItems = `# Changelog + +## Unreleased + +- File this release note. + +## 1.0.0 + +Older. +`; + const withoutItems = `# Changelog + +## Unreleased + +Notes stay here. + +## 1.0.0 + +Older. +`; + + t.throws(() => prepareReadyReleaseChangelog(withItems, '1.0.1'), { + message: /still has Unreleased list items/, + }); + t.throws(() => prepareReadyReleaseChangelog(withoutItems, '1.0.1'), { + message: /missing ## 1\.0\.1.*no list items/, + }); +}); + +test('publish changelog gate accepts a filed release section', (t) => { + const prepared = prepareReadyReleaseChangelog( + `# Changelog + +## Unreleased + +Notes stay here. + +## 1.0.1 + +- Filed release note. + +## 1.0.0 + +Older. +`, + '1.0.1' + ); + + t.false(prepared.didShift); + t.is(prepared.notes, '- Filed release note.'); +}); + +test('publish changelog gate rejects an empty filed release section', (t) => { + t.throws( + () => + prepareReadyReleaseChangelog( + `# Changelog + +## Unreleased + +Notes stay here. + +## 1.0.1 + +## 1.0.0 + +Older. +`, + '1.0.1' + ), + { message: /section ## 1\.0\.1 has no release notes/ } + ); +}); + +test('GitHub preflight propagates CLI and commit failures', (t) => { + const inspectGithubRelease = (): 'create' => 'create'; + const commitFailure = new Error('release commit is not on GitHub'); + + t.throws( + () => + preflightGithubRelease('v1.0.1', 'abc123', 'Filed release notes.', { + assertGithubCli: () => { + throw new Error('gh is not authenticated'); + }, + assertCommitOnGithub: () => undefined, + inspectGithubRelease, + }), + { message: 'gh is not authenticated' } + ); + t.throws( + () => + preflightGithubRelease('v1.0.1', 'abc123', 'Filed release notes.', { + assertGithubCli: () => undefined, + assertCommitOnGithub: () => { + throw commitFailure; + }, + inspectGithubRelease, + }), + { is: commitFailure } + ); +}); + +test('remote release tag preflight accepts an absent tag', (t) => { + t.is(classifyRemoteReleaseTag('', 'v1.0.1', 'release-sha'), 'absent'); +}); + +test('remote release tag preflight accepts a lightweight tag on the release commit', (t) => { + t.is( + classifyRemoteReleaseTag( + 'release-sha\trefs/tags/v1.0.1\n', + 'v1.0.1', + 'release-sha' + ), + 'same-commit' + ); +}); + +test('remote release tag preflight compares an annotated tag peeled commit', (t) => { + t.is( + classifyRemoteReleaseTag( + `tag-object-sha\trefs/tags/v1.0.1 +release-sha\trefs/tags/v1.0.1^{} +`, + 'v1.0.1', + 'release-sha' + ), + 'same-commit' + ); +}); + +test('remote release tag preflight rejects a different commit', (t) => { + t.throws( + () => + classifyRemoteReleaseTag( + `tag-object-sha\trefs/tags/v1.0.1 +other-commit\trefs/tags/v1.0.1^{} +`, + 'v1.0.1', + 'release-sha' + ), + { message: /points at other-commit.*not the release commit release-sha/ } + ); +}); diff --git a/scripts/publish.ts b/scripts/publish.ts index 6c6125a6..6a581d72 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,7 +1,17 @@ import { execFileSync, execSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { fileURLToPath } from 'url'; import path from 'path'; import colors from 'kleur'; +import { + formatGithubReleaseNotes, + githubReleaseTag, + parseChangelog, + prepareReleaseChangelog, + splitUnreleasedBody, +} from './utils/changelog'; +import type { PreparedChangelog } from './utils/changelog'; import { getMonorepoPublicPackages } from './utils/packageManifest'; // import { version } from '../package.json'; @@ -12,6 +22,28 @@ export type NpmViewResult = { export type PreflightStatus = 'publish' | 'already-published'; +export type GithubReleaseStatus = 'create' | 'already-exists'; + +export type RemoteReleaseTagStatus = 'absent' | 'same-commit'; + +export type PublishOptions = { + readonly dryRun: boolean; +}; + +export type GhReleaseViewResult = { + stdout?: string; + error?: unknown; +}; + +export type GithubPreflightChecks = { + readonly assertGithubCli: () => void; + readonly assertCommitOnGithub: (sha: string) => void; + readonly inspectGithubRelease: ( + tag: string, + expectedNotes: string + ) => GithubReleaseStatus; +}; + export type PublishPackage = { name: string; dirName: string; @@ -185,6 +217,279 @@ function insertSorted(values: string[], value: string): void { values.splice(index === -1 ? values.length : index, 0, value); } +export function parsePublishArgs(argv: readonly string[]): PublishOptions { + let dryRun = false; + for (const arg of argv) { + if (arg === '--') continue; + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown publish flag: ${arg}`); + } + return { dryRun }; +} + +export function classifyGhReleaseView( + result: GhReleaseViewResult, + tag: string, + expectedNotes: string +): GithubReleaseStatus { + if (!result.error) { + let value: unknown; + try { + value = JSON.parse(result.stdout ?? ''); + } catch { + throw new Error('gh release view returned an unexpected response'); + } + if ( + !value || + typeof value !== 'object' || + typeof (value as { tagName?: unknown }).tagName !== 'string' || + typeof (value as { body?: unknown }).body !== 'string' + ) { + throw new Error('gh release view returned an unexpected response'); + } + + const release = value as { + readonly body: string; + readonly tagName: string; + }; + if (release.tagName !== tag) { + throw new Error( + `gh release view returned ${release.tagName}, not ${tag}` + ); + } + if ( + normalizeReleaseNotes(release.body) !== + normalizeReleaseNotes(expectedNotes) + ) { + throw new Error( + `GitHub release ${tag} does not match its CHANGELOG.md notes. Update or remove the release before publishing.` + ); + } + return 'already-exists'; + } + + const details = collectErrorDetails(result.error); + const errorRecord = getErrorRecord(result.error); + const hasHttp404 = + errorRecord?.status === 404 || + errorRecord?.statusCode === 404 || + /\bHTTP(?:\/\d(?:\.\d)?)?\s+404\b/i.test(details); + if (/\brelease not found\b/i.test(details) || hasHttp404) { + return 'create'; + } + + throw new Error(`gh release view failed: ${details || 'unknown error'}`); +} + +function normalizeReleaseNotes(notes: string): string { + return notes.replace(/\r\n/g, '\n').trim(); +} + +export function inspectGithubRelease( + tag: string, + expectedNotes: string +): GithubReleaseStatus { + let stdout: string; + try { + stdout = execFileSync( + 'gh', + ['release', 'view', tag, '--json', 'tagName,body'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ); + } catch (error) { + return classifyGhReleaseView({ error }, tag, expectedNotes); + } + return classifyGhReleaseView({ stdout }, tag, expectedNotes); +} + +export function assertGithubCli(): void { + try { + execFileSync('gh', ['auth', 'status'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + throw new Error( + `GitHub CLI \`gh\` must be installed and authenticated before publish so the npm publish and GitHub release stay one step. ${collectErrorDetails( + error + )}` + ); + } +} + +export function assertCommitOnGithub(sha: string): void { + try { + execFileSync( + 'gh', + ['api', `repos/{owner}/{repo}/commits/${sha}`, '-q', '.sha'], + { + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + } catch (error) { + throw new Error( + `Commit ${sha} is not on GitHub. Push the release commit before publishing. ${collectErrorDetails( + error + )}` + ); + } +} + +export function preflightGithubRelease( + tag: string, + sha: string, + expectedNotes: string, + checks: GithubPreflightChecks = { + assertGithubCli, + assertCommitOnGithub, + inspectGithubRelease, + } +): GithubReleaseStatus { + checks.assertGithubCli(); + checks.assertCommitOnGithub(sha); + return checks.inspectGithubRelease(tag, expectedNotes); +} + +function getErrorRecord(error: unknown): Record | undefined { + return error && typeof error === 'object' + ? (error as Record) + : undefined; +} + +function collectErrorDetails(error: unknown): string { + const errorRecord = getErrorRecord(error); + return errorRecord + ? [ + errorRecord.code, + errorRecord.status, + errorRecord.statusCode, + errorRecord.stderr, + errorRecord.stdout, + errorRecord.message, + ] + .filter((value) => value != null && value !== '') + .map((value) => + Buffer.isBuffer(value) ? value.toString('utf8') : String(value) + ) + .join(' ') + : String(error ?? ''); +} + +export function classifyRemoteReleaseTag( + stdout: string, + tag: string, + releaseSha: string +): RemoteReleaseTagStatus { + const directRef = `refs/tags/${tag}`; + const peeledRef = `${directRef}^{}`; + const refs = new Map(); + + for (const line of stdout.split('\n')) { + if (!line.trim()) continue; + const parts = line.trim().split(/\s+/); + if (parts.length !== 2) { + throw new Error(`git ls-remote returned an unexpected line: ${line}`); + } + const [sha, ref] = parts; + if (ref !== directRef && ref !== peeledRef) { + throw new Error(`git ls-remote returned an unexpected ref: ${ref}`); + } + const prior = refs.get(ref); + if (prior && prior !== sha) { + throw new Error(`git ls-remote returned conflicting values for ${ref}`); + } + refs.set(ref, sha); + } + + const remoteSha = refs.get(peeledRef) ?? refs.get(directRef); + if (!remoteSha) return 'absent'; + if (remoteSha === releaseSha) return 'same-commit'; + + throw new Error( + `Remote tag ${tag} points at ${remoteSha}, not the release commit ${releaseSha}. Release tags cannot be moved.` + ); +} + +export function preflightRemoteReleaseTag( + tag: string, + releaseSha: string +): RemoteReleaseTagStatus { + const stdout = execFileSync( + 'git', + ['ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + return classifyRemoteReleaseTag(stdout, tag, releaseSha); +} + +export function ensureAnnotatedReleaseTag(tag: string, sha: string): void { + try { + const existing = execFileSync('git', ['rev-parse', `${tag}^{}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (existing !== sha) { + throw new Error( + `Tag ${tag} points at ${existing}, not the release commit ${sha}` + ); + } + return; + } catch (error) { + const details = collectErrorDetails(error); + if ( + !details.includes('unknown revision') && + !details.includes('Not a valid object') + ) { + throw error instanceof Error ? error : new Error(details); + } + } + + execFileSync('git', ['tag', '-a', tag, sha, '-m', `Release ${tag}`], { + stdio: 'inherit', + }); +} + +export function pushReleaseTag(tag: string): void { + execFileSync('git', ['push', 'origin', `refs/tags/${tag}`], { + stdio: 'inherit', + }); +} + +export function createGithubRelease(options: { + readonly tag: string; + readonly notes: string; + readonly target: string; +}): void { + const directory = mkdtempSync(path.join(tmpdir(), 'flatbread-release-')); + const notesPath = path.join(directory, 'notes.md'); + writeFileSync(notesPath, `${options.notes}\n`); + try { + execFileSync( + 'gh', + [ + 'release', + 'create', + options.tag, + '--title', + options.tag, + '--notes-file', + notesPath, + '--target', + options.target, + '--verify-tag', + ], + { stdio: 'inherit' } + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + export function assertCleanRelease(): string { const status = execSync('git status --porcelain', { encoding: 'utf8', @@ -221,19 +526,104 @@ export function preflightPackage( } } -export async function publishPackages(): Promise { +export function prepareReadyReleaseChangelog( + markdown: string, + version: string +): PreparedChangelog { + const doc = parseChangelog(markdown); + const existing = doc.versions.find((entry) => entry.version === version); + if (existing && !existing.body.trim()) { + throw new Error( + `CHANGELOG.md section ## ${version} has no release notes. Add the release notes before publishing.` + ); + } + + const prepared = prepareReleaseChangelog(markdown, version); + if (!prepared.didShift) return prepared; + + const { releaseNotes } = splitUnreleasedBody(doc.unreleased); + if (releaseNotes) { + throw new Error( + `CHANGELOG.md still has Unreleased list items that belong under ## ${version}. Run \`pnpm changelog:shift\` and commit CHANGELOG.md before publishing.` + ); + } + + throw new Error( + `CHANGELOG.md is missing ## ${version}, and Unreleased has no list items to file. Run \`pnpm changelog:shift\` and commit CHANGELOG.md before publishing.` + ); +} + +export async function publishPackages( + options: PublishOptions = { dryRun: false } +): Promise { + const dryRun = options.dryRun; + if (dryRun) { + console.log( + colors + .bold() + .yellow( + 'Dry run: no npm publish, no GitHub release, no changelog write' + ) + ); + } + const releaseSha = assertCleanRelease(); + const packages = sortPackages( (await getMonorepoPublicPackages()) as unknown as PublishPackage[] ); const releaseVersion = assertLockstepVersions(packages); + const releaseTag = githubReleaseTag(releaseVersion); console.log( colors.bold().green(`Public package release version: ${releaseVersion}`) ); - execSync('pnpm run build', { stdio: 'inherit' }); - execSync('pnpm run skills:check', { stdio: 'inherit' }); - execSync('pnpm run skills:pack-check', { stdio: 'inherit' }); + const changelogMarkdown = readFileSync('CHANGELOG.md', 'utf8'); + const preparedChangelog = prepareReadyReleaseChangelog( + changelogMarkdown, + releaseVersion + ); + const formattedReleaseNotes = formatGithubReleaseNotes( + preparedChangelog.notes, + releaseVersion + ); + console.log(colors.bold('\nGitHub release notes\n')); + console.log(formattedReleaseNotes); + + const githubStatus = preflightGithubRelease( + releaseTag, + releaseSha, + formattedReleaseNotes + ); + const remoteTagStatus = preflightRemoteReleaseTag(releaseTag, releaseSha); + console.log( + colors + .bold() + .green( + remoteTagStatus === 'same-commit' + ? `Remote tag ${releaseTag} already points at ${releaseSha}` + : `Remote tag ${releaseTag} is available` + ) + ); + if (dryRun) { + console.log( + colors + .bold() + .green( + githubStatus === 'already-exists' + ? `GitHub release ${releaseTag} already exists` + : remoteTagStatus === 'same-commit' + ? `Would create the GitHub release for ${releaseTag} at ${releaseSha}` + : `Would push ${releaseTag} and create the GitHub release at ${releaseSha}` + ) + ); + } + + if (!dryRun) { + execSync('pnpm run build', { stdio: 'inherit' }); + execSync('pnpm run skills:check', { stdio: 'inherit' }); + execSync('pnpm run skills:pack-check', { stdio: 'inherit' }); + } for (const { dirName, name, version } of packages) { try { @@ -248,6 +638,11 @@ export async function publishPackages(): Promise { continue; } + if (dryRun) { + console.log(colors.bold().green(`Would publish ${name} v${version}`)); + continue; + } + execSync('pnpm publish --access public --no-git-checks', { stdio: 'inherit', cwd: path.resolve(path.join('packages', dirName)), @@ -260,11 +655,41 @@ export async function publishPackages(): Promise { break; } } - if (process.exitCode === undefined) { - console.log(colors.bold().green(`Published release commit: ${releaseSha}`)); + if (process.exitCode !== undefined) return; + + if (dryRun) { + console.log( + colors + .bold() + .green( + `Dry run finished for ${releaseTag} at ${releaseSha}. Nothing was published.` + ) + ); + return; } + + if (githubStatus === 'already-exists') { + console.log( + colors + .bold() + .yellow(`GitHub release ${releaseTag} already exists; skipping`) + ); + } else { + if (remoteTagStatus === 'absent') { + ensureAnnotatedReleaseTag(releaseTag, releaseSha); + pushReleaseTag(releaseTag); + } + createGithubRelease({ + tag: releaseTag, + notes: formattedReleaseNotes, + target: releaseSha, + }); + console.log(colors.bold().green(`Created GitHub release ${releaseTag}`)); + } + + console.log(colors.bold().green(`Published release commit: ${releaseSha}`)); } if (process.argv[1] === fileURLToPath(import.meta.url)) { - await publishPackages(); + await publishPackages(parsePublishArgs(process.argv.slice(2))); } diff --git a/scripts/utils/changelog.test.ts b/scripts/utils/changelog.test.ts new file mode 100644 index 00000000..bd97e141 --- /dev/null +++ b/scripts/utils/changelog.test.ts @@ -0,0 +1,201 @@ +import test from 'ava'; +import { + fallbackReleaseNotes, + formatGithubReleaseNotes, + githubReleaseNotesHeader, + githubReleaseTag, + parseChangelog, + prepareReleaseChangelog, + serializeChangelog, + splitUnreleasedBody, +} from './changelog'; + +const SAMPLE = `# Changelog + +## Unreleased + +- First item + continues here +- Second item + +Notes stay under Unreleased. + +## 1.0.0 + +First stable release. +`; + +test('githubReleaseTag prefixes a v when missing', (t) => { + t.is(githubReleaseTag('1.0.1'), 'v1.0.1'); + t.is(githubReleaseTag('v1.0.1'), 'v1.0.1'); +}); + +test('formatGithubReleaseNotes puts the brand header and version title first', (t) => { + const header = githubReleaseNotesHeader('1.1.0'); + t.true(header.includes('Flatbread - v1.1.0 Release Notes')); + t.is( + formatGithubReleaseNotes('- Already filed.', '1.1.0'), + `${header}\n\n- Already filed.` + ); + t.is(formatGithubReleaseNotes(' \n', 'v1.1.0'), header); + t.true( + formatGithubReleaseNotes(fallbackReleaseNotes('1.0.1'), '1.0.1').startsWith( + githubReleaseNotesHeader('1.0.1') + ) + ); +}); + +test('parseChangelog reads Unreleased and version sections', (t) => { + const doc = parseChangelog(SAMPLE); + t.is(doc.title, '# Changelog'); + t.is(doc.preamble, ''); + t.true(doc.unreleased.startsWith('- First item')); + t.is(doc.versions[0]?.version, '1.0.0'); + t.is(doc.versions[0]?.body, 'First stable release.'); +}); + +test('parseChangelog accepts dated and bracketed version headings', (t) => { + const doc = parseChangelog(`# Changelog + +## Unreleased + +## [1.2.0] - 2026-08-22 + +Notes. +`); + t.is(doc.versions[0]?.version, '1.2.0'); + t.is(doc.versions[0]?.heading, '## [1.2.0] - 2026-08-22'); +}); + +test('parseChangelog rejects a missing Unreleased heading', (t) => { + const error = t.throws(() => + parseChangelog(`# Changelog + +## 1.0.0 + +Notes. +`) + ); + t.regex(error?.message ?? '', /must have an Unreleased heading/); +}); + +test('parseChangelog rejects an unrecognized heading', (t) => { + const error = t.throws(() => + parseChangelog(`# Changelog + +## Unreleased + +## Later + +Nope. +`) + ); + t.regex(error?.message ?? '', /unrecognized heading/); +}); + +test('splitUnreleasedBody keeps trailing prose and shifts list items', (t) => { + t.deepEqual(splitUnreleasedBody(parseChangelog(SAMPLE).unreleased), { + releaseNotes: `- First item + continues here +- Second item`, + retained: 'Notes stay under Unreleased.', + }); +}); + +test('splitUnreleasedBody treats a note-only Unreleased section as retained', (t) => { + t.deepEqual(splitUnreleasedBody('Notes for the release train.'), { + releaseNotes: '', + retained: 'Notes for the release train.', + }); +}); + +test('prepareReleaseChangelog moves Unreleased items under the new version', (t) => { + const prepared = prepareReleaseChangelog(SAMPLE, '1.0.1'); + t.true(prepared.didShift); + t.true(prepared.didMoveItems); + t.is( + prepared.notes, + `- First item + continues here +- Second item` + ); + + const doc = parseChangelog(prepared.markdown); + t.is(doc.unreleased, 'Notes stay under Unreleased.'); + t.deepEqual( + doc.versions.map((entry) => entry.version), + ['1.0.1', '1.0.0'] + ); + t.is(doc.versions[0]?.body, prepared.notes); + t.is(doc.versions[1]?.body, 'First stable release.'); +}); + +test('prepareReleaseChangelog is a no-op when the version section already exists', (t) => { + const markdown = `# Changelog + +## Unreleased + +Notes stay. + +## 1.0.1 + +- Already filed. + +## 1.0.0 + +Older. +`; + const prepared = prepareReleaseChangelog(markdown, '1.0.1'); + t.false(prepared.didShift); + t.false(prepared.didMoveItems); + t.is(prepared.markdown, markdown); + t.is(prepared.notes, '- Already filed.'); +}); + +test('prepareReleaseChangelog rejects leftover Unreleased items when the version exists', (t) => { + const error = t.throws(() => + prepareReleaseChangelog( + `# Changelog + +## Unreleased + +- Leftover + +## 1.0.1 + +- Filed. +`, + '1.0.1' + ) + ); + t.regex(error?.message ?? '', /already has a 1\.0\.1 section/); +}); + +test('prepareReleaseChangelog still opens a version heading when Unreleased has no items', (t) => { + const prepared = prepareReleaseChangelog( + `# Changelog + +## Unreleased + +Notes stay. + +## 1.0.0 + +Older. +`, + '1.0.1' + ); + t.true(prepared.didShift); + t.false(prepared.didMoveItems); + t.is(prepared.notes, fallbackReleaseNotes('1.0.1')); + const doc = parseChangelog(prepared.markdown); + t.is(doc.unreleased, 'Notes stay.'); + t.is(doc.versions[0]?.heading, '## 1.0.1'); + t.is(doc.versions[0]?.body, ''); +}); + +test('serializeChangelog round-trips a shifted document', (t) => { + const prepared = prepareReleaseChangelog(SAMPLE, '1.0.1'); + const again = serializeChangelog(parseChangelog(prepared.markdown)); + t.is(again, prepared.markdown); +}); diff --git a/scripts/utils/changelog.ts b/scripts/utils/changelog.ts new file mode 100644 index 00000000..44f0f164 --- /dev/null +++ b/scripts/utils/changelog.ts @@ -0,0 +1,203 @@ +export type ChangelogVersion = { + readonly heading: string; + readonly version: string; + readonly body: string; +}; + +export type ChangelogDocument = { + readonly title: string; + readonly preamble: string; + readonly unreleased: string; + readonly versions: readonly ChangelogVersion[]; +}; + +export type SplitUnreleased = { + readonly releaseNotes: string; + readonly retained: string; +}; + +export type PreparedChangelog = { + readonly markdown: string; + readonly notes: string; + readonly didShift: boolean; + readonly didMoveItems: boolean; + readonly version: string; +}; + +const UNRELEASED_HEADING = /^##\s+Unreleased\s*$/i; +const VERSION_HEADING = + /^##\s+(?:\[)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?(?:\+[0-9A-Za-z.]+)?)(?:\])?(?:\s+[-–—]\s+\S+)?\s*$/; +const LIST_ITEM = /^\s*(?:[-*+]|\d+\.)\s+/; +const INDENTED_CONTINUATION = /^\s+\S/; + +export function githubReleaseTag(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +export function githubReleaseNotesHeader(version: string): string { + return `

+ Flatbread logo +

+ +

Flatbread - ${githubReleaseTag(version)} Release Notes

`; +} + +export function fallbackReleaseNotes(version: string): string { + return `Release ${version}.`; +} + +export function formatGithubReleaseNotes( + notes: string, + version: string +): string { + const header = githubReleaseNotesHeader(version); + const body = notes.trim(); + return body ? `${header}\n\n${body}` : header; +} + +export function parseChangelog(markdown: string): ChangelogDocument { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + let index = 0; + while (index < lines.length && lines[index].trim() === '') index += 1; + + let title = '# Changelog'; + if ( + index < lines.length && + /^#\s+/.test(lines[index]) && + !lines[index].startsWith('##') + ) { + title = lines[index]; + index += 1; + } + + const preambleLines: string[] = []; + while (index < lines.length && !lines[index].startsWith('## ')) { + preambleLines.push(lines[index]); + index += 1; + } + + let unreleased: string | undefined; + const versions: ChangelogVersion[] = []; + + while (index < lines.length) { + const heading = lines[index]; + index += 1; + const bodyLines: string[] = []; + while (index < lines.length && !lines[index].startsWith('## ')) { + bodyLines.push(lines[index]); + index += 1; + } + const body = trimSectionBody(bodyLines.join('\n')); + + if (UNRELEASED_HEADING.test(heading)) { + if (unreleased !== undefined) { + throw new Error('CHANGELOG.md has more than one Unreleased heading'); + } + unreleased = body; + continue; + } + + const match = heading.match(VERSION_HEADING); + if (!match) { + throw new Error(`CHANGELOG.md has an unrecognized heading: ${heading}`); + } + versions.push({ heading, version: match[1], body }); + } + + if (unreleased === undefined) { + throw new Error('CHANGELOG.md must have an Unreleased heading'); + } + + return { + title, + preamble: trimSectionBody(preambleLines.join('\n')), + unreleased, + versions, + }; +} + +export function splitUnreleasedBody(body: string): SplitUnreleased { + const lines = body.replace(/\r\n/g, '\n').split('\n'); + let lastNotesIndex = -1; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (LIST_ITEM.test(line)) { + lastNotesIndex = index; + continue; + } + if (lastNotesIndex >= 0 && INDENTED_CONTINUATION.test(line)) { + lastNotesIndex = index; + } + } + + if (lastNotesIndex === -1) { + return { releaseNotes: '', retained: trimSectionBody(body) }; + } + + return { + releaseNotes: trimSectionBody( + lines.slice(0, lastNotesIndex + 1).join('\n') + ), + retained: trimSectionBody(lines.slice(lastNotesIndex + 1).join('\n')), + }; +} + +export function serializeChangelog(doc: ChangelogDocument): string { + const parts: string[] = [doc.title]; + if (doc.preamble) parts.push(doc.preamble); + parts.push(formatSection('## Unreleased', doc.unreleased)); + for (const version of doc.versions) { + parts.push(formatSection(version.heading, version.body)); + } + return `${parts.join('\n\n')}\n`; +} + +export function prepareReleaseChangelog( + markdown: string, + version: string +): PreparedChangelog { + const doc = parseChangelog(markdown); + const existing = doc.versions.find((entry) => entry.version === version); + const { releaseNotes, retained } = splitUnreleasedBody(doc.unreleased); + + if (existing) { + if (releaseNotes) { + throw new Error( + `CHANGELOG.md already has a ${version} section, but Unreleased still has items. Move or delete those items before releasing.` + ); + } + return { + markdown, + notes: existing.body.trim() || fallbackReleaseNotes(version), + didShift: false, + didMoveItems: false, + version, + }; + } + + const next: ChangelogDocument = { + ...doc, + unreleased: retained, + versions: [ + { heading: `## ${version}`, version, body: releaseNotes }, + ...doc.versions, + ], + }; + + return { + markdown: serializeChangelog(next), + notes: releaseNotes || fallbackReleaseNotes(version), + didShift: true, + didMoveItems: Boolean(releaseNotes), + version, + }; +} + +function formatSection(heading: string, body: string): string { + return body ? `${heading}\n\n${body}` : heading; +} + +function trimSectionBody(value: string): string { + return value.replace(/^\n+/, '').replace(/\n+$/, ''); +}