From bb7d7275a6004e1aa3cc331b8366797c4643d429 Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Tue, 7 Apr 2026 11:28:30 +0100 Subject: [PATCH] migrate `actualbudget/actions` to the main repo (#7406) * migrate release note actions * move workflows to use the local actions * note * fix failing cleanup in release notes action * fix codeQL violation --- .../actions/release-notes/check/action.yml | 17 ++ .../actions/release-notes/generate/action.yml | 37 ++++ .github/workflows/release-notes.yml | 4 +- .../ci-actions/bin/release-notes-check.mjs | 68 +++++++ .../ci-actions/bin/release-notes-generate.mjs | 184 ++++++++++++++++++ packages/ci-actions/package.json | 2 + .../ci-actions/src/release-notes/util.mjs | 12 ++ upcoming-release-notes/7406.md | 6 + yarn.lock | 9 + 9 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 .github/actions/release-notes/check/action.yml create mode 100644 .github/actions/release-notes/generate/action.yml create mode 100644 packages/ci-actions/bin/release-notes-check.mjs create mode 100644 packages/ci-actions/bin/release-notes-generate.mjs create mode 100644 packages/ci-actions/src/release-notes/util.mjs create mode 100644 upcoming-release-notes/7406.md diff --git a/.github/actions/release-notes/check/action.yml b/.github/actions/release-notes/check/action.yml new file mode 100644 index 0000000000..c71ce43758 --- /dev/null +++ b/.github/actions/release-notes/check/action.yml @@ -0,0 +1,17 @@ +name: Check release notes +description: Validate that a PR includes a properly formatted release note file + +runs: + using: composite + steps: + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + - name: Install dependencies + shell: bash + run: yarn --immutable + - name: Check release notes + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: node packages/ci-actions/bin/release-notes-check.mjs diff --git a/.github/actions/release-notes/generate/action.yml b/.github/actions/release-notes/generate/action.yml new file mode 100644 index 0000000000..d3f42f958c --- /dev/null +++ b/.github/actions/release-notes/generate/action.yml @@ -0,0 +1,37 @@ +name: Generate release notes +description: Aggregate individual release note files into a formatted PR comment + +runs: + using: composite + steps: + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + - name: Install dependencies + shell: bash + run: yarn --immutable + - name: Generate release notes + id: generate + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: node packages/ci-actions/bin/release-notes-generate.mjs + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ steps.generate.outputs.pr_number }} + body-includes: auto-generated-release-notes + - name: Create Comment + if: ${{ steps.fc.outputs.comment-id == 0 }} + uses: peter-evans/create-or-update-comment@v2 + with: + issue-number: ${{ steps.generate.outputs.pr_number }} + body: ${{ steps.generate.outputs.comment }} + - name: Update Comment + if: ${{ steps.fc.outputs.comment-id != 0 }} + uses: peter-evans/create-or-update-comment@v2 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace + body: ${{ steps.generate.outputs.comment }} diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 4de02c39eb..d02fd68c53 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -30,7 +30,7 @@ jobs: fi - name: Check release notes if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true' - uses: actualbudget/actions/release-notes/check@main + uses: ./.github/actions/release-notes/check - name: Generate release notes if: startsWith(github.head_ref, 'release/') == true - uses: actualbudget/actions/release-notes/generate@main + uses: ./.github/actions/release-notes/generate diff --git a/packages/ci-actions/bin/release-notes-check.mjs b/packages/ci-actions/bin/release-notes-check.mjs new file mode 100644 index 0000000000..cb600b5c85 --- /dev/null +++ b/packages/ci-actions/bin/release-notes-check.mjs @@ -0,0 +1,68 @@ +import * as fs from 'node:fs'; + +import matter from 'gray-matter'; + +import { + categoryAutocorrections, + categoryOrder, +} from '../src/release-notes/util.mjs'; + +console.log('Looking in ' + fs.realpathSync('upcoming-release-notes')); + +const expectedPath = `upcoming-release-notes/${process.env.PR_NUMBER}.md`; + +function reportError(message) { + console.log(`::error::${message}`); + + process.stdout.write('::notice::'); + fs.createReadStream('upcoming-release-notes/README.md').pipe(process.stdout); + + fs.createReadStream('upcoming-release-notes/README.md') + .pipe(fs.createWriteStream(process.env.GITHUB_STEP_SUMMARY)) + .on('close', () => { + process.exit(1); + }); +} + +(() => { + if (!fs.existsSync(expectedPath)) { + reportError(`Release note file ${expectedPath} not found`); + return; + } + + const { data, content } = matter(fs.readFileSync(expectedPath, 'utf-8')); + + if (!data.category) { + reportError(`Release note is missing a category.`); + return; + } + if (categoryAutocorrections[data.category]) { + data.category = categoryAutocorrections[data.category]; + } + if (!categoryOrder.includes(data.category)) { + reportError( + `Release note category "${data.category}" is not one of ${categoryOrder + .map(JSON.stringify) + .join(', ')}`, + ); + return; + } + + if (!data.authors) { + reportError(`Release note is missing authors.`); + return; + } + if (!Array.isArray(data.authors)) { + reportError(`Release note authors should be a list.`); + return; + } + + if (content.trim().split('\n').length !== 1) { + reportError( + `Release note file ${expectedPath} body should contain exactly one line`, + ); + return; + } + + console.log('Everything looks good! \u{1f389}'); +})(); diff --git a/packages/ci-actions/bin/release-notes-generate.mjs b/packages/ci-actions/bin/release-notes-generate.mjs new file mode 100644 index 0000000000..1aa5d78648 --- /dev/null +++ b/packages/ci-actions/bin/release-notes-generate.mjs @@ -0,0 +1,184 @@ +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { inspect, promisify } from 'node:util'; + +import matter from 'gray-matter'; +import listify from 'listify'; + +import { + categoryAutocorrections, + categoryOrder, +} from '../src/release-notes/util.mjs'; + +const exec = promisify(childProcess.exec); + +const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + +const apiResult = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query GetPRMetadata( + $name: String! + $owner: String! + $headRefName: String! + ) { + repository(name: $name, owner: $owner) { + pullRequests(headRefName: $headRefName, first: 1) { + edges { + node { + number + baseRef { + target { + oid + } + } + headRefName + } + } + } + } + } + `, + variables: { + name: repo, + owner, + headRefName: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME, + }, + }), +}).then(res => res.json()); + +await collapsedLog('API Response', apiResult); + +const prData = apiResult.data.repository.pullRequests.edges[0].node; + +await setOutput('pr_number', prData.number); +const version = prData.headRefName.split('/')[1]; +const baseRef = prData.baseRef.target.oid; + +await group('Checkout base ref in worktree', async () => { + await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' }); + await exec(`git worktree add ../../tmp ${baseRef}`, { + stdio: 'inherit', + }); +}); + +const notes = printNotes( + await parseReleaseNotes('../../tmp/upcoming-release-notes'), +); + +await collapsedLog('Release Notes', notes); + +await setOutput( + 'comment', + `\nHere are the automatically generated release notes!\n\n~~~markdown\n${notes}\n~~~`, +); + +const releaseNotes = await fs + .readdir('upcoming-release-notes') + .then(contents => + contents.filter(f => f.endsWith('.md') && f !== 'README.md'), + ); + +if (releaseNotes.length === 0) { + console.log('No release notes found, no cleanup needed'); + process.exit(0); +} + +await group('Remove used release notes', async () => { + if (process.env.GITHUB_HEAD_REF) { + await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, { + stdio: 'inherit', + }); + await exec(`git checkout ${process.env.GITHUB_HEAD_REF}`, { + stdio: 'inherit', + }); + } + await exec('rm -r upcoming-release-notes/*.md', { stdio: 'inherit' }); + await exec('git checkout upcoming-release-notes/README.md', { + stdio: 'inherit', + }); +}); + +await group('Commit and push', async () => { + await exec('git add upcoming-release-notes', { stdio: 'inherit' }); + const name = 'github-actions[bot]'; + const email = '41898282+github-actions[bot]@users.noreply.github.com'; + await exec("git commit -m 'Remove used release notes'", { + stdio: 'inherit', + env: { + ...process.env, + GIT_AUTHOR_NAME: name, + GIT_COMMITTER_NAME: name, + GIT_AUTHOR_EMAIL: email, + GIT_COMMITTER_EMAIL: email, + }, + }); + await exec('git push origin', { stdio: 'inherit' }); +}); + +async function parseReleaseNotes(dir) { + const notes = (await fs.readdir(dir)) + .filter(f => f.match(/^\d+\.md$/)) + .map(async name => { + const content = await fs.readFile(join(dir, name), 'utf-8'); + const { data, content: body } = matter(content); + const number = name.replace('.md', ''); + const authors = listify( + data.authors.map(a => `@${a}`), + { finalWord: '&' }, + ); + return { + category: categoryAutocorrections[data.category] ?? data.category, + value: `- [#${number}](https://github.com/actualbudget/${repo}/pull/${number}) ${body.trim()} — thanks ${authors}`, + }; + }); + + return (await Promise.all(notes)).reduce( + (acc, note) => { + if (!acc[note.category]) { + console.log(`WARNING: Unrecognized category "${note.category}"`); + acc[note.category] = []; + } + acc[note.category].push(note.value); + return acc; + }, + Object.fromEntries(categoryOrder.map(c => [c, []])), + ); +} + +function printNotes(notes) { + const printedNotes = Object.entries(notes) + .filter(([_, values]) => values.length > 0) + .map(([category, values]) => `#### ${category}\n\n${values.join('\n')}`); + return `Version: ${version}\n\n${printedNotes.join('\n\n')}`; +} + +async function collapsedLog(name, value) { + await group(name, () => { + if (typeof value === 'string') { + console.log(value); + } else { + console.log(inspect(value, { depth: null })); + } + }); +} + +async function group(name, cb) { + console.log(`::group::${name}`); + await cb(); + console.log('::endgroup::'); +} + +async function setOutput(name, value) { + const delimiter = Math.random().toString(36).slice(2); + await fs.appendFile( + process.env.GITHUB_OUTPUT, + `\n${name}<<${delimiter}\n${value}\n${delimiter}\n`, + ); +} diff --git a/packages/ci-actions/package.json b/packages/ci-actions/package.json index a6b262af9d..4268e08ad1 100644 --- a/packages/ci-actions/package.json +++ b/packages/ci-actions/package.json @@ -10,6 +10,8 @@ "devDependencies": { "@typescript/native-preview": "^7.0.0-dev.20260309.1", "extensionless": "^2.0.6", + "gray-matter": "^4.0.3", + "listify": "^1.0.3", "vitest": "^4.1.0" }, "extensionless": { diff --git a/packages/ci-actions/src/release-notes/util.mjs b/packages/ci-actions/src/release-notes/util.mjs new file mode 100644 index 0000000000..f25eb5ca70 --- /dev/null +++ b/packages/ci-actions/src/release-notes/util.mjs @@ -0,0 +1,12 @@ +export const categoryAutocorrections = { + Feature: 'Features', + Enhancement: 'Enhancements', + Bugfix: 'Bugfixes', +}; + +export const categoryOrder = [ + 'Features', + 'Enhancements', + 'Bugfixes', + 'Maintenance', +]; diff --git a/upcoming-release-notes/7406.md b/upcoming-release-notes/7406.md new file mode 100644 index 0000000000..858967ef50 --- /dev/null +++ b/upcoming-release-notes/7406.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Consolidate all GitHub actions into the main repository diff --git a/yarn.lock b/yarn.lock index ee000d5acd..17e595a1ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,6 +44,8 @@ __metadata: dependencies: "@typescript/native-preview": "npm:^7.0.0-dev.20260309.1" extensionless: "npm:^2.0.6" + gray-matter: "npm:^4.0.3" + listify: "npm:^1.0.3" vitest: "npm:^4.1.0" languageName: unknown linkType: soft @@ -20068,6 +20070,13 @@ __metadata: languageName: node linkType: hard +"listify@npm:^1.0.3": + version: 1.0.3 + resolution: "listify@npm:1.0.3" + checksum: 10/1f9756ac1ad7641b1eb4552348c663419e6df98a3c2486d6a5b579ac20f12f2ac9bb634a966d34da61b0357fec2baf09b5c346d089d4c0a7c48bef904cdc5a42 + languageName: node + linkType: hard + "listr2@npm:^9.0.5": version: 9.0.5 resolution: "listr2@npm:9.0.5"