Files
actual/packages/ci-actions/bin/release-notes-check.mjs
Matiss Janis AboltinsGitHubClaudegithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
ab35bd633b [AI] Remove AI-generated release notes workflow and support custom filenames (#7963)
* [AI] Relax release-notes filename convention

Allow descriptive filenames (e.g. add-payee-autocomplete.md) for
upcoming release notes instead of requiring {PR_NUMBER}.md.

The PR number was only used to build the [#1234](.../pull/1234) link in
the generated changelog. The generation script now resolves the PR per
file by finding the commit that added it (git log --diff-filter=A
--follow) and querying GitHub's commits/{sha}/pulls endpoint. Numeric
filenames remain valid via a fast path.

Also retires the CodeRabbit/OpenAI-triggered auto-creator workflow,
which existed to guess summaries and stamp the PR-numbered filename;
neither is needed once contributors can pick a slug up front.

* Add release notes for PR #7963

* [AI] Use execFile to look up release-note add-commits

resolvePrNumber() interpolated a path from fs.readdir into a shell-
evaluated `git log` command. Switch to execFile with an argv array so
filenames containing shell metacharacters can't break out of the
intended command.

* [AI] Drop duplicate release note added by the to-be-removed auto-creator

The CodeRabbit-triggered workflow ran one last time from master before
this PR removes it, and committed upcoming-release-notes/7963.md
duplicating the existing relax-release-notes-filenames.md entry. Keep
the slug-named note since it exercises the new flexible-filename code
introduced in this PR.

* [AI] Don't fail release-notes generation on transient API errors

fetchPrForCommit() handled non-OK HTTP responses but let fetch() and
res.json() exceptions propagate, so a single network blip or malformed
response would abort the whole release-notes generation. Wrap them in a
try/catch that logs and returns null, matching the pattern already used
in resolvePrNumber for execFile errors.

* [AI] Address CodeRabbit full-review nits

- bin/release-note-generator.ts: tighten slug regex to reject trailing
  and consecutive dashes (matching slugify output), and make slugify
  fall back to "untitled" so an all-non-alphanumeric input can't
  produce a hidden ".md" filename.
- packages/ci-actions/bin/release-notes-check.mjs: switch to execFile
  for git fetch/diff, matching release-notes-generate.mjs and removing
  shell interpolation of BASE_REF.
- packages/ci-actions/bin/release-notes-generate.mjs: explicitly check
  GITHUB_REPOSITORY before splitting, consistent with the other env
  var guards a few lines below.
- upcoming-release-notes/relax-release-notes-filenames.md: fix author
  casing (matiss -> MatissJanis) so changelog attribution is correct.

* [AI] Reject empty release-note bodies

content.trim().split('\n').length === 1 is true for the empty string
(''.split('\n') returns [''] of length 1), so blank notes slipped past
the single-line check. Reject empty trimmed content explicitly.

* [AI] Resolve PR numbers from commit subjects instead of GitHub's API

parseReleaseNotes was doing one GitHub API round-trip per non-numeric
release-note file. That scales badly for release generation, and the
docs site's generate-upcoming-release-notes.mjs runs on every docs
build — so contributors were hitting the GitHub API (or failing 401
without a token) every time they previewed docs locally.

actualbudget squash-merges every PR through the GitHub UI, which
appends "(#NNNN)" to the resulting commit subject. So:

  git log -1 --format=%s -- <path>

plus a /\(#(\d+)\)\s*$/ match recovers the PR number without touching
the network, and works offline / without a GITHUB_TOKEN.

Side effect: switched from "first commit that added this file" to
"most recent commit touching this file", which is what we actually
want — when a file is renamed (e.g. 7907.md -> 7954.md when a wrong
PR number is corrected), the changelog should point at the rename PR,
not the original add. The SHA->PR cache and fetchPrForCommit go away;
no longer needed.

If a file's subject doesn't match (direct push to master, manually
rewritten subject), the entry still emits without a PR-link prefix —
same graceful degradation as before.

* [AI] Pin PR-number lookup to each note's add commit

resolvePrNumber was using git log -1 with no diff filter, returning
the latest commit that touched the file. That's fine until someone
edits an existing release note in a follow-up PR (typo fix, author
correction) — at which point the changelog entry would suddenly
point at the wrong PR.

Add --diff-filter=A so the lookup pins to the commit that originally
added the path. Empirically verified against this repo: renames keep
working (git records a rename as A at the new path when --follow
isn't used), and the edit case now correctly returns the original
add commit instead of the most recent touch.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-04 21:01:19 +00:00

107 lines
2.6 KiB
JavaScript

import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import { promisify } from 'node:util';
import matter from 'gray-matter';
import {
categoryAutocorrections,
categoryOrder,
} from '../src/release-notes/util.mjs';
const execFile = promisify(childProcess.execFile);
const NOTES_DIR = 'upcoming-release-notes';
console.log('Looking in ' + fs.realpathSync(NOTES_DIR));
const baseRef = process.env.BASE_REF;
if (!baseRef) {
console.log('::error::BASE_REF env var is not set');
process.exit(1);
}
function reportError(message) {
console.log(`::error::${message}`);
process.stdout.write('::notice::');
fs.createReadStream(`${NOTES_DIR}/README.md`).pipe(process.stdout);
fs.createReadStream(`${NOTES_DIR}/README.md`)
.pipe(fs.createWriteStream(process.env.GITHUB_STEP_SUMMARY))
.on('close', () => {
process.exit(1);
});
}
function validateFile(path) {
const { data, content } = matter(fs.readFileSync(path, 'utf-8'));
if (!data.category) {
reportError(`Release note ${path} is missing a category.`);
return false;
}
const category = categoryAutocorrections[data.category] ?? data.category;
if (!categoryOrder.includes(category)) {
reportError(
`Release note ${path} category "${data.category}" is not one of ${categoryOrder
.map(JSON.stringify)
.join(', ')}`,
);
return false;
}
if (!data.authors) {
reportError(`Release note ${path} is missing authors.`);
return false;
}
if (!Array.isArray(data.authors)) {
reportError(`Release note ${path} authors should be a list.`);
return false;
}
const trimmedContent = content.trim();
if (!trimmedContent || trimmedContent.includes('\n')) {
reportError(`Release note ${path} body should contain exactly one line`);
return false;
}
return true;
}
void (async () => {
await execFile('git', ['fetch', 'origin', baseRef]);
const { stdout } = await execFile('git', [
'diff',
'--name-only',
'--diff-filter=A',
`origin/${baseRef}...HEAD`,
'--',
`${NOTES_DIR}/`,
]);
const added = stdout
.split('\n')
.map(s => s.trim())
.filter(Boolean)
.filter(p => p.endsWith('.md') && p !== `${NOTES_DIR}/README.md`);
if (added.length === 0) {
reportError(
`No release note added under ${NOTES_DIR}/. Add a *.md file describing your change.`,
);
return;
}
for (const path of added) {
if (!fs.existsSync(path)) {
reportError(`Release note ${path} was added but does not exist on HEAD.`);
return;
}
if (!validateFile(path)) {
return;
}
}
console.log(`Validated ${added.length} release note(s). \u{1f389}`);
})();