ci: add lightweight PR profiles (#6186)

* ci: add lightweight PR profiles

* fix(ci): harden lightweight profile classification

* fix(ci): harden lightweight profile edge cases

* fix(ci): report classifier invocation failures

* fix(ci): tighten docs-only path matching

* ci: restrict light profiles to same-repo PRs

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
易良 2026-07-03 15:40:25 +08:00 committed by GitHub
parent e743f0e2e0
commit 183ad54b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 287 additions and 25 deletions

102
.github/scripts/ci/classify-profile.mjs vendored Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
export const CI_PROFILES = {
DOCS_ONLY: 'docs_only',
GITHUB_CI_ONLY: 'github_ci_only',
FULL: 'full',
};
export const GITHUB_CI_ONLY_FILES = new Set([
'.github/scripts/pr-safety-precheck.mjs',
'.github/scripts/pr-safety-precheck.test.mjs',
'.github/workflows/qwen-pr-safety-precheck.yml',
]);
function isDocsOnlyFile(file) {
const normalized = file.replace(/\\/g, '/');
return (
/^docs\/.+\.(?:md|mdx)$/i.test(normalized) ||
/^(?:README|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY|SUPPORT|LICENSE|NOTICE)(?:\.[^/]*)?$/i.test(
normalized,
)
);
}
function classifyPath(file) {
if (isDocsOnlyFile(file)) return CI_PROFILES.DOCS_ONLY;
if (GITHUB_CI_ONLY_FILES.has(file)) return CI_PROFILES.GITHUB_CI_ONLY;
return CI_PROFILES.FULL;
}
function classifyFileEntry(entry) {
if (typeof entry === 'string') return classifyPath(entry);
const filename = entry?.filename;
if (!filename) return CI_PROFILES.FULL;
const profile = classifyPath(filename);
if (entry.status !== 'renamed') return profile;
const previousProfile = entry.previous_filename
? classifyPath(entry.previous_filename)
: CI_PROFILES.FULL;
return previousProfile === profile ? profile : CI_PROFILES.FULL;
}
export function classifyChangedFiles(files) {
const changedFiles = files.filter(Boolean);
if (changedFiles.length === 0) return CI_PROFILES.FULL;
if (
changedFiles.every(
(entry) => classifyFileEntry(entry) === CI_PROFILES.DOCS_ONLY,
)
) {
return CI_PROFILES.DOCS_ONLY;
}
if (
changedFiles.every(
(entry) => classifyFileEntry(entry) === CI_PROFILES.GITHUB_CI_ONLY,
)
) {
return CI_PROFILES.GITHUB_CI_ONLY;
}
return CI_PROFILES.FULL;
}
function parseChangedFiles(text) {
return text
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return line;
}
});
}
function main() {
const filePath = process.argv[2];
if (!filePath) {
console.log(CI_PROFILES.FULL);
return;
}
try {
const files = parseChangedFiles(readFileSync(filePath, 'utf8'));
console.log(classifyChangedFiles(files));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`::warning::Failed to read changed files: ${message}`);
console.log(CI_PROFILES.FULL);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View file

@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GITHUB_CI_ONLY_FILES,
classifyChangedFiles,
} from './classify-profile.mjs';
test('uses docs_only for markdown-only changes', () => {
assert.equal(
classifyChangedFiles(['README.md', 'docs/usage.md', '.qwen/design/foo.md']),
'full',
);
assert.equal(
classifyChangedFiles(['README.md', 'docs/usage.md']),
'docs_only',
);
});
test('uses docs_only for uppercase and extensionless docs', () => {
assert.equal(
classifyChangedFiles(['README.MD', 'docs/guide.MDX', 'LICENSE', 'README']),
'docs_only',
);
});
test('falls back to full for root docs names used as directories', () => {
assert.equal(classifyChangedFiles(['README.md/evil.ts']), 'full');
assert.equal(classifyChangedFiles(['LICENSE.txt/src/index.ts']), 'full');
});
test('uses github_ci_only for the allowed GitHub CI helper files', () => {
assert.equal(
classifyChangedFiles([...GITHUB_CI_ONLY_FILES]),
'github_ci_only',
);
});
test('uses github_ci_only for each allowed GitHub CI helper file', () => {
for (const file of GITHUB_CI_ONLY_FILES) {
assert.equal(classifyChangedFiles([file]), 'github_ci_only');
}
});
test('falls back to full for case-mismatched GitHub CI helper paths', () => {
assert.equal(
classifyChangedFiles(['.GITHUB/SCRIPTS/PR-SAFETY-PRECHECK.MJS']),
'full',
);
});
test('classifies renamed files using both old and new paths', () => {
assert.equal(
classifyChangedFiles([
{
filename: 'docs/new.md',
previous_filename: 'packages/core/src/runtime.ts',
status: 'renamed',
},
]),
'full',
);
assert.equal(
classifyChangedFiles([
{
filename: 'docs/new.md',
previous_filename: 'docs/old.md',
status: 'renamed',
},
]),
'docs_only',
);
});
test('falls back to full when changed files are unavailable', () => {
assert.equal(classifyChangedFiles([]), 'full');
assert.equal(classifyChangedFiles(['', null, undefined]), 'full');
});
test('falls back to full for source or mixed changes', () => {
assert.equal(
classifyChangedFiles(['README.md', 'packages/cli/src/index.ts']),
'full',
);
assert.equal(
classifyChangedFiles([
'README.md',
'.github/scripts/pr-safety-precheck.mjs',
]),
'full',
);
});
test('falls back to full for main CI workflow changes', () => {
assert.equal(classifyChangedFiles(['.github/workflows/ci.yml']), 'full');
assert.equal(classifyChangedFiles(['.github/workflows/codeql.yml']), 'full');
});
test('falls back to full for classifier changes', () => {
assert.equal(
classifyChangedFiles(['.github/scripts/ci/classify-profile.mjs']),
'full',
);
assert.equal(
classifyChangedFiles(['.github/scripts/ci/classify-profile.test.mjs']),
'full',
);
});
test('falls back to full for runtime markdown assets and instruction files', () => {
assert.equal(
classifyChangedFiles(['packages/core/src/skills/bundled/foo/SKILL.md']),
'full',
);
assert.equal(classifyChangedFiles(['AGENTS.md']), 'full');
});