diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 8d8b83477ee..7a36897328b 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -747,6 +747,9 @@ jobs: exit 1 fi + # The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below + # are duplicated in build-cli and review-address; the workflow + # contract tests pin every copy in lockstep — edit them together. - name: 'Install dependencies and build' env: QWEN_SKIP_PREPARE: '1' @@ -2586,12 +2589,107 @@ jobs: echo "targets=${TARGETS}" >> "${GITHUB_OUTPUT}" echo "has_targets=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}" + # =========================================================================== + # REVIEW PHASE (build) — compile the trusted-base CLI bundle ONCE per scan + # and fan it out to the address legs as an artifact. Each leg otherwise + # repeated the same base build: measured 3.5-5 minutes of npm ci + build + + # bundle per leg (6 legs ≈ 25 runner-minutes on one 2026-08 scan) before + # the agent could start. Gated on has_targets so an idle tick builds + # nothing — the issue phase runs only when there are no review targets, so + # gating on do_issue too would rebuild on every quiet scheduled tick. + # fetch-depth 1 suffices: nothing here needs history, and the legs (full + # fetch) always contain this commit as an ancestor of their base fetch. + # =========================================================================== + build-cli: + needs: ['route', 'review-scan'] + if: |- + ${{ needs.review-scan.outputs.has_targets == 'true' }} + runs-on: 'ubuntu-latest' + timeout-minutes: 30 + permissions: + contents: 'read' + outputs: + base_sha: '${{ steps.meta.outputs.base_sha }}' + steps: + - name: 'Checkout trusted base' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + ref: '${{ github.event.repository.default_branch }}' + fetch-depth: 1 + persist-credentials: false + + - name: 'Set up Node.js (hosted)' + if: |- + ${{ runner.environment == 'github-hosted' }} + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + with: + node-version: '22.x' + cache: 'npm' + cache-dependency-path: 'package-lock.json' + + # The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below + # are duplicated in issue-autofix and review-address; the workflow + # contract tests pin every copy in lockstep — edit them together. + - name: 'Install dependencies and build' + env: + QWEN_SKIP_PREPARE: '1' + run: |- + for attempt in 1 2 3; do + if npm ci --prefer-offline --no-audit --progress=false; then + break + fi + if [[ "${attempt}" == "3" ]]; then + exit 1 + fi + sleep $((attempt * 15)) + done + npm run build + npm run bundle + + # Producer-side sanity: a bundle that cannot even answer --version + # fails THIS job once, instead of every leg failing its restore or + # first qwen invocation minutes later and N times over. + - name: 'Prepare Qwen Code CLI' + run: |- + qwen_version="$(node -p "require('./package.json').version")" + echo "Using checked-out Qwen Code bundle ${qwen_version}" + qwen_bin="${RUNNER_TEMP}/qwen-bin" + mkdir -p "${qwen_bin}" + cat > "${qwen_bin}/qwen" <<'EOF' + #!/usr/bin/env bash + exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@" + EOF + chmod +x "${qwen_bin}/qwen" + echo "${qwen_bin}" >> "${GITHUB_PATH}" + PATH="${qwen_bin}:${PATH}" + qwen --version + + # Only the repo-root dist/ is shipped: copy_bundle_assets.js already + # gathers every runtime asset (chunks, vendor, web-shell, locales) + # under it. packages/*/dist would triple the artifact size and is + # never read by the legs — the verify gate's full `npm run build` + # wipes and rebuilds each package's dist from branch sources + # (build_package.js rms it first, so no staleness leaks through). + - name: 'Upload CLI bundle' + id: 'meta' + run: |- + echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist + + - name: 'Publish CLI bundle artifact' + uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 + with: + name: 'qwen-autofix-cli-dist' + path: '${{ runner.temp }}/qwen-cli-dist.tar.gz' + retention-days: 1 + if-no-files-found: 'error' + # =========================================================================== # REVIEW PHASE (address) — one job per PR: triage, address (incl. conflict # resolution), verify, push, and report. # =========================================================================== review-address: - needs: ['route', 'review-scan'] + needs: ['route', 'review-scan', 'build-cli'] if: |- ${{ needs.review-scan.outputs.has_targets == 'true' }} runs-on: 'ubuntu-latest' @@ -2660,14 +2758,30 @@ jobs: HEAD_REPO: '${{ matrix.target.head_repo }}' steps: # SECURITY: checkout trusted base code first. The PR branch is checked - # out later in "Prepare branch and feedback" after the trusted CLI bundle - # is built from this base. Without this pin, pull_request_review events - # would check out the PR merge ref by default, letting PR-controlled - # code influence the secret-bearing address run. + # out later in "Prepare branch and feedback" after the trusted CLI + # bundle (built once from this base in build-cli) is in place. Without + # this pin, pull_request_review events would check out the PR merge ref + # by default, letting PR-controlled code influence the secret-bearing + # address run. The ref is pinned to the SHA build-cli compiled — not + # the live default branch — so a mid-run base push can never leave a + # leg running a bundle built from DIFFERENT sources than its checkout. + # The SHA is validated fail-loud FIRST: actions/checkout resolves an + # empty ref to the event default — on pull_request_review triggers the + # PR merge ref — so a broken build-cli output must fail this leg + # instead of silently unpinning it. + - name: 'Validate bundle SHA' + env: + BASE_SHA: '${{ needs.build-cli.outputs.base_sha }}' + run: |- + if [[ ! "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::build-cli published no usable base_sha ('${BASE_SHA:-}') — refusing to fall back to the event-default ref." + exit 1 + fi + - name: 'Checkout trusted base' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: - ref: '${{ github.event.repository.default_branch }}' + ref: '${{ needs.build-cli.outputs.base_sha }}' fetch-depth: 0 persist-credentials: false @@ -2736,7 +2850,14 @@ jobs: exit 1 fi - - name: 'Install dependencies and build' + # npm ci only — the build itself ran ONCE in build-cli. The legs still + # need node_modules: the agent builds/tests the PR branch, and the + # verify gate rebuilds from branch sources (wiping the restored + # packages/*/dist via build_package.js, so no base staleness leaks + # into branch verification). The retry recipe (and the shim below) is + # duplicated in issue-autofix and build-cli; the workflow contract + # tests pin every copy in lockstep — edit them together. + - name: 'Install dependencies' env: QWEN_SKIP_PREPARE: '1' run: |- @@ -2750,8 +2871,17 @@ jobs: sleep $((attempt * 15)) done git config core.hooksPath .husky - npm run build - npm run bundle + + - name: 'Download CLI bundle' + uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 + with: + name: 'qwen-autofix-cli-dist' + path: '${{ runner.temp }}/cli-dist' + + - name: 'Restore CLI bundle' + run: |- + tar -xzf "${RUNNER_TEMP}/cli-dist/qwen-cli-dist.tar.gz" + test -f dist/cli.js - name: 'Prepare Qwen Code CLI' run: |- diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index d231957e631..335e27de695 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -520,18 +520,25 @@ describe('package scripts', () => { it('skips autofix install-time prepare without disabling dependency scripts', () => { const workflow = readWorkflow('.github/workflows/qwen-autofix.yml'); - for (const jobName of ['issue-autofix', 'review-address']) { + // review-address restores the shared build-cli bundle instead of + // compiling, so its install step is npm ci only; the other two jobs + // still build from sources. Husky hooks are re-armed after the + // prepare-skip only where git commits happen (build-cli never commits). + for (const [jobName, stepName, armsHooks] of [ + ['issue-autofix', 'Install dependencies and build', true], + ['build-cli', 'Install dependencies and build', false], + ['review-address', 'Install dependencies', true], + ]) { const job = getWorkflowJob(workflow, jobName); - const installStep = getWorkflowStep( - job, - 'Install dependencies and build', - ); + const installStep = getWorkflowStep(job, stepName); expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'"); expect(installStep).toContain( 'npm ci --prefer-offline --no-audit --progress=false', ); - expect(installStep).toContain('git config core.hooksPath .husky'); + if (armsHooks) { + expect(installStep).toContain('git config core.hooksPath .husky'); + } expect(installStep).not.toContain('--ignore-scripts'); } }); diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 587ad0119a8..910ec4c427a 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -147,6 +147,10 @@ const installAndBuildSteps = workflow.match( /- name: 'Install dependencies and build'[\s\S]*?(?=\n[ ]{6}- name: ')/g, ) ?? []; +const nodeSetupSteps = + workflow.match( + /- name: 'Set up Node.js \(hosted\)'[\s\S]*?(?=\n[ ]{6}- name: ')/g, + ) ?? []; function readAutofixSkill() { return readFileSync('.qwen/skills/autofix/SKILL.md', 'utf8'); @@ -5205,6 +5209,7 @@ describe('qwen-autofix workflow', () => { expect(workflow).toMatch(/issue-autofix:[\s\S]*?runs-on: 'ubuntu-latest'/); expect(workflow).toMatch(/review-address:[\s\S]*?runs-on: 'ubuntu-latest'/); + expect(workflow).toMatch(/build-cli:[\s\S]*?runs-on: 'ubuntu-latest'/); expect(workflow).not.toContain( '["self-hosted", "linux", "x64", "autofix"]', ); @@ -5214,7 +5219,9 @@ describe('qwen-autofix workflow', () => { expect(workflow).toContain( "RUNNER_ENVIRONMENT: '${{ runner.environment }}'", ); - expect(prepareQwenCliSteps).toHaveLength(2); + // issue-autofix, build-cli, and review-address each stage the qwen shim + // against the workspace bundle. + expect(prepareQwenCliSteps).toHaveLength(3); for (const step of prepareQwenCliSteps) { expect(step).toContain( 'qwen_version="$(node -p "require(\'./package.json\').version")"', @@ -5223,6 +5230,9 @@ describe('qwen-autofix workflow', () => { 'exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@"', ); expect(step).toContain('qwen-bin'); + expect(step).toContain('chmod +x "${qwen_bin}/qwen"'); + expect(step).toContain('echo "${qwen_bin}" >> "${GITHUB_PATH}"'); + expect(step).toContain('qwen --version'); expect(step).not.toContain('current_version="$(qwen --version'); expect(step).not.toContain('Using pre-installed Qwen Code'); expect(step).not.toContain('npm install -g'); @@ -5279,6 +5289,8 @@ describe('qwen-autofix workflow', () => { }); it('retries dependency installation before building', () => { + // issue-autofix and build-cli build from sources; review-address restores + // the shared bundle instead (see 'builds the review CLI bundle once...'). expect(installAndBuildSteps).toHaveLength(2); for (const step of installAndBuildSteps) { expect(step).toContain('for attempt in 1 2 3; do'); @@ -5291,6 +5303,111 @@ describe('qwen-autofix workflow', () => { } }); + it('keeps the Node setup recipe identical across the autofix jobs', () => { + // The three setup steps are lockstep copies; a partial recipe edit (a + // Node bump applied to two of the three jobs) must not ship green. + expect(nodeSetupSteps).toHaveLength(3); + for (const step of nodeSetupSteps) { + expect(step).toContain( + 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e', + ); + expect(step).toContain("node-version: '22.x'"); + expect(step).toContain("cache: 'npm'"); + expect(step).toContain("cache-dependency-path: 'package-lock.json'"); + } + }); + + it('builds the review CLI bundle once and fans it out to the address legs', () => { + // Measured driver: 6 review-address legs each spent 3.5-5 minutes on + // npm ci + build + bundle of the SAME trusted base (~25 runner-minutes + // per scan) before the agent could start. The legs download the shared + // artifact instead; only their npm ci remains (the agent and the verify + // gate still need node_modules against the PR branch). + const buildCliJob = + workflow.match( + /\n {2}build-cli:[\s\S]*?(?=\n {2}review-address:)/, + )?.[0] ?? ''; + const addressJob = + workflow.match(/\n {2}review-address:[\s\S]*$/)?.[0] ?? ''; + const stepOf = (job, name) => + job.match( + new RegExp(`- name: '${name}'[\\s\\S]*?(?=\\n[ ]{6}- name: |$)`), + )?.[0] ?? ''; + expect(buildCliJob).toBeTruthy(); + expect(addressJob).toBeTruthy(); + + expect(buildCliJob).toContain("needs: ['route', 'review-scan']"); + expect(addressJob).toContain( + "needs: ['route', 'review-scan', 'build-cli']", + ); + // An idle tick (no review targets) must not spend a build; the issue + // phase only runs when there are none, so gating on do_issue too would + // rebuild on every quiet scheduled tick. + expect(buildCliJob).toContain( + "needs.review-scan.outputs.has_targets == 'true'", + ); + expect(buildCliJob).not.toContain('do_issue'); + + // The leg checks out the SHA the bundle was compiled from — a mid-run + // base push can never leave a leg running a bundle built from different + // sources than its checkout. + expect(buildCliJob).toContain( + "base_sha: '${{ steps.meta.outputs.base_sha }}'", + ); + expect(buildCliJob).toContain( + 'echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"', + ); + // The step id is the LINK between those two pins: without id: 'meta', + // steps.meta.outputs.base_sha resolves to '' at runtime and every leg + // silently checks out the event-default ref. + expect(stepOf(buildCliJob, 'Upload CLI bundle')).toContain("id: 'meta'"); + expect(stepOf(addressJob, 'Checkout trusted base')).toContain( + "ref: '${{ needs.build-cli.outputs.base_sha }}'", + ); + // The guard must fail the leg LOUD before the checkout: an empty ref + // makes actions/checkout fall back to the event default — on + // pull_request_review triggers the PR merge ref. + const validateShaStep = stepOf(addressJob, 'Validate bundle SHA'); + expect(validateShaStep).toContain( + "BASE_SHA: '${{ needs.build-cli.outputs.base_sha }}'", + ); + expect(validateShaStep).toContain( + 'if [[ ! "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then', + ); + expect(validateShaStep).toContain('exit 1'); + expect(addressJob.indexOf("- name: 'Validate bundle SHA'")).toBeLessThan( + addressJob.indexOf("- name: 'Checkout trusted base'"), + ); + + // The artifact is the repo-root dist/ only — copy_bundle_assets.js + // already gathers every runtime asset under it; packages/*/dist would + // triple the size and is rebuilt from branch sources by the verify gate. + expect(buildCliJob).toContain( + 'tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist', + ); + expect(buildCliJob).toContain("name: 'qwen-autofix-cli-dist'"); + expect(buildCliJob).toContain('retention-days: 1'); + expect(buildCliJob).toContain("if-no-files-found: 'error'"); + + const restoreStep = stepOf(addressJob, 'Restore CLI bundle'); + expect(restoreStep).toContain( + 'tar -xzf "${RUNNER_TEMP}/cli-dist/qwen-cli-dist.tar.gz"', + ); + expect(restoreStep).toContain('test -f dist/cli.js'); + const downloadStep = stepOf(addressJob, 'Download CLI bundle'); + expect(downloadStep).toContain("name: 'qwen-autofix-cli-dist'"); + // Download directory and restore extract path are one contract — pin + // both sides so a rename of either fails this suite. + expect(downloadStep).toContain("path: '${{ runner.temp }}/cli-dist'"); + + // The leg itself never rebuilds the base bundle — that is the entire + // point of the fan-out. + const legInstall = stepOf(addressJob, 'Install dependencies'); + expect(legInstall).toContain('npm ci --prefer-offline'); + expect(legInstall).not.toContain('npm run build'); + expect(legInstall).not.toContain('npm run bundle'); + }); + it('uses the standard checkout action for autonomous runner jobs', () => { expect(workflow).toContain('actions/checkout@'); expect(workflow).not.toContain('Checkout with retry');