Skip to content

Commit a22ec37

Browse files
authored
Adds a new GH action to update the CHANGELOG (#8658)
2 parents 84826a6 + 97605c0 commit a22ec37

File tree

3 files changed

+169
-0
lines changed

3 files changed

+169
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Update CHANGELOG
2+
on:
3+
workflow_dispatch
4+
5+
schedule:
6+
# Runs every Tuesday at 9 PM Pacific Time (5 AM UTC Wednesday)
7+
- cron: '0 5 * * 3'
8+
9+
permissions:
10+
contents: write
11+
pull-requests: write
12+
13+
jobs:
14+
update-changelog:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Check out
18+
uses: actions/checkout@v2
19+
- name: Install NodeJS
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: '20.x'
23+
- name: Install dependencies
24+
run: npm ci
25+
- name: Update version.json
26+
run: npx gulp updateChangelog
27+
- name: Create version update PR
28+
uses: peter-evans/create-pull-request@v4
29+
with:
30+
token: ${{ secrets.GITHUB_TOKEN }}
31+
commit-message: Update ${{ github.ref_name }} CHANGELOG
32+
title: '[automated] Update ${{ github.ref_name }} CHANGELOG'
33+
branch: merge/update-${{ github.ref_name }}-changelog

tasks/gitTasks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { spawnSync } from 'child_process';
77
import { Octokit } from '@octokit/rest';
8+
import { EOL } from 'os';
89

910
/**
1011
* Execute a git command with optional logging
@@ -146,3 +147,16 @@ export async function createPullRequest(
146147
return null;
147148
}
148149
}
150+
151+
/**
152+
* Find all tags that match the given version pattern
153+
* @param version The `Major.Minor` version pattern to match
154+
* @returns A sorted list of matching tags from oldest to newest
155+
*/
156+
export async function findTagsByVersion(version: string): Promise<string[]> {
157+
const tagList = await git(['tag', '--list', `v${version}*`, '--sort=creatordate'], false);
158+
return tagList
159+
.split(EOL)
160+
.map((tag) => tag.trim())
161+
.filter((tag) => tag.length > 0);
162+
}

tasks/snapTasks.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import * as gulp from 'gulp';
77
import * as fs from 'fs';
88
import * as path from 'path';
99
import * as os from 'os';
10+
import { exec } from 'child_process';
11+
import { promisify } from 'util';
12+
import { findTagsByVersion } from './gitTasks';
13+
14+
const execAsync = promisify(exec);
15+
16+
function logWarning(message: string, error?: unknown): void {
17+
console.log(`##vso[task.logissue type=warning]${message}`);
18+
if (error instanceof Error && error.stack) {
19+
console.log(`##[debug]${error.stack}`);
20+
}
21+
}
1022

1123
gulp.task('incrementVersion', async (): Promise<void> => {
1224
// Get the current version from version.json
@@ -64,3 +76,113 @@ gulp.task('incrementVersion', async (): Promise<void> => {
6476
changelogLines.splice(lineToInsertAt, 0, ...linesToInsert);
6577
fs.writeFileSync(changelogPath, changelogLines.join(os.EOL));
6678
});
79+
80+
gulp.task('updateChangelog', async (): Promise<void> => {
81+
// Add a new changelog section for the new version.
82+
console.log('Determining version from CHANGELOG');
83+
84+
const changelogPath = path.join(path.resolve(__dirname, '..'), 'CHANGELOG.md');
85+
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
86+
const changelogLines = changelogContent.split(os.EOL);
87+
88+
// Find all the headers in the changelog (and their line numbers)
89+
const [currentHeaderLine, currentVersion] = findNextVersionHeaderLine(changelogLines);
90+
if (currentHeaderLine === -1) {
91+
throw new Error('Could not find the current header in the CHANGELOG');
92+
}
93+
94+
console.log(`Adding PRs for ${currentVersion} to CHANGELOG`);
95+
96+
const [previousHeaderLine, previousVersion] = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1);
97+
if (previousHeaderLine === -1) {
98+
throw new Error('Could not find the previous header in the CHANGELOG');
99+
}
100+
101+
const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine);
102+
console.log(`PRs [#${presentPrIds.join(', #')}] already in the CHANGELOG`);
103+
104+
const versionTags = await findTagsByVersion(previousVersion!);
105+
if (versionTags.length === 0) {
106+
throw new Error(`Could not find any tags for version ${previousVersion}`);
107+
}
108+
109+
// The last tag is the most recent one created.
110+
const versionTag = versionTags.pop();
111+
console.log(`Using tag ${versionTag} for previous version ${previousVersion}`);
112+
113+
console.log(`Generating PR list from ${versionTag} to HEAD`);
114+
const currentPrs = await generatePRList(versionTag!, 'HEAD');
115+
116+
const newPrs = [];
117+
for (const pr of currentPrs) {
118+
const match = prRegex.exec(pr);
119+
if (!match) {
120+
continue;
121+
}
122+
123+
const prId = match[1];
124+
if (presentPrIds.includes(prId)) {
125+
console.log(`PR #${prId} is already present in the CHANGELOG`);
126+
continue;
127+
}
128+
129+
console.log(`Adding new PR to CHANGELOG: ${pr}`);
130+
newPrs.push(pr);
131+
}
132+
133+
if (newPrs.length === 0) {
134+
console.log('No new PRs to add to the CHANGELOG');
135+
return;
136+
}
137+
138+
console.log(`Writing ${newPrs.length} new PRs to the CHANGELOG`);
139+
140+
changelogLines.splice(currentHeaderLine + 1, 0, ...newPrs);
141+
fs.writeFileSync(changelogPath, changelogLines.join(os.EOL));
142+
});
143+
144+
const prRegex = /^\*.+\(PR: \[#(\d+)\]\(/g;
145+
146+
function findNextVersionHeaderLine(changelogLines: string[], startLine: number = 0): [number, string] {
147+
const headerRegex = /^#\s(\d+\.\d+)\.(x|\d+)$/gm;
148+
for (let i = startLine; i < changelogLines.length; i++) {
149+
const line = changelogLines.at(i);
150+
const match = headerRegex.exec(line!);
151+
if (match) {
152+
return [i, match[1]];
153+
}
154+
}
155+
return [-1, ''];
156+
}
157+
158+
function getPrIdsBetweenHeaders(changelogLines: string[], startLine: number, endLine: number): string[] {
159+
const prs: string[] = [];
160+
for (let i = startLine; i < endLine; i++) {
161+
const line = changelogLines.at(i);
162+
const match = prRegex.exec(line!);
163+
if (match && match[1]) {
164+
prs.push(match[1]);
165+
}
166+
}
167+
return prs;
168+
}
169+
170+
async function generatePRList(startSHA: string, endSHA: string): Promise<string[]> {
171+
try {
172+
console.log(`Executing: roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`);
173+
let { stdout } = await execAsync(
174+
`roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`,
175+
{ maxBuffer: 10 * 1024 * 1024 } // 10MB buffer
176+
);
177+
178+
stdout = stdout.trim();
179+
if (stdout.length === 0) {
180+
return [];
181+
}
182+
183+
return stdout.split(os.EOL).filter((pr) => pr.length > 0);
184+
} catch (error) {
185+
logWarning(`PR finder failed: ${error instanceof Error ? error.message : error}`, error);
186+
throw error;
187+
}
188+
}

0 commit comments

Comments
 (0)