ci: auto-skip internal CI changes in release notes (#7251)

* ci: auto-skip internal CI changes in release notes

* ci: harden release note classifier

* fix(ci): close release note classifier review gaps

* fix(ci): close release classifier review gaps

* fix(ci): avoid release classifier label loop

* test(ci): cover release classifier test path variants
This commit is contained in:
易良 authored and GitHub committed 2026-07-20 11:10:55 +00:00
1 parent 17ae6a4b58
commit 8fe1f926e9
5 files changed
+501 -2

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
# Configuration for GitHub's automatic release notes generation
# PRs with 'skip-changelog' label will be excluded from release notes
# Manual and automatically classified internal PRs are excluded.
changelog:
exclude:
labels:
- 'skip-changelog'
- 'skip-changelog-auto'
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const INTERNAL_LABELS = new Set([
'category/development',
'scope/build-system',
'scope/ci-cd',
'scope/github-actions',
'scope/testing',
]);
const AUTO_LABEL = 'skip-changelog-auto';
const RELEASE_AUTOMATION_RE =
/^\.github\/.*(?:changelog|release|publish|deploy|sync|prebuild|package|installer|artifact|image|cd-)/i;
const TEST_FILE_RE =
/(?:^|\/)(?:__tests__\/|[^/]+\.(?:test|spec)\.[^/]+$|(?:vitest|playwright)(?:\.[^/]+)?\.config\.[^/]+$)/;
export function shouldAutoSkipChangelog({ title, labels = [], files = [] }) {
const names = labels
.map((label) =>
(typeof label === 'string' ? label : label.name).toLowerCase(),
)
.filter((name) => name !== AUTO_LABEL);
if (names.includes('skip-changelog')) return false;
const subject = /^(\w+)(?:\([^)]*\))?(!)?:/.exec(title.trim());
const type = subject?.[1].toLowerCase();
if (
subject?.[2] ||
(type
? type !== 'ci'
: !names.some((label) =>
['scope/ci-cd', 'scope/github-actions'].includes(label),
)) ||
names.includes('bug') ||
names.includes('breaking-change') ||
names.some(
(label) =>
/^(?:type|category|scope)\//.test(label) && !INTERNAL_LABELS.has(label),
)
) {
return false;
}
return (
files.length > 0 &&
files.every(
(file) =>
TEST_FILE_RE.test(file) ||
(!RELEASE_AUTOMATION_RE.test(file) &&
(file.startsWith('.github/') || file.startsWith('.qwen/'))),
)
);
}
function main() {
const repo = process.env.GITHUB_REPOSITORY || '';
const number = process.env.PR_NUMBER || '';
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) ||
!/^[1-9]\d*$/.test(number)
) {
throw new Error(
'GITHUB_REPOSITORY and PR_NUMBER must identify a pull request.',
);
}
const metadata = JSON.parse(
execFileSync(
'gh',
['pr', 'view', number, '--repo', repo, '--json', 'title,labels'],
{
encoding: 'utf8',
},
),
);
const files = execFileSync(
'gh',
[
'api',
'--paginate',
`repos/${repo}/pulls/${number}/files`,
'--jq',
'.[] | .filename, (.previous_filename // empty)',
],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
)
.split(/\r?\n/)
.filter(Boolean);
process.stdout.write(
`${shouldAutoSkipChangelog({ ...metadata, files }) ? 'skip' : 'include'}\n`,
);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}
@@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { shouldAutoSkipChangelog } from './classify-release-notes.mjs';
describe('release note classification', () => {
it('only skips internal CI changes', () => {
const cases = [
{
title: 'ci: speed up PR checks',
files: ['.github/workflows/ci.yml'],
expected: true,
},
{
title: 'ci(autofix): harden review automation',
files: [
'.github/workflows/qwen-autofix.yml',
'.qwen/skills/autofix/SKILL.md',
'scripts/tests/qwen-autofix-workflow.test.js',
],
expected: true,
},
{
title: 'ci: update test helpers',
files: ['packages/core/src/__tests__/utils.ts'],
expected: true,
},
{
title: 'ci: update vitest config',
files: ['packages/cli/vitest.config.ts'],
expected: true,
},
{
title: 'ci: update spec tests',
files: ['packages/core/src/auth.spec.ts'],
expected: true,
},
{
title: 'ci: update playwright config',
files: ['packages/e2e/playwright.config.ts'],
expected: true,
},
{
title: 'ci: update release classifier tests',
files: ['.github/scripts/classify-release-notes.test.mjs'],
expected: true,
},
{
title: 'Fix flaky CI routing',
labels: [{ name: 'scope/ci-cd' }],
files: ['.github/workflows/ci.yml'],
expected: true,
},
{
title: 'ci!: breaking dispatch change',
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'Update action permissions',
labels: ['scope/github-actions'],
files: ['.github/workflows/ci.yml'],
expected: true,
},
{
title: 'fix(ci): repair check results',
labels: ['scope/ci-cd'],
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'ci: fix check',
labels: ['bug'],
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'ci: fix check',
labels: ['breaking-change'],
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'ci: keep automatic exclusion stable',
labels: ['skip-changelog-auto'],
files: ['.github/workflows/ci.yml'],
expected: true,
},
{
title: 'Update docs',
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'ci: empty',
files: [],
expected: false,
},
{
title: 'ci: update release automation',
files: ['.github/workflows/finalize-release.yml'],
expected: false,
},
{
title: 'ci: update release helper',
files: ['.github/scripts/publish-release.mjs'],
expected: false,
},
{
title: 'ci: update changelog helper',
files: ['.github/workflows/update-changelog.yml'],
expected: false,
},
{
title: 'ci: update deploy pipeline',
files: ['.github/workflows/deploy-app.yml'],
expected: false,
},
{
title: 'ci: bump sync action',
files: ['.github/workflows/sync-labels.yml'],
expected: false,
},
{
title: 'ci: update prebuild pipeline',
files: ['.github/workflows/prebuild.yml'],
expected: false,
},
{
title: 'ci: update package pipeline',
files: ['.github/workflows/package-cli.yml'],
expected: false,
},
{
title: 'ci: update installer pipeline',
files: ['.github/workflows/build-installer.yml'],
expected: false,
},
{
title: 'ci: update artifact upload',
files: ['.github/workflows/upload-artifact.yml'],
expected: false,
},
{
title: 'ci: update image build',
files: ['.github/scripts/build-image.mjs'],
expected: false,
},
{
title: 'ci: update cd pipeline',
files: ['.github/workflows/cd-pages.yml'],
expected: false,
},
{
title: 'ci: support a new platform build',
labels: ['category/platform'],
files: ['.github/workflows/ci.yml'],
expected: false,
},
{
title: 'ci: update checks and runtime',
files: ['.github/workflows/ci.yml', 'packages/core/src/index.ts'],
expected: false,
},
{
title: 'ci: move a runtime file into automation',
files: ['.github/scripts/runtime.ts', 'packages/core/src/runtime.ts'],
expected: false,
},
{
title: 'ci: keep manual exclusion',
labels: ['skip-changelog'],
files: ['.github/workflows/ci.yml'],
expected: false,
},
];
for (const { expected, ...pullRequest } of cases) {
assert.equal(
shouldAutoSkipChangelog(pullRequest),
expected,
pullRequest.title,
);
}
});
it('rejects invalid pull request environment before invoking gh', () => {
const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-'));
try {
const marker = join(dir, 'gh-invoked');
const gh = join(dir, 'gh');
writeFileSync(
gh,
[
'#!/usr/bin/env node',
`require('node:fs').writeFileSync(${JSON.stringify(marker)}, '1');`,
'process.exit(1);',
].join('\n'),
);
chmodSync(gh, 0o755);
for (const env of [
{ GITHUB_REPOSITORY: 'QwenLM/qwen-code/extra', PR_NUMBER: '1' },
{ GITHUB_REPOSITORY: 'QwenLM/qwen-code', PR_NUMBER: 'abc' },
]) {
assert.throws(() =>
execFileSync(
process.execPath,
[join(import.meta.dirname, 'classify-release-notes.mjs')],
{
env: {
...process.env,
...env,
PATH: `${dir}:${process.env.PATH}`,
},
},
),
);
assert.equal(existsSync(marker), false);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('keeps renamed production files by checking their previous paths', () => {
const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-'));
try {
const gh = join(dir, 'gh');
writeFileSync(
gh,
[
'#!/usr/bin/env node',
'const args = process.argv.slice(2);',
"if (args[0] === 'pr') { process.stdout.write(JSON.stringify({ title: 'ci: move runtime', labels: [] })); process.exit(0); }",
"if (args.includes('.[] | .filename, (.previous_filename // empty)')) { process.stdout.write('.github/scripts/runtime.ts\\npackages/core/src/runtime.ts\\n'); process.exit(0); }",
'process.exit(1);',
].join('\n'),
);
chmodSync(gh, 0o755);
const decision = execFileSync(
process.execPath,
[join(import.meta.dirname, 'classify-release-notes.mjs')],
{
encoding: 'utf8',
env: {
...process.env,
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
PATH: `${dir}:${process.env.PATH}`,
PR_NUMBER: '1',
},
},
);
assert.equal(decision, 'include\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('prints skip for internal CI changes through main', () => {
const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-'));
try {
const gh = join(dir, 'gh');
writeFileSync(
gh,
[
'#!/usr/bin/env node',
'const args = process.argv.slice(2);',
"if (args[0] === 'pr') { process.stdout.write(JSON.stringify({ title: 'ci: speed up checks', labels: [] })); process.exit(0); }",
"if (args.includes('.[] | .filename, (.previous_filename // empty)')) { process.stdout.write('.github/workflows/ci.yml\\n'); process.exit(0); }",
'process.exit(1);',
].join('\n'),
);
chmodSync(gh, 0o755);
const decision = execFileSync(
process.execPath,
[join(import.meta.dirname, 'classify-release-notes.mjs')],
{
encoding: 'utf8',
env: {
...process.env,
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
PATH: `${dir}:${process.env.PATH}`,
PR_NUMBER: '1',
},
},
);
assert.equal(decision, 'skip\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('wires reclassification and exclusion to the same automatic label', () => {
const workflow = readFileSync(
join(import.meta.dirname, '../workflows/classify-release-notes.yml'),
'utf8',
);
const release = readFileSync(
join(import.meta.dirname, '../release.yml'),
'utf8',
);
for (const action of ['synchronize', 'edited', 'labeled', 'unlabeled']) {
assert.match(workflow, new RegExp(`- '${action}'`));
}
assert.match(workflow, /AUTO_LABEL: 'skip-changelog-auto'/);
assert.match(
workflow,
/github\.event\.label\.name != 'skip-changelog-auto'/,
);
assert.match(workflow, /classification failed; including this PR/);
assert.doesNotMatch(workflow, /decision=unchanged/);
assert.match(release, /- 'skip-changelog-auto'/);
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ env:
# BOTH the github_ci_only helper step and the full-profile Test step, so a
# new helper test can't be added to one path and silently dropped from the
# other.
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs'
jobs:
classify_pr:
@@ -0,0 +1,66 @@
name: 'Classify Release Notes'
on:
pull_request_target:
branches:
- 'main'
- 'release/**'
types:
- 'opened'
- 'reopened'
- 'synchronize'
- 'edited'
- 'labeled'
- 'unlabeled'
- 'ready_for_review'
permissions:
contents: 'read'
issues: 'write'
pull-requests: 'write'
concurrency:
group: '${{ github.workflow }}-${{ github.event.pull_request.number }}'
cancel-in-progress: true
jobs:
classify:
if: "${{ github.repository == 'QwenLM/qwen-code' && github.event.label.name != 'skip-changelog-auto' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 5
steps:
# pull_request_target checks out the trusted base branch; PR code is never
# executed with this workflow's label-write permission.
- name: 'Checkout trusted classifier'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Classify pull request'
id: 'classify'
env:
GH_TOKEN: '${{ github.token }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |-
if decision="$(node .github/scripts/classify-release-notes.mjs)"; then
echo "decision=${decision}" >> "${GITHUB_OUTPUT}"
else
echo "::warning::Release-note classification failed; including this PR."
echo "decision=include" >> "${GITHUB_OUTPUT}"
fi
- name: 'Update automatic changelog label'
env:
AUTO_LABEL: 'skip-changelog-auto'
DECISION: '${{ steps.classify.outputs.decision }}'
GH_TOKEN: '${{ github.token }}'
HAS_AUTO_LABEL: "${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog-auto') }}"
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |-
set -euo pipefail
if [[ "${DECISION}" == "skip" ]]; then
gh label create "${AUTO_LABEL}" --repo "${GITHUB_REPOSITORY}" --color 'ededed' --description 'Automatically exclude internal CI changes from release notes' --force
if [[ "${HAS_AUTO_LABEL}" != "true" ]]; then
gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "${AUTO_LABEL}"
fi
elif [[ "${DECISION}" == "include" && "${HAS_AUTO_LABEL}" == "true" ]]; then
gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "${AUTO_LABEL}"
fi