From 183ad54b115ffddc57fb073f47f451dc2a69e1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 3 Jul 2026 15:40:25 +0800 Subject: [PATCH] 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 --- .github/scripts/ci/classify-profile.mjs | 102 ++++++++++++++++ .github/scripts/ci/classify-profile.test.mjs | 116 +++++++++++++++++++ .github/workflows/ci.yml | 94 +++++++++++---- 3 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/ci/classify-profile.mjs create mode 100644 .github/scripts/ci/classify-profile.test.mjs diff --git a/.github/scripts/ci/classify-profile.mjs b/.github/scripts/ci/classify-profile.mjs new file mode 100644 index 0000000000..4188e9d10c --- /dev/null +++ b/.github/scripts/ci/classify-profile.mjs @@ -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(); +} diff --git a/.github/scripts/ci/classify-profile.test.mjs b/.github/scripts/ci/classify-profile.test.mjs new file mode 100644 index 0000000000..99e6721d9d --- /dev/null +++ b/.github/scripts/ci/classify-profile.test.mjs @@ -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'); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583e9e5fcc..f28bce85e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: if: "${{ !cancelled() && github.event_name != 'push' }}" runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}' timeout-minutes: 60 + outputs: + ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}' permissions: contents: 'read' checks: 'write' @@ -158,9 +160,50 @@ jobs: exit 1 fi + - name: 'Classify CI profile' + id: 'ci_profile' + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + env: + GH_TOKEN: '${{ github.token }}' + PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}" + IS_SAME_REPO_PR: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}" + run: |- + profile=full + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then + if [[ "${IS_SAME_REPO_PR}" == "true" ]]; then + changed_files="${RUNNER_TEMP}/changed-files.jsonl" + if gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[] | {filename, status, previous_filename}' > "${changed_files}"; then + if ! profile="$(node .github/scripts/ci/classify-profile.mjs "${changed_files}")"; then + echo "::error::CI profile classifier exited non-zero; running full CI." + profile=full + fi + else + echo "::warning::Unable to list PR changed files; running full CI." + fi + else + echo "Fork PR detected; running full CI." + fi + fi + echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}" + echo "Selected CI profile: ${profile}" + + - name: 'Docs-only CI' + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'docs_only' }}" + run: 'echo "Docs-only change; full CI skipped."' + + - name: 'GitHub CI helper checks' + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'github_ci_only' }}" + timeout-minutes: 5 + run: |- + # Keep this path dependency-free; script formatting is checked when those files hit full CI. + node scripts/lint.js --setup + node scripts/lint.js --actionlint + node scripts/lint.js --yamllint + node --test .github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs + # Self-hosted can't reach nodejs.org reliably; reuse the machine's Node. - name: 'Set up Node.js 22.x (hosted)' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'github-hosted' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version: '22.x' @@ -169,7 +212,7 @@ jobs: registry-url: 'https://registry.npmjs.org/' - name: 'Use pre-installed Node.js (self-hosted)' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}" run: |- if ! command -v node >/dev/null 2>&1; then echo "::error::Node.js is not on PATH for this self-hosted runner. Provision Node 22.x or set the MAINTAINER_ECS_RUNNER_DISABLED repository variable to 'true' to route PRs back to hosted runners." @@ -181,7 +224,7 @@ jobs: fi - name: 'Configure persistent npm cache (self-hosted)' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}" run: |- cache_dir="${HOME}/.cache/qwen-code/npm" mkdir -p "${cache_dir}" @@ -190,7 +233,7 @@ jobs: du -sh "${cache_dir}" 2>/dev/null || true - name: 'Configure npm for rate limiting' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: |- npm config set fetch-retry-mintimeout 20000 npm config set fetch-retry-maxtimeout 120000 @@ -198,68 +241,68 @@ jobs: npm config set fetch-timeout 300000 - name: 'Install dependencies' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: |- npm ci --prefer-offline --no-audit --progress=false - name: 'Report npm cache usage (self-hosted)' - if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}" + if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}" run: |- cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}" echo "npm cache: ${cache_dir}" du -sh "${cache_dir}" 2>/dev/null || true - name: 'Audit critical runtime dependencies' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run audit:runtime:critical' - name: 'Check lockfile' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run check:lockfile' - name: 'Check desktop workspace isolation' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run check:desktop-isolation' - name: 'Install linters' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --setup' - name: 'Run ESLint' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --eslint' - name: 'Run actionlint' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" timeout-minutes: 5 run: 'node scripts/lint.js --actionlint' - name: 'Run shellcheck' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --shellcheck' - name: 'Run yamllint' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --yamllint' - name: 'Run Prettier' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --prettier' - name: 'Run sensitive keyword linter' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'node scripts/lint.js --sensitive-keywords' - name: 'Run i18n check' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run check-i18n' - name: 'Generate settings schema' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run generate:settings-schema' - name: 'Check settings schema is up-to-date' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: |- if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then echo "Error: settings.schema.json is out of date." @@ -274,12 +317,12 @@ jobs: # npm run test:ci only, so they intentionally do not repeat this # platform-independent bundle closure check. - name: 'Check serve fast-path bundle closure' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: 'npm run check:serve-fast-path-bundle' - name: 'Run tests and generate reports' id: 'unit_tests' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" env: NO_COLOR: true HOME: '${{ runner.temp }}/qwen-ci-home' @@ -294,12 +337,12 @@ jobs: npm run test:ci - name: 'Run no-AK integration smoke tests' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' }}" + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name == 'pull_request' }}" run: 'npm run test:integration:no-ak:sandbox:none' - name: 'Publish Test Report (for non-forks)' if: |- - ${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.unit_tests.outcome != 'skipped' && (github.event.pull_request.head.repo.full_name == github.repository) }} + ${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && steps.unit_tests.outcome != 'skipped' && (github.event.pull_request.head.repo.full_name == github.repository) }} uses: 'dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2' # ratchet:dorny/test-reporter@v3 with: name: 'Test Results (ubuntu-latest, Node 22.x)' @@ -309,14 +352,14 @@ jobs: - name: 'Upload Test Results Artifact (for forks)' if: |- - ${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} + ${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 with: name: 'test-results-fork-22.x-ubuntu-latest' path: 'packages/*/junit.xml' - name: 'Upload coverage reports' - if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' }}" + if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 with: name: 'coverage-reports-22.x-ubuntu-latest' @@ -450,6 +493,7 @@ jobs: ${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && + needs.test.outputs.ci_profile == 'full' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}