diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index f1995fd957b..075930e0e90 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -578,13 +578,65 @@ jobs: id: 'ensure_qwen' run: |- set -euo pipefail + # Resolve latest and upgrade when stale — the previous shape + # short-circuited on any pre-installed qwen, so a stale image kept + # its version forever and pinned the action's reinstall to it (the + # same bug the review runner fixed in c9f0d0657). The install runs + # from RUNNER_TEMP because the persistent workspace still holds the + # previous run's checked-out tree, and npm reads a cwd .npmrc into a + # global install — same hazard the verify lane documents. Retries + # cover the transient npm crashes the shared runners produce (a + # triage run died on exit 217 mid-install); the fallback covers the + # registry being the thing that is broken. + want='' + for attempt in 1 2 3; do + want="$(npm view --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest' version | tail -n 1 || true)" + if [ -n "$want" ]; then break; fi + if [ "$attempt" -lt 3 ]; then sleep $((attempt * 5)); fi + done + have='' if command -v qwen >/dev/null 2>&1; then - echo "qwen already installed:" - else - npm install -g --loglevel=error --no-audit @qwen-code/qwen-code@latest + have="$(qwen --version 2>/dev/null || true)" fi - qwen --version - echo "version=$(qwen --version)" >> "${GITHUB_OUTPUT}" + if [ -n "$have" ] && [ "$have" = "$want" ]; then + echo "qwen $have (latest)" + echo "version=$have" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [ -z "$want" ]; then + if [ -n "$have" ]; then + echo "::warning::npm registry query failed; triaging with installed qwen $have" + echo "version=$have" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo '::error::qwen is not installed and the npm registry query failed' + exit 1 + fi + installed='' + for attempt in 1 2 3; do + if (cd "${RUNNER_TEMP:?}" && npm install -g --loglevel=error --no-audit --registry=https://registry.npmjs.org "@qwen-code/qwen-code@$want"); then + installed=1 + break + fi + echo "::warning::qwen install attempt $attempt failed (wanted $want)" + if [ "$attempt" -lt 3 ]; then sleep $((attempt * 10)); fi + done + if [ -z "$installed" ]; then + if [ -n "$have" ]; then + echo "::warning::upgrade to qwen $want failed after 3 attempts; triaging with installed qwen $have" + echo "version=$have" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "::error::qwen is not installed and installing $want failed after 3 attempts" + exit 1 + fi + hash -r + actual="$(qwen --version)" + echo "qwen $actual" + if [ "$actual" != "$want" ]; then + echo "::warning::wanted qwen $want but PATH resolves $actual — a system-wide install shadows the per-run upgrade" + fi + echo "version=$actual" >> "${GITHUB_OUTPUT}" - name: 'Run Qwen Triage' id: 'triage' @@ -1114,7 +1166,19 @@ jobs: # reads a cwd .npmrc — whose settings (script-shell, hooks) a # --registry flag does not override — into a root-privileged # install. Same fix as the verify lane. - (cd "${RUNNER_TEMP:?}" && npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest') + # Retried: the shared runners produce transient npm crashes (a + # triage install died on exit 217), and one flake must not kill the + # whole job when the next attempt succeeds. + installed='' + for attempt in 1 2 3; do + if (cd "${RUNNER_TEMP:?}" && npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest'); then + installed=1 + break + fi + echo "::warning::qwen install attempt $attempt failed" + if [ "$attempt" -lt 3 ]; then sleep $((attempt * 10)); fi + done + [ -n "$installed" ] || { echo '::error::installing @qwen-code/qwen-code@latest failed after 3 attempts'; exit 1; } qwen --version tmux -V @@ -2386,7 +2450,19 @@ jobs: # npm reads a cwd .npmrc — whose settings (script-shell, hooks) a # --registry flag does not override — into a root-privileged # install. - (cd "${RUNNER_TEMP:?}" && npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest') + # Retried: the shared runners produce transient npm crashes (a + # triage install died on exit 217), and one flake must not kill the + # whole job when the next attempt succeeds. + installed='' + for attempt in 1 2 3; do + if (cd "${RUNNER_TEMP:?}" && npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest'); then + installed=1 + break + fi + echo "::warning::qwen install attempt $attempt failed" + if [ "$attempt" -lt 3 ]; then sleep $((attempt * 10)); fi + done + [ -n "$installed" ] || { echo '::error::installing @qwen-code/qwen-code@latest failed after 3 attempts'; exit 1; } qwen --version # Chromium system dependencies (apt packages) for evidence diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index ea691f330c5..33535e2c5de 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -220,15 +220,48 @@ describe('qwen-triage tmux workflow', () => { }); it('pins the action reinstall to the version the job already runs', () => { + const ensure = step('Ensure qwen CLI'); expect(workflow).toContain("id: 'ensure_qwen'"); - expect(workflow).toContain( - 'echo "version=$(qwen --version)" >> "${GITHUB_OUTPUT}"', - ); + // Every exit-0 path of the step must emit the version output — the action + // pin reads it, and a path that forgets it re-pins the reinstall to an + // empty string (which the action treats as `latest`, resurrecting the + // stale-dist-tag bug the pin exists to prevent). Three success paths, + // four emission sites (fallback-to-installed is reached twice: registry + // down, install failed), plus fresh-install. + const emissions = ( + ensure.match(/echo "version=\$\{?\w+\}?" >> "\$\{GITHUB_OUTPUT\}"/g) ?? [] + ).length; + expect(emissions).toBeGreaterThanOrEqual(4); expect(workflow).toContain( "qwen_cli_version: '${{ steps.ensure_qwen.outputs.version }}'", ); }); + it('resolves latest, retries the install, and never trusts a cwd .npmrc', () => { + // The step used to short-circuit on any pre-installed qwen — the same bug + // the review runner fixed in c9f0d0657 — and its stale version output then + // pinned the action reinstall to a stale release. Pin the repaired shape: + // resolve-then-compare, a bounded retry loop, the RUNNER_TEMP cwd (npm + // reads a cwd .npmrc from the persistent workspace into a global install), + // and the registry-down fallback that still triages on the installed CLI. + const ensure = step('Ensure qwen CLI'); + expect(ensure).toContain("@qwen-code/qwen-code@latest' version"); + expect(ensure).toContain('for attempt in 1 2 3; do'); + expect(ensure).toContain('cd "${RUNNER_TEMP:?}"'); + expect(ensure).toContain('triaging with installed qwen'); + // The two hard-fail paths must keep failing the job — pin the exit code, + // not just the message: a softened `exit 0` would otherwise ship undetected. + // Anchor each message to its own `exit 1`; the step has two hard-fail + // sites, so an unanchored `toContain('exit 1')` stays green when either + // one is softened while the other still fails the job. + expect(ensure).toMatch( + /::error::qwen is not installed and the npm registry query failed'\n\s*exit 1/, + ); + expect(ensure).toMatch( + /::error::qwen is not installed and installing \$want failed after 3 attempts"\n\s*exit 1/, + ); + }); + it('passes triage output through env before bash reads it', () => { const checkStep = step('Check triage response'); @@ -565,6 +598,8 @@ describe('qwen-triage tmux workflow', () => { ); expect(installStep).toContain('qwen --version'); expect(installStep).toContain('tmux -V'); + expect(installStep).toContain('for attempt in 1 2 3; do'); + expect(installStep).toContain('failed after 3 attempts'); expect(resolverStep).not.toContain('tmux'); expect(resolverStep).not.toContain('npm install'); expect(resolverStep).not.toContain('qwen --version'); @@ -3979,6 +4014,8 @@ describe('qwen-triage verify round-3 hardening', () => { // No hardcoded Playwright pin here either: the apt list must track // current Playwright so it covers the lockfile-matched binary below. expect(tools).not.toMatch(/playwright@[\d.]/); + expect(tools).toContain('for attempt in 1 2 3; do'); + expect(tools).toContain('failed after 3 attempts'); // Browser binary: downloaded after npm ci, and by the CLI of the // package the capture harness actually imports — never a hardcoded pin