mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-26 02:43:26 -05:00
* [AI] Resolve release-note PR links from base branch history The release-note generator recovered each note's PR number by reading the "(#NNNN)" suffix that GitHub appends to squash-merge commit subjects, searching HEAD — the release-notes/X.Y.Z branch it commits to. That branch only carries the note file *contents* (checked out from origin/release), not the commit history, so any note added to the release branch after it was cut (e.g. release cherry-picks) has no add-commit reachable from HEAD and silently loses its link. Search origin/master instead, where every note traces back to its original squash commit with the correct PR number. The release branch is unsuitable because "bring release branch up to date" squashes collapse original PRs. Verified across all 63 descriptively-named notes in the 26.7.0 set: 60 resolve identically to before, 3 previously-unlinked cherry-picks (#8301, #8335, #8339) are now linked, 0 regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * make the note human * Update upcoming-release-notes/release-notes-resolve-pr-link-from-master.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
296 lines
7.5 KiB
JavaScript
296 lines
7.5 KiB
JavaScript
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 { formatNotes, parseReleaseNotes } from '../src/release-notes/util.mjs';
|
|
|
|
const exec = promisify(childProcess.exec);
|
|
|
|
if (!process.env.GITHUB_REPOSITORY) {
|
|
throw new Error('GITHUB_REPOSITORY env var is not set');
|
|
}
|
|
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
|
|
|
|
const releaseBranch = process.env.RELEASE_BRANCH;
|
|
const notesBranch = process.env.NOTES_BRANCH;
|
|
const version = process.env.VERSION;
|
|
|
|
if (!releaseBranch || !notesBranch || !version) {
|
|
throw new Error(
|
|
'RELEASE_BRANCH, NOTES_BRANCH, and VERSION env vars are required',
|
|
);
|
|
}
|
|
|
|
const baseBranch = 'master';
|
|
|
|
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
|
|
headRefName
|
|
body
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
name: repo,
|
|
owner,
|
|
headRefName: notesBranch,
|
|
},
|
|
}),
|
|
}).then(res => res.json());
|
|
|
|
await collapsedLog('API Response', apiResult);
|
|
|
|
const prData = apiResult.data.repository.pullRequests.edges[0]?.node;
|
|
if (!prData) {
|
|
console.error(`No PR found for branch ${notesBranch}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const releaseDateMatch = (prData.body || '').match(
|
|
/<!-- release-date:(\d{4}-\d{2}-\d{2}) -->/,
|
|
);
|
|
if (!releaseDateMatch) {
|
|
console.error(
|
|
`PR for ${notesBranch} body missing <!-- release-date:YYYY-MM-DD --> marker`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const releaseDate = releaseDateMatch[1];
|
|
|
|
const author = process.env.GITHUB_ACTOR;
|
|
if (!author) {
|
|
console.error('::error::GITHUB_ACTOR env var is not set');
|
|
process.exit(1);
|
|
}
|
|
|
|
const slug = version.replace(/\./g, '-');
|
|
const commitMessage = `Generate release notes for v${version}`;
|
|
|
|
const botName = 'github-actions[bot]';
|
|
const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
|
|
|
|
await exec(`git config user.name '${botName}'`);
|
|
await exec(`git config user.email '${botEmail}'`);
|
|
|
|
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
|
|
|
|
await group('Prepare branch', async () => {
|
|
// recover deleted release note files from previous generation commits
|
|
await exec(`git fetch origin ${baseBranch}`, { stdio: 'inherit' });
|
|
const { stdout: mergeBase } = await exec(
|
|
`git merge-base HEAD origin/${baseBranch}`,
|
|
);
|
|
const base = mergeBase.trim();
|
|
const { stdout: genLog } = await exec(
|
|
`git log --grep='${commitMessage}' --format=%H ${base}..HEAD`,
|
|
);
|
|
const genCommits = genLog.split('\n').filter(Boolean);
|
|
console.log(
|
|
`Reversing upcoming-release-notes deletions from ${genCommits.length} prior generation commit(s)`,
|
|
);
|
|
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
|
|
for (const sha of genCommits) {
|
|
const patchPath = join(tmpDir, `revert-${sha}.patch`);
|
|
try {
|
|
await exec(
|
|
`git diff --diff-filter=D ${sha}~1..${sha} -- upcoming-release-notes > ${patchPath}`,
|
|
);
|
|
const { size } = await fs.stat(patchPath);
|
|
if (size > 0) {
|
|
await exec(`git apply -R --3way ${patchPath}`, { stdio: 'inherit' });
|
|
}
|
|
} finally {
|
|
await fs.unlink(patchPath).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
await exec(`git fetch origin ${releaseBranch}`, { stdio: 'inherit' });
|
|
await exec(`git checkout origin/${releaseBranch} -- upcoming-release-notes`, {
|
|
stdio: 'inherit',
|
|
});
|
|
});
|
|
|
|
const { notesByCategory, files } = await parseReleaseNotes(
|
|
'upcoming-release-notes',
|
|
owner,
|
|
repo,
|
|
`origin/${baseBranch}`,
|
|
);
|
|
const categorizedNotes = formatNotes(notesByCategory);
|
|
|
|
await collapsedLog('Release Notes', categorizedNotes);
|
|
|
|
if (files.length === 0) {
|
|
console.log('No release notes found, nothing to generate');
|
|
process.exit(0);
|
|
}
|
|
|
|
const highlights = '- TODO: Add release highlights';
|
|
|
|
const blogPath = join(
|
|
'packages/docs/blog',
|
|
`${releaseDate}-release-${slug}.md`,
|
|
);
|
|
const releasesPath = 'packages/docs/docs/releases.md';
|
|
|
|
await group('Generate blog post', async () => {
|
|
const template = `---
|
|
title: Release ${version}
|
|
description: New release of Actual.
|
|
date: ${releaseDate}T10:00
|
|
slug: release-${version}
|
|
tags: [announcement, release]
|
|
hide_table_of_contents: false
|
|
authors: ${author}
|
|
---
|
|
|
|
${highlights}
|
|
|
|
<!--truncate-->
|
|
|
|
**Docker Tag: ${version}**
|
|
|
|
${AUTOGEN_MARKER}
|
|
|
|
${categorizedNotes}
|
|
`;
|
|
|
|
let blogContent;
|
|
try {
|
|
const existing = await fs.readFile(blogPath, 'utf-8');
|
|
const idx = existing.indexOf(AUTOGEN_MARKER);
|
|
if (idx === -1) {
|
|
console.log(
|
|
`WARNING: ${blogPath} missing ${AUTOGEN_MARKER}, rewriting from template`,
|
|
);
|
|
blogContent = template;
|
|
} else {
|
|
blogContent =
|
|
existing.slice(0, idx + AUTOGEN_MARKER.length) +
|
|
'\n' +
|
|
categorizedNotes +
|
|
'\n';
|
|
}
|
|
} catch (e) {
|
|
if (e.code !== 'ENOENT') throw e;
|
|
blogContent = template;
|
|
}
|
|
|
|
await fs.writeFile(blogPath, blogContent);
|
|
console.log(`Wrote ${blogPath}`);
|
|
});
|
|
|
|
await group('Update releases.md', async () => {
|
|
const existing = await fs.readFile(releasesPath, 'utf-8');
|
|
|
|
const sectionRe = new RegExp(
|
|
`(^|\\n)## ${escapeRegExp(version)}\\n[\\s\\S]*?(?=\\n## |$)`,
|
|
);
|
|
const match = existing.match(sectionRe);
|
|
|
|
let updated;
|
|
if (match) {
|
|
const section = match[0];
|
|
const idx = section.indexOf(AUTOGEN_MARKER);
|
|
if (idx === -1) {
|
|
console.log(
|
|
`WARNING: section for ${version} in ${releasesPath} missing ${AUTOGEN_MARKER}, leaving as-is`,
|
|
);
|
|
updated = existing;
|
|
} else {
|
|
const newSection =
|
|
section.slice(0, idx + AUTOGEN_MARKER.length) + '\n' + categorizedNotes;
|
|
updated = existing.replace(section, newSection);
|
|
}
|
|
} else {
|
|
const newSection = `## ${version}
|
|
|
|
Release date: ${releaseDate}
|
|
|
|
${highlights}
|
|
|
|
**Docker Tag: ${version}**
|
|
|
|
${AUTOGEN_MARKER}
|
|
|
|
${categorizedNotes}`;
|
|
updated = existing.replace(
|
|
'# Release Notes\n',
|
|
`# Release Notes\n\n${newSection}\n`,
|
|
);
|
|
}
|
|
|
|
await fs.writeFile(releasesPath, updated);
|
|
console.log(`Updated ${releasesPath}`);
|
|
});
|
|
|
|
await group('Remove used release notes', async () => {
|
|
await Promise.all(
|
|
files.map(f => fs.unlink(join('upcoming-release-notes', f))),
|
|
);
|
|
});
|
|
|
|
await group('Format generated files', async () => {
|
|
await exec(`yarn exec oxfmt ${blogPath} ${releasesPath}`, {
|
|
stdio: 'inherit',
|
|
});
|
|
});
|
|
|
|
await group('Commit and push', async () => {
|
|
await exec(
|
|
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
|
|
{ stdio: 'inherit' },
|
|
);
|
|
|
|
try {
|
|
await exec('git diff --cached --quiet');
|
|
console.log('No changes to commit');
|
|
return;
|
|
} catch {
|
|
// there are staged changes
|
|
}
|
|
|
|
await exec(`git commit -m '${commitMessage}'`);
|
|
await exec('git push origin', { stdio: 'inherit' });
|
|
});
|
|
|
|
function escapeRegExp(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
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::');
|
|
}
|