Files
qwen-code/.github/workflows/ci.yml
T
易良andQwen-Coder 64dd058c9c ci(pnpm): install with pnpm everywhere and retire package-lock.json (#11859)
* ci(pnpm): install dependencies with pnpm in CI and release

CI tests the graph release ships only if both install it the same way, so
this moves both, keeping every `npm run` script and `npm publish` as they are.

- ci.yml (7), release.yml (3) and release-vscode-companion.yml (2) install
  with `corepack pnpm install --frozen-lockfile`; release keeps
  --ignore-scripts plus its explicit postinstall and generate steps.
- setup-node drops the npm cache and sets package-manager-cache: false,
  which setup-node v5+ needs once package.json declares a pnpm
  packageManager.
- A composite action caches the pnpm store on the three always-hosted jobs;
  the ECS pool keeps its store on local disk.
- version.js no longer lets npm reify node_modules: --no-workspaces-update on
  both `npm version` calls, and a package-lock-only refresh in step 9.
- The notices generator, prepare-package's sharp pin and the standalone
  release's native package specs read the installed tree or pnpm-lock.yaml
  instead of package-lock.json keys.
- New size baselines for the three workflows, which were already over them.

* chore(vscode): regenerate NOTICES from the pnpm-installed tree

Generated in CI (pnpm Worktree Smoke on ubuntu-latest) by the ported
generator. Same entry set as before except the five packages npm and pnpm
resolve differently: fdir 6.5.0, picomatch 4.0.5, mdurl 2.1.0,
@types/node 22.20.1 and mime-db 1.52.0 (form-data's pinned copy). No entry
lacks a license text or repository.

* fix(pnpm): resolve undeclared zod peers to the repo's zod, as npm does

The first CI run on this branch failed the export renderer's size budget:
1,930,015 bytes against 1,930,000, where the same commit built on npm's
tree comes to 1,858,186. Comparing both trees' esbuild metafiles and
prebuilt bundles put the whole difference in web-shell's transcript.js
(+100,903 bytes pre-minify), and the added code is zod 4 plus the
@modelcontextprotocol/ext-apps schemas; the sdk daemon bundle is identical.

web-shell bundles ext-apps, whose zod and MCP SDK are peers, and declares
no zod itself, so pnpm filled the peer with the highest locked zod (4.4.3)
while npm serves the hoisted 3.25.76. Declaring zod 3.25.76 as a root
devDependency makes pnpm resolve every such peer to it
(resolve-peers-from-workspace-root is on by default), which is npm's
hoisting behaviour. mobile-mcp keeps its own zod 4.4.3, as under npm; the
npm tree does not change.

* fix(pnpm): keep npm's root @types/node so root-level typechecks match

The no-AK integration gate typechecks integration-tests/, which takes
Node's types from the root node_modules. npm hoists @types/node 20.19.1
there, because acp-bridge and core pin it; pnpm, with no root declaration,
hoisted 22.20.1, whose spawnSync overloads type process-registry.ts's
stdout as string | NonSharedBuffer and fail TS2322. The same code passes on
every npm-installed PR.

Declaring 20.19.1 as a root devDependency puts npm's copy back where a
root-level tsconfig looks. The npm tree does not change.

* ci(pnpm): install with pnpm in the remaining workflows

The follow-up to switching CI and release: every other workflow that
installs the root workspace now uses the pinned pnpm with
--frozen-lockfile, so no job tests a graph that release does not ship.

- e2e, tui-parity, repo-hygiene, web-shell-visuals, serve-ab, sdk-java,
  windows-runner-smoke, qwen-autofix, the SDK releases, finalize-release,
  sync-release-to-oss, the root install in desktop-release and
  cd-cua-driver, and the sandbox Dockerfile.
- setup-node drops the root npm cache (package-manager-cache: false); hosted
  jobs restore the pnpm store through the shared composite action, and
  qwen-autofix keeps af-012's rule that the persistent pool restores no
  remote cache.
- serve-ab and web-shell-visuals install a separate base checkout that can
  predate pnpm-lock.yaml, so those installs fall back to npm ci there.
- Out of scope, unchanged: packages with their own lockfile (cua-driver's
  TypeScript SDK, desktop-shell, live-host, mobile-mcp), and the triage
  lanes with npm-cache.yml and the npm audit gate, which move with the
  package-lock.json retirement.
- New size baselines for the touched workflows; qwen-autofix stays under
  the 470000-byte gate.

* fix(pnpm): link the workspace packages that root-level code imports

npm links every workspace package into the root node_modules, so
integration-tests/ and scripts/, which are not workspace packages
themselves, can import @qwen-code/* by name. pnpm links a workspace
package only where it is a declared dependency, and the root declared
none, so the no-AK integration gate could not find
@qwen-code/qwen-code-core.

The root now declares the seven packages that root-level code imports at
runtime as file: devDependencies: acp-bridge, channel-base, the CLI,
core, sdk, web-shell and web-templates. npm already links them, so its
tree does not change (letting npm re-resolve the lock reproduces the
same root entry); .pnpmfile.mjs rewrites them to workspace:*, which pnpm
links at the root.

* ci(pnpm): move triage, the store producer and the root audit to pnpm

- qwen-triage's verify and tmux-testing lanes install the PR with
  `corepack pnpm install --frozen-lockfile` from a restored pnpm store.
  The one retry clears node_modules first, because pnpm install, unlike
  npm ci, keeps it.
- npm-cache.yml becomes pnpm-store.yml, keyed on pnpm-lock.yaml.
- security-checks audits the root workspace with `pnpm audit`, which reads
  the lockfile, so the root install step goes. The vendored lockfiles keep
  npm, and the retry recognises pnpm's endpoint error too.
- qwen-autofix drops the hosted-only store cache: its heavy jobs forbid
  local `uses: './...'` actions, and the file sits at the size gate.
- cd-cua-driver: setup-node@v4 has no package-manager-cache input.
- package-assets fixtures install sharp rather than listing it in
  package-lock.json, matching how prepare-package now resolves it.
- AGENTS.md, CONTRIBUTING.md and the npm workspace guide install with pnpm.

* feat(review): install pnpm repos in qwen review's build/test

qwen review installed dependencies only for a repo with package-lock.json
and reported a pnpm repo as unsupported, so reviews of qwen-code would
lose their build and test once its npm lockfile goes.

- lockfileInstaller() picks npm or pnpm from the committed lockfile. With
  both, `packageManager: pnpm@...` chooses pnpm; anything else keeps npm.
- A pnpm repo installs with `corepack pnpm install --frozen-lockfile`
  through the same disk, budget and sandbox path as `npm ci`, and counts
  as installed once node_modules/.modules.yaml exists.
- Scripts still run through `npm run`, and the npm path is unchanged.

* ci(pnpm): retire the root package-lock.json

pnpm-lock.yaml is now the only root lockfile.

- check-lockfile keeps the pnpm integrity check and Playwright parity,
  which now reads pnpm importers and snapshots. The npm-vs-pnpm agreement
  and build-approval checks go: pnpm 11's strictDepBuilds and every CI
  install's --frozen-lockfile enforce what they covered.
- pnpm-lock-freshness.yml goes; it regenerated pnpm-lock.yaml from
  package-lock.json.
- Release: version.js drops its package-lock refresh, the release commit
  stages pnpm-lock.yaml and the SDK release stages no lockfile, so neither
  `git add` fails on the missing file. `npm version` in the SDK and
  mobile-mcp releases passes --no-workspaces-update so it cannot reinstall
  the root with npm.
- cd-mobile-mcp installs from the root pnpm lockfile. setup-node in
  cd-mobile-mcp, qwen-code-pr-review and desktop-release no longer keys an
  npm cache on the root lockfile.
- check-desktop-isolation reads pnpm importers; build.js, build_sandbox.js
  and preflight install with pnpm.
- pnpm-lock.yaml, pnpm-workspace.yaml and .pnpmfile.mjs join the autofix
  supply-chain class and repo-hygiene's deny list; pnpm-lock.yaml joins
  autofix's generated-diff exclusions, sdk-java's path filter and the
  platform-sensitivity manifests.
- .gitignore lists /package-lock.json so an npm install cannot commit it
  back. Tests and docs follow.

* fix(standalone): read the node-pty versions from the pnpm lockfile

* chore(vscode-ide-companion): regenerate NOTICES.txt after the lockfile swap

* chore(vscode-ide-companion): regenerate NOTICES.txt from the pnpm dependency tree

The committed NOTICES.txt was generated from an npm install and does not
match the tree produced by the pnpm install the release pipeline now uses
(659 resolved dependencies, differing @types/node and mime-db versions).
Regenerating with the pinned pnpm install makes the freshness check
reproducible.

* fix(ci): drop the npm cache from the daily dependency audit

actions/setup-node's npm cache keys off the root package-lock.json, which this branch retires, so the scheduled job would fail at Set up Node before the audit ran. No pull_request run reaches this workflow to catch it. The root tree is never installed here either: pnpm audit reads pnpm-lock.yaml directly.

Matches the sibling workflows that already set package-manager-cache: false; scripts/tests/security-workflows.test.js now pins it.

* chore(ci): stop banking main-side drift in the workflow size ratchet

The ratchet exists to turn a workflow's growth into one reviewed line. This branch re-recorded entries for 17 workflows it never touches, several upward by 0.4-2.8 KB - drift that happened on main, banked into the manifest without review.

Restore those to their merge-base values, so .size-baseline changes only for the 22 workflows this branch actually edits. The entries were not needed to go green: ci.yml passes the PR's base commit, so a file that drifted on main and is otherwise untouched earns a warning, not a failure.

* fix(release): initialize standalone state before main

* test(browser-use): read Playwright pin from pnpm lock

* fix(ci): address pnpm migration review blockers

* fix(desktop): stop reading the retired root lockfile

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-20 02:11:25 +00:00

2341 lines
135 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# .github/workflows/ci.yml
name: 'Qwen Code CI'
on:
# This trigger was removed on the premise that the merge queue validates the
# merged tree before it lands. No queue is enabled (none since 2026-07-02),
# so nothing validated that tree on either side of a merge: `main` had no
# gate before one and nothing faster than the ~40-minute E2E after one.
# `test`, `lint_and_static`, and the `classify_pr` they depend on accept
# it (see their `if`s); every other job excludes push, so a post-merge run
# is exactly those three. The no-AK integration gate is its own
# `integration_no_ak` job on pull_request / merge_group, so this lane is
# lint, static analysis and unit tests. Removing it once the queue is
# enabled means dropping the trigger AND the push arm from ALL THREE
# `if`s: a partial removal leaves every merge running the survivors alone,
# publishing outputs nothing consumes.
push:
branches:
- 'main'
pull_request:
branches:
- 'main'
- 'release/**'
- 'omni-experiment'
merge_group:
# The macOS and Windows lanes' ONLY remaining trigger, and therefore this
# repository's only signal about a host that is not Linux with a GNU
# userland. Those two are otherwise gated on `merge_group`, and the merge
# queue is not enabled here — no queue run since 2026-07-02 — while their
# pull-request trigger is off until the standing Windows failures are fixed
# (see test_macos). A regression therefore surfaces here, one day later, on
# `main`, and nowhere else: treat a red nightly as a blocker, not as noise.
# Every other job here excludes `schedule` explicitly, so a nightly run is
# exactly two jobs.
schedule:
- cron: '17 19 * * *'
workflow_dispatch:
inputs:
branch_ref:
description: 'Branch to run on'
required: true
default: 'main'
type: 'string'
linux_runner:
description: 'Linux runner to use for manual validation'
required: true
default: 'self-hosted'
type: 'choice'
options:
- 'self-hosted'
- 'hosted'
concurrency:
# Event + source repo + ref, not ref alone. Without the event the group
# collapses onto `Qwen Code CI-refs/heads/main`, shared with the nightly
# (60-minute lanes, neither cancelling on `main`). Without the source repo,
# same-named branches across forks collide — they evaluate in the base
# repo's namespace and PR runs DO cancel in progress, so a fork whose
# default branch is `main` cancels a victim PR's run on every push.
# Supersede within a PR is kept: one branch backs one open PR per fork.
group: '${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: |-
${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}
permissions:
checks: 'write'
contents: 'read'
statuses: 'write'
defaults:
run:
shell: 'bash'
env:
ACTIONLINT_VERSION: '1.7.12'
SHELLCHECK_VERSION: '0.11.0'
YAMLLINT_VERSION: '1.35.1'
# Single source of truth for the .github/scripts node:test suites — run by
# BOTH helper steps in `lint_and_static` (the github_ci_only fast lane and
# the full-profile one), so a new helper test can't be added to one path
# and silently dropped from the other. The fast lane runs before ANY
# dependency install in that job — its setup-node, self-hosted node and
# `npm ci` steps are all gated on the full profile — so it takes only
# HELPER_TESTS_DEP_FREE, suites whose import closure is node: builtins
# alone. HELPER_TESTS keeps the full list: the full-profile helper step
# re-runs every suite after `npm ci`, and a push to main always classifies
# `full` (classify_pr computes a profile only for pull_request), so what
# the fast lane skips is still re-checked post-merge.
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.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 .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs'
# The growth ratchet and its vitest mirror compare each workflow against
# the PR's base commit to tell "this PR grew the file" apart from "the
# baseline went stale on main" (#9904). Wired once here so every lane
# inherits it — the gate step and every `npm run test:ci` step, whatever
# it is named — instead of each step hand-wiring a copy.
# `github.event.before` is the push lane's base: on a squash-merge push it is
# main's previous tip, restoring the PR lane's leniency for a baseline that
# went stale on main. Without it both enforcers see an empty base and fail
# closed, so the next stale baseline reddens every merge while every PR stays
# green — telling you to "update .size-baseline in this PR" when no PR
# exists. `github.sha` is deliberately not used: on workflow_dispatch it
# resolves to the checked-out commit and would compare the file against
# itself, failing open. An all-zeros `before` still fails closed.
WORKFLOW_SIZE_BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}'
jobs:
classify_pr:
name: 'Classify PR'
if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}"
# Gate runs on ECS for in-repo PRs, fork PRs whose author has write access
# (OWNER/MEMBER/COLLABORATOR association — a write-access author is as
# trusted as an in-repo branch), and the merge queue (base-repo context),
# else a busy hosted pool delays it and blocks the ECS-bound jobs. The
# kill-switch is read here, so flipping it reverts everything to hosted.
# This runs-on and the pick_runner step below are the canonical home of
# the association routing; sdk-java.yml and serve-ab.yml mirror it (the
# routing tests hold the mirrors to it). test_windows deliberately does
# not: a pull_request run executes the PR's own YAML, so no runs-on trust
# clause is enforceable there and its lane never admits pull requests to
# the pool.
runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
continue-on-error: true
permissions:
contents: 'read'
pull-requests: 'read'
outputs:
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
steps:
- name: 'Detect release version-sync PR'
id: 'release_sync'
env:
# Repository variables can override these defaults if release naming
# or the CI bot account changes.
HEAD_REPO: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || '' }}"
HEAD_REF: "${{ github.event_name == 'pull_request' && github.head_ref || '' }}"
PR_TITLE: "${{ github.event_name == 'pull_request' && github.event.pull_request.title || '' }}"
RELEASE_SYNC_HEAD_PREFIX: "${{ vars.RELEASE_SYNC_HEAD_PREFIX || 'release/' }}"
RELEASE_SYNC_TITLE_PREFIX: "${{ vars.RELEASE_SYNC_TITLE_PREFIX || 'chore(release):' }}"
RELEASE_SYNC_ACTOR: "${{ vars.RELEASE_SYNC_ACTOR || 'qwen-code-ci-bot' }}"
run: |-
skip_ci=false
repo_match=false
actor_match=false
head_match=false
title_match=false
[[ "${HEAD_REPO}" == "${GITHUB_REPOSITORY}" ]] && repo_match=true
[[ "${GITHUB_ACTOR}" == "${RELEASE_SYNC_ACTOR}" ]] && actor_match=true
[[ "${HEAD_REF}" == "${RELEASE_SYNC_HEAD_PREFIX}"* ]] && head_match=true
[[ "${PR_TITLE}" == "${RELEASE_SYNC_TITLE_PREFIX}"* ]] && title_match=true
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" &&
"${repo_match}" == "true" &&
"${actor_match}" == "true" &&
"${head_match}" == "true" &&
"${title_match}" == "true" ]]; then
skip_ci=true
echo "Release sync PR detected: actor=${GITHUB_ACTOR}, head_ref=${HEAD_REF}, title=${PR_TITLE}"
else
echo "Not a release sync PR: event=${GITHUB_EVENT_NAME}, actor=${GITHUB_ACTOR}, expected_actor=${RELEASE_SYNC_ACTOR}, repo_match=${repo_match}, head_match=${head_match}, title_match=${title_match}"
fi
echo "skip_ci=${skip_ci}" >> "${GITHUB_OUTPUT}"
echo "skip_ci=${skip_ci}"
# In-repo PRs, fork PRs whose author has write access
# (OWNER/MEMBER/COLLABORATOR association), the merge queue (base-repo
# context) and a push to `main` run the Linux jobs on ECS; other fork
# PRs stay hosted. Disable via repo var
# MAINTAINER_ECS_RUNNER_DISABLED=true.
#
# push is deliberately absent from the `runs-on` expression above, so a
# post-merge run leaves classify_pr hosted while the Test job it feeds
# goes to ECS. That is intentional: the expression is the canonical
# association-routing text sdk-java.yml and serve-ab.yml mirror, a push
# has no author association to route on, and classify_pr costs seconds —
# while Test should not spend a scarce hosted runner on every merge.
- name: 'Select Linux runner'
id: 'pick_runner'
env:
SAME_REPO: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
AUTHOR_ASSOCIATION: '${{ github.event.pull_request.author_association }}'
ECS_DISABLED: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED }}'
EVENT_NAME: '${{ github.event_name }}'
DISPATCH_LINUX_RUNNER: '${{ github.event.inputs.linux_runner }}'
run: |-
ubuntu_runner='["ubuntu-latest"]'
trusted_author=false
case "${AUTHOR_ASSOCIATION}" in
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
esac
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
fi
elif [[ "${ECS_DISABLED}" != "true" && ( "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ) ]]; then
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
fi
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected Linux runner: ${ubuntu_runner}"
# The product jobs later check out the PR head. Classify before that
# from an explicit base-SHA checkout so no fork can replace the
# classifier implementation it asks CI to execute. The event token only
# needs PR read access.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# actions/checkout reuses a leftover directory whose origin URL
# matches and runs git (hooks included) inside it, so the trusted
# classifier checkout below must not inherit a residue from an
# earlier run on the shared pool; the sweep removes it like the
# .qwen names (see the test job's sweep comment for the rule).
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Verify trusted classifier checkout is clean'
if: "${{ github.event_name == 'pull_request' }}"
run: |-
if [ -e "$GITHUB_WORKSPACE/trusted-ci-classifier" ] ||
[ -L "$GITHUB_WORKSPACE/trusted-ci-classifier" ]; then
echo "::error::trusted-ci-classifier survived cleanup; refusing to reuse it"
exit 1
fi
- name: 'Checkout trusted CI classifier'
if: "${{ github.event_name == 'pull_request' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
repository: '${{ github.repository }}'
ref: '${{ github.event.pull_request.base.sha }}'
path: 'trusted-ci-classifier'
sparse-checkout: '.github/scripts/ci'
fetch-depth: 1
persist-credentials: false
- name: 'Classify CI profile from trusted base'
id: 'ci_profile'
env:
GH_TOKEN: '${{ github.token }}'
PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}"
run: |-
profile=full
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then
set +e
profile="$(trusted-ci-classifier/.github/scripts/ci/classify-pr-profile.sh "${GITHUB_REPOSITORY}" "${PR_NUMBER}")"
classify_rc=$?
set -e
if [ "$classify_rc" -eq 2 ]; then
echo "::warning::Unable to list PR changed files; running full CI."
profile=full
elif [ "$classify_rc" -ne 0 ]; then
echo "::error::Trusted CI profile classifier exited non-zero; running full CI."
profile=full
fi
fi
case "${profile}" in
docs_only|github_ci_only|full) ;;
*)
echo "::error::Trusted CI profile classifier returned '${profile}'; running full CI."
profile=full
;;
esac
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
echo "Selected trusted CI profile: ${profile}"
#
# Test: Node
#
test:
name: 'Test (ubuntu-latest, Node 22.x)'
needs: 'classify_pr'
# Stay running on release-sync PRs so the required Test contexts still
# report; the per-step skip_ci guards below make them no-op (pass) there.
# Accepts push on `main`: one of the two post-merge checks faster than the
# ~40-minute E2E — the lint/static half lives in `lint_and_static` — and
# with no merge queue enabled nothing validates the merged tree before it
# lands either. The expensive and PR-shaped steps stay off it on their own
# gates, each by its own mechanism — the checkout-head verifier is
# restricted to pull_request / merge_group, while the test reporter
# carries no event gate at all: its same-repo clause (head.repo.full_name
# == github.repository) is empty on push. Do not "normalise" that clause
# into the event allowlist — it also keeps fork PRs out, and
# dorny/test-reporter needs `checks: write`, which fork-event runs do not
# have. So a push run is lint, static analysis and unit tests —
# lint/static in `lint_and_static`, unit tests here.
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Shared ECS hosts can stretch the normally 25-minute lane beyond an hour
# while tests are still making progress. Keep a finite ceiling without
# turning host contention into a false cancellation. The extension is
# scoped to the pool that shows the contention: fork PRs from untrusted
# authors and MAINTAINER_ECS_RUNNER_DISABLED=true fall back to
# GitHub-hosted runners (see classify_pr), which keep the pre-contention
# ceiling so a genuine hang there does not burn the extra 60 minutes.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''ecs-qwen'') && ''120'' || ''60'') }}'
outputs:
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
permissions:
contents: 'read'
checks: 'write'
pull-requests: 'write'
steps:
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
# verify) may leave root-owned, read-only files anywhere in the workspace.
# Restore ownership and write permission unconditionally so the checkout
# below can wipe the tree without EACCES. Do not gate this behind a
# .qwen/.git probe: poisoning is workspace-wide (root-owned node_modules/
# dist with no .qwen/.git), so a probe reports "healthy" and skips the
# recovery exactly when it is needed.
- name: 'Restore workspace ownership'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Remove .qwen/ and its recovery backup left by a previous job so
# actions/checkout doesn't trip on restrictive permissions.
# `.qwen.root-orig` is emitted by recovery tooling OUTSIDE this repo —
# nothing here produces it (git grep matches only these sweep copies
# and their pins in scripts/tests/review-worktree-cleanup-workflow.
# test.js). It is the backup name a cancelled verify's recovery leaves
# after renaming an unreadable, root-owned `.qwen` aside (observed on
# the shared pool; first recorded around review run 33146730771). If
# that producer's naming changes or a third residue name appears,
# update the for-loop list in every sweep copy or the checkout
# poisoning this sweep exists for silently recurs.
# `trusted-ci-classifier` is the third residue name: classify_pr's
# base-SHA checkout path. actions/checkout reuses a leftover copy
# whose origin URL matches and runs git (hooks included) inside it,
# so it is swept rather than warmed.
- name: 'Clean stale .qwen before checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
# On PRs, check out refs/pull/N/head (the immutable PR head, published the
# instant the branch is pushed) instead of github.ref. github.ref is the
# merge ref (refs/pull/N/merge), which GitHub rebuilds asynchronously and
# can serve stale for minutes after a push, repeatedly flaking this gate.
# Merge queue refs are ephemeral; check out the event head SHA directly so
# slow hosted runners do not fail after the queue branch is removed.
# Non-PR/non-queue events keep github.ref.
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# Pin push runs to the event SHA: the `github.ref` fallback resolves
# `refs/heads/main` at fetch time — a moving tip — while the check run
# attaches to the triggering commit, so the lane would validate a tree
# it does not report on. Nothing detects that (the checkout-head
# verifier is gated to pull_request / merge_group, and its ancestor
# check passes for a newer tip regardless), and merges land close
# enough together to hit it routinely. Regressions are still caught —
# the tested tree is a descendant — but attribution breaks: red on a
# clean commit, autofix filed against the wrong merge. `e2e.yml` pins
# the event SHA by taking actions/checkout's default.
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}"
# Shallow: nothing here walks git history except on demand (the
# verify guard below checks head.sha == HEAD; the size gate and its
# vitest mirror fetch the PR's base commit at depth 1 when the
# baseline went stale; everything else touches only the working
# tree). On the in-repo ECS runner a full-history clone is the
# heaviest transfer and chokes the squid egress proxy, flaking
# checkout. depth 1 is enough.
fetch-depth: 1
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
# ref) silently testing the wrong tree. Cheap: one merge-base, sub-second.
# Also runs in the merge queue — now that the queue's Ubuntu checkout is on
# ECS/squid, a wrong-tree pass would merge bad code.
- name: 'Verify checkout includes expected head commit'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
- name: 'Use trusted CI profile'
id: 'ci_profile'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
run: |-
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
fi
profile="${TRUSTED_CI_PROFILE:-full}"
case "${profile}" in
docs_only|github_ci_only|full) ;;
*) profile=full ;;
esac
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."'
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
# through the egress proxy; reuse the machine's Node instead.
- name: 'Set up Node.js 22.x (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'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- name: 'Configure persistent npm cache (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}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
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
npm config set fetch-retries 5
npm config set fetch-timeout 300000
# Fail fast on a saturated self-hosted host instead of dying on ENOSPC
# mid-install (#10035): the workspace cleaner skips busy runners, so a
# busy fleet can hit 100% while jobs keep being admitted. Gating before
# `npm ci` turns that into a clear, retryable reschedule onto a host
# with headroom, instead of a corrupted half-run.
- name: 'Disk floor gate (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
sample_disk() {
hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true)
sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true
echo "$sample"
echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true
}
sample_disk
( while sleep 10; do sample_disk; done ) &
SAMPLER_PID=$!
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (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
# The install-script packaging suite needs zip/unzip, and throws on a
# CI host that ships neither, so a silent skip there is impossible.
# tmux is PRE-LANDED for #8388: that PR's capture-tui carries a
# describe.skipIf(!hasTmux)-gated real-tmux suite which, on a runner
# without tmux (the hosted ubuntu-latest image ships none), would
# silently skip every real-tmux behaviour — holder survival, matching,
# server reaping, refusal contracts — inside a green required check.
# Until it lands, the tmux half installs a tool nothing here uses;
# that is deliberate, so the tooling is in place before the suite that
# depends on it (qwen-autofix.yml already installs tmux the same way).
- name: 'Install tmux and zip tooling'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.os == 'Linux' }}"
# Bounded AND advisory: a stalled mirror or dpkg lock must neither
# hang the job toward its 120-minute cap nor red the required check —
# continue-on-error absorbs the step-level timeout. The apt calls
# carry their OWN shorter bound (140 s + 140 s = 280 s < the 300 s
# step cap) so the guard below still runs: when the step-level
# timeout fires there is no `|| echo`, and the lane loses its
# tooling with no annotation to say so.
timeout-minutes: 5
continue-on-error: true
run: |-
if command -v tmux > /dev/null 2>&1 && command -v zip > /dev/null 2>&1 && command -v unzip > /dev/null 2>&1; then
# Advisory here too: a broken-but-installed tmux (dangling
# symlink, missing lib) must not fail the required Test check
# before a single test has run.
tmux -V || echo '::warning::tmux present but not answering; real-tmux capture tests will be skipped.'
zip -v > /dev/null 2>&1 || echo '::warning::zip present but not answering; the zip-packaging suite will throw on CI.'
unzip -v > /dev/null 2>&1 || echo '::warning::unzip present but not answering; the zip-packaging suite will throw on CI.'
elif [ "$(id -u)" = '0' ] && command -v apt-get > /dev/null 2>&1; then
# Root-container lanes have no sudo (and need none): apt-get
# runs as-is. Advisory, not a gate: an apt hiccup must not red
# this step before a single test has run — the suites carry
# their own signal (the real-tmux suite skips; the zip suite
# throws on CI). ::warning:: (not a plain echo) so a lane
# where the install PERMANENTLY fails shows the loss in the
# check UI instead of hiding it in a multi-thousand-line log.
timeout 140 apt-get update -qq && timeout 140 apt-get install -y -qq --no-install-recommends tmux zip unzip \
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
elif sudo -n true > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
# Same advisory contract as the root branch; sudo -n here so a
# runner without passwordless sudo fails fast instead of
# hanging on a password prompt toward the step's bound.
timeout 140 sudo -n apt-get update -qq && timeout 140 sudo -n apt-get install -y -qq --no-install-recommends tmux zip unzip \
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
else
echo '::warning::tmux/zip unavailable; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
fi
- name: 'Run tests and generate reports'
id: 'unit_tests'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
# A hung suite used to burn the whole job budget: #10490's run 3 was
# cancelled at ~60 minutes with the runner terminating orphaned test
# processes, which reads as a timeout rather than a failure.
#
# The cap has to clear the slowest honest run, not the healthy one.
# ~32 minutes was measured at six Vitest forks; the pool now runs
# three, which roughly doubles the test phase on an idle host, and a
# failing run adds its retries on top. 110 leaves room for that and
# still fits the job's 120-minute budget, which this step enters at
# around minute 18.
timeout-minutes: 110
env:
NO_COLOR: true
# Every attempt of a contended shard is a fresh roll: the same
# commit run three times on this fleet failed three disjoint test
# sets (#10490), and a shard of identical work measures 6.7min or
# 36min depending only on which host it lands on. A retry lets a
# contention flake pass; a real break still fails all three
# attempts. Operators can retune or disable ('off') without a PR.
VITEST_RETRY: "${{ vars.QWEN_CI_VITEST_RETRY || '2' }}"
# Coverage is collected only on the post-merge main run: nothing
# read the pull-request reports, and v8 instrumentation cost about
# a fifth of the suite's wall time on every push. The vitest configs
# of cli, core, web-shell and vscode-ide-companion read this switch.
QWEN_CI_COVERAGE: "${{ github.event_name == 'push' && '1' || '' }}"
HOME: '${{ runner.temp }}/qwen-ci-home'
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
# Bound each Vitest process on the shared ECS host. Operators can
# tune the maximum without changing this workflow.
VITEST_MAX_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
VITEST_MIN_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
VITEST_MAX_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
VITEST_MIN_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
OPENAI_API_KEY: ''
DASHSCOPE_API_KEY: ''
QWEN_API_KEY: ''
GEMINI_API_KEY: ''
QWEN_DEFAULT_AUTH_TYPE: ''
# A millisecond budget written on a developer machine measures the
# code; on the shared pool it measures the neighbours, where the same
# work runs 5x apart depending on placement (#10490). Skip those
# assertions here — they still run locally and on the hosted lanes.
QWEN_SKIP_LATENCY_BUDGETS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
run: |-
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
# ENOSPC has failed test steps mid-suite while the host looks
# healthy afterwards — a transient spike, likely /tmp inodes or a
# tmpfs cap. Sample the routed temp filesystem every 10s so the
# failing run captures the spike, and dump full state on failure.
# Keep Linux temp paths real and short on disk-backed /var/tmp.
# Symlink aliases break tests that intentionally compare real paths.
# The sample-file setup sits BEFORE the TMPDIR routing block (#10035)
# because no-ak-integration-ci pins that block byte-identical across
# the test/test_macos/test_windows legs; each DFSAMPLE line already
# carries the routed tmpdir, so the header only needs job/runner ids.
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
if [ ! -s "$DISK_SAMPLES" ]; then
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
fi
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
if [ "${RUNNER_OS:-}" = "Linux" ]; then
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
if [ -n "$QWEN_CI_TMPDIR" ]; then
TMPDIR="$QWEN_CI_TMPDIR"
export TMPDIR
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
fi
fi
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]"; echo "$sample"; echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true; sleep 10; done ) &
SAMPLER_PID=$!
retry_arg=()
if [ -n "${VITEST_RETRY:-}" ] && [ "${VITEST_RETRY}" != 'off' ]; then
retry_arg=(--retry="${VITEST_RETRY}")
fi
set +e
npm run test:ci:workspaces -- "${retry_arg[@]}"
RC=$?
if [ "$RC" -eq 0 ]; then
npm run test:scripts -- "${retry_arg[@]}"
RC=$?
fi
set -e
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
kill "$SAMPLER_PID" 2>/dev/null || true
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
exit "$RC"
# The host looks healthy after cleanup, so persist the disk-pressure
# timeline from a failed run (#10035): the artifact keeps the samples
# around long enough to correlate them with this job and runner.
- name: 'Upload disk-pressure samples'
if: '${{ failure() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'disk-pressure-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}'
path: '${{ runner.temp }}/disk-pressure-samples.log'
if-no-files-found: 'ignore'
retention-days: 14
# Release guard for the Chrome extension: build, package, and scan real
# artifacts for forbidden adapter signatures instead of leaving
# `scan:artifacts` manual-only. Linux leg only — packaging shells out to
# the POSIX `zip`; skip (not fail) where it is missing.
- name: 'Package and scan Chrome extension artifacts'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
if ! command -v zip >/dev/null 2>&1; then
echo "::warning::zip is not installed; skipping the Chrome extension artifact scan"
exit 0
fi
npm -w packages/chrome-extension run package
npm -w packages/chrome-extension run scan:artifacts
- name: 'Publish Test Report (for non-forks)'
if: |-
${{ 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)'
path: 'packages/*/junit.xml'
reporter: 'java-junit'
fail-on-error: 'false'
- name: 'Upload Test Results Artifact (for forks)'
if: |-
${{ 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'
# Push only: that is the one lane where QWEN_CI_COVERAGE turns
# collection on, so a pull-request run has nothing to upload.
- name: 'Upload coverage reports'
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name == 'push' }}"
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'coverage-reports-22.x-ubuntu-latest'
path: 'packages/*/coverage'
#
# Lint & Static: the checks whose verdict depends only on the tree.
#
lint_and_static:
name: 'Lint & Static (ubuntu-latest, Node 22.x)'
needs: 'classify_pr'
# Split out of `test` so this half can become a required status check on
# its own. The property that justifies that role is deterministic
# OUTCOME — no vitest unit-test run, whose flake was measured failing
# 41% of post-merge runs in the week of the split — not lightness: the
# bundle-closure check builds and bundles the CLI, the helper-suite
# steps execute the 26-file HELPER_TESTS battery (most spawn
# subprocesses), and the lane runs on the same contended pool as
# everything else, which is what the routed 90/45 ceiling below prices.
# One honest limit, recorded so the required-check follow-up is argued
# on true premises: the incidents that motivated the split all failed in
# `Run tests and generate reports`, which stayed in `test` — this lane
# would have been green on those merges. What it gates is the class a
# required check can hold without flake hostages, not that class.
# One red gate must not hold the other's hostages.
# Same event surface as `test` (see its `if` for the reasoning), and the
# same release-sync convention: stay running on skip_ci PRs so a required
# context still reports, with the per-step guards making it a no-op pass.
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Routed like test's, not a bare constant: this lane was beheaded twice
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
# (install 892s vs the sibling Test job's 347s) and again at 45 (install
# 940s, bundle closure 681s vs a warm 171s, every step 2–4x). The pool's
# contention is why test runs 120/60 and no_ak 60/30; a flat 45 priced
# only the warm half of that reality. Hosted runners keep the tighter
# ceiling so a genuine hang there does not burn the ECS allowance.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''ecs-qwen'') && ''90'' || ''45'') }}'
permissions:
contents: 'read'
steps:
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
# verify) may leave root-owned, read-only files anywhere in the workspace.
# Restore ownership and write permission unconditionally so the checkout
# below can wipe the tree without EACCES. Do not gate this behind a
# .qwen/.git probe: poisoning is workspace-wide (root-owned node_modules/
# dist with no .qwen/.git), so a probe reports "healthy" and skips the
# recovery exactly when it is needed.
- name: 'Restore workspace ownership'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Remove .qwen/ and its recovery backup left by a previous job so
# actions/checkout doesn't trip on restrictive permissions.
# `.qwen.root-orig` is emitted by recovery tooling OUTSIDE this repo —
# nothing here produces it (git grep matches only these sweep copies
# and their pins in scripts/tests/review-worktree-cleanup-workflow.
# test.js). It is the backup name a cancelled verify's recovery leaves
# after renaming an unreadable, root-owned `.qwen` aside (observed on
# the shared pool; first recorded around review run 33146730771). If
# that producer's naming changes or a third residue name appears,
# update the for-loop list in every sweep copy or the checkout
# poisoning this sweep exists for silently recurs.
# `trusted-ci-classifier` is the third residue name: classify_pr's
# base-SHA checkout path. actions/checkout reuses a leftover copy
# whose origin URL matches and runs git (hooks included) inside it,
# so it is swept rather than warmed.
- name: 'Clean stale .qwen before checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
# On PRs, check out refs/pull/N/head (the immutable PR head, published the
# instant the branch is pushed) instead of github.ref. github.ref is the
# merge ref (refs/pull/N/merge), which GitHub rebuilds asynchronously and
# can serve stale for minutes after a push, repeatedly flaking this gate.
# Merge queue refs are ephemeral; check out the event head SHA directly so
# slow hosted runners do not fail after the queue branch is removed.
# Non-PR/non-queue events keep github.ref.
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# Pin push runs to the event SHA: the `github.ref` fallback resolves
# `refs/heads/main` at fetch time — a moving tip — while the check run
# attaches to the triggering commit, so the lane would validate a tree
# it does not report on. Nothing detects that (the checkout-head
# verifier is gated to pull_request / merge_group, and its ancestor
# check passes for a newer tip regardless), and merges land close
# enough together to hit it routinely. Regressions are still caught —
# the tested tree is a descendant — but attribution breaks: red on a
# clean commit, autofix filed against the wrong merge. `e2e.yml` pins
# the event SHA by taking actions/checkout's default.
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}"
# Shallow: nothing here walks git history except on demand (the
# verify guard below checks head.sha == HEAD; the size gate and its
# vitest mirror fetch the PR's base commit at depth 1 when the
# baseline went stale; everything else touches only the working
# tree). On the in-repo ECS runner a full-history clone is the
# heaviest transfer and chokes the squid egress proxy, flaking
# checkout. depth 1 is enough.
fetch-depth: 1
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
# ref) silently testing the wrong tree. Cheap: one merge-base, sub-second.
# Also runs in the merge queue — now that the queue's Ubuntu checkout is on
# ECS/squid, a wrong-tree pass would merge bad code.
- name: 'Verify checkout includes expected head commit'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
- name: 'Use trusted CI profile'
id: 'ci_profile'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
run: |-
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
fi
profile="${TRUSTED_CI_PROFILE:-full}"
case "${profile}" in
docs_only|github_ci_only|full) ;;
*) profile=full ;;
esac
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
echo "Selected CI profile: ${profile}"
# Deliberately gated on skip_ci ONLY, not on the CI profile: a PR that
# touches nothing but .github/ classifies as `github_ci_only`, and that
# is exactly the PR most likely to push a workflow file past GitHub's
# 500 KB start-runs limit — the limit that silently killed the autofix
# loop for a day on 2026-08-19. Costs one `wc -c` per workflow file.
- name: 'Check workflow file size'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: '.github/scripts/check-workflow-size.sh'
# A green lane proves the branch passes the gate AS THE BRANCH defines
# it — the checkout above is refs/pull/N/head by deliberate trade-off —
# not as the base defines it. #11117 flipped Prettier --write →
# --check on main while #11216's branch still ran --write; it merged
# green and reddened every later full-profile PR (#11378 cleaned up).
# Fail a branch whose base has since changed a gate-defining file.
# PRs only: merge_group validates the prospective merged tree and push
# validates the base itself. docs_only runs no lint lane, so there is
# no stale green to catch; github_ci_only runs lint.js lanes in the
# helper step below, so it is covered. Seconds of API calls before the
# expensive steps; details in the script's header.
- name: 'Check lint gate freshness'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
BASE_REF: '${{ github.event.pull_request.base.ref }}'
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: 'node .github/scripts/check-lint-gate-freshness.mjs'
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
# through the egress proxy; reuse the machine's Node instead.
- name: 'Set up Node.js 22.x (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'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- name: 'Configure persistent npm cache (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}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
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
npm config set fetch-retries 5
npm config set fetch-timeout 300000
# Fail fast on a saturated self-hosted host instead of dying on ENOSPC
# mid-install (#10035): the workspace cleaner skips busy runners, so a
# busy fleet can hit 100% while jobs keep being admitted. Gating before
# `npm ci` turns that into a clear, retryable reschedule onto a host
# with headroom, instead of a corrupted half-run.
- name: 'Disk floor gate (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
sample_disk() {
hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true)
sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true
echo "$sample"
echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true
}
sample_disk
( while sleep 10; do sample_disk; done ) &
SAMPLER_PID=$!
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (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: '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: it runs before any dependency install in this job (setup-node, self-hosted node and `npm ci` above are all gated on the full profile), so only the node-builtin-only HELPER_TESTS_DEP_FREE suites run here. The full-profile helper step below re-runs the complete HELPER_TESTS list after `npm ci`, and 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 --test-concurrency=1 ${{ env.HELPER_TESTS_DEP_FREE }}
- name: 'Audit critical runtime dependencies'
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' && 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' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:desktop-isolation'
- name: 'Check TUI dependency direction'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:tui-dep-direction'
- name: 'Install linters'
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' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node scripts/lint.js --eslint'
- name: 'Run actionlint'
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' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node scripts/lint.js --shellcheck'
- name: 'Run yamllint'
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' && 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' && 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' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check-i18n'
- name: 'Generate settings schema'
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' && 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."
echo "Please run: npm run generate:settings-schema"
echo "Then commit the updated schema file."
git diff packages/vscode-ide-companion/schemas/settings.schema.json
exit 1
fi
echo "Settings schema is up-to-date"
- name: 'Generate VS Code companion notices'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run generate:notices --workspace=qwen-code-vscode-ide-companion'
- name: 'Check VS Code companion notices are up-to-date'
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/NOTICES.txt) ]]; then
echo "Error: NOTICES.txt is out of date."
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
echo "Then commit the updated file."
git diff --stat packages/vscode-ide-companion/NOTICES.txt
exit 1
fi
echo "NOTICES.txt is up-to-date"
# Keep this Linux-only PR gate explicit. macOS/Windows merge-queue jobs run
# 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' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:serve-fast-path-bundle'
# cli sources name core modules by path (`.../config/storage.js`). In the
# repo those resolve through tsconfig paths or vitest aliases; in the
# published package neither exists and Node resolves them against core's
# `exports`, where one `./*` entry carries all of them. Nothing else
# exercises that entry, so without this a change to it — or to the
# dist layout — leaves every suite green and breaks `qwen` at startup.
# Runs after the step above has already built the workspace.
- name: 'Check core subpath exports resolve'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
npm run build --workspace=packages/core
npm run check:core-subpath-exports
# The `github_ci_only` profile runs the .github/scripts helper tests, but a
# `full` PR that touches those scripts skips that path and `npm run
# test:ci` (vitest) does not collect `node:test` files — so run them here
# too, or a compositor/publisher change could pass CI without its
# regression tests. Linux-only (they're platform-independent).
- name: 'Run .github/scripts helper tests'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node --test --test-concurrency=1 ${{ env.HELPER_TESTS }}'
# State-at-failure dump for the 19 substantive steps after install: the
# install sampler is reaped by that step's `trap … EXIT`, so a death in
# ESLint or the bundle-closure build would otherwise upload an artifact
# holding only install-window samples. `tee -a` appends — a bare `>`
# would truncate exactly those samples — and mirrors to the job log,
# because the file it appends to is the one whose writability is under
# investigation: redirected straight into it, an ENOSPC death loses the
# dump and its own diagnostic together, leaving oncall unable to tell
# "dumped, nothing interesting" from "could not write". tee still emits
# the data to stdout when the file write fails, and reports why on the
# stderr this block does not swallow; it exits 1 there, hence `|| true`.
- name: 'Dump disk state on failure'
if: '${{ failure() }}'
run: |-
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
{
echo "DISKCONTEXT failure-dump $(date -u +%Y-%m-%dT%H:%M:%SZ)"
df -hT || true
df -i || true
grep -E 'MemTotal|MemAvailable' /proc/meminfo || true
} 2>/dev/null | tee -a "$DISK_SAMPLES" || true
# Collector for the #10035 telemetry, kept as the LAST step: failure()
# is evaluated when the step is reached and never revisited, so at its
# old slot just after install it had already been passed (and skipped,
# nothing yet failed) by the time any lint/static step could die — it
# could only ever collect a prelude failure. `test` is NOT mirrored here
# and does not get that treatment: its collector sits four steps from the
# end, so a failure in the packaging or upload steps that follow it is
# never collected in that job. Same loss class, deliberately left to a
# follow-up rather than widened into this change. Distinct artifact name:
# both jobs can fail in one run and upload-artifact v4+ rejects
# duplicate names.
- name: 'Upload disk-pressure samples'
if: '${{ failure() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'disk-pressure-lint-${{ github.run_id }}-attempt-${{ github.run_attempt }}'
path: '${{ runner.temp }}/disk-pressure-samples.log'
if-no-files-found: 'ignore'
retention-days: 14
web_shell_e2e_smoke:
name: 'web-shell E2E Smoke (ubuntu-latest, Node 22.x)'
needs:
- 'classify_pr'
- 'test'
if: |-
${{
!cancelled() &&
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
needs.classify_pr.outputs.skip_ci != 'true' &&
needs.test.outputs.ci_profile == 'full'
}}
# The same commit repeatedly lost browser resources or exceeded the
# document performance budget on ECS, while both gates passed on hosted.
# Keep this browser job hosted without relaxing its test assertions.
runs-on: 'ubuntu-latest'
timeout-minutes: 20
permissions:
contents: 'read'
steps:
# Retain the shared-pool recovery steps so rerouting needs only a runner
# change. Hosted workspaces are fresh; on a reused workspace a prior
# containerised job can leave root-owned, read-only files. Restore them
# unconditionally before checkout, as in the test job.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Keep this retained sweep pinned to the test job's hardened recovery
# recipe, even while hosted. The `.qwen.root-orig` name's provenance
# (an external recovery tool) is documented on the test job's copy.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || github.ref }}"
fetch-depth: 1
- name: 'Verify checkout includes expected head commit'
if: "${{ github.event_name == 'pull_request' }}"
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: '${{ github.event.pull_request.head.sha }}'
# Self-hosted can't reach nodejs.org reliably; reuse the machine's Node.
- name: 'Set up Node.js 22.x (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- name: 'Configure persistent npm cache (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
# Retained for a future shared-pool reroute: apply the test job's
# pre-install admission check (#10035) before dependency installation.
- name: 'Disk floor gate (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Install dependencies'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Install Playwright Chromium (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
run: |-
node node_modules/playwright/cli.js install --with-deps chromium
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
if [ -f "${nested_cli}" ]; then
node "${nested_cli}" install chromium
fi
- name: 'Install Playwright Chromium (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
# Self-hosted ECS runners already include system deps; --with-deps can race apt locks.
run: |-
node node_modules/playwright/cli.js install chromium
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
if [ -f "${nested_cli}" ]; then
node "${nested_cli}" install chromium
fi
- name: 'Choose web-shell Playwright port'
run: |-
port="$(node -e "const net=require('node:net');const server=net.createServer();server.listen(0,'127.0.0.1',()=>{console.log(server.address().port);server.close();});")"
echo "PLAYWRIGHT_PORT=${port}" >> "${GITHUB_ENV}"
echo "Using web-shell Playwright port ${port}"
- name: 'Run transcript document browser gate'
run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0'
- name: 'Run web-shell browser smoke'
run: 'npm run test:e2e:smoke --workspace=packages/web-shell'
- name: 'Upload web-shell Playwright artifacts'
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-e2e-smoke'
path: |-
packages/web-shell/client/e2e/test-results
packages/web-shell/client/e2e/playwright-report
if-no-files-found: 'ignore'
# macOS/Windows: slowest/costliest runners, and the only signal this
# repository has about a host that is not Linux with a GNU userland.
#
# They used to run in the merge queue alone. That queue is not enabled here —
# no `merge_group` run since 2026-07-02, and merges land as squashes — so the
# gate meant they never ran at all: reported as "skipped" on every pull
# request, and never reached afterwards. A macOS-only failure could ship and
# sit in `main` indefinitely, which is what happened in #9220 (a GNU-only
# `realpath -m` in a workflow guard, with the suite that pinned it red on
# every Mac).
#
# The pull-request trigger is OFF, and this is the whole reason the nightly
# above exists. Over the 18 hours measured before it was pulled, the Windows
# lane reported 13 failures and 0 successes on pull requests — one standing
# set of Windows-only path and symlink failures (resolved paths into
# read_many_files, releaseWorktree through an ancestor symlink, the SHA-256
# review worktree) repeating across unrelated PRs — and macOS queued for a
# p90 of 42 minutes, 159 at worst, on a hosted pool this repository does not
# saturate by itself. Neither lane gates a merge: the `main` ruleset carries
# no required status check. So the cost landed entirely on contributors, who
# read a red X no diff of theirs caused and waited on checks that never had
# to pass.
#
# Two triggers remain, in cost order: the merge queue if it is ever enabled
# again, and a nightly run on `main` — which is now the only thing keeping
# macOS and Windows visible at all, so it is load-bearing rather than a
# backstop. Read a red nightly as a blocker; nothing else will report it.
#
# Restoring the pull-request trigger is the last step of fixing those
# failures, not a separate decision — and it is a REVERT of the commit that
# carried this change, not an edit: the classifier job, its `if` arms on
# both lanes, and the contract pins in scripts/tests/ci-platform-lanes.
# test.js all moved together, and that suite now fails a half-restoration
# (a trigger without its classifier, or the reverse). The classifier script
# `classify-platform-sensitivity.mjs`, the `platform` mode of
# `classify-pr-profile.sh` and both their test files were left untouched
# precisely so the revert stays clean. Worth revisiting at the same time:
# that classifier calls every fork pull request platform-sensitive, which
# is most of the traffic here and most of what the macOS queue was spent
# on.
#
# Two named jobs, not a matrix: a skipped matrix job reports one collapsed
# check name, never the per-OS contexts, so a required-check configuration
# would sit "Expected" forever. A skipped named job reports under its exact
# name. (No status check is required on `main` today — the ruleset carries
# only deletion, non-fast-forward and pull_request rules — so this shape is
# currently insurance, not a live constraint.)
test_macos:
name: 'Test (macos-latest, Node 22.x)'
needs: 'classify_pr'
if: |-
${{
!cancelled() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
)
}}
runs-on: 'macos-latest'
timeout-minutes: 60
permissions:
contents: 'read'
steps:
# See the Ubuntu gate's checkout: PRs use the immutable refs/pull/N/head
# and merge queue uses the event head SHA.
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
- name: 'Set up Node.js 22.x'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
NO_COLOR: true
HOME: '${{ runner.temp }}/qwen-ci-home'
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
OPENAI_API_KEY: ''
DASHSCOPE_API_KEY: ''
QWEN_API_KEY: ''
GEMINI_API_KEY: ''
QWEN_DEFAULT_AUTH_TYPE: ''
run: |-
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
# ENOSPC has failed test steps mid-suite while the host looks
# healthy afterwards — a transient spike, likely /tmp inodes or a
# tmpfs cap. Sample the routed temp filesystem every 10s so the
# failing run captures the spike, and dump full state on failure.
# Keep Linux temp paths real and short on disk-backed /var/tmp.
# Symlink aliases break tests that intentionally compare real paths.
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
if [ "${RUNNER_OS:-}" = "Linux" ]; then
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
if [ -n "$QWEN_CI_TMPDIR" ]; then
TMPDIR="$QWEN_CI_TMPDIR"
export TMPDIR
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
fi
fi
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
SAMPLER_PID=$!
set +e
npm run test:ci
RC=$?
set -e
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
kill "$SAMPLER_PID" 2>/dev/null || true
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
exit "$RC"
# Windows counterpart of test_macos (see that job's note). ECS is the default
# with a windows-2022 kill-switch fallback; the check name stays unchanged so
# it matches the required-status-check context. The `!= 'pull_request'` arm
# of `runs-on` is inert while the pull-request trigger is off, and stays for
# when it returns: a pull_request run executes the workflow YAML from the
# PR's own merge commit, so any PR this lane admits could rewrite `runs-on`
# in the same diff that reaches it. A gate the gated tree controls is no
# gate; the pool is reached only by triggers an unreviewed PR cannot open —
# the post-approval merge queue, schedule and dispatch. ECS-only tuning is
# gated on runner.environment; the hosted fallback is the pre-ECS job plus
# the checkout guard and a job-level timeout-minutes.
test_windows:
name: 'Test (windows-latest, Node 22.x)'
needs: 'classify_pr'
if: |-
${{
!cancelled() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
)
}}
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event_name != ''pull_request'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'
timeout-minutes: 60
permissions:
contents: 'read'
steps:
# Belt-and-braces alongside .gitattributes' `eol=lf`: turn autocrlf off
# before the checkout so even a freshly provisioned runner that defaults
# autocrlf on checks out LF-only files. Repository-local `./` actions
# resolve from the job workspace, so the checkout must precede them;
# the rest of the self-hosted tuning runs after the checkout via the
# configure-windows-runner action, shared verbatim with
# windows-runner-smoke.yml so the runner-validation smoke exercises
# exactly what this gate uses. LC_ALL mirrors the Linux gates' locale
# env (inert on Windows, where Node collates through ICU), and Git Bash
# goes on PATH so the remaining steps can run under the workflow-level
# bash default.
- name: 'Disable Git CRLF conversion (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
shell: 'powershell'
run: 'git config --global core.autocrlf false'
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
- name: 'Configure self-hosted Windows test environment'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/configure-windows-runner'
# Hosted runners expose TEMP through an 8.3 short alias. Self-hosted
# runners keep their configured, alias-free RUNNER_TEMP path.
- name: 'Point temp at a short-alias-free directory'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
shell: 'powershell'
run: |-
$temp = Join-Path $env:RUNNER_WORKSPACE 'qwen-code-temp'
New-Item -ItemType Directory -Force -Path $temp | Out-Null
"TEMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "TEMP set to $temp"
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS,
# so fail loud if the checkout lacks the merge-queue head rather than
# silently testing the wrong tree into a merge.
# Written when this lane ran in the merge queue alone, so its expected
# SHA named only the queue's event: on any other trigger the input is
# empty and the step fails the whole lane before a single test runs.
# That is what the revived triggers hit first. Same event-aware shape as
# the Ubuntu gate now, and skipped where there is no head to verify —
# the scheduled and dispatch runs check out a branch by name.
- name: 'Verify checkout includes expected head commit'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
# through the egress proxy; reuse the machine's Node instead.
- name: 'Set up Node.js 22.x (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
# Compare case-insensitively: Windows paths are case-insensitive and
# realpathSync returns the on-disk casing, so a drive-letter or
# directory-case difference is the SAME directory under one spelling,
# not the 8.3 alias this guards. A strict !== there would fail the
# self-hosted lane too — it runs this step against the runner's
# pre-existing RUNNER_TEMP — for a reason unrelated to the alias. The
# alias case (RUNNER~1 -> runneradmin) differs by more than casing and
# still fails; a casing-only difference warns so it stays visible.
- name: 'Verify temp paths carry no short alias'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
node -e "const fs = require('node:fs'); for (const key of ['TEMP', 'TMP']) { const value = process.env[key]; if (!value) { console.error(key + ' is not set'); process.exitCode = 1; continue; } const real = fs.realpathSync(value); if (real === value) continue; if (real.toLowerCase() === value.toLowerCase()) { console.log('::warning::' + key + ' differs from its real path only by casing: ' + value + ' -> ' + real); continue; } console.error(key + ' carries a short alias: ' + value + ' -> ' + real); process.exitCode = 1; }"
- name: 'Configure persistent npm cache (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
run: |-
cache_dir="${HOME}/.cache/qwen-code/npm"
# Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH,
# so create the directory through the Node the preflight verified.
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
NO_COLOR: true
HOME: '${{ runner.temp }}/qwen-ci-home'
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
OPENAI_API_KEY: ''
DASHSCOPE_API_KEY: ''
QWEN_API_KEY: ''
GEMINI_API_KEY: ''
QWEN_DEFAULT_AUTH_TYPE: ''
run: |-
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
# Not a duplicate of the pre-checkout autocrlf step: HOME and
# USERPROFILE are redirected for this step, so git reads a different
# global config here — and that step is gated on self-hosted runners,
# so on the hosted windows-2022 fallback it never runs. Deleting this
# line silently returns CRLF conversion to every git operation the
# suites perform, and no test notices.
git config --global core.autocrlf false
# ENOSPC has failed test steps mid-suite while the host looks
# healthy afterwards — a transient spike, likely /tmp inodes or a
# tmpfs cap. Sample the routed temp filesystem every 10s so the
# failing run captures the spike, and dump full state on failure.
# Keep Linux temp paths real and short on disk-backed /var/tmp.
# Symlink aliases break tests that intentionally compare real paths.
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
if [ "${RUNNER_OS:-}" = "Linux" ]; then
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
if [ -n "$QWEN_CI_TMPDIR" ]; then
TMPDIR="$QWEN_CI_TMPDIR"
export TMPDIR
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
fi
fi
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
SAMPLER_PID=$!
set +e
npm run test:ci
RC=$?
set -e
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
kill "$SAMPLER_PID" 2>/dev/null || true
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
exit "$RC"
# Integration tests run only in the merge queue, not on every PR push.
# They are the suite that previously ran *only* in the nightly Release
# pipeline (`release.yml`), so regressions stayed hidden until release
# time. Gating them on `merge_group` catches the failure before the PR
# lands on `main`, while keeping the per-PR critical path fast. The
# `merge_group` event runs in the base-repo context, so the same model
# secrets used by the release jobs are available here.
#
# Until merge queue is enabled on `main` this job simply never triggers,
# so adding it is a no-op for existing PR/push runs. Reuses the exact
# `test:integration:cli:sandbox:none` script from `release.yml`.
integration_no_ak:
name: 'Integration Tests (no-AK, No Sandbox)'
needs: 'classify_pr'
# The deterministic no-credential integration set (#8313) used to run as a
# step inside the Ubuntu `test` job. That made it invisible to anything
# that reads check names: the `Integration Tests (CLI, No Sandbox)` check
# is merge_group-only and shows as skipped on every PR, and the PR review
# bot ruled from that skip that a changed integration test "never ran"
# (#9895 round 15) while this very gate had executed it and passed inside
# `test`. A check of its own carries the fact in its name. Same runner
# routing as the Ubuntu gate; same PR + merge-queue events; same profile
# gate, classified here because depending on `test` for its output would
# serialize this job behind the hour-long unit run.
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Dependency installation alone can exceed 30 minutes on a contended shared
# ECS host, before the separately bounded 20-minute integration step starts.
# Same ECS-only scoping as the `test` job: hosted fallback routing keeps
# the pre-contention ceiling.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''ecs-qwen'') && ''60'' || ''30'') }}'
permissions:
contents: 'read'
steps:
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Same pre-checkout recovery as the test job: this job lands on the
# same reused pool, so leftover review worktrees and branches from an
# interrupted review would break this checkout too.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
fetch-depth: 1
- name: 'Verify checkout includes expected head commit'
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
# KEEP IN SYNC with the `test` job's profile step so a docs-only or
# github-ci-only PR skips this gate exactly when it skips the unit run.
- name: 'Use trusted CI profile'
id: 'ci_profile'
env:
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
run: |-
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
fi
profile="${TRUSTED_CI_PROFILE:-full}"
case "${profile}" in
docs_only|github_ci_only|full) ;;
*) profile=full ;;
esac
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
echo "Selected CI profile: ${profile}"
- name: 'Setup Node.js (hosted)'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
package-manager-cache: false
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- name: 'Configure persistent npm cache (self-hosted)'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
run: |-
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Disk floor gate (self-hosted)'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Install Dependencies'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' }}"
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && 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: 'Run required no-AK integration gate'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 20
env:
HOME: '${{ runner.temp }}/qwen-no-ak-home'
USERPROFILE: '${{ runner.temp }}/qwen-no-ak-home'
QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home/.qwen'
API_KEY: ''
ANTHROPIC_API_KEY: ''
ANTHROPIC_BASE_URL: ''
ANTHROPIC_MODEL: ''
BAILIAN_CODING_PLAN_API_KEY: ''
BAILIAN_TOKEN_PLAN_API_KEY: ''
DEEPSEEK_API_KEY: ''
OPENAI_API_KEY: ''
OPENAI_BASE_URL: ''
OPENAI_MODEL: ''
DASHSCOPE_API_KEY: ''
GOOGLE_API_KEY: ''
GOOGLE_MODEL: ''
QWEN_API_KEY: ''
QWEN_MODEL: ''
GEMINI_API_KEY: ''
GEMINI_MODEL: ''
IDEALAB_API_KEY: ''
MINIMAX_API_KEY: ''
MODELSCOPE_API_KEY: ''
MOONSHOT_API_KEY: ''
OPENROUTER_API_KEY: ''
REQUESTY_API_KEY: ''
XAI_API_KEY: ''
ZAI_API_KEY: ''
QWEN_DEFAULT_AUTH_TYPE: ''
run: |-
mkdir -p "${HOME}" "${QWEN_HOME}"
npm run typecheck:integration
npm run test:integration:no-ak:sandbox:none
integration_cli:
name: 'Integration Tests (CLI, No Sandbox)'
needs: 'classify_pr'
# Same ECS routing as the Ubuntu gate (via classify_pr): the merge queue runs
# in the base-repo context, so use the self-hosted ECS pool and keep the
# scarce hosted Linux runners free. Falls back to hosted if classify_pr is
# skipped or the ECS kill-switch is set.
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
permissions:
contents: 'read'
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job can
# leave root-owned, read-only files anywhere in it. Restore ownership and
# write permission unconditionally before checkout — see the test job's
# 'Restore workspace ownership' step for why probing first is unsafe.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Same pre-checkout recovery as the test job: this job lands on the
# same reused pool, so leftover review worktrees and branches from an
# interrupted review would break this checkout too. The
# `.qwen.root-orig` name's provenance (an external recovery tool) is
# documented on the test job's copy.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
# Shallow, mirroring the Ubuntu gate: nothing here walks git history,
# and a full-history clone is the heaviest transfer on the ECS runner.
fetch-depth: 1
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS
# via classify_pr, so fail loud if the checkout lacks the merge-queue head
# rather than silently testing the wrong tree into a merge.
- name: 'Verify checkout includes expected head commit'
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: '${{ github.event.merge_group.head_sha }}'
# Hosted downloads Node; self-hosted ECS reuses its pre-installed Node 22
# (it can't reach nodejs.org reliably). Mirrors the Ubuntu gate.
- name: 'Setup Node.js (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
package-manager-cache: false
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
- name: 'Configure persistent npm cache (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
# Same pre-install admission check as the test job (#10035): this job
# shares the self-hosted pool in the merge queue.
- name: 'Disk floor gate (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && 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: 'Run CLI Integration Tests'
run: |-
npm run test:integration:cli:sandbox:none
#
# Desktop Shell: compile + test the Tauri crate in PR CI.
#
# The desktop-release workflow (workflow_dispatch only) is otherwise the sole
# place this crate is built, so a compile error can land on a PR and stay
# invisible until release time. This job compiles the crate and runs its
# release-config tests on every PR that touches the shell. It does not need
# the bundled runtime, so it is cheap. `cargo test` builds the crate and thus
# catches compile failures (e.g. a moved-value error); fmt/clippy are not run
# here because the release pipeline does not gate on them either.
desktop_shell:
name: 'Desktop Shell (${{ matrix.os }})'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' && needs.classify_pr.outputs.skip_ci != 'true' }}"
strategy:
fail-fast: false
matrix:
os: ['ubuntu-22.04', 'windows-2022']
runs-on: '${{ matrix.os }}'
timeout-minutes: 45
permissions:
contents: 'read'
pull-requests: 'read'
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
fetch-depth: 1
# Fail open: any uncertainty (non-PR event, API failure) runs the job.
#
# Ask GitHub which files the PR changed, the same call the CI profile
# classifier makes above. The two-tree `git diff BASE HEAD` this replaced
# is not the PR's diff: it reports everything the base gained since the
# branch point as a change on the PR's side. #8132 added
# packages/desktop-shell, so every branch older than it saw 78 phantom
# desktop-shell paths, ran this job, and failed in a checkout of
# refs/pull/N/head that has no such directory.
- name: 'Detect desktop-shell changes'
id: 'filter'
env:
GH_TOKEN: '${{ github.token }}'
PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}"
run: |-
changed=true
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then
# `previous_filename` too: renaming a file out of the crate changes
# it, and only the old path says so.
if files="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[] | .filename, (.previous_filename // empty)')"; then
if grep -Eq '^(packages/desktop-shell/|\.github/scripts/create-desktop-update-manifest\.mjs|\.github/workflows/ci\.yml|\.github/workflows/desktop-release\.yml)' <<<"${files}"; then
changed=true
else
changed=false
fi
else
echo "::warning::Unable to list the PR's changed files; compiling the crate."
fi
fi
# Whatever the filter decided, a tree without the crate cannot have
# regressed it, and cargo has no manifest to run against — the job
# would report a missing working directory as a failure of the PR.
if [[ "${changed}" == "true" && ! -f packages/desktop-shell/src-tauri/Cargo.toml ]]; then
echo "::notice::packages/desktop-shell/src-tauri is absent from this head; nothing to compile."
changed=false
fi
echo "changed=${changed}" >> "${GITHUB_OUTPUT}"
echo "desktop-shell changed: ${changed}"
# cargo test links the Tauri/wry webview, so the WebKit/GTK dev headers
# must be present (mirrors the Linux build job in desktop-release.yml).
- name: 'Install Linux dependencies'
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
with:
node-version: '22.x'
- uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable
if: "${{ steps.filter.outputs.changed == 'true' }}"
- uses: 'Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae' # v2
if: "${{ steps.filter.outputs.changed == 'true' }}"
with:
workspaces: 'packages/desktop-shell/src-tauri -> target'
- name: 'Compile and test the desktop crate'
if: "${{ steps.filter.outputs.changed == 'true' }}"
working-directory: 'packages/desktop-shell'
run: 'cargo test --manifest-path src-tauri/Cargo.toml'
- name: 'Run desktop release tests'
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
working-directory: 'packages/desktop-shell'
run: 'node scripts/test-release.js'