From 8fe1f926e9304d8d80283679f94eaae249f9fb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 20 Jul 2026 19:10:55 +0800 Subject: [PATCH] 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 --- .github/release.yml | 3 +- .github/scripts/classify-release-notes.mjs | 102 ++++++ .../scripts/classify-release-notes.test.mjs | 330 ++++++++++++++++++ .github/workflows/ci.yml | 2 +- .github/workflows/classify-release-notes.yml | 66 ++++ 5 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/classify-release-notes.mjs create mode 100644 .github/scripts/classify-release-notes.test.mjs create mode 100644 .github/workflows/classify-release-notes.yml diff --git a/.github/release.yml b/.github/release.yml index 05c7dc55e67..33804fbc18d 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -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' diff --git a/.github/scripts/classify-release-notes.mjs b/.github/scripts/classify-release-notes.mjs new file mode 100644 index 00000000000..ac85d329005 --- /dev/null +++ b/.github/scripts/classify-release-notes.mjs @@ -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(); +} diff --git a/.github/scripts/classify-release-notes.test.mjs b/.github/scripts/classify-release-notes.test.mjs new file mode 100644 index 00000000000..b25fb005640 --- /dev/null +++ b/.github/scripts/classify-release-notes.test.mjs @@ -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'/); + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448d577a8a1..3e020111a20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/classify-release-notes.yml b/.github/workflows/classify-release-notes.yml new file mode 100644 index 00000000000..1be7963a77e --- /dev/null +++ b/.github/workflows/classify-release-notes.yml @@ -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