also looking at this
fix(release): add non-mutating changelog previews
#306
5 files
+132
-0
| @@ -289,6 +289,9 @@ jobs: | ||
| 289 | 289 | - name: Test merged SBOM inventory |
| 290 | 290 | run: bun test scripts/merge-sbom.test.ts |
| 291 | 291 | |
| 292 | - name: Test non-mutating changelog previews | |
| 293 | run: bun test scripts/changelog-preview.test.ts | |
| 294 | ||
| 292 | 295 | - name: Run pickier (lint + format check) |
| 293 | 296 | run: bun run lint |
| 294 | 297 | |
| @@ -0,0 +1,23 @@ | ||
| 1 | # Preview release notes safely | |
| 2 | ||
| 3 | After `bun install`, preview changes since the latest reachable tag without | |
| 4 | writing release notes into the checkout: | |
| 5 | ||
| 6 | ```sh | |
| 7 | bun run changelog:preview | |
| 8 | bun run changelog:preview --from v0.0.92 --to HEAD | |
| 9 | ``` | |
| 10 | ||
| 11 | The preview resolves both refs to commits, directs the installed logsmith CLI | |
| 12 | to an isolated temporary file, prints its contents, and removes that temporary | |
| 13 | directory. Existing edits to `CHANGELOG.md` are preserved. Only `--from` and | |
| 14 | `--to` are accepted; output overrides are deliberately unavailable. | |
| 15 | ||
| 16 | Do not use logsmith 0.2.3's `--no-output` for a dry preview: its CLI currently | |
| 17 | writes `CHANGELOG.md` despite that flag. This command is a Craft workaround, | |
| 18 | not a fix to the upstream parser or its duplicate issue-reference rendering | |
| 19 | (tracked in [#283](https://github.com/craft-native/craft/issues/283)). Review | |
| 20 | generated issue links before releasing. | |
| 21 | ||
| 22 | `bun run changelog:generate` remains the intentional command for updating | |
| 23 | `CHANGELOG.md`. Neither command publishes packages or creates a release tag. | |
| @@ -57,6 +57,7 @@ | ||
| 57 | 57 | "zig": "bun scripts/zig.ts", |
| 58 | 58 | "dev": "cd packages/typescript && bun run dev", |
| 59 | 59 | "changelog": "bun x logsmith --verbose", |
| 60 | "changelog:preview": "bun scripts/changelog-preview.ts", | |
| 60 | 61 | "changelog:generate": "bun x logsmith --output CHANGELOG.md", |
| 61 | 62 | "release": "bun x --bun bumpx prompt --recursive", |
| 62 | 63 | "release:patch": "bun x --bun bumpx patch --recursive --yes" |
| @@ -0,0 +1,65 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { previewChangelog } from './changelog-preview' | |
| 6 | ||
| 7 | const cli = join(import.meta.dir, 'changelog-preview.ts') | |
| 8 | function fixture(run: (directory: string, git: (...args: string[]) => string) => void) { | |
| 9 | const directory = mkdtempSync(join(tmpdir(), 'craft-changelog-fixture-')) | |
| 10 | const git = (...args: string[]) => { | |
| 11 | const result = Bun.spawnSync(['git', ...args], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }) | |
| 12 | if (result.exitCode !== 0) throw new Error(result.stderr.toString()) | |
| 13 | return result.stdout.toString() | |
| 14 | } | |
| 15 | try { | |
| 16 | git('init', '-q') | |
| 17 | git('config', 'user.name', 'Fixture') | |
| 18 | git('config', 'user.email', 'fixture@example.invalid') | |
| 19 | git('config', 'commit.gpgsign', 'false') | |
| 20 | git('remote', 'add', 'origin', 'https://github.com/example/fixture.git') | |
| 21 | writeFileSync(join(directory, 'package.json'), '{"name":"fixture","version":"1.0.1"}') | |
| 22 | writeFileSync(join(directory, 'CHANGELOG.md'), '# Existing release notes\n') | |
| 23 | git('add', '.') | |
| 24 | git('commit', '-qm', 'chore: initial fixture') | |
| 25 | git('tag', 'v1.0.0') | |
| 26 | git('commit', '--allow-empty', '-qm', 'fix: preserve preview sentinel') | |
| 27 | run(directory, git) | |
| 28 | } | |
| 29 | finally { rmSync(directory, { recursive: true, force: true }) } | |
| 30 | } | |
| 31 | ||
| 32 | test('real generator preview preserves a clean checkout and tracked changelog', () => { | |
| 33 | fixture((directory, git) => { | |
| 34 | const before = readFileSync(join(directory, 'CHANGELOG.md'), 'utf8') | |
| 35 | const output = previewChangelog(directory) | |
| 36 | expect(output).toContain('preserve preview sentinel') | |
| 37 | expect(readFileSync(join(directory, 'CHANGELOG.md'), 'utf8')).toBe(before) | |
| 38 | expect(git('status', '--porcelain')).toBe('') | |
| 39 | }) | |
| 40 | }) | |
| 41 | ||
| 42 | test('CLI emits only the preview and preserves pre-existing local edits', () => { | |
| 43 | fixture((directory, git) => { | |
| 44 | const notes = '# Uncommitted user notes\n' | |
| 45 | writeFileSync(join(directory, 'CHANGELOG.md'), notes) | |
| 46 | const before = git('status', '--porcelain') | |
| 47 | const result = Bun.spawnSync([process.execPath, cli, '--from', 'v1.0.0', '--to', 'HEAD'], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }) | |
| 48 | expect(result.exitCode).toBe(0) | |
| 49 | expect(result.stdout.toString()).toContain('preserve preview sentinel') | |
| 50 | expect(result.stdout.toString()).not.toContain('Changelog written') | |
| 51 | expect(readFileSync(join(directory, 'CHANGELOG.md'), 'utf8')).toBe(notes) | |
| 52 | expect(git('status', '--porcelain')).toBe(before) | |
| 53 | }) | |
| 54 | }) | |
| 55 | ||
| 56 | test('rejects output overrides and invalid refs without mutating the repository', () => { | |
| 57 | fixture((directory, git) => { | |
| 58 | for (const args of [['--output', 'CHANGELOG.md'], ['--no-output'], ['--from', 'missing-ref'], ['--to', '--help']]) { | |
| 59 | const result = Bun.spawnSync([process.execPath, cli, ...args], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }) | |
| 60 | expect(result.exitCode).not.toBe(0) | |
| 61 | expect(result.stdout.toString()).toBe('') | |
| 62 | expect(git('status', '--porcelain')).toBe('') | |
| 63 | } | |
| 64 | }) | |
| 65 | }) | |
| @@ -0,0 +1,40 @@ | ||
| 1 | import { mkdtempSync, readFileSync, rmSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | import { parseArgs } from 'node:util' | |
| 5 | ||
| 6 | function git(cwd: string, args: string[]): string { | |
| 7 | const result = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }) | |
| 8 | if (result.exitCode !== 0) | |
| 9 | throw new Error(`Cannot resolve changelog range: ${result.stderr.toString().trim()}`) | |
| 10 | return result.stdout.toString().trim() | |
| 11 | } | |
| 12 | ||
| 13 | export function previewChangelog(cwd: string, from?: string, to = 'HEAD'): string { | |
| 14 | // Resolve refs before invoking the generator, so only commit hashes reach it. | |
| 15 | const start = from ?? git(cwd, ['describe', '--tags', '--abbrev=0']) | |
| 16 | const commits = [start, to].map(ref => git(cwd, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`])) | |
| 17 | const directory = mkdtempSync(join(tmpdir(), 'craft-changelog-preview-')) | |
| 18 | try { | |
| 19 | const output = join(directory, 'preview.md') | |
| 20 | // logsmith 0.2.3's --no-output writes CHANGELOG.md. An explicit temporary | |
| 21 | // output avoids that parser bug without changing the intentional write command. | |
| 22 | const cli = Bun.resolveSync('@stacksjs/logsmith/bin/cli.js', import.meta.dir) | |
| 23 | const result = Bun.spawnSync([ | |
| 24 | process.execPath, cli, '--dir', resolve(cwd), '--from', commits[0]!, '--to', commits[1]!, | |
| 25 | '--output', output, '--hide-author-email', '--no-dates', '--theme', 'default', | |
| 26 | ], { cwd, env: { ...process.env, DO_NOT_TRACK: '1' }, stdout: 'pipe', stderr: 'pipe' }) | |
| 27 | if (result.exitCode !== 0) | |
| 28 | throw new Error(`Changelog generation failed: ${result.stderr.toString().trim()}`) | |
| 29 | // The dependency can log an error and exit zero; a missing output is still failure. | |
| 30 | return readFileSync(output, 'utf8') | |
| 31 | } | |
| 32 | finally { | |
| 33 | rmSync(directory, { recursive: true, force: true }) | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | if (import.meta.main) { | |
| 38 | const { values } = parseArgs({ args: Bun.argv.slice(2), options: { from: { type: 'string' }, to: { type: 'string' } }, strict: true, allowPositionals: false }) | |
| 39 | process.stdout.write(previewChangelog(process.cwd(), values.from, values.to)) | |
| 40 | } | |