qwen-code/scripts/tests/ai-release-notes-workflow.test.js
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

153 lines
6 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const releaseWorkflow = readFileSync('.github/workflows/release.yml', 'utf8');
const finalizeWorkflow = readFileSync(
'.github/workflows/finalize-release.yml',
'utf8',
);
const releaseNotesScript = readFileSync(
'scripts/generate-release-notes.js',
'utf8',
);
function getStep(workflow, name) {
const match = new RegExp(
`\\n - name: '${name}'[\\s\\S]*?(?=\\n - name: '|\\n [A-Za-z0-9_-]+:|$)`,
).exec(`\n${workflow}`);
if (!match) {
throw new Error(`Could not find workflow step: ${name}`);
}
return match[0];
}
describe('stable release notes workflow', () => {
it('publishes immediately with GitHub-generated notes', () => {
const step = getStep(releaseWorkflow, 'Create GitHub Release and Tag');
expect(step).toContain(
'repos/${GITHUB_REPOSITORY}/releases/generate-notes',
);
expect(step).toContain('-f "previous_tag_name=${PREVIOUS_RELEASE_TAG}"');
expect(step).toContain('"${NOTES_ARGS[@]}"');
expect(step).toContain('--notes-file "${NOTES_FILE}"');
// Stable tags live on their own release/* branch and are merged back to
// main only afterwards, so the previous tag is never an ancestor of the
// branch being released. Anchoring on ancestry dropped the anchor on every
// stable release, and unanchored notes span the whole branch history and
// overrun the 125000 character body limit.
expect(step).not.toContain('git merge-base --is-ancestor');
expect(step).toContain('node .github/scripts/cap-release-notes.mjs');
expect(step).toContain('--file "${NOTES_FILE}"');
// gh prints the API error payload on stdout, so a failed attempt's output
// must not survive into the release body.
expect(step).toContain(
'generate_notes > "${NOTES_FILE}" || : > "${NOTES_FILE}"',
);
expect(step).toContain("GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'");
expect(releaseWorkflow).not.toContain(
"name: 'Generate AI-assisted stable release notes'",
);
expect(releaseWorkflow).not.toContain("name: 'Regenerate CHANGELOG.md'");
expect(releaseWorkflow).not.toContain(
"name: 'Create PR to merge release branch into main'",
);
});
it('finalizes stable releases asynchronously', () => {
const validate = getStep(finalizeWorkflow, 'Validate stable release tag');
const generate = getStep(
finalizeWorkflow,
'Generate AI-assisted release notes',
);
const update = getStep(finalizeWorkflow, 'Update GitHub Release notes');
const changelog = getStep(finalizeWorkflow, 'Regenerate CHANGELOG.md');
expect(finalizeWorkflow).toContain("types: ['published']");
expect(finalizeWorkflow).toContain(
'github.event.release.prerelease == false',
);
expect(finalizeWorkflow).toContain('workflow_dispatch:');
expect(finalizeWorkflow).toContain(
'if [[ "${TAG}" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]',
);
expect(validate).toContain('is not a stable release tag');
expect(validate).toContain('exit 1');
expect(generate).toContain('timeout-minutes: 35');
expect(generate).toContain('continue-on-error: true');
// The step timeout must exceed the script's internal budget; otherwise
// the runner SIGKILLs the step and even the fallback notes are lost.
const stepTimeoutMin = Number(
generate.match(/timeout-minutes:\s*(\d+)/)[1],
);
const budgetMs = releaseNotesScript
.match(/totalTimeoutMs\s*=\s*([\d_]+)\s*\*\s*([\d_]+)/)
.slice(1)
.map((part) => Number(part.replace(/_/g, '')))
.reduce((a, b) => a * b, 1);
expect(stepTimeoutMin * 60_000).toBeGreaterThan(budgetMs);
expect(generate).toContain('GitHub-generated notes');
expect(generate).toContain('node scripts/generate-release-notes.js');
expect(update).toContain('continue-on-error: true');
expect(update).toContain(
'gh release edit "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_FILE}"',
);
expect(changelog).not.toContain('continue-on-error: true');
});
it('updates the changelog before opening the release PR', () => {
const changelog = finalizeWorkflow.indexOf(
"name: 'Regenerate CHANGELOG.md'",
);
const pr = finalizeWorkflow.indexOf(
"name: 'Create PR to merge release branch into main'",
);
expect(changelog).toBeGreaterThanOrEqual(0);
expect(pr).toBeGreaterThan(changelog);
expect(finalizeWorkflow).toContain("name: 'Approve release PR'");
expect(finalizeWorkflow).toContain(
"name: 'Enable auto-merge for release PR'",
);
});
it('comments released-in version only for squash-merge PR trailers', () => {
const step = getStep(
finalizeWorkflow,
'Comment released-in version on merged PRs',
);
expect(step).toContain('continue-on-error: true');
expect(step).toContain("grep -oE '\\(#[0-9]+\\)$'");
expect(step).toContain("tr -d '()#'");
expect(step).not.toContain("grep -oE '#[0-9]+'");
expect(step).toContain("marker='<!-- qwen-release-comment:v1 -->'");
expect(step).toContain('gh pr view "${num}" --json comments');
expect(step).toContain('grep -qF "${marker}" <<<"${existing}"');
expect(step).toContain('gh pr comment "${num}" --body "${body}"');
});
it('does not recreate an already merged release PR during retries', () => {
const pr = getStep(
finalizeWorkflow,
'Create PR to merge release branch into main',
);
const approve = getStep(finalizeWorkflow, 'Approve release PR');
const merge = getStep(finalizeWorkflow, 'Enable auto-merge for release PR');
expect(pr).toContain('--state all');
expect(pr).toContain('select(.state == "MERGED")');
expect(pr).toContain('if [[ "${pr_state}" == "MERGED" ]]');
expect(pr).toContain('SHOULD_MERGE=false');
expect(approve).toContain("steps.pr.outputs.SHOULD_MERGE == 'true'");
expect(merge).toContain("steps.pr.outputs.SHOULD_MERGE == 'true'");
});
});