Skip to content

Commit 8ee0382

Browse files
cameroncookeclaude
andcommitted
fix(release): Sync GitHub release notes with changelog
Generate GitHub release bodies from CHANGELOG.md and use them in the\nrelease workflow instead of a static release body.\n\nWhen the latest changelog section is [Unreleased], promote it to the\ntarget release version during release preparation so release notes are\nconsistent and validation succeeds.\n\nKeep dry-run behavior non-mutating by preparing changelog updates in a\ntemporary file only. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent af58d1a commit 8ee0382

File tree

4 files changed

+313
-16
lines changed

4 files changed

+313
-16
lines changed

.github/workflows/release.yml

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ jobs:
7272
# For tag-based releases, package.json was already updated by release script
7373
fi
7474
75+
- name: Generate GitHub release notes (production releases only)
76+
if: github.event_name == 'push'
77+
run: |
78+
node scripts/generate-github-release-notes.mjs \
79+
--version "${{ steps.get_version.outputs.VERSION }}" \
80+
--out github-release-body.md
81+
7582
- name: Create package
7683
run: npm pack
7784

@@ -122,20 +129,7 @@ jobs:
122129
with:
123130
tag_name: v${{ steps.get_version.outputs.VERSION }}
124131
name: Release v${{ steps.get_version.outputs.VERSION }}
125-
body: |
126-
## Release v${{ steps.get_version.outputs.VERSION }}
127-
128-
### Installation
129-
```bash
130-
npm install -g xcodebuildmcp@${{ steps.get_version.outputs.VERSION }}
131-
```
132-
133-
Or use with npx:
134-
```bash
135-
npx xcodebuildmcp@${{ steps.get_version.outputs.VERSION }}
136-
```
137-
138-
📦 **NPM Package**: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }}
132+
body_path: github-release-body.md
139133
files: |
140134
xcodebuildmcp-${{ steps.get_version.outputs.VERSION }}.tgz
141135
draft: false

docs/dev/RELEASE_PROCESS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Release Process
22

3+
## GitHub Release Notes Source of Truth
4+
5+
GitHub release descriptions are generated from the matching version section in `CHANGELOG.md`. The release process now enforces this in both local and CI flows:
6+
7+
- `scripts/release.sh` validates release notes generation before tagging and pushing
8+
- `.github/workflows/release.yml` generates the final GitHub release body from `CHANGELOG.md`
9+
10+
If the changelog section for the target version is missing or empty, release execution fails with a clear error.
11+
12+
If the latest changelog section is `## [Unreleased]` and no matching version heading exists yet, `scripts/release.sh` automatically renames that heading to `## [<version>]` for the release. In `--dry-run`, this rename is performed only in a temporary file and does not modify `CHANGELOG.md`.
13+
14+
Preview release notes locally:
15+
16+
```bash
17+
node scripts/generate-github-release-notes.mjs --version 2.0.0-beta.1
18+
```
19+
320
## Step-by-Step Development Workflow
421

522
### 1. Starting New Work
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env node
2+
3+
import { readFile, writeFile } from 'node:fs/promises';
4+
import process from 'node:process';
5+
6+
const VERSION_HEADING_REGEX = /^##\s+\[([^\]]+)\](?:\s+-\s+.*)?\s*$/;
7+
8+
function normalizeVersion(value) {
9+
return value.trim().replace(/^v/, '');
10+
}
11+
12+
function parseArgs(argv) {
13+
const args = {
14+
changelog: 'CHANGELOG.md',
15+
out: '',
16+
packageName: 'xcodebuildmcp',
17+
version: '',
18+
};
19+
20+
for (let i = 0; i < argv.length; i += 1) {
21+
const arg = argv[i];
22+
const next = argv[i + 1];
23+
24+
if (arg === '--version') {
25+
if (!next) {
26+
throw new Error('Missing value for --version');
27+
}
28+
args.version = next;
29+
i += 1;
30+
continue;
31+
}
32+
33+
if (arg === '--changelog') {
34+
if (!next) {
35+
throw new Error('Missing value for --changelog');
36+
}
37+
args.changelog = next;
38+
i += 1;
39+
continue;
40+
}
41+
42+
if (arg === '--out') {
43+
if (!next) {
44+
throw new Error('Missing value for --out');
45+
}
46+
args.out = next;
47+
i += 1;
48+
continue;
49+
}
50+
51+
if (arg === '--package') {
52+
if (!next) {
53+
throw new Error('Missing value for --package');
54+
}
55+
args.packageName = next;
56+
i += 1;
57+
continue;
58+
}
59+
60+
if (arg === '--help' || arg === '-h') {
61+
printHelp();
62+
process.exit(0);
63+
}
64+
65+
throw new Error(`Unknown argument: ${arg}`);
66+
}
67+
68+
if (!args.version) {
69+
throw new Error('Missing required argument: --version');
70+
}
71+
72+
return args;
73+
}
74+
75+
function printHelp() {
76+
console.log(`Generate GitHub release notes from CHANGELOG.md.
77+
78+
Usage:
79+
node scripts/generate-github-release-notes.mjs --version <version> [options]
80+
81+
Options:
82+
--version <version> Required release version (e.g. 2.0.0 or 2.0.0-beta.1)
83+
--changelog <path> Changelog path (default: CHANGELOG.md)
84+
--out <path> Output file path (default: stdout)
85+
--package <name> Package name for install snippets (default: xcodebuildmcp)
86+
-h, --help Show this help
87+
`);
88+
}
89+
90+
function extractChangelogSection(changelog, version) {
91+
const normalizedTarget = normalizeVersion(version);
92+
const lines = changelog.split(/\r?\n/);
93+
let sectionStartLine = -1;
94+
95+
for (let index = 0; index < lines.length; index += 1) {
96+
const match = lines[index].match(VERSION_HEADING_REGEX);
97+
if (!match) {
98+
continue;
99+
}
100+
101+
if (normalizeVersion(match[1]) === normalizedTarget) {
102+
sectionStartLine = index + 1;
103+
break;
104+
}
105+
}
106+
107+
if (sectionStartLine === -1) {
108+
throw new Error(
109+
`Missing CHANGELOG section for version: ${normalizedTarget}\n` +
110+
`Add a heading like: ## [${normalizedTarget}] (or ## [v${normalizedTarget}] - YYYY-MM-DD)`
111+
);
112+
}
113+
114+
let sectionEndLine = lines.length;
115+
for (let index = sectionStartLine; index < lines.length; index += 1) {
116+
if (VERSION_HEADING_REGEX.test(lines[index])) {
117+
sectionEndLine = index;
118+
break;
119+
}
120+
}
121+
122+
const section = lines.slice(sectionStartLine, sectionEndLine).join('\n').trim();
123+
if (!section) {
124+
throw new Error(`CHANGELOG section for version ${normalizedTarget} is empty`);
125+
}
126+
127+
return section;
128+
}
129+
130+
function buildInstallAndSetupSection(version, packageName) {
131+
const normalizedVersion = normalizeVersion(version);
132+
return [
133+
'### CLI Installation',
134+
'```bash',
135+
`npm install -g ${packageName}@${normalizedVersion}`,
136+
`${packageName} --help`,
137+
'```',
138+
'',
139+
'### MCP Setup',
140+
'```json',
141+
'"XcodeBuildMCP": {',
142+
' "command": "npx",',
143+
` "args": ["-y", "${packageName}@${normalizedVersion}", "mcp"]`,
144+
'}',
145+
'```',
146+
'',
147+
`📦 **NPM Package**: https://www.npmjs.com/package/${packageName}/v/${normalizedVersion}`,
148+
].join('\n');
149+
}
150+
151+
function buildReleaseBody(version, changelogSection, packageName) {
152+
const normalizedVersion = normalizeVersion(version);
153+
const installAndSetup = buildInstallAndSetupSection(normalizedVersion, packageName);
154+
return [
155+
`## Release v${normalizedVersion}`,
156+
'',
157+
changelogSection,
158+
'',
159+
installAndSetup,
160+
'',
161+
].join('\n');
162+
}
163+
164+
async function main() {
165+
try {
166+
const { changelog, out, packageName, version } = parseArgs(process.argv.slice(2));
167+
const changelogContent = await readFile(changelog, 'utf8').catch(() => {
168+
throw new Error(`Could not read CHANGELOG.md at ${changelog}`);
169+
});
170+
171+
const section = extractChangelogSection(changelogContent, version);
172+
const body = buildReleaseBody(version, section, packageName);
173+
174+
if (out) {
175+
await writeFile(out, body, 'utf8');
176+
return;
177+
}
178+
179+
process.stdout.write(body);
180+
} catch (error) {
181+
const message = error instanceof Error ? error.message : String(error);
182+
process.stderr.write(`❌ ${message}\n`);
183+
process.exit(1);
184+
}
185+
}
186+
187+
await main();

scripts/release.sh

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,57 @@ sed_inplace() {
270270
fi
271271
}
272272

273+
prepare_changelog_for_release_notes() {
274+
local source_path="$1"
275+
local destination_path="$2"
276+
local target_version="$3"
277+
278+
node - "$source_path" "$destination_path" "$target_version" <<'NODE'
279+
const fs = require('fs');
280+
281+
const [sourcePath, destinationPath, targetVersion] = process.argv.slice(2);
282+
const versionHeadingRegex = /^##\s+\[([^\]]+)\](?:\s+-\s+.*)?\s*$/;
283+
const normalizeVersion = (value) => value.trim().replace(/^v/, '');
284+
285+
try {
286+
const changelog = fs.readFileSync(sourcePath, 'utf8');
287+
const lines = changelog.split(/\r?\n/);
288+
const normalizedTargetVersion = normalizeVersion(targetVersion);
289+
let firstHeadingIndex = -1;
290+
let firstHeadingLabel = '';
291+
292+
for (let index = 0; index < lines.length; index += 1) {
293+
const match = lines[index].match(versionHeadingRegex);
294+
if (!match) {
295+
continue;
296+
}
297+
298+
const label = match[1].trim();
299+
if (normalizeVersion(label) === normalizedTargetVersion) {
300+
process.exit(3);
301+
}
302+
303+
if (firstHeadingIndex === -1) {
304+
firstHeadingIndex = index;
305+
firstHeadingLabel = label;
306+
}
307+
}
308+
309+
if (firstHeadingIndex === -1 || firstHeadingLabel !== 'Unreleased') {
310+
process.exit(3);
311+
}
312+
313+
lines[firstHeadingIndex] = lines[firstHeadingIndex].replace('[Unreleased]', `[${targetVersion}]`);
314+
fs.writeFileSync(destinationPath, `${lines.join('\n')}\n`, 'utf8');
315+
process.exit(0);
316+
} catch (error) {
317+
const message = error instanceof Error ? error.message : String(error);
318+
console.error(`❌ Failed to prepare changelog for release notes: ${message}`);
319+
process.exit(1);
320+
}
321+
NODE
322+
}
323+
273324
# Ensure we're in the project root (parent of scripts directory)
274325
cd "$(dirname "$0")/.."
275326

@@ -286,6 +337,48 @@ else
286337
fi
287338
fi
288339

340+
CHANGELOG_PATH="CHANGELOG.md"
341+
CHANGELOG_FOR_VALIDATION="$CHANGELOG_PATH"
342+
CHANGELOG_VALIDATION_TEMP=""
343+
CHANGELOG_RENAMED_ON_DISK=false
344+
345+
if $DRY_RUN; then
346+
CHANGELOG_VALIDATION_TEMP=$(mktemp "${TMPDIR:-/tmp}/xcodebuildmcp-changelog-validation.XXXXXX")
347+
if prepare_changelog_for_release_notes "$CHANGELOG_PATH" "$CHANGELOG_VALIDATION_TEMP" "$VERSION"; then
348+
CHANGELOG_FOR_VALIDATION="$CHANGELOG_VALIDATION_TEMP"
349+
echo "ℹ️ Dry-run: prepared release changelog from [Unreleased] in a temp file."
350+
else
351+
PREPARE_STATUS=$?
352+
if [[ $PREPARE_STATUS -eq 3 ]]; then
353+
rm "$CHANGELOG_VALIDATION_TEMP"
354+
CHANGELOG_VALIDATION_TEMP=""
355+
else
356+
rm "$CHANGELOG_VALIDATION_TEMP"
357+
exit $PREPARE_STATUS
358+
fi
359+
fi
360+
else
361+
if prepare_changelog_for_release_notes "$CHANGELOG_PATH" "$CHANGELOG_PATH" "$VERSION"; then
362+
CHANGELOG_RENAMED_ON_DISK=true
363+
echo "📝 Renamed CHANGELOG heading [Unreleased] -> [$VERSION]"
364+
else
365+
PREPARE_STATUS=$?
366+
if [[ $PREPARE_STATUS -ne 3 ]]; then
367+
exit $PREPARE_STATUS
368+
fi
369+
fi
370+
fi
371+
372+
echo ""
373+
echo "🧾 Validating CHANGELOG release notes for v$VERSION..."
374+
RELEASE_NOTES_TMP=$(mktemp "${TMPDIR:-/tmp}/xcodebuildmcp-release-notes.XXXXXX")
375+
node scripts/generate-github-release-notes.mjs --version "$VERSION" --changelog "$CHANGELOG_FOR_VALIDATION" --out "$RELEASE_NOTES_TMP"
376+
rm "$RELEASE_NOTES_TMP"
377+
if [[ -n "$CHANGELOG_VALIDATION_TEMP" ]]; then
378+
rm "$CHANGELOG_VALIDATION_TEMP"
379+
fi
380+
echo "✅ CHANGELOG entry found and release notes generated."
381+
289382
# Check if package.json already has this version (from previous attempt)
290383
CURRENT_PACKAGE_VERSION=$(node -p "require('./package.json').version")
291384
if [[ "$CURRENT_PACKAGE_VERSION" == "$VERSION" ]]; then
@@ -351,9 +444,9 @@ if [[ "$SKIP_VERSION_UPDATE" == "false" ]]; then
351444
echo ""
352445
echo "📦 Committing version changes..."
353446
if [[ -f server.json ]]; then
354-
run git add package.json package-lock.json README.md docs/SKILLS.md server.json
447+
run git add package.json package-lock.json README.md docs/SKILLS.md CHANGELOG.md server.json
355448
else
356-
run git add package.json package-lock.json README.md docs/SKILLS.md
449+
run git add package.json package-lock.json README.md docs/SKILLS.md CHANGELOG.md
357450
fi
358451
run git commit -m "Release v$VERSION"
359452
else
@@ -368,6 +461,12 @@ else
368461
run git commit -m "Align server.json for v$VERSION"
369462
fi
370463
fi
464+
465+
if $CHANGELOG_RENAMED_ON_DISK; then
466+
echo "📝 Committing changelog release heading update..."
467+
run git add CHANGELOG.md
468+
run git commit -m "Finalize changelog for v$VERSION"
469+
fi
371470
fi
372471

373472
# Create or recreate tag at current HEAD

0 commit comments

Comments
 (0)