ReviewOS

also looking at this

craft-native/craft

fix(release): add non-mutating changelog previews

#306
Merged glennmichael123 wants to merge codex/safe-changelog-preview into main
5 files +132 -0

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -289,6 +289,9 @@ jobs:
289289 - name: Test merged SBOM inventory
290290 run: bun test scripts/merge-sbom.test.ts
291291
292 - name: Test non-mutating changelog previews
293 run: bun test scripts/changelog-preview.test.ts
294
292295 - name: Run pickier (lint + format check)
293296 run: bun run lint
294297
docs/CHANGELOG_PREVIEW.mdadded+23-0
Changes to docs/CHANGELOG_PREVIEW.md
@@ -0,0 +1,23 @@
1# Preview release notes safely
2
3After `bun install`, preview changes since the latest reachable tag without
4writing release notes into the checkout:
5
6```sh
7bun run changelog:preview
8bun run changelog:preview --from v0.0.92 --to HEAD
9```
10
11The preview resolves both refs to commits, directs the installed logsmith CLI
12to an isolated temporary file, prints its contents, and removes that temporary
13directory. Existing edits to `CHANGELOG.md` are preserved. Only `--from` and
14`--to` are accepted; output overrides are deliberately unavailable.
15
16Do not use logsmith 0.2.3's `--no-output` for a dry preview: its CLI currently
17writes `CHANGELOG.md` despite that flag. This command is a Craft workaround,
18not 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
20generated 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.
package.jsonmodified+1-0
Changes to package.json
@@ -57,6 +57,7 @@
5757 "zig": "bun scripts/zig.ts",
5858 "dev": "cd packages/typescript && bun run dev",
5959 "changelog": "bun x logsmith --verbose",
60 "changelog:preview": "bun scripts/changelog-preview.ts",
6061 "changelog:generate": "bun x logsmith --output CHANGELOG.md",
6162 "release": "bun x --bun bumpx prompt --recursive",
6263 "release:patch": "bun x --bun bumpx patch --recursive --yes"
scripts/changelog-preview.test.tsadded+65-0
Changes to scripts/changelog-preview.test.ts
@@ -0,0 +1,65 @@
1import { expect, test } from 'bun:test'
2import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { previewChangelog } from './changelog-preview'
6
7const cli = join(import.meta.dir, 'changelog-preview.ts')
8function 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
32test('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
42test('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
56test('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})
scripts/changelog-preview.tsadded+40-0
Changes to scripts/changelog-preview.ts
@@ -0,0 +1,40 @@
1import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join, resolve } from 'node:path'
4import { parseArgs } from 'node:util'
5
6function 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
13export 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
37if (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}