mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
5 files changed
+501
-2
No files matched your search
+2
-1
@@ -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'
|
||||
@@ -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'/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
Reference in new issue
Block a user