From db2301baa8dce5a79fdeb09cb57388d2b62fc1c4 Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Fri, 26 Sep 2025 09:02:58 -0700 Subject: [PATCH 1/4] Adds a new GH action to update the changelog --- .github/workflows/update-changelog.yml | 29 ++++++ tasks/gitTasks.ts | 14 +++ tasks/snapTasks.ts | 135 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 .github/workflows/update-changelog.yml diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000000..6ffc2518a6 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,29 @@ +name: Update CHANGELOG +on: + workflow_dispatch + +permissions: + contents: write + pull-requests: write + +jobs: + update-changelog: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v2 + - name: Install NodeJS + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - name: Install dependencies + run: npm ci + - name: Update version.json + run: npx gulp updateChangelog + - name: Create version update PR + uses: peter-evans/create-pull-request@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: Update ${{ github.ref_name }} CHANGELOG + title: '[automated] Update ${{ github.ref_name }} CHANGELOG' + branch: merge/update-${{ github.ref_name }}-changelog diff --git a/tasks/gitTasks.ts b/tasks/gitTasks.ts index 9230729e2e..5f52050639 100644 --- a/tasks/gitTasks.ts +++ b/tasks/gitTasks.ts @@ -5,6 +5,7 @@ import { spawnSync } from 'child_process'; import { Octokit } from '@octokit/rest'; +import { EOL } from 'os'; /** * Execute a git command with optional logging @@ -146,3 +147,16 @@ export async function createPullRequest( return null; } } + +/** + * Find all tags that match the given version pattern + * @param version The `Major.Minor` version pattern to match + * @returns A sorted list of matching tags from oldest to newest + */ +export async function findTagsByVersion(version: string): Promise { + const tagList = await git(['tag', '--list', `v${version}*`, '--sort=creatordate'], false); + return tagList + .split(EOL) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); +} diff --git a/tasks/snapTasks.ts b/tasks/snapTasks.ts index 4579910b18..ff8b4c20e1 100644 --- a/tasks/snapTasks.ts +++ b/tasks/snapTasks.ts @@ -7,6 +7,18 @@ import * as gulp from 'gulp'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { findTagsByVersion } from './gitTasks'; + +const execAsync = promisify(exec); + +function logWarning(message: string, error?: unknown): void { + console.log(`##vso[task.logissue type=warning]${message}`); + if (error instanceof Error && error.stack) { + console.log(`##[debug]${error.stack}`); + } +} gulp.task('incrementVersion', async (): Promise => { // Get the current version from version.json @@ -64,3 +76,126 @@ gulp.task('incrementVersion', async (): Promise => { changelogLines.splice(lineToInsertAt, 0, ...linesToInsert); fs.writeFileSync(changelogPath, changelogLines.join(os.EOL)); }); + +gulp.task('updateChangelog', async (): Promise => { + // Add a new changelog section for the new version. + console.log('Adding new prs to changelog'); + + const changelogPath = path.join(path.resolve(__dirname, '..'), 'CHANGELOG.md'); + const changelogContent = fs.readFileSync(changelogPath, 'utf8'); + const changelogLines = changelogContent.split(os.EOL); + + // Find all the headers in the changelog (and their line numbers) + const currentHeaderLine = findNextVersionHeaderLine(changelogLines); + if (currentHeaderLine === -1) { + throw new Error('Could not find the current header in the changelog.'); + } + + const currentVersion = getVersionFromHeader(changelogLines[currentHeaderLine]); + if (!currentVersion) { + throw new Error('Could not determine the current version from the changelog header.'); + } + + console.log(`Adding PRs for ${currentVersion}`); + + const previousHeaderLine = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1); + if (previousHeaderLine === -1) { + throw new Error('Could not find the previous header before the current version header in the changelog.'); + } + + const previousVersion = getVersionFromHeader(changelogLines[previousHeaderLine]); + if (!previousVersion) { + throw new Error('Could not determine the previous version from the changelog header.'); + } + + const versionTags = await findTagsByVersion(previousVersion!); + if (versionTags.length === 0) { + throw new Error(`Could not find any tags for version ${previousVersion}`); + } + + // The last tag is the most recent one created. + const versionTag = versionTags.pop(); + + console.log(`Using tag ${versionTag} for previous version ${previousVersion}`); + + const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine); + console.log(`PRs [#${presentPrIds.join(', #')}] already in the changelog.`); + const currentPrs = await generatePRList(versionTag!, 'HEAD'); + + const newPrs = []; + for (const pr of currentPrs) { + const match = prRegex.exec(pr); + if (!match) { + continue; + } + + const prId = match[1]; + if (presentPrIds.includes(prId)) { + console.log(`PR #${prId} is already present in the changelog.`); + continue; + } + + console.log(`Adding new PR to changelog: ${pr}`); + newPrs.push(pr); + } + + changelogLines.splice(currentHeaderLine + 1, 0, ...newPrs); + fs.writeFileSync(changelogPath, changelogLines.join(os.EOL)); +}); + +const prRegex = /^\*.+\(PR: \[#(\d+)\]\(/g; + +function findNextVersionHeaderLine(changelogLines: string[], startLine: number = 0): number { + const headerRegex = /^#\s\d+\.\d+\.(x|\d+)$/gm; + for (let i = startLine; i < changelogLines.length; i++) { + const line = changelogLines.at(i); + const match = headerRegex.exec(line!); + if (match) { + return i; + } + } + return -1; +} + +function getPrIdsBetweenHeaders(changelogLines: string[], startLine: number, endLine: number): string[] { + const prs: string[] = []; + for (let i = startLine; i < endLine; i++) { + const line = changelogLines.at(i); + const match = prRegex.exec(line!); + if (match && match[1]) { + prs.push(match[1]); + } + } + return prs; +} + +function getVersionFromHeader(header: string): string | null { + const versionRegex = /^#+\s+(\d+\.\d+)/; + const match = versionRegex.exec(header); + if (match && match[1]) { + return match[1]; + } + return null; +} + +async function generatePRList(startSHA: string, endSHA: string): Promise { + console.log(`Generating PR list from ${startSHA} to ${endSHA}...`); + + try { + console.log(`Executing: roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`); + let { stdout } = await execAsync( + `roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`, + { maxBuffer: 10 * 1024 * 1024 } // 10MB buffer + ); + + stdout = stdout.trim(); + if (stdout.length === 0) { + return []; + } + + return stdout.split(os.EOL).filter((pr) => pr.length > 0); + } catch (error) { + logWarning(`PR finder failed: ${error instanceof Error ? error.message : error}`, error); + return []; + } +} From d6006e5e5bc036fad6d7b57e5b3b4eadc32ed251 Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Fri, 26 Sep 2025 09:21:10 -0700 Subject: [PATCH 2/4] Simplify --- tasks/snapTasks.ts | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/tasks/snapTasks.ts b/tasks/snapTasks.ts index ff8b4c20e1..28ff90b538 100644 --- a/tasks/snapTasks.ts +++ b/tasks/snapTasks.ts @@ -79,34 +79,27 @@ gulp.task('incrementVersion', async (): Promise => { gulp.task('updateChangelog', async (): Promise => { // Add a new changelog section for the new version. - console.log('Adding new prs to changelog'); + console.log('Determining version from CHANGELOG'); const changelogPath = path.join(path.resolve(__dirname, '..'), 'CHANGELOG.md'); const changelogContent = fs.readFileSync(changelogPath, 'utf8'); const changelogLines = changelogContent.split(os.EOL); // Find all the headers in the changelog (and their line numbers) - const currentHeaderLine = findNextVersionHeaderLine(changelogLines); + const [currentHeaderLine, currentVersion] = findNextVersionHeaderLine(changelogLines); if (currentHeaderLine === -1) { throw new Error('Could not find the current header in the changelog.'); } - const currentVersion = getVersionFromHeader(changelogLines[currentHeaderLine]); - if (!currentVersion) { - throw new Error('Could not determine the current version from the changelog header.'); - } - - console.log(`Adding PRs for ${currentVersion}`); + console.log(`Adding PRs for ${currentVersion} to CHANGELOG`); - const previousHeaderLine = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1); + const [previousHeaderLine, previousVersion] = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1); if (previousHeaderLine === -1) { - throw new Error('Could not find the previous header before the current version header in the changelog.'); + throw new Error('Could not find the previous header in the changelog.'); } - const previousVersion = getVersionFromHeader(changelogLines[previousHeaderLine]); - if (!previousVersion) { - throw new Error('Could not determine the previous version from the changelog header.'); - } + const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine); + console.log(`PRs [#${presentPrIds.join(', #')}] already in the changelog.`); const versionTags = await findTagsByVersion(previousVersion!); if (versionTags.length === 0) { @@ -115,11 +108,9 @@ gulp.task('updateChangelog', async (): Promise => { // The last tag is the most recent one created. const versionTag = versionTags.pop(); - console.log(`Using tag ${versionTag} for previous version ${previousVersion}`); - const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine); - console.log(`PRs [#${presentPrIds.join(', #')}] already in the changelog.`); + console.log(`Generating PR list from ${versionTag} to HEAD...`); const currentPrs = await generatePRList(versionTag!, 'HEAD'); const newPrs = []; @@ -145,16 +136,16 @@ gulp.task('updateChangelog', async (): Promise => { const prRegex = /^\*.+\(PR: \[#(\d+)\]\(/g; -function findNextVersionHeaderLine(changelogLines: string[], startLine: number = 0): number { - const headerRegex = /^#\s\d+\.\d+\.(x|\d+)$/gm; +function findNextVersionHeaderLine(changelogLines: string[], startLine: number = 0): [number, string] { + const headerRegex = /^#\s(\d+\.\d+)\.(x|\d+)$/gm; for (let i = startLine; i < changelogLines.length; i++) { const line = changelogLines.at(i); const match = headerRegex.exec(line!); if (match) { - return i; + return [i, match[1]]; } } - return -1; + return [-1, '']; } function getPrIdsBetweenHeaders(changelogLines: string[], startLine: number, endLine: number): string[] { @@ -169,18 +160,7 @@ function getPrIdsBetweenHeaders(changelogLines: string[], startLine: number, end return prs; } -function getVersionFromHeader(header: string): string | null { - const versionRegex = /^#+\s+(\d+\.\d+)/; - const match = versionRegex.exec(header); - if (match && match[1]) { - return match[1]; - } - return null; -} - async function generatePRList(startSHA: string, endSHA: string): Promise { - console.log(`Generating PR list from ${startSHA} to ${endSHA}...`); - try { console.log(`Executing: roslyn-tools pr-finder -s "${startSHA}" -e "${endSHA}" --format o#`); let { stdout } = await execAsync( From 6a817fcfcec384b8c058977cbfd372f9ee8c16fd Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Fri, 26 Sep 2025 10:22:10 -0700 Subject: [PATCH 3/4] Fix up logging --- tasks/snapTasks.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tasks/snapTasks.ts b/tasks/snapTasks.ts index 28ff90b538..82a324d623 100644 --- a/tasks/snapTasks.ts +++ b/tasks/snapTasks.ts @@ -88,18 +88,18 @@ gulp.task('updateChangelog', async (): Promise => { // Find all the headers in the changelog (and their line numbers) const [currentHeaderLine, currentVersion] = findNextVersionHeaderLine(changelogLines); if (currentHeaderLine === -1) { - throw new Error('Could not find the current header in the changelog.'); + throw new Error('Could not find the current header in the CHANGELOG'); } console.log(`Adding PRs for ${currentVersion} to CHANGELOG`); const [previousHeaderLine, previousVersion] = findNextVersionHeaderLine(changelogLines, currentHeaderLine + 1); if (previousHeaderLine === -1) { - throw new Error('Could not find the previous header in the changelog.'); + throw new Error('Could not find the previous header in the CHANGELOG'); } const presentPrIds = getPrIdsBetweenHeaders(changelogLines, currentHeaderLine, previousHeaderLine); - console.log(`PRs [#${presentPrIds.join(', #')}] already in the changelog.`); + console.log(`PRs [#${presentPrIds.join(', #')}] already in the CHANGELOG`); const versionTags = await findTagsByVersion(previousVersion!); if (versionTags.length === 0) { @@ -110,7 +110,7 @@ gulp.task('updateChangelog', async (): Promise => { const versionTag = versionTags.pop(); console.log(`Using tag ${versionTag} for previous version ${previousVersion}`); - console.log(`Generating PR list from ${versionTag} to HEAD...`); + console.log(`Generating PR list from ${versionTag} to HEAD`); const currentPrs = await generatePRList(versionTag!, 'HEAD'); const newPrs = []; @@ -122,14 +122,21 @@ gulp.task('updateChangelog', async (): Promise => { const prId = match[1]; if (presentPrIds.includes(prId)) { - console.log(`PR #${prId} is already present in the changelog.`); + console.log(`PR #${prId} is already present in the CHANGELOG`); continue; } - console.log(`Adding new PR to changelog: ${pr}`); + console.log(`Adding new PR to CHANGELOG: ${pr}`); newPrs.push(pr); } + if (newPrs.length === 0) { + console.log('No new PRs to add to the CHANGELOG'); + return; + } + + console.log(`Writing ${newPrs.length} new PRs to the CHANGELOG`); + changelogLines.splice(currentHeaderLine + 1, 0, ...newPrs); fs.writeFileSync(changelogPath, changelogLines.join(os.EOL)); }); From 97605c0589f6c391139e72282779cdcdfcc25140 Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Fri, 26 Sep 2025 11:00:24 -0700 Subject: [PATCH 4/4] Add schedule triger --- .github/workflows/update-changelog.yml | 4 ++++ tasks/snapTasks.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 6ffc2518a6..7e9a584f21 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -2,6 +2,10 @@ name: Update CHANGELOG on: workflow_dispatch +schedule: + # Runs every Tuesday at 9 PM Pacific Time (5 AM UTC Wednesday) + - cron: '0 5 * * 3' + permissions: contents: write pull-requests: write diff --git a/tasks/snapTasks.ts b/tasks/snapTasks.ts index 82a324d623..b284d2cb03 100644 --- a/tasks/snapTasks.ts +++ b/tasks/snapTasks.ts @@ -183,6 +183,6 @@ async function generatePRList(startSHA: string, endSHA: string): Promise pr.length > 0); } catch (error) { logWarning(`PR finder failed: ${error instanceof Error ? error.message : error}`, error); - return []; + throw error; } }