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
This commit is contained in:
Matt Fiddaman
2026-04-07 11:28:30 +01:00
committed by GitHub
parent 4fe79e890b
commit bb7d7275a6
9 changed files with 337 additions and 2 deletions

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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}');
})();

View File

@@ -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',
`<!-- auto-generated-release-notes -->\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`,
);
}

View File

@@ -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": {

View File

@@ -0,0 +1,12 @@
export const categoryAutocorrections = {
Feature: 'Features',
Enhancement: 'Enhancements',
Bugfix: 'Bugfixes',
};
export const categoryOrder = [
'Features',
'Enhancements',
'Bugfixes',
'Maintenance',
];

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Consolidate all GitHub actions into the main repository

View File

@@ -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"