Files
actual/packages/ci-actions/bin/release-notes-generate.mjs
Matt FiddamanGitHubClaude Opus 4.8coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
b1d713ac55 [AI] resolve release-note PR links from base branch history (#8368)
* [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>
2026-07-01 17:42:41 +00:00

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