qwen-code/.github/scripts/cap-release-notes.test.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

179 lines
5.3 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { MAX_BODY_CHARS, prepareReleaseNotes } from './cap-release-notes.mjs';
const SCRIPT = fileURLToPath(
new URL('./cap-release-notes.mjs', import.meta.url),
);
const context = {
tag: 'v0.21.2',
previousTag: 'v0.21.1',
repo: 'QwenLM/qwen-code',
serverUrl: 'https://github.com',
};
describe('release notes body preparation', () => {
it('leaves a body that fits untouched', () => {
const body = "## What's Changed\n* one thing\n";
const result = prepareReleaseNotes({ ...context, body });
assert.equal(result.body, body.trim());
assert.equal(result.truncated, false);
assert.equal(result.fallback, false);
});
it('keeps the truncated body plus footer within the limit', () => {
const maxChars = 500;
const body = 'x'.repeat(5000);
const result = prepareReleaseNotes({ ...context, body, maxChars });
assert.equal(result.truncated, true);
assert.ok(Array.from(result.body).length <= maxChars);
assert.ok(
result.body.endsWith(
'https://github.com/QwenLM/qwen-code/compare/v0.21.1...v0.21.2',
),
);
});
// The whole point of the cap is that the release still publishes, so the
// default must leave room for the footer under GitHub's 125000 limit.
it('stays under the API limit at the default cap', () => {
const result = prepareReleaseNotes({
...context,
body: 'x'.repeat(MAX_BODY_CHARS * 2),
});
assert.ok(Array.from(result.body).length <= MAX_BODY_CHARS);
assert.ok(MAX_BODY_CHARS < 125000);
});
it('omits the compare link when there is no previous tag', () => {
const result = prepareReleaseNotes({
...context,
previousTag: '',
body: 'x'.repeat(5000),
maxChars: 500,
});
assert.ok(result.body.endsWith('_Release notes were truncated._'));
assert.ok(!result.body.includes('compare'));
});
it('never splits a multi-byte character', () => {
// Cut lands inside the emoji run: a code-unit slice would leave a lone
// surrogate and an invalid body.
const maxChars = 200;
const body = `${'a'.repeat(50)}${'😀'.repeat(200)}`;
const result = prepareReleaseNotes({ ...context, body, maxChars });
assert.equal(
Buffer.from(result.body, 'utf8').toString('utf8'),
result.body,
);
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result.body));
});
it('falls back to a minimal body when the footer alone exceeds the cap', () => {
const result = prepareReleaseNotes({
...context,
body: 'x'.repeat(100),
maxChars: 10,
});
assert.equal(result.body, 'Release v0.21.2');
assert.equal(result.truncated, true);
assert.equal(result.fallback, true);
});
it('falls back to a minimal body when nothing was generated', () => {
for (const body of ['', ' \n ']) {
const result = prepareReleaseNotes({ ...context, body });
assert.equal(result.body, 'Release v0.21.2');
assert.equal(result.fallback, true);
}
});
it('requires a tag', () => {
assert.throws(
() => prepareReleaseNotes({ body: 'notes', tag: '' }),
/requires a tag/,
);
});
});
describe('release notes body preparation (cli)', () => {
it('rewrites the notes file in place and warns on truncation', () => {
const dir = mkdtempSync(join(tmpdir(), 'cap-release-notes-'));
try {
const file = join(dir, 'release-notes.md');
writeFileSync(file, 'y'.repeat(5000));
const run = spawnSync(
process.execPath,
[
SCRIPT,
'--file',
file,
'--tag',
'v0.21.2',
'--previous-tag',
'v0.21.1',
'--repo',
'QwenLM/qwen-code',
'--max-chars',
'500',
],
{ encoding: 'utf8' },
);
assert.equal(run.status, 0);
assert.match(run.stdout, /::warning::Release notes exceeded 500/);
const written = readFileSync(file, 'utf8');
assert.ok(Array.from(written.trimEnd()).length <= 500);
assert.ok(written.includes('_Release notes were truncated._'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('writes a minimal body when generate-notes produced no file', () => {
const dir = mkdtempSync(join(tmpdir(), 'cap-release-notes-'));
try {
const file = join(dir, 'missing.md');
const run = spawnSync(
process.execPath,
[SCRIPT, '--file', file, '--tag', 'v0.21.2'],
{ encoding: 'utf8' },
);
assert.equal(run.status, 0);
assert.match(run.stdout, /::warning::No release notes were generated/);
assert.equal(readFileSync(file, 'utf8'), 'Release v0.21.2\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('rejects unknown options', () => {
const run = spawnSync(
process.execPath,
[SCRIPT, '--file', 'notes.md', '--tag', 'v1.0.0', '--nope', '1'],
{ encoding: 'utf8' },
);
assert.notEqual(run.status, 0);
assert.match(run.stderr, /Unknown option: --nope/);
});
});