qwen-code/.github/scripts/cap-release-notes.mjs
Shaojin Wen f66bfaad57
fix(release): keep notes anchored and cap the release body (#8199)
* fix(release): keep notes anchored and cap the release body

The v0.21.2 publish failed at "Create GitHub Release and Tag" with
HTTP 422 "body is too long (maximum is 125000 characters)", after every
npm package had already been published.

Stable releases are tagged on their own release/* branch and merged back
to main only afterwards, so the previous stable tag is never an ancestor
of the branch being released. The ancestor guard therefore dropped
--notes-start-tag on every stable release, and without an anchor GitHub
generates notes across the entire branch history (8000+ commits), which
overruns the body limit.

Always pass the previous tag instead: GitHub diffs it through the merge
base, which is how v0.21.1 produced a 27KB body from a tag that was
equally divergent. Generate the body through the generate-notes API
first so an oversized changelog is truncated on a UTF-8 boundary, and
degrade to an unanchored body and then a minimal one, rather than
aborting a release whose packages are already on npm.

* test(release): pin the anchored release-notes contract

The workflow test asserted the ancestor guard that dropped
--notes-start-tag on every stable release. Assert the replacement
instead: the previous tag is always passed to generate-notes, the body
is capped, and ancestry no longer decides whether notes are anchored.

* refactor(release): extract release-notes capping into a tested helper

The degradation chain lived inline in the workflow bash, so nothing
pinned that a capped body plus its footer stays under GitHub's 125000
character limit, that truncation never splits a multi-byte character, or
that the chain always yields a non-empty body. Move it to
.github/scripts/cap-release-notes.mjs with a collocated node:test suite,
matching the other workflow helpers.

Capping on code points rather than bytes drops the head/iconv dance and
makes the surrogate-pair case testable. The helper also absorbs the
empty-body fallback, which caught a real defect: gh writes the API error
payload to stdout when generate-notes fails, so a doubly failed call
would have published `{"message":"Not Found",...}` as the release body.
Discard a failed attempt's output instead.

* test(release): exercise the surrogate-pair cut and footer-overflow branch (#8199)

---------

Co-authored-by: Qwen Code Bot <qwen-code-bot@alibabacloud.com>
2026-07-31 09:55:38 +00:00

132 lines
3.5 KiB
JavaScript

#!/usr/bin/env node
// Prepares the body handed to `gh release create`. GitHub rejects release
// bodies over 125000 characters, and that rejection lands *after* the npm
// packages have been published, so an oversized or empty changelog has to
// degrade the notes rather than the release.
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// GitHub's limit is 125000; the margin absorbs the finalize step's rewrite and
// any counting difference between code points and UTF-16 units.
export const MAX_BODY_CHARS = 120000;
const TRUNCATION_NOTE = '_Release notes were truncated._';
/**
* @returns {{body: string, truncated: boolean, fallback: boolean}} a body that
* is non-empty and at most `maxChars` code points long.
*/
export function prepareReleaseNotes({
body = '',
tag,
previousTag = '',
repo = '',
serverUrl = 'https://github.com',
maxChars = MAX_BODY_CHARS,
}) {
if (!tag) {
throw new Error('prepareReleaseNotes requires a tag');
}
const trimmed = body.trim();
if (!trimmed) {
return { body: `Release ${tag}`, truncated: false, fallback: true };
}
// Split on code points so a cut never lands inside a surrogate pair and
// leaves the body invalid.
const chars = Array.from(trimmed);
if (chars.length <= maxChars) {
return { body: trimmed, truncated: false, fallback: false };
}
const compare =
previousTag && repo
? ` Full changelog: ${serverUrl}/${repo}/compare/${previousTag}...${tag}`
: '';
const footer = `\n\n${TRUNCATION_NOTE}${compare}`;
const keep = maxChars - Array.from(footer).length;
if (keep <= 0) {
return { body: `Release ${tag}`, truncated: true, fallback: true };
}
return {
body: `${chars.slice(0, keep).join('')}${footer}`,
truncated: true,
fallback: false,
};
}
function parseArgs(argv) {
const args = {
file: '',
tag: '',
previousTag: '',
repo: '',
serverUrl: 'https://github.com',
maxChars: MAX_BODY_CHARS,
};
const options = {
'--file': 'file',
'--tag': 'tag',
'--previous-tag': 'previousTag',
'--repo': 'repo',
'--server-url': 'serverUrl',
'--max-chars': 'maxChars',
};
for (let index = 0; index < argv.length; index += 1) {
const key = options[argv[index]];
if (!key) {
throw new Error(`Unknown option: ${argv[index]}`);
}
const value = argv[index + 1];
if (value === undefined) {
throw new Error(`Missing value for ${argv[index]}`);
}
args[key] = key === 'maxChars' ? Number(value) : value;
index += 1;
}
if (!args.file || !args.tag) {
throw new Error('--file and --tag are required');
}
if (!Number.isInteger(args.maxChars) || args.maxChars <= 0) {
throw new Error(`--max-chars must be a positive integer`);
}
return args;
}
function main(argv) {
const args = parseArgs(argv);
let body = '';
try {
body = readFileSync(args.file, 'utf8');
} catch {
// A failed generate-notes call leaves no file; the fallback body covers it.
}
const result = prepareReleaseNotes({ ...args, body });
writeFileSync(args.file, `${result.body}\n`);
if (result.truncated) {
process.stdout.write(
`::warning::Release notes exceeded ${args.maxChars} characters; truncated\n`,
);
}
if (result.fallback) {
process.stdout.write(
`::warning::No release notes were generated; using a minimal body\n`,
);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main(process.argv.slice(2));
}