Skip to content

Commit c14e557

Browse files
authored
Merge pull request #28 from rajbos/copilot/fix-61d0b57c-aada-429c-ad23-4420d6e3e829
feat: add automated release notes synchronization from GitHub releases
2 parents 6146eca + 39dc061 commit c14e557

5 files changed

Lines changed: 343 additions & 1 deletion

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Sync Release Notes
2+
3+
on:
4+
workflow_dispatch: # Manual trigger
5+
release:
6+
types: [published, edited] # Automatic trigger when releases are published or edited
7+
8+
jobs:
9+
sync-release-notes:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write # Need write permission to update CHANGELOG.md
13+
14+
steps:
15+
- name: Harden the runner (Audit all outbound calls)
16+
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
17+
with:
18+
egress-policy: audit
19+
20+
- name: Checkout code
21+
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
22+
with:
23+
fetch-depth: 0 # Fetch all history so we can work with all releases
24+
25+
- name: Setup Node.js
26+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
27+
with:
28+
node-version: '20.x'
29+
30+
- name: Sync GitHub release notes to CHANGELOG.md
31+
env:
32+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
run: |
34+
# Run the sync script from the scripts directory
35+
node scripts/sync-changelog.js
36+
37+
- name: Check for changes
38+
id: changes
39+
run: |
40+
if git diff --quiet CHANGELOG.md; then
41+
echo "changed=false" >> $GITHUB_OUTPUT
42+
echo "No changes detected in CHANGELOG.md"
43+
else
44+
echo "changed=true" >> $GITHUB_OUTPUT
45+
echo "Changes detected in CHANGELOG.md"
46+
fi
47+
48+
- name: Commit and push changes
49+
if: steps.changes.outputs.changed == 'true'
50+
run: |
51+
git config --local user.email "action@github.com"
52+
git config --local user.name "GitHub Action"
53+
git add CHANGELOG.md
54+
git commit -m "docs: sync CHANGELOG.md with GitHub release notes
55+
56+
This commit automatically updates the CHANGELOG.md file to match
57+
the release notes from GitHub releases, ensuring consistency
58+
between local documentation and published releases."
59+
git push
60+
61+
- name: Summary
62+
run: |
63+
if [ "${{ steps.changes.outputs.changed }}" == "true" ]; then
64+
echo "✅ CHANGELOG.md has been successfully updated with GitHub release notes"
65+
else
66+
echo "ℹ️ CHANGELOG.md was already up to date with GitHub release notes"
67+
fi

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ dist
33
node_modules
44
.vscode-test/
55
*.vsix
6+
*.backup

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,25 @@ The release workflow will:
110110

111111
**Note**: The workflow will fail if the tag version doesn't match the version in `package.json`.
112112

113+
### Syncing Release Notes
114+
115+
To keep the local `CHANGELOG.md` file synchronized with GitHub release notes:
116+
117+
**Manual Sync:**
118+
```bash
119+
npm run sync-changelog
120+
```
121+
122+
**Automatic Sync:**
123+
The project includes a GitHub workflow that automatically updates `CHANGELOG.md` whenever:
124+
- A new release is published
125+
- An existing release is edited
126+
- The workflow is manually triggered
127+
128+
**Test the Sync:**
129+
```bash
130+
npm run sync-changelog:test
131+
```
132+
133+
This ensures that the local changelog always reflects the latest release information from GitHub, preventing the documentation from becoming outdated.
134+

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
"pretest": "npm run compile-tests && npm run compile && npm run lint",
4949
"check-types": "tsc --noEmit",
5050
"lint": "eslint src",
51-
"test": "vscode-test"
51+
"test": "vscode-test",
52+
"sync-changelog": "node scripts/sync-changelog.js",
53+
"sync-changelog:test": "node scripts/sync-changelog.js --test"
5254
},
5355
"devDependencies": {
5456
"@types/mocha": "^10.0.10",

scripts/sync-changelog.js

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Sync CHANGELOG.md with GitHub release notes
5+
*
6+
* This script fetches GitHub release notes and updates the local CHANGELOG.md file
7+
* to ensure consistency between local documentation and published releases.
8+
*
9+
* Usage:
10+
* node scripts/sync-changelog.js [--test]
11+
*
12+
* Options:
13+
* --test Use hardcoded test data instead of fetching from GitHub
14+
*
15+
* Requirements:
16+
* - GitHub CLI (gh) installed and authenticated OR GITHUB_TOKEN environment variable
17+
* - Run from the repository root directory
18+
*/
19+
20+
const { execSync } = require('child_process');
21+
const fs = require('fs');
22+
const path = require('path');
23+
const https = require('https');
24+
25+
const TEST_MODE = process.argv.includes('--test');
26+
27+
// Test data matching the actual GitHub releases
28+
const TEST_RELEASES = [
29+
{
30+
tagName: "v0.0.2",
31+
name: "Release 0.0.2",
32+
body: "\n- Automated VSIX build and release workflow",
33+
createdAt: "2025-09-28T12:31:58Z",
34+
isPrerelease: false
35+
},
36+
{
37+
tagName: "v0.0.1",
38+
name: "First draft",
39+
body: "First rough version, not complete of course! \r\n\r\n- Only tested on windows\r\n- Use at your own risk 😄\r\n- Screenshots in the README\r\n- VS Code v1.104 or higher\r\n\r\n**Full Changelog**: https://github.com/rajbos/github-copilot-token-usage/commits/v0.0.1",
40+
createdAt: "2025-09-26T21:55:29Z",
41+
isPrerelease: true
42+
}
43+
];
44+
45+
async function fetchGitHubReleases() {
46+
if (TEST_MODE) {
47+
console.log('🧪 Using test data (--test mode)...');
48+
return TEST_RELEASES;
49+
}
50+
51+
// Try GitHub CLI first
52+
try {
53+
execSync('gh --version', { stdio: 'ignore' });
54+
console.log('📡 Fetching GitHub releases using GitHub CLI...');
55+
const releasesJson = execSync('gh release list --json tagName,name,body,createdAt,isPrerelease --limit 50', { encoding: 'utf8' });
56+
return JSON.parse(releasesJson);
57+
} catch (error) {
58+
console.log('⚠️ GitHub CLI not available or not authenticated, falling back to GitHub API...');
59+
}
60+
61+
// Fall back to GitHub API
62+
const token = process.env.GITHUB_TOKEN;
63+
if (!token) {
64+
console.error('❌ Error: GitHub CLI is not available and GITHUB_TOKEN environment variable is not set');
65+
console.error(' Please either:');
66+
console.error(' 1. Install and authenticate GitHub CLI: https://cli.github.com/');
67+
console.error(' 2. Set GITHUB_TOKEN environment variable with a GitHub personal access token');
68+
console.error(' 3. Use --test flag to test with sample data');
69+
throw new Error('No authentication method available');
70+
}
71+
72+
// Extract repository info from package.json
73+
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
74+
const repoUrl = packageJson.repository?.url || '';
75+
const match = repoUrl.match(/github\.com[\/:](.+?)\/(.+?)(?:\.git)?$/);
76+
if (!match) {
77+
throw new Error('Could not extract repository information from package.json');
78+
}
79+
80+
const [, owner, repo] = match;
81+
console.log(`📡 Fetching releases for ${owner}/${repo} using GitHub API...`);
82+
83+
return new Promise((resolve, reject) => {
84+
const options = {
85+
hostname: 'api.github.com',
86+
port: 443,
87+
path: `/repos/${owner}/${repo}/releases?per_page=50`,
88+
method: 'GET',
89+
headers: {
90+
'Authorization': `token ${token}`,
91+
'User-Agent': 'changelog-sync-script',
92+
'Accept': 'application/vnd.github.v3+json'
93+
}
94+
};
95+
96+
const req = https.request(options, (res) => {
97+
let data = '';
98+
res.on('data', (chunk) => data += chunk);
99+
res.on('end', () => {
100+
if (res.statusCode !== 200) {
101+
reject(new Error(`GitHub API returned ${res.statusCode}: ${data}`));
102+
return;
103+
}
104+
105+
const apiReleases = JSON.parse(data);
106+
// Convert API format to CLI format
107+
const releases = apiReleases.map(release => ({
108+
tagName: release.tag_name,
109+
name: release.name,
110+
body: release.body,
111+
createdAt: release.created_at,
112+
isPrerelease: release.prerelease
113+
}));
114+
115+
resolve(releases);
116+
});
117+
});
118+
119+
req.on('error', reject);
120+
req.end();
121+
});
122+
}
123+
124+
async function syncReleaseNotes() {
125+
try {
126+
console.log('🔄 Syncing CHANGELOG.md with GitHub release notes...');
127+
128+
// Check if we're in the right directory
129+
if (!fs.existsSync('package.json')) {
130+
console.error('❌ Error: This script must be run from the repository root directory');
131+
process.exit(1);
132+
}
133+
134+
const releases = await fetchGitHubReleases();
135+
136+
console.log(`📋 Found ${releases.length} releases`);
137+
138+
if (releases.length === 0) {
139+
console.log('ℹ️ No releases found. Nothing to sync.');
140+
return;
141+
}
142+
143+
// Sort releases by creation date (newest first)
144+
releases.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
145+
146+
// Read current CHANGELOG.md
147+
let changelog = '';
148+
const changelogPath = 'CHANGELOG.md';
149+
if (fs.existsSync(changelogPath)) {
150+
changelog = fs.readFileSync(changelogPath, 'utf8');
151+
console.log('📖 Reading existing CHANGELOG.md');
152+
} else {
153+
console.log('📝 CHANGELOG.md does not exist, creating new file');
154+
}
155+
156+
// Extract the header and unreleased section
157+
const lines = changelog.split('\n');
158+
const headerEndIndex = lines.findIndex(line => line.startsWith('## [Unreleased]'));
159+
const unreleasedEndIndex = lines.findIndex((line, index) =>
160+
index > headerEndIndex && line.startsWith('## [') && !line.includes('Unreleased')
161+
);
162+
163+
let header = '';
164+
let unreleasedSection = '';
165+
166+
if (headerEndIndex >= 0) {
167+
header = lines.slice(0, headerEndIndex + 1).join('\n');
168+
if (unreleasedEndIndex >= 0) {
169+
unreleasedSection = lines.slice(headerEndIndex + 1, unreleasedEndIndex).join('\n');
170+
} else {
171+
// Take everything after unreleased header until we find a release or end
172+
const restOfFile = lines.slice(headerEndIndex + 1);
173+
const nextReleaseIndex = restOfFile.findIndex(line => line.startsWith('## [') && !line.includes('Unreleased'));
174+
if (nextReleaseIndex >= 0) {
175+
unreleasedSection = restOfFile.slice(0, nextReleaseIndex).join('\n');
176+
} else {
177+
unreleasedSection = restOfFile.join('\n');
178+
}
179+
}
180+
} else {
181+
// Create basic header if none exists
182+
header = '# Change Log\n\nAll notable changes to the "copilot-token-tracker" extension will be documented in this file.\n\nCheck [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.\n\n## [Unreleased]';
183+
unreleasedSection = '\n';
184+
}
185+
186+
// Build new changelog content
187+
let newChangelog = header + unreleasedSection + '\n';
188+
189+
console.log('✏️ Building changelog entries from releases...');
190+
191+
// Add releases
192+
for (const release of releases) {
193+
const version = release.tagName.startsWith('v') ? release.tagName.substring(1) : release.tagName;
194+
const releaseType = release.isPrerelease ? ' - Pre-release' : '';
195+
196+
newChangelog += `## [${version}]${releaseType}\n\n`;
197+
198+
if (release.body && release.body.trim()) {
199+
// Clean up the release body
200+
let body = release.body.trim();
201+
202+
// Remove any "Full Changelog" links at the end
203+
body = body.replace(/\*\*Full Changelog\*\*:.*$/gm, '').trim();
204+
205+
// Ensure bullet points are properly formatted
206+
const bodyLines = body.split('\n').map(line => {
207+
line = line.trim();
208+
if (line && !line.startsWith('-') && !line.startsWith('*') && !line.startsWith('#')) {
209+
return `- ${line}`;
210+
}
211+
return line;
212+
}).filter(line => line.length > 0);
213+
214+
newChangelog += bodyLines.join('\n') + '\n\n';
215+
} else {
216+
newChangelog += `- Release ${version}\n\n`;
217+
}
218+
}
219+
220+
// Write the new changelog
221+
fs.writeFileSync(changelogPath, newChangelog.trim() + '\n');
222+
console.log('💾 CHANGELOG.md updated successfully!');
223+
224+
// Show what changed
225+
try {
226+
const diff = execSync('git diff CHANGELOG.md', { encoding: 'utf8' });
227+
if (diff.trim()) {
228+
console.log('\n📊 Changes made to CHANGELOG.md:');
229+
console.log(diff);
230+
console.log('\n✅ Sync completed successfully! Review the changes and commit them when ready.');
231+
} else {
232+
console.log('ℹ️ No changes needed - CHANGELOG.md is already up to date');
233+
}
234+
} catch (error) {
235+
console.log('💡 Could not show diff, but file was updated');
236+
console.log('✅ Sync completed successfully!');
237+
}
238+
239+
} catch (error) {
240+
console.error('❌ Error syncing release notes:', error.message);
241+
process.exit(1);
242+
}
243+
}
244+
245+
// Run the sync if this script is executed directly
246+
if (require.main === module) {
247+
syncReleaseNotes();
248+
}
249+
250+
module.exports = { syncReleaseNotes };

0 commit comments

Comments
 (0)