qwen-code/scripts/generate-changelog.js
Shaojin Wen 90c166585a
feat(release): user-facing bilingual digest for release notes (#9216)
* feat(release): user-facing bilingual digest for release notes

Stable release notes read as a type-bucketed PR list, which users find
hard to scan. The finalize step now asks the model to group changes into
user-facing themes with short intros, mirrors highlights and themes into
a Chinese digest, attaches screenshots found in merged PR bodies (host
allowlist, per-release cap), and collapses the full PR list into an
appendix with normalized titles. Every model failure path keeps today's
v1 output byte-for-byte, and CHANGELOG.md accepts the new v2 marker.

* fix(release): tighten v2 digest fallbacks and changelog skeleton (#9216)

Address review round 1 findings:

- usedAi only counts themes that carry content, so a release whose
  digest has zero model text is no longer reported as AI-generated
- hasChinese is derived from what the Chinese block actually renders,
  not raw model output, so zh-only-on-breaking releases no longer emit
  an empty or English-only section
- a PR repeated inside one theme is deduped instead of discarding the
  whole themes digest with a misleading cross-theme error
- fallback titles in the v2 digest are normalized like the appendix,
  killing the mixed-style look in the degradation case
- normalizeAppendixTitle strips only the conventional types the
  changelog's formatEntry strips, keeping ci/test/security prefixes
- the changelog unwraps the v2 appendix at the same sibling rank as
  v1's Complete Change List instead of nesting it under the previous
  section
- drop a dead summaries max_tokens scaling term and a verbatim copy of
  renderChangeLine's attribution rendering

* fix(release): close digest image breakout and tighten fallback signals (#9216)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(release): drop camo proxy and harden digest text validation (#9216)

* fix(release): neutralize markdown breakouts in digest text and images (#9216)

* fix(release): close classification, image-URL, and text-validation bypasses (#9216)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-17 00:12:04 +00:00

374 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Generate `CHANGELOG.md` from the project's GitHub Releases.
*
* The changelog only lists *stable* releases (`vX.Y.Z`); nightly and preview
* pre-releases are intentionally omitted because they ship daily and would
* drown out the signal. Each release's auto-generated "What's Changed" list is
* re-grouped into Keep a Changelog sections (Added / Changed / Fixed / ...) by
* the conventional-commit prefix every PR title uses in this repo.
*
* The file is fully derived from the GitHub Releases API, so it is safe to
* regenerate at any time and should not be edited by hand.
*
* Usage:
* node scripts/generate-changelog.js # write ./CHANGELOG.md
* node scripts/generate-changelog.js --dry-run # print to stdout instead
* node scripts/generate-changelog.js --repo=owner/name --output=path.md
*
* Requires the GitHub CLI (`gh`) to be installed and authenticated.
*/
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readJson } from './lib/release-helpers.js';
import { isMainModule, parseArgs } from './release-script-utils.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
/**
* Keep a Changelog sections, in render order, each listing the
* conventional-commit types that feed it. `Other` is the catch-all for unmapped
* types. This is the single source of truth — `TYPE_TO_SECTION` and
* `SECTION_ORDER` are derived from it so the two can never drift apart.
*/
const SECTIONS = [
{ name: 'Added', types: ['feat'] },
{ name: 'Changed', types: ['refactor', 'revert'] },
{ name: 'Fixed', types: ['fix'] },
{ name: 'Performance', types: ['perf'] },
{ name: 'Documentation', types: ['docs'] },
{ name: 'Other', types: [] },
];
const TYPE_TO_SECTION = Object.fromEntries(
SECTIONS.flatMap((section) =>
section.types.map((type) => [type, section.name]),
),
);
const SECTION_ORDER = SECTIONS.map((section) => section.name);
/** Matches a stable `vX.Y.Z` tag (no `-preview` / `-nightly` suffix). */
const STABLE_TAG_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
/** Marker comment AI-assisted release notes start with (`v1`, `v2`, …). */
const CURATED_RELEASE_MARKER_RE = /^<!-- qwen-release-notes:v(\d+) -->/;
/**
* Matches a GitHub "What's Changed" bullet, e.g.
* * fix(core): do a thing by @octocat in https://github.com/o/r/pull/42
* The title is captured greedily so a trailing " by @user in <pr-url>" binds to
* the last occurrence, and "New Contributors" / "Full Changelog" lines (which
* lack the " by @… in …/pull/N" tail) are skipped. Author groups allow a
* trailing `[bot]` and optional `with @collaborator` credits generated by
* GitHub.
*/
const ENTRY_RE =
/^[*-]\s+(.+)\s+by\s+@([A-Za-z0-9-]+(?:\[bot\])?)(?:\s+with\s+@[A-Za-z0-9-]+(?:\[bot\])?)*\s+in\s+(https?:\/\/\S+\/pull\/(\d+))\s*$/;
/**
* Splits a conventional-commit subject into
* `{ type, scope, description, breaking }`. The `!` breaking-change marker
* (e.g. `feat(core)!: …`) is captured so it can be surfaced in the output.
*/
export function categorize(title) {
const match = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/.exec(title.trim());
if (!match) {
return {
type: null,
scope: null,
description: title.trim(),
breaking: false,
};
}
return {
type: match[1].toLowerCase(),
scope: match[2] || null,
description: match[4],
breaking: Boolean(match[3]),
};
}
/**
* Pure version-bump commits the release bot makes (`chore(release): vX.Y.Z`)
* are noise in a user-facing changelog, so they are dropped. Takes a parsed
* `categorize()` result so callers that already parsed the title don't re-parse.
*/
export function isNoiseEntry({ type, scope }) {
return type === 'chore' && scope === 'release';
}
/** Parse the "What's Changed" bullets out of a release body. */
export function parseReleaseEntries(body) {
const entries = [];
for (const line of (body || '').split(/\r?\n/)) {
const match = ENTRY_RE.exec(line);
if (!match) {
continue;
}
entries.push({
title: match[1].trim(),
author: match[2],
prUrl: match[3],
prNumber: match[4],
});
}
return entries;
}
/** Render a single "What's Changed" entry as a changelog list item. */
export function formatEntry(entry, cat = categorize(entry.title)) {
const { type, scope, description, breaking } = cat;
let text;
if (TYPE_TO_SECTION[type]) {
// Recognised type: drop the redundant leading keyword (the section heading
// already conveys it) but keep the scope for context.
text = scope ? `${scope}: ${description}` : description;
} else {
// Unknown or prefix-less title: keep it verbatim.
text = entry.title;
}
if (breaking) {
text = `**BREAKING** ${text}`;
}
return `- ${text} ([#${entry.prNumber}](${entry.prUrl}))`;
}
/**
* Per-line adjustments applied before the heading demotion. v1 notes embed
* verbatim; v2 notes are a digest with a collapsed appendix and inline
* screenshots, and the changelog keeps the text while unwrapping the
* collapse and dropping the images and the Chinese-digest divider.
*/
export function transformCuratedLine(line, version) {
if (version < 2) {
return [line];
}
if (/^\s*!\[[^\]]*\]\(/.test(line)) {
return [];
}
if (/^\s*---\s*$/.test(line)) {
return [];
}
if (/^\s*<\/?details>\s*$/.test(line)) {
return [];
}
const summary = /^\s*<summary>([\s\S]*?)<\/summary>\s*$/.exec(line);
if (summary) {
// Emit at ## so the heading demotion below lands the unwrapped appendix
// at ### — the same sibling rank v1's "Complete Change List" reaches.
return [`## ${summary[1]}`];
}
return [line];
}
/** Render one release as a Markdown block. */
export function formatRelease(release) {
const lines = [];
const heading = release.htmlUrl
? `## [${release.version}](${release.htmlUrl}) - ${release.date}`
: `## [${release.version}] - ${release.date}`;
lines.push(heading, '');
const marker = CURATED_RELEASE_MARKER_RE.exec(release.body?.trimStart());
if (marker) {
const version = Number(marker[1]);
const curated = release.body
.trimStart()
.split(/\r?\n/)
.filter((line) => !CURATED_RELEASE_MARKER_RE.test(line))
.flatMap((line) => transformCuratedLine(line, version))
.map((line) => line.replace(/^(#{2,5})(\s+)/, '#$1$2'))
.join('\n')
.trim();
lines.push(curated, '');
return lines.join('\n');
}
const buckets = new Map();
for (const entry of release.entries) {
const cat = categorize(entry.title);
if (isNoiseEntry(cat)) {
continue;
}
const section = TYPE_TO_SECTION[cat.type] || 'Other';
if (!buckets.has(section)) {
buckets.set(section, []);
}
buckets.get(section).push(formatEntry(entry, cat));
}
let rendered = false;
for (const section of SECTION_ORDER) {
const items = buckets.get(section);
if (!items || items.length === 0) {
continue;
}
rendered = true;
lines.push(`### ${section}`, '', ...items, '');
}
if (!rendered) {
const link = release.htmlUrl
? `[GitHub release](${release.htmlUrl})`
: 'the GitHub release';
lines.push(`_See ${link} for details._`, '');
}
return lines.join('\n');
}
const HEADER = `# Changelog
All notable changes to [Qwen Code](https://github.com/QwenLM/qwen-code) are
documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Only stable releases
are listed; nightly and preview pre-releases are intentionally omitted.
> **This file is generated automatically** from
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
> by hand — run \`npm run changelog\` to regenerate.
`;
/** Build the full CHANGELOG.md contents from an ordered list of releases. */
export function buildChangelog(releases) {
const blocks = releases.map((release) => formatRelease(release));
const body = `${HEADER}\n${blocks.join('\n')}`;
// Collapse any run of blank lines and guarantee a single trailing newline.
return `${body.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '')}\n`;
}
/** Convert a raw GitHub Releases API object into our release model. */
export function toReleaseModel(raw) {
const match = STABLE_TAG_RE.exec(raw.tag || '');
return {
tag: raw.tag,
version: match ? `${match[1]}.${match[2]}.${match[3]}` : null,
date: (raw.date || '').slice(0, 10),
htmlUrl: raw.url || '',
body: raw.body || '',
entries: parseReleaseEntries(raw.body),
};
}
/** Keep only stable releases, newest first. */
export function selectStableReleases(rawReleases) {
return rawReleases
.filter((raw) => !raw.prerelease && !raw.draft)
.map(toReleaseModel)
.filter((release) => release.version)
.sort((a, b) => {
// Newest first, comparing numeric semver components.
const x = a.version.split('.').map(Number);
const y = b.version.split('.').map(Number);
return y[0] - x[0] || y[1] - x[1] || y[2] - x[2];
});
}
/** Fetch every release (paginated) as newline-delimited JSON via the gh CLI. */
function fetchReleasesJsonl(repo) {
// Validate before shelling out, and pass args via execFileSync (no shell) so
// a `repo` value can never be interpreted as a shell command.
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error(`Invalid repository "${repo}"; expected "owner/name".`);
}
return execFileSync(
'gh',
[
'api',
`repos/${repo}/releases?per_page=100`,
'--paginate',
'--jq',
'.[] | {tag: .tag_name, date: .published_at, prerelease: .prerelease, draft: .draft, url: .html_url, body: .body}',
],
{ encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
);
}
/** Parse newline-delimited JSON (one release object per line). */
export function parseJsonl(jsonl) {
return jsonl
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
/** Resolve the default `owner/repo` from the environment or package.json. */
function getDefaultRepo() {
if (process.env.GITHUB_REPOSITORY) {
return process.env.GITHUB_REPOSITORY;
}
const url = readJson(path.join(REPO_ROOT, 'package.json'))?.repository?.url;
const match = /github\.com[/:]([^/]+\/[^/.]+)/.exec(url || '');
return match ? match[1] : 'QwenLM/qwen-code';
}
const HELP = `Generate CHANGELOG.md from GitHub Releases.
Usage:
node scripts/generate-changelog.js [options]
Options:
--repo=<owner/name> Source repository (default: $GITHUB_REPOSITORY or package.json).
--output=<path> Output file (default: ./CHANGELOG.md).
--dry-run Print to stdout instead of writing the file.
-h, --help Show this help.
`;
function main() {
// parseArgs rejects unknown options (so a `--dryrun` typo errors instead of
// silently overwriting) and handles `-h`/`--help` natively.
const args = parseArgs(process.argv.slice(2), {
'--repo': { key: 'repo', type: 'value' },
'--output': { key: 'output', type: 'value' },
'--dry-run': { key: 'dry-run', type: 'flag' },
});
if (args.help) {
process.stdout.write(HELP);
return;
}
const repo = args.repo || getDefaultRepo();
const output = args.output || path.join(REPO_ROOT, 'CHANGELOG.md');
const rawReleases = parseJsonl(fetchReleasesJsonl(repo));
const releases = selectStableReleases(rawReleases);
if (releases.length === 0) {
// A populated repo returning zero stable releases means the fetch failed
// (rate limit, auth expiry, transient 5xx). Refuse to overwrite a good
// CHANGELOG with an empty stub; the next release run regenerates it.
console.error(
`ERROR: no stable releases found for ${repo}; refusing to overwrite ${path.basename(output)}.`,
);
process.exit(1);
}
const changelog = buildChangelog(releases);
if (args['dry-run']) {
process.stdout.write(changelog);
return;
}
writeFileSync(output, changelog);
console.error(
`Wrote ${releases.length} stable releases to ${path.relative(process.cwd(), output)}`,
);
}
if (isMainModule(import.meta.url)) {
main();
}