feat(release): track prerelease deltas and validate committed notes

- Compare fragment changes with previous beta/RC tags
- Reject stale prerelease notes in CI
This commit is contained in:
2026-08-23 03:02:09 -07:00
parent 0a0aa3ec98
commit 1cbc5f3853
9 changed files with 738 additions and 41 deletions
+4 -2
View File
@@ -32,9 +32,11 @@ jobs:
- name: Guard stable docs tag shape
id: tag_guard
if: github.ref_type == 'tag'
env:
TAG_NAME: ${{ github.ref_name }}
run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag $TAG_NAME"
echo "stable_tag=false" >> "$GITHUB_OUTPUT"
exit 0
fi
+15 -8
View File
@@ -297,15 +297,22 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify committed prerelease notes
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
exit 1
fi
- name: Publish Prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
@@ -327,27 +334,27 @@ jobs:
exit 1
fi
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
gh release edit "$RELEASE_VERSION" \
--draft \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
gh release create "$RELEASE_VERSION" \
--draft \
--latest=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
gh release upload "$RELEASE_VERSION" "$asset" --clobber
done
gh release edit "${{ steps.version.outputs.VERSION }}" \
gh release edit "$RELEASE_VERSION" \
--draft=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
+24 -13
View File
@@ -296,33 +296,40 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Guard against pending changelog fragments
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version $RELEASE_VERSION' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
exit 1
fi
- name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: bun run changelog:check --version "$RELEASE_VERSION"
- name: Generate release notes from changelog
run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}"
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: bun run changelog:release-notes --version "$RELEASE_VERSION"
- name: Publish Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
# Do not pass the prerelease flag here; gh defaults to a normal release.
gh release edit "${{ steps.version.outputs.VERSION }}" \
gh release edit "$RELEASE_VERSION" \
--draft=false \
--title "${{ steps.version.outputs.VERSION }}" \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
gh release create "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
fi
@@ -345,7 +352,7 @@ jobs:
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
gh release upload "$RELEASE_VERSION" "$asset" --clobber
done
aur-publish:
@@ -421,9 +428,10 @@ jobs:
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
version="${{ steps.version.outputs.VERSION }}"
version="$RELEASE_VERSION"
install -dm755 .tmp/aur-release-assets
gh release download "$version" \
--dir .tmp/aur-release-assets \
@@ -433,15 +441,17 @@ jobs:
- name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}"
version_no_v="$RELEASE_VERSION"
version_no_v="${version_no_v#v}"
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO
bash scripts/update-aur-package.sh \
--pkg-dir aur-subminer-bin \
--version "${{ steps.version.outputs.VERSION }}" \
--version "$RELEASE_VERSION" \
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
--wrapper ".tmp/aur-release-assets/subminer" \
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
@@ -451,6 +461,7 @@ jobs:
working-directory: aur-subminer-bin
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
if git diff --quiet -- PKGBUILD .SRCINFO; then
@@ -460,7 +471,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
git commit -m "Update to $RELEASE_VERSION"
attempts=3
for attempt in $(seq 1 "$attempts"); do
+1
View File
@@ -49,6 +49,7 @@ How fragments turn into a release:
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful — editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: release
- Prerelease notes now open with a "Changes since" section that lists only what changed compared to the previous beta/RC of the same version, above the cumulative highlights.
- CI now rejects prerelease tags whose committed notes were generated for a different beta/RC, instead of silently shipping stale notes.
+11 -6
View File
@@ -58,12 +58,15 @@
`latest*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline only when their hidden
`prerelease-base-version` marker matches the current base version, and asks
Claude to merge only the new fragment material. Do not run
committed file — so review it before committing. Rerun
`bun run changelog:prerelease-notes --version <version>` for every later
beta/RC, even if no fragments changed: the notes carry a hidden
`prerelease-version` marker and CI rejects the tag when the marker does not
match it (verify locally with
`bun run changelog:check-prerelease-notes --version <version>`). The
generator reuses the existing notes as the cumulative baseline when their
marker (or legacy `prerelease-base-version` marker) matches the current base
version, and asks Claude to merge only the new fragment material. Do not run
`bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -78,6 +81,8 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
+1
View File
@@ -32,6 +32,7 @@
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
"changelog:check-prerelease-notes": "bun run scripts/build-changelog.ts check-prerelease-notes",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:src": "bash scripts/prettier-scope.sh --write",
+378 -6
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
@@ -583,7 +584,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
deps: { runClaude: stub.runClaude },
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
@@ -605,7 +606,8 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(prereleaseNotes, /<!-- prerelease-base-version: 0\.11\.3 -->/);
assert.match(prereleaseNotes, /<!-- prerelease-version: 0\.11\.3-beta\.1 -->/);
assert.doesNotMatch(prereleaseNotes, /## Changes since /);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
@@ -668,7 +670,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -723,7 +725,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.17.0-beta.1',
deps: { runClaude: stub.runClaude },
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -790,7 +792,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.2',
deps: { runClaude: stub.runClaude },
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -830,7 +832,7 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-rc.1',
deps: { runClaude: stub.runClaude },
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
@@ -1447,3 +1449,373 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('selectPreviousPrereleaseTag orders betas before rcs and filters other base versions', async () => {
const { selectPreviousPrereleaseTag } = await loadModule();
const tags = [
'v0.19.4-beta.1',
'v0.19.4-beta.3',
'v0.19.4-beta.2',
'v0.19.3-beta.9',
'v0.19.4-rc.1',
'not-a-tag',
];
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.1'), null);
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.2'), 'v0.19.4-beta.1');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.4'), 'v0.19.4-beta.3');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.1'), 'v0.19.4-beta.3');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.2'), 'v0.19.4-rc.1');
// Regenerating notes for an already-tagged version must not pick itself.
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.3'), 'v0.19.4-beta.2');
assert.equal(selectPreviousPrereleaseTag(['v0.19.3-beta.1'], '0.19.4-beta.2'), null);
});
test('writePrereleaseNotesForVersion adds a delta section generated from fragment diffs', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-delta-section');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus and macOS helper.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude((input) =>
input.includes('MODIFIED FRAGMENT')
? '- Fixed the macOS helper deployment target for older systems.'
: '### Fixed\n- Overlay: cumulative fixed entry.',
);
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.2',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1'],
resolveFragmentDelta: (_cwd, previousTag) => {
assert.equal(previousTag, 'v0.12.0-beta.1');
return [
{
path: 'changes/002.md',
status: 'added',
after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.',
},
{
path: 'changes/001.md',
status: 'modified',
before: '- Fixed overlay focus.',
after: '- Fixed overlay focus and macOS helper.',
},
{
path: 'changes/003.md',
status: 'deleted',
before: 'type: added\narea: stats\n\n- Reverted experimental stats view.',
},
];
},
},
});
assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls');
const deltaPrompt = stub.calls[0]!.input;
assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/);
assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/);
assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./);
assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./);
assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/);
assert.match(deltaPrompt, /If the edit is editorial/);
assert.match(deltaPrompt, /removed or reverted/);
assert.match(deltaPrompt, /No user-facing changes since v0\.12\.0-beta\.1\./);
assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/<!-- prerelease-version: 0\.12\.0-beta\.2; since: v0\.12\.0-beta\.1 -->/,
);
const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1');
const highlightsIndex = prereleaseNotes.indexOf('## Highlights');
assert.ok(deltaIndex !== -1, 'delta section heading should be present');
assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights');
assert.match(prereleaseNotes, /- Fixed the macOS helper deployment target for older systems\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion renders a fallback delta line when no fragments changed', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-empty-delta');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.3',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'],
resolveFragmentDelta: () => [],
},
});
assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects non-bullet delta output from Claude', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-delta-invalid-output');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.');
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.2',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1'],
resolveFragmentDelta: () => [
{ path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' },
],
},
}),
/delta output must contain only Markdown bullets/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion strips the stale delta section from the reused baseline', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-reuse-strips-delta');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->',
'',
'## Changes since v0.12.0-beta.1',
'',
'- Stale beta-to-beta delta bullet.',
'',
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added overlay coverage.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.3',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => [],
resolveFragmentDelta: () => [],
},
});
assert.equal(stub.calls.length, 1);
const prompt = stub.calls[0]!.input;
assert.match(prompt, /EXISTING PRERELEASE NOTES/);
assert.match(prompt, /Overlay: Previous beta entry\./);
assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./);
assert.doesNotMatch(prompt, /## Changes since /);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale or legacy markers', async () => {
const { verifyPrereleaseNotesMatchVersion } = await loadModule();
const workspace = createWorkspace('verify-prerelease-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const notesPath = path.join(projectRoot, 'release', 'prerelease-notes.md');
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
try {
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/Missing .*prerelease-notes\.md/,
);
fs.writeFileSync(
notesPath,
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->\n\n## Highlights\n',
'utf8',
);
verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' });
verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: 'v0.12.0-beta.2' });
fs.writeFileSync(
notesPath,
'<!-- prerelease-version: 0.12.0-beta.1 -->\n\n## Highlights\n',
'utf8',
);
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/,
);
fs.writeFileSync(
notesPath,
'<!-- prerelease-base-version: 0.12.0 -->\n\n## Highlights\n',
'utf8',
);
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/missing or legacy prerelease-version marker/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('default git tag listing and fragment delta resolution work against a real repository', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-git-defaults');
const projectRoot = path.join(workspace, 'SubMiner');
const git = (...args: string[]): void => {
execFileSync('git', args, { cwd: projectRoot, stdio: 'ignore' });
};
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'kept.md'),
['type: added', 'area: overlay', '', '- Kept change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'edited.md'),
['type: fixed', 'area: launcher', '', '- Original launcher fix.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'removed.md'),
['type: added', 'area: stats', '', '- Reverted stats change.'].join('\n'),
'utf8',
);
try {
git('init', '--quiet');
git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.');
git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1');
git('tag', 'v0.11.3-beta.1');
fs.writeFileSync(
path.join(projectRoot, 'changes', 'edited.md'),
['type: fixed', 'area: launcher', '', '- Broader launcher fix.'].join('\n'),
'utf8',
);
fs.rmSync(path.join(projectRoot, 'changes', 'removed.md'));
fs.writeFileSync(
path.join(projectRoot, 'changes', 'new.md'),
['type: added', 'area: anki', '', '- New anki change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
'utf8',
);
const stub = recordingRunClaude((input) =>
input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input),
);
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 2);
const deltaPrompt = stub.calls[0]!.input;
assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/);
assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/);
assert.match(deltaPrompt, /- New anki change\./);
assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/);
assert.match(deltaPrompt, /- Original launcher fix\./);
assert.match(deltaPrompt, /- Broader launcher fix\./);
assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/);
assert.match(deltaPrompt, /- Reverted stats change\./);
assert.doesNotMatch(deltaPrompt, /kept\.md/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+299 -6
View File
@@ -18,6 +18,15 @@ type Contribution = {
// and the GitHub API.
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
// One changelog fragment's change between the previous prerelease tag and the
// working tree. `before` is the content at the tag, `after` the current content.
export type FragmentDeltaEntry = {
path: string;
status: 'added' | 'modified' | 'deleted';
before?: string;
after?: string;
};
type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -28,6 +37,8 @@ type ChangelogFsDeps = {
log?: (message: string) => void;
runClaude?: RunClaude;
resolveContributions?: ResolveContributions;
listPrereleaseTags?: (cwd: string, baseVersion: string) => string[];
resolveFragmentDelta?: (cwd: string, previousTag: string) => FragmentDeltaEntry[];
};
type PolishMode = 'changelog' | 'release-notes';
@@ -103,16 +114,57 @@ function resolvePrereleaseBaseVersion(version: string): string {
return match[1]!;
}
function renderPrereleaseBaseVersionMarker(version: string): string {
return `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`;
// The marker records which exact prerelease the committed notes were generated
// for (and which prior tag the delta section compares against), so CI can
// reject notes that were prepared for a different beta/RC.
function renderPrereleaseVersionMarker(version: string, previousTag: string | null): string {
const since = previousTag ? `; since: ${previousTag}` : '';
return `<!-- prerelease-version: ${normalizeVersion(version)}${since} -->`;
}
export function extractPrereleaseVersionMarker(notes: string): string | null {
return (
/<!--\s*prerelease-version:\s*(\d+\.\d+\.\d+-(?:beta|rc)\.\d+)(?:;\s*since:\s*\S+)?\s*-->/u.exec(
notes,
)?.[1] ?? null
);
}
// Legacy marker written before the per-version marker existed. Still accepted
// when deciding whether existing notes can seed the cumulative baseline.
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
const fullVersion = extractPrereleaseVersionMarker(notes);
if (fullVersion) {
return resolvePrereleaseBaseVersion(fullVersion);
}
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
}
const DELTA_SECTION_HEADING_PREFIX = '## Changes since ';
// Removes the previous run's "Changes since" section so the cumulative baseline
// fed back to Claude never carries a stale beta-to-beta delta.
function stripDeltaSection(notes: string): string {
const lines = notes.split(/\r?\n/);
const start = lines.findIndex((line) => line.startsWith(DELTA_SECTION_HEADING_PREFIX));
if (start === -1) {
return notes;
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (lines[index]!.startsWith('## ')) {
end = index;
break;
}
}
return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
}
function stripPrereleaseMetadata(notes: string): string {
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
return notes
.replace(/<!--\s*prerelease-version:[^>]*-->\s*/u, '')
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
.trim();
}
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
@@ -120,7 +172,119 @@ function resolveReusablePrereleaseNotes(notes: string, version: string): string
if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) {
return undefined;
}
return stripPrereleaseMetadata(notes);
return stripPrereleaseMetadata(stripDeltaSection(notes));
}
type ParsedPrereleaseTag = {
tag: string;
base: string;
channel: 'beta' | 'rc';
iteration: number;
};
function parsePrereleaseTag(tag: string): ParsedPrereleaseTag | null {
const match = /^v?(\d+\.\d+\.\d+)-(beta|rc)\.(\d+)$/u.exec(tag.trim());
if (!match) {
return null;
}
return {
tag: tag.trim(),
base: match[1]!,
channel: match[2] as 'beta' | 'rc',
iteration: Number.parseInt(match[3]!, 10),
};
}
// Semver prerelease order: every beta sorts before every rc, then numerically.
function comparePrereleaseTags(a: ParsedPrereleaseTag, b: ParsedPrereleaseTag): number {
if (a.channel !== b.channel) {
return a.channel === 'beta' ? -1 : 1;
}
return a.iteration - b.iteration;
}
// Picks the newest prerelease tag for the same base version that strictly
// precedes the version being released. Returns null for the first prerelease.
export function selectPreviousPrereleaseTag(tags: string[], version: string): string | null {
const current = parsePrereleaseTag(normalizeVersion(version));
if (!current) {
return null;
}
const candidates = tags
.map(parsePrereleaseTag)
.filter((parsed): parsed is ParsedPrereleaseTag => parsed !== null)
.filter((parsed) => parsed.base === current.base)
.filter((parsed) => comparePrereleaseTags(parsed, current) < 0)
.sort(comparePrereleaseTags);
return candidates[candidates.length - 1]?.tag ?? null;
}
function defaultListPrereleaseTags(cwd: string, baseVersion: string): string[] {
return execFileSync('git', ['tag', '--list', `v${baseVersion}-beta.*`, `v${baseVersion}-rc.*`], {
cwd,
encoding: 'utf8',
})
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
// Diffs changes/*.md between the previous prerelease tag and the working tree.
// Renamed fragments are treated as modifications of the new path.
function defaultResolveFragmentDelta(cwd: string, previousTag: string): FragmentDeltaEntry[] {
const output = execFileSync(
'git',
['diff', '--name-status', '--find-renames', previousTag, '--', 'changes'],
{ cwd, encoding: 'utf8' },
);
const showAtTag = (fragmentPath: string): string =>
execFileSync('git', ['show', `${previousTag}:${fragmentPath}`], { cwd, encoding: 'utf8' });
const readCurrent = (fragmentPath: string): string =>
fs.readFileSync(path.join(cwd, fragmentPath), 'utf8');
const entries: FragmentDeltaEntry[] = [];
for (const line of output.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
const [status = '', ...paths] = line.split('\t');
const oldPath = paths[0] ?? '';
const newPath = paths[paths.length - 1] ?? '';
if (!isFragmentPath(newPath) && !isFragmentPath(oldPath)) {
continue;
}
if (status.startsWith('A')) {
entries.push({ path: newPath, status: 'added', after: readCurrent(newPath) });
} else if (status.startsWith('D')) {
entries.push({ path: oldPath, status: 'deleted', before: showAtTag(oldPath) });
} else if (status.startsWith('M') || status.startsWith('R')) {
entries.push({
path: newPath,
status: 'modified',
before: showAtTag(oldPath),
after: readCurrent(newPath),
});
}
}
// git diff misses fragments that exist only in the working tree; treat
// untracked fragments as additions so a pre-commit run still sees them.
const untracked = execFileSync(
'git',
['ls-files', '--others', '--exclude-standard', '--', 'changes'],
{ cwd, encoding: 'utf8' },
)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((candidate) => candidate && isFragmentPath(candidate));
for (const fragmentPath of untracked) {
entries.push({ path: fragmentPath, status: 'added', after: readCurrent(fragmentPath) });
}
return entries;
}
function verifyRequestedVersionMatchesPackageVersion(
@@ -615,7 +779,7 @@ function polishFragmentsWithClaude(
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'',
].join('\n')
: '';
@@ -627,6 +791,75 @@ function polishFragmentsWithClaude(
return validatePolishedOutput(output, mode, hasInternalFragments);
}
const DELTA_PROMPT_INSTRUCTIONS = `You are writing the "changes since the previous prerelease" section of a prerelease notes file for SubMiner, an Electron app for Japanese sentence mining.
You will receive changelog fragment diffs between the previous prerelease tag and the current build. Fragments are engineer-written release-note sources; a fragment diff is a proxy for what changed, not proof of a behavior change.
Rules:
1. Output Markdown bullets ONLY. No headings, no preamble, no commentary. Every line must be a top-level "- " bullet or an indented nested bullet.
2. Describe only what changed for users between the two prerelease builds, in user-facing language. Drop implementation jargon, file paths, and PR numbers.
3. ADDED fragments describe changes that are new in this build; summarize them.
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
6. Keep bullets short and concrete. Use nested bullets sparingly.
7. Do not invent changes. Every bullet must be grounded in the diffs.
8. If no bullet survives rules 2-5, output exactly this single line:
- No user-facing changes since PREVIOUS_TAG.
The input begins below.
`;
function serializeFragmentDeltaForPrompt(
delta: FragmentDeltaEntry[],
version: string,
previousTag: string,
): string {
const header = [`VERSION: ${version}`, `PREVIOUS_TAG: ${previousTag}`];
const blocks = delta.map((entry) => {
if (entry.status === 'added') {
return [`ADDED FRAGMENT ${entry.path}`, entry.after ?? ''].join('\n');
}
if (entry.status === 'deleted') {
return [`DELETED FRAGMENT ${entry.path}`, entry.before ?? ''].join('\n');
}
return [
`MODIFIED FRAGMENT ${entry.path}`,
'BEFORE:',
entry.before ?? '',
'AFTER:',
entry.after ?? '',
].join('\n');
});
return [...header, '', ...blocks].join('\n\n');
}
function validateDeltaOutput(output: string): string {
const trimmed = output.trim();
if (!trimmed) {
throw new Error('claude returned empty output for the prerelease delta section.');
}
const invalidLine = trimmed.split(/\r?\n/).find((line) => line.trim() && !/^\s*- /.test(line));
if (invalidLine !== undefined) {
throw new Error(
`claude delta output must contain only Markdown bullets. Offending line:\n${invalidLine}`,
);
}
return trimmed;
}
function buildDeltaSectionWithClaude(
delta: FragmentDeltaEntry[],
options: { version: string; previousTag: string; deps?: ChangelogFsDeps },
): string {
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const prompt =
DELTA_PROMPT_INSTRUCTIONS.replace('PREVIOUS_TAG', options.previousTag) +
serializeFragmentDeltaForPrompt(delta, options.version, options.previousTag);
return validateDeltaOutput(runClaude(prompt, CLAUDE_CLI_ARGS));
}
function stripDetailsBlocks(body: string): string {
return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
}
@@ -709,15 +942,18 @@ function renderReleaseNotes(
contributions?: Contribution[];
contributorSections?: string[];
metadata?: string[];
deltaSection?: string[];
},
): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const metadata = options?.metadata?.length ? [...options.metadata, ''] : [];
const deltaSection = options?.deltaSection?.length ? [...options.deltaSection, ''] : [];
const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [
...prefix,
...metadata,
...deltaSection,
'## Highlights',
changes,
'',
@@ -748,6 +984,7 @@ function writeReleaseNotesFile(
contributions?: Contribution[];
contributorSections?: string[];
metadata?: string[];
deltaSection?: string[];
},
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -1079,6 +1316,26 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const listPrereleaseTags = options?.deps?.listPrereleaseTags ?? defaultListPrereleaseTags;
const previousTag = selectPreviousPrereleaseTag(
listPrereleaseTags(cwd, resolvePrereleaseBaseVersion(version)),
version,
);
// Later betas/RCs get a "Changes since <previous tag>" section on top of the
// cumulative Highlights, generated from the fragment diff between the
// previous prerelease tag and the working tree.
let deltaSection: string[] = [];
if (previousTag) {
const resolveFragmentDelta = options?.deps?.resolveFragmentDelta ?? defaultResolveFragmentDelta;
const delta = resolveFragmentDelta(cwd, previousTag);
const deltaBody =
delta.length === 0
? `- No changelog fragment changes since ${previousTag}; this build contains packaging or internal-only updates.`
: buildDeltaSectionWithClaude(delta, { version, previousTag, deps: options?.deps });
deltaSection = [`${DELTA_SECTION_HEADING_PREFIX}${previousTag}`, '', deltaBody];
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath)
? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version)
@@ -1095,10 +1352,41 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH,
contributions,
metadata: [renderPrereleaseBaseVersionMarker(version)],
metadata: [renderPrereleaseVersionMarker(version, previousTag)],
deltaSection,
});
}
// CI gate: the committed prerelease notes must carry a marker generated for
// exactly the version being tagged, so stale beta.N-1 notes can't ship.
export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): void {
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
);
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
if (!existsSync(prereleaseNotesPath)) {
throw new Error(
`Missing ${prereleaseNotesPath}. Run 'bun run changelog:prerelease-notes --version ${version}' and commit the file before tagging.`,
);
}
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
if (markerVersion !== version) {
throw new Error(
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
);
}
}
function parseCliArgs(argv: string[]): {
baseRef?: string;
cwd?: string;
@@ -1206,6 +1494,11 @@ function main(): void {
return;
}
if (command === 'check-prerelease-notes') {
verifyPrereleaseNotesMatchVersion(options);
return;
}
if (command === 'docs') {
generateDocsChangelog(options);
return;