Files
qwen-code/.github/workflows/ci.yml
T
易良andyiliang114 708ba347d3 feat(ci): add disposable external PR runners (#16513)
* feat(ci): add disposable external PR runners

* test(ci): align platform lanes with disposable runner routing

---------

Co-authored-by: yiliang114 <[email protected]>
2026-11-07 14:24:33 +00:00

3492 lines
202 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_build`, `test`, `test_ubuntu`, `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 five. 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 FIVE `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'
# The default three plus ready_for_review: a draft skips the no-AK
# integration gate and the browser smoke, so leaving draft has to start
# the run that includes them.
types:
- 'opened'
- 'synchronize'
- 'reopened'
- 'ready_for_review'
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'
- 'self-hosted-arm64'
- '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.
# A ref-only group replaces older pending main pushes even when cancellation
# is disabled. Give each push-tip SHA its own group so every merged tree can
# report independently. Repeated pushes/reruns of one SHA still share a group.
# Keep running main and release validations uncancellable.
group: |-
${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.sha || 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 .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.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 .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.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 EVERY event it accepts — pull requests (untrusted
# forks included), merge queue, pushes to main, and dispatch — unless the
# kill-switch forces hosted. classify executes only trusted base-SHA code
# with a read-only token; the residual PR-YAML risk is the same class
# accepted for lint_and_static (2026-10-31 decision, see
# docs/design/2026-10-31-lint-static-ecs-routing.md). Keeping the gate on
# hosted for forks queued the entire fork CI chain behind a scarce pool.
# The association routing for the downstream lanes lives in pick_runner
# (ubuntu_runner) below; sdk-java.yml and serve-ab.yml mirror that form
# (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'' && 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 }}'
pr_test_runner: '${{ steps.pick_runner.outputs.pr_test_runner }}'
# Untrusted forks use hosted runners unless the disposable pool is enabled.
lint_runner: '${{ steps.pick_runner.outputs.lint_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.
#
# Keep push routing aligned with the classifier above so a hosted queue
# does not delay the downstream ECS jobs.
#
# Untrusted forks opt into the disposable pool; otherwise their build,
# test, lint and no-AK integration jobs stay hosted.
- 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 }}'
EPHEMERAL_ENABLED: '${{ vars.EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED }}'
EPHEMERAL_ARCH: '${{ vars.EXTERNAL_PR_EPHEMERAL_ARCH }}'
# Optional fleet selector: 'hosted' forces github.com-hosted runners,
# 'arm64' routes to the experimental ARM64 ECS fleet, anything else
# keeps the x64 ecs-qwen fleet. Empty/unset keeps current behavior.
LINUX_RUNNER_FLEET: '${{ vars.LINUX_RUNNER_FLEET }}'
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
# Resolve fleet labels once; both outputs assign from this.
fleet_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
fleet_runner='["self-hosted", "linux", "arm64"]'
fi
# Dispatch input wins over the fleet variable; MAINTAINER_ECS_RUNNER_
# DISABLED=true still hard-forces hosted runners everywhere.
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
elif [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted-arm64" ]]; then
# Experimental ARM64 ECS fleet (Hangzhou, ecs.q8y). No arch label
# set is required by any production lane; this only selects the
# fleet that self-identifies as ARM64.
ubuntu_runner='["self-hosted", "linux", "arm64"]'
fi
elif [[ "${ECS_DISABLED}" == "true" || "${LINUX_RUNNER_FLEET}" == "hosted" ]]; then
ubuntu_runner='["ubuntu-latest"]'
elif [[ "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ]]; then
ubuntu_runner="${fleet_runner}"
fi
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected Linux runner: ${ubuntu_runner}"
pr_test_runner="${ubuntu_runner}"
lint_runner="${ubuntu_runner}"
if [[ "${EPHEMERAL_ENABLED}" == "true" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" && "${EVENT_NAME}" == "pull_request" && "${SAME_REPO}" == "false" && "${trusted_author}" == "false" ]]; then
pr_test_runner='["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"]'
if [[ "${EPHEMERAL_ARCH}" == "arm64" ]]; then
pr_test_runner='["self-hosted", "linux", "arm64", "ecs-qwen-ephemeral"]'
fi
lint_runner="${pr_test_runner}"
fi
echo "pr_test_runner=${pr_test_runner}" >> "${GITHUB_OUTPUT}"
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected lint runner: ${lint_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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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|java_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
#
# Builds the tree every unit-test shard runs against. The `prepare` build
# takes most of ten minutes on the shared pool; doing it once here lets
# each shard install without it and unpack this archive. A pull request
# also starts from the newest cached build and rebuilds only the
# workspaces whose inputs changed (scripts/ci-build-cache.mjs).
test_build:
name: 'Build unit-test tree (ubuntu-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || ''["ubuntu-latest"]'') }}'
timeout-minutes: 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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
# retry, while github.ref is the asynchronously rebuilt merge ref and
# can lag behind a push. Neither mutable ref identifies this run's head.
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- 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' && github.event.pull_request.head.sha) || (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|java_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: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- 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 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}"'
# scripts/ci-build-cache.mjs runs the build after the install.
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
# Every cap below stays under the job's 45 so a stalled host fails the
# step and leaves the failure-gated collectors at the end reachable. A
# job timeout cancels the run and skips them, which is the case those
# collectors exist for.
timeout-minutes: 30
env:
QWEN_SKIP_PREPARE: '1'
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
# Observed: the pinned cache entry existed with its pnpm binary
# missing, corepack trusts an existing entry without re-verifying
# its files, and the install died instantly with MODULE_NOT_FOUND
# (main run 41927887452). What left the entry incomplete is
# undetermined; _work/_temp survives across jobs on self-hosted
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
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
# base_key is the key the base commit's own push run saved under. A
# bare prefix restore returns the NEWEST entry, which can be many main
# commits away from this branch; once any root file or build script
# differs, every workspace rebuilds ("29 of 29 workspaces changed").
# The base commit is fetched treeless-of-blobs: the key reads blob ids
# from its trees, never file contents. A failed fetch only drops back
# to the prefix restore.
- name: 'Hash the build inputs'
id: 'build_key'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
env:
BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}'
run: |-
echo "key=$(node scripts/ci-build-cache.mjs key)" >> "${GITHUB_OUTPUT}"
base_key=''
if [ -n "${BASE_SHA}" ] &&
git fetch --quiet --no-tags --depth=1 --filter=blob:none origin "${BASE_SHA}"; then
base_key="$(node scripts/ci-build-cache.mjs key --at "${BASE_SHA}")" || base_key=''
fi
echo "base_key=${base_key}" >> "${GITHUB_OUTPUT}"
echo "Base ${BASE_SHA:-none} build key: ${base_key:-none}"
# A push to main always builds from scratch, so every cache a pull
# request starts from is a clean build of main; a pull request whose
# restore found nothing saves its own clean build for its next run.
# The path stays relative: actions/cache versions an entry by its path,
# and runner.temp differs between pool hosts, so one host could never
# restore what another saved.
#
# The key carries no runner.arch. The x64-labelled pool includes ARM64
# hosts, and every built path is JavaScript except the bundle's opentui
# native library, which `build` recopies for the host after restoring
# and every consumer recopies after unpacking. Split
# by arch, each half of the pool restored the other half's misses.
- name: 'Restore the build cache'
id: 'build_cache'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name != 'push' }}"
timeout-minutes: 5
continue-on-error: true
uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '.qwen-build-cache'
key: 'qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.key }}'
restore-keys: |-
qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.base_key || 'no-base-key' }}
qwen-build-v2-${{ runner.os }}-
# DEV=true makes the bundle write dist/esbuild.json, the metafile the
# serve fast-path closure check below reads; nothing else in the build
# reads DEV.
- name: 'Build'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 25
env:
DEV: 'true'
run: 'node scripts/ci-build-cache.mjs build .qwen-build-cache'
# These checks read the built tree. They used to run in Lint & Static,
# which built and bundled the CLI a second time for them (~9 minutes);
# here they read the tree every other lane tests, in seconds. They stay
# required: `Test (ubuntu-latest, Node 22.x)` passes only when this job
# does. Linux-only on purpose — macOS/Windows run `test:ci` alone.
- name: 'Check serve fast-path bundle closure'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 5
run: 'node scripts/check-serve-fast-path-bundle.js'
# 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.
- name: 'Check core subpath exports resolve'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 5
run: 'npm run check:core-subpath-exports'
# The generator imports core's dist, so it lives with the build too.
- 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: 'Pack the build cache'
id: 'pack_build_cache'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && (github.event_name == 'push' || steps.build_cache.outputs.cache-matched-key == '') }}"
timeout-minutes: 8
run: |-
rm -rf .qwen-build-cache
node scripts/ci-build-cache.mjs pack .qwen-build-cache
- name: 'Save the build cache'
if: "${{ steps.pack_build_cache.outcome == 'success' }}"
timeout-minutes: 5
continue-on-error: true
uses: 'actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '.qwen-build-cache'
key: 'qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.key }}'
# Everything the build wrote: the ignored, untracked paths outside
# node_modules, which each shard's own install recreates. The
# checkout's clean leaves nothing else ignored in the workspace. The
# path list comes from `ci-build-cache.mjs` so `pack` and this archive
# cannot disagree about what the build produces.
- name: 'Archive the built tree'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 8
run: |-
node scripts/ci-build-cache.mjs paths |
tar --null -T - -czf "${RUNNER_TEMP}/unit-test-tree.tgz"
du -h "${RUNNER_TEMP}/unit-test-tree.tgz"
- name: 'Upload the built tree'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree.tgz'
compression-level: 0
overwrite: true
retention-days: 7
# This job carries the same sampling install as the shards but builds for
# up to its whole timeout afterwards, and its sampler dies with that
# step's trap … EXIT. Without a collector here, a build that dies on a
# saturated host (the #10035 class, and the OOM that took hk2-11) keeps
# no evidence at all — the samples go with the runner temp dir.
- 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 || df -h || true
df -i || true
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo || true
} 2>/dev/null | tee -a "$DISK_SAMPLES" || true
# Last step, and a name distinct from the test and lint collectors:
# upload-artifact v4+ rejects duplicate names when several jobs fail in
# one run.
- name: 'Upload disk-pressure samples'
if: '${{ failure() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'disk-pressure-build-${{ github.run_id }}-attempt-${{ github.run_attempt }}'
path: '${{ runner.temp }}/disk-pressure-samples.log'
if-no-files-found: 'ignore'
retention-days: 14
# The required `Test (ubuntu-latest, Node 22.x)` check is `test_ubuntu`,
# which passes only when the build and every shard below pass.
test:
name: 'Linux unit tests (shard ${{ matrix.shard }}/8)'
needs: ['classify_pr', 'test_build']
# 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.pr_test_runner || ''["ubuntu-latest"]'') }}'
# Every Vitest run takes one eighth of its suite's files (`--shard`): the
# unsharded lane ran 45-59 minutes, almost all of it the CLI and core
# suites one after the other. A shard keeps the old ceiling's headroom
# for a contended ECS host; the test step's own cap bounds a genuine hang.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
timeout-minutes: 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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
# retry, while github.ref is the asynchronously rebuilt merge ref and
# can lag behind a push. Neither mutable ref identifies this run's head.
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- 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' && github.event.pull_request.head.sha) || (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|java_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: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- 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 npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
# The test step's side lane (.github/scripts/ci/run-unit-test-lanes.sh)
# runs the standalone runtime regression against pinned Node and Bun
# archives. Keep them on the persistent host so only the first run per
# host downloads them: the download cost minutes per run, and a 502 from
# npm once reddened an otherwise green run. Exported here because the
# test step overrides HOME.
- name: 'Configure persistent standalone runtime 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/standalone-runtimes"
echo "QWEN_STANDALONE_RUNTIME_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent standalone runtime 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}"'
# `test_build` already ran the `prepare` build; its archive below
# brings the outputs this install skips.
- name: 'Install dependencies'
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 30
env:
QWEN_SKIP_PREPARE: '1'
QWEN_SKIP_NOTICE_GENERATION: '1'
# Pin COREPACK_HOME to a fixed path under runner.temp so the
# corepack cache written here is visible to the test step below,
# which overrides HOME for isolation. runner.temp survives across
# jobs on self-hosted runners, and corepack does not re-verify
# extracted entries on cache hit, so the install body below clears
# this exact path before use; the test step then reuses the cache
# this step warms within the same job.
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack'
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
# Observed: the pinned cache entry existed with its pnpm binary
# missing, corepack trusts an existing entry without re-verifying
# its files, and the install died instantly with MODULE_NOT_FOUND
# (main run 41927887452). What left the entry incomplete is
# undetermined; _work/_temp survives across jobs on self-hosted
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# Eight shards pull this same artifact at once, so the cap has to clear
# the slow tail rather than the median: on main four shards finished
# inside a minute, three needed between 2m40s and just under five
# minutes, and one exceeded the old five-minute cap on two consecutive
# runs, which skipped the unpack and failed the required check. Fifteen
# minutes leaves that measured tail about three times the headroom; if
# main still expires, the fix is fewer concurrent fetches, not a bigger
# cap.
- name: 'Download the built tree'
id: 'download_tree'
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 15
continue-on-error: true
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Retry downloading the built tree'
if: "${{ steps.download_tree.outcome == 'failure' }}"
timeout-minutes: 15
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Unpack the built tree'
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
rm -rf "${RUNNER_TEMP}/unit-test-tree"
# PR checkouts may predate the build-cache host-assets command.
if [ -f dist/cli.js ]; then
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
fi
- 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: 'Provision linters for real-binary tests'
# The script-lint real-binary regressions (the #15974 unloadable
# workflow, the PR-planted ignore-all config, the merge-base trust
# boundary) are skipIf-gated on actionlint/shellcheck being on PATH;
# without this step they silently skip on every CI run (PR #16001
# review). Same pinned, SHA-256-verified installer the lint lane
# uses, promoted onto GITHUB_PATH ahead of anything a shared runner
# left there. Advisory: a failed install degrades to the skips, not
# a red required check. Windows is excluded — the installer ships
# linux/darwin archives only.
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.os != 'Windows' }}"
timeout-minutes: 5
continue-on-error: true
run: |-
node --input-type=module -e '
import { execSync } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { getLinters, getLinterTempDir } from "./scripts/lint.js";
const tempDir = getLinterTempDir();
rmSync(tempDir, { recursive: true, force: true });
mkdirSync(tempDir, { recursive: true });
const linters = getLinters();
for (const name of ["actionlint", "shellcheck"]) {
execSync(linters[name].installer, { stdio: "inherit" });
// Verify the install produced its binary BEFORE promoting the
// dir: a silent no-op install must fail this step visibly
// (continue-on-error tolerates it, the UI shows it), not
// degrade to skipped tests nobody notices (PR #16001 F2).
const bin = tempDir + "/" + name + "/" + name;
if (!existsSync(bin)) throw new Error(name + " installer produced no binary at " + bin);
appendFileSync(process.env.GITHUB_PATH, tempDir + "/" + name + "\n");
}
console.log("PROVISIONED actionlint + shellcheck into " + tempDir);
'
- name: 'Run tests and generate reports'
id: 'unit_tests'
if: "${{ needs.test_build.result == 'success' && 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 shard, not the healthy one:
# the same 1/8 slice measures ~7 minutes on an idle host and ~36 on a
# contended one, and a failing run adds its retries on top. 45 leaves
# room for that while still firing before this job's 60-minute cap,
# which this step enters around minute 10 — a step timeout keeps the
# diagnostic steps below alive, a job timeout does not.
timeout-minutes: 45
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'
# HOME above hides the runner's corepack cache; without this the
# vitest globalSetup (scripts/tests/corepack-warmup.js) spawns
# `corepack pnpm --version` on a cold cache and downloads the pinned
# pnpm from npmjs, which the ECS egress can time out on. Must match
# the Install dependencies step's COREPACK_HOME so it hits the cache
# install already warmed.
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack'
# 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' || '' }}"
# The lane script's side-lane log, outside the TMPDIR this step
# removes. The script deletes it once replayed, so it is left only
# when this step is killed first; the next step prints it.
SIDE_LANE_LOG: '${{ runner.temp }}/side-lane-tests.log'
# This shard's slice of every Vitest run. A small suite has fewer
# files than shards, hence --passWithNoTests beside it. The
# standalone regression is no Vitest run; the first shard runs it.
UNIT_TEST_SHARD: '${{ matrix.shard }}/8'
QWEN_CI_SKIP_STANDALONE_RUNTIMES: "${{ matrix.shard != 1 && '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 }); }"
test_runner=()
if [ "$(id -u)" -eq 0 ]; then
# Permission-denial fixtures must also work on root-run ECS lanes.
chown -R 0 "$GITHUB_WORKSPACE" "$HOME"
echo 'Dropping DAC override capabilities for root unit tests'
test_runner=(setpriv '--bounding-set=-dac_override,-dac_read_search' '--inh-caps=-dac_override,-dac_read_search')
fi
# 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=(--shard="${UNIT_TEST_SHARD}" --passWithNoTests)
if [ -n "${VITEST_RETRY:-}" ] && [ "${VITEST_RETRY}" != 'off' ]; then
retry_arg+=(--retry="${VITEST_RETRY}")
fi
set +e
# Every workspace suite, the standalone regression and scripts/tests,
# in two concurrent lanes; the script says why. Each command it runs
# inherits the capabilities test_runner drops.
"${test_runner[@]}" .github/scripts/ci/run-unit-test-lanes.sh "${retry_arg[@]}"
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"
# A timeout or a cancel kills the test step before the lane script
# replays its side lane, which runs every suite but the CLI and core
# ones. Print what the side lane got through.
- name: 'Print the side lane of a killed test step'
if: '${{ failure() || cancelled() }}'
run: |-
side_log="${RUNNER_TEMP}/side-lane-tests.log"
if [ -f "${side_log}" ]; then
cat "${side_log}"
fi
# 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 }}-shard-${{ matrix.shard }}'
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.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && matrix.shard == 1 }}"
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) }}
# The parser rejects the whole step when any matched file is not
# well-formed XML, and vitest's JUnit writer can leave
# packages/cli/junit.xml truncated at the tail when the suite exits
# mid-write: #15305 (attempt 1) failed at line 47397 and #15261 at
# 47232 with "Invalid character in entity name", while both runs'
# tests had passed. The report is advisory (`fail-on-error: false`),
# so a broken artifact must not turn a green suite red.
continue-on-error: true
uses: 'dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2' # ratchet:dorny/test-reporter@v3
with:
name: 'Test Results (ubuntu-latest, Node 22.x, shard ${{ matrix.shard }})'
path: 'packages/*/junit.xml'
reporter: 'java-junit'
fail-on-error: 'false'
- name: 'Upload Test Results Artifact'
if: |-
${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}
# Advisory like the publisher above: a transient artifact-service
# failure must not turn a green suite red. `always()` also fires on a
# run cancelled mid-suite, so a partial report can be retained under
# the complete run's name.
continue-on-error: true
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'test-results-fork-22.x-ubuntu-latest' || 'test-results-22.x-ubuntu-latest' }}-shard-${{ matrix.shard }}"
path: 'packages/*/junit.xml'
if-no-files-found: 'ignore'
retention-days: 7
# 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-shard-${{ matrix.shard }}'
path: 'packages/*/coverage'
#
# Lint & Static: the checks whose verdict depends only on the tree.
#
test_ubuntu:
name: 'Test (ubuntu-latest, Node 22.x)'
needs: ['classify_pr', 'test_build', 'test']
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
# The shards' runners: a hosted runner queued this one-line check for
# 9 minutes after the last shard finished.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
timeout-minutes: 5
permissions:
contents: 'read'
steps:
- name: 'Require the build and every Linux test shard to pass'
env:
BUILD_RESULT: '${{ needs.test_build.result }}'
SHARDS_RESULT: '${{ needs.test.result }}'
run: 'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success'
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' }}"
# Shares the external-PR hosted/disposable switch with test and integration_no_ak.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
# Pool-scoped 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.
# Keyed on `self-hosted`, not on the x64 fleet's `ecs-qwen` label: the
# ARM64 fleet renders ["self-hosted", "linux", "arm64"], so a
# fleet-specific substring priced those reserved machines as hosted.
# Reads the same lint_runner as runs-on so the ceiling always prices the
# pool the job actually lands on.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
env:
LINT_BASE_SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}"
permissions:
contents: 'read'
pull-requests: 'read'
steps:
# Untrusted fork code runs here; job env cannot read the runner context
# and RUNNER_TEMP survives across jobs, so publish a run-unique HOME via
# GITHUB_ENV. Rationale: docs/design/2026-10-31-lint-static-ecs-routing.md.
- name: 'Create isolated HOME'
run: |-
home="${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -rf -- "$home"
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
# Linter archives are SHA-256-verified on every cache hit, so sharing
# them is safe; corepack does NOT re-verify extracted entries on hit,
# so its home stays per-run (a shared one is a poisoning channel).
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
# 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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
# retry, while github.ref is the asynchronously rebuilt merge ref and
# can lag behind a push. Neither mutable ref identifies this run's head.
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- 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' && github.event.pull_request.head.sha) || (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}"
scripts_unit=false
case "${profile}" in
docs_only|github_ci_only|full) ;;
# A change confined to the Java packages skips the TypeScript
# build and test lanes, so this job runs its whole payload (the
# Flyway and contract checks included) plus the scripts/tests
# suites, seventeen of which read the Java trees.
java_only)
profile=full
scripts_unit=true
;;
*) profile=full ;;
esac
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
echo "scripts_unit=${scripts_unit}" >> "${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'
- name: 'Check PR-stage CI budget'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: 'node .github/scripts/check-ci-budget.mjs'
# A green lane proves the branch passes the gate AS THE BRANCH defines
# it — the checkout above is the event PR 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'
# Keep the cheap schema guards on PRs and main while Java runs daily.
- name: 'Check Flyway migration versions are unique'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
run: 'node scripts/check-flyway-migrations.js packages/managed-agent-server packages/sdk-java/qwencode'
- name: 'Check the API contract version advances'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
env:
BASE_REF: '${{ github.event.pull_request.base.ref }}'
GH_TOKEN: '${{ github.token }}'
REPO: '${{ github.repository }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |-
set -euo pipefail
DOCUMENT='packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json'
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | .filename, .previous_filename // empty' > "${RUNNER_TEMP}/contract-pr-files.txt"
if ! grep -qxF "${DOCUMENT}" "${RUNNER_TEMP}/contract-pr-files.txt"; then
exit 0
fi
BASE_DOCUMENT="${RUNNER_TEMP}/contract-base.json"
git fetch --quiet --depth=1 --filter=blob:none origin "refs/heads/${BASE_REF}"
if ! git show "FETCH_HEAD:${DOCUMENT}" > "${BASE_DOCUMENT}" 2>/dev/null; then
PREVIOUS_DOCUMENT="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | select(.filename == "packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json") | .previous_filename // empty')"
if [ -n "${PREVIOUS_DOCUMENT}" ]; then
git show "FETCH_HEAD:${PREVIOUS_DOCUMENT}" > "${BASE_DOCUMENT}"
else
: > "${BASE_DOCUMENT}"
fi
fi
node scripts/check-contract-version.js "${BASE_DOCUMENT}" "${DOCUMENT}"
# Advisory test-growth summary and flag; see the script header.
- name: 'Report test growth'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' }}"
continue-on-error: true
timeout-minutes: 5
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: 'node .github/scripts/check-test-growth.mjs'
# Stays below the two steps above that refetch shallow history: a
# depth-1 fetch of the base ref grafts the commit LINT_BASE_SHA names and
# `merge-base` then fails closed to a silent full scan (design doc,
# "PR-lane history"). The helper-checks step that runs lint.js is below.
- name: 'Fetch incremental lint history'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
timeout-minutes: 2
continue-on-error: true
env:
LINT_HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: |-
# Blobless: an earlier blobless merge-base fetch leaves this
# checkout without those blobs, and lint-scope reads only commits and
# trees anyway.
partial=(-c extensions.partialClone=origin -c remote.origin.promisor=true)
git "${partial[@]}" fetch --no-tags --depth=100 --filter=blob:none \
origin "${LINT_BASE_SHA}" "${LINT_HEAD_SHA}"
# Match lint-scope's merge-base and shallow-boundary checks.
# Ambiguity stops deepening but still announces full-lint fallback.
history_usable() {
local merge_base above shallow
merge_base="$(git merge-base --all "${LINT_BASE_SHA}" HEAD 2>/dev/null)" || return 1
case "${merge_base}" in
'') return 1 ;;
*$'\n'*) return 2 ;;
esac
[ "$(git rev-parse --is-shallow-repository)" = 'true' ] || return 0
shallow="$(git rev-parse --git-path shallow)"
above="$(git rev-list "${merge_base}..HEAD" "${merge_base}..${LINT_BASE_SHA}")" || return 1
! grep -qxF -f "${shallow}" <<< "${above}"
}
deepens=0
reason='deepen cap reached'
for _attempt in 1 2 3; do
state=0
history_usable || state=$?
case "${state}" in 0|2) break ;; esac
git "${partial[@]}" fetch --no-tags --deepen=300 --filter=blob:none \
origin "${LINT_BASE_SHA}" "${LINT_HEAD_SHA}" || { reason='deepen fetch aborted'; break; }
deepens=$((deepens + 1))
done
# Later fetches in this checkout must stay ordinary.
git config --unset remote.origin.partialclonefilter || true
state=0
history_usable || state=$?
if [ "${state}" != 0 ]; then
[ "${state}" != 2 ] || reason='ambiguous merge base'
message="Incremental lint history: ${deepens} deepen(s), base depth $(git rev-list --count "${LINT_BASE_SHA}") — ${reason}"
echo "::warning::${message}"
echo "- ${message}" >>"${GITHUB_STEP_SUMMARY}"
fi
# 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: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- 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 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' }}"
timeout-minutes: 30
env:
QWEN_SKIP_PREPARE: '1'
QWEN_SKIP_NOTICE_GENERATION: '1'
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
# Observed: the pinned cache entry existed with its pnpm binary
# missing, corepack trusts an existing entry without re-verifying
# its files, and the install died instantly with MODULE_NOT_FOUND
# (main run 41927887452). What left the entry incomplete is
# undetermined; _work/_temp survives across jobs on self-hosted
# runners, so such state persists. Start the shard-owned cache
# empty — and only that exact path, so a runner-exported shared
# cache and lint's already-warmed run-unique home stay untouched.
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
rm -rf -- "${COREPACK_HOME}"
fi
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 Web Shell and 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/web-shell
npm run generate:notices --workspace=qwen-code-vscode-ide-companion
- name: 'Check Web Shell and 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 packages/web-shell/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 "And: npm run generate:notices --workspace=@qwen-code/web-shell"
echo "Then commit the updated file."
git diff --stat packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt
exit 1
fi
echo "NOTICES.txt is up-to-date"
# 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).
# One retry, the unit lane's contention doctrine (#10868): the battery
# spawns real subprocess loops on the shared ECS pool, and a main push
# has no flaky-rerun patrol — a single host hiccup failed the lane on a
# CSS-only commit (#12772). A real break still fails both attempts.
# Attempt 1 tees to a log so the warning can name the failed suites;
# pipefail (defaults.run.shell: bash) is what keeps node's status
# through that pipe. The warning is gated on the retry's success, so
# only an absorbed flake is annotated — a double failure reddens the
# lane directly (the #11364 doctrine from e2e.yml).
- name: 'Run .github/scripts helper tests'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
# Route os.tmpdir() off the host /tmp tmpfs: its inode quota is
# small and shared across the pool, and e2e-build.test.mjs creates
# thousands of chunk files there (#15965 ENOSPC with 202G free).
# Same routing as the unit lanes (#10035), private per-run dir
# included: RUNNER_TEMP is shared, and a cancelled battery never
# reaches its own after() cleanup, so the fixture tree would stick
# around in _work/_temp. A hard kill stays uncovered either way.
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
HELPER_TMPDIR="$(mktemp -d "${TMPDIR}/helper-tests-XXXXXX" 2>/dev/null)" || HELPER_TMPDIR=""
if [ -n "$HELPER_TMPDIR" ]; then
TMPDIR="$HELPER_TMPDIR"
export TMPDIR
trap 'rm -rf "$HELPER_TMPDIR"' EXIT
fi
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} 2>&1 | tee "${RUNNER_TEMP}/helper-attempt1.log" || {
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} &&
echo "::warning::helper tests failed once and the retry absorbed it; attempt-1 failures: $(grep -E '^not ok' "${RUNNER_TEMP}/helper-attempt1.log" | head -5 | paste -sd ';' -) — count this toward the #12772 helper-battery recurrence threshold"
}
# Only on a java_only change, whose unit-test shards skip: the
# scripts/tests suites that name the Java trees. Selected by content so
# a new suite joins without editing this step; the rest of
# scripts/tests reads the TypeScript build, which this job never makes.
- name: 'Run scripts/tests for a Java-only change'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.scripts_unit == 'true' }}"
run: |-
mapfile -t suites < <(git grep -l -E 'managed-agent-server/|sdk-java/' -- 'scripts/tests/*.test.*')
if [ "${#suites[@]}" -eq 0 ]; then
echo '::error::Found no scripts/tests suite naming the Java trees.'
exit 1
fi
npm run test:scripts:unit -- "${suites[@]}"
# Reclaim the run-unique HOME (constructed path, never $HOME) before the
# failure collector, which must stay last.
- name: 'Clean isolated HOME'
if: '${{ always() }}'
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
# 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 || df -h || true
df -i || true
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /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
# The `web-shell E2E Smoke (ubuntu-latest, Node 22.x)` check is
# `web_shell_e2e_smoke_result`, which passes only when every shard passes.
web_shell_e2e_smoke:
name: 'web-shell E2E Smoke (shard ${{ matrix.shard }}/4)'
# Gate on the classifier's own profile output instead of the copy `test`
# re-derives: it is the same value (the classifier already forces an
# unknown one to `full`), and dropping the `test` edge lets this
# 35-minute lane run beside Test instead of behind it. `|| 'full'`
# extends that fail-safe to a classifier that emitted nothing at all.
#
# It tests test_build's tree instead of building its own. Each shard
# used to rebuild every workspace (~10 minutes, four times per run):
# this lane's ARM64 cache was saved once and never refreshed, so it
# restored a stale build every time. The tree is JavaScript apart from
# the opentui native library, which is recopied for ARM64 after unpacking.
# Drafts skip it, as they skip the no-AK integration gate.
needs:
- 'classify_pr'
- 'test_build'
if: |-
${{
!cancelled() &&
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
needs.classify_pr.outputs.skip_ci != 'true' &&
(needs.classify_pr.outputs.ci_profile || 'full') == 'full' &&
needs.test_build.result == 'success' &&
!github.event.pull_request.draft
}}
# Keep the classifier's trust and hosted overrides, but use ARM64 for
# this lane independently of the ordinary Linux CI fleet. The history
# below is x64-specific, not a verdict on ARM64, but it is why this lane
# is fragile: #11250 moved it back to hosted after four pool attempts
# failed (run 34081409590). Three ran the smoke unpinned at 8 workers,
# half the host's 16 cores, against hosted's 2, and timed out broadly on
# visibility and clock waits. The fourth's transcript gate took 73 s
# against its 60 s budget while npm install alone took 28 min on that
# host. The smoke step pins the hosted worker count.
runs-on: '${{ contains(fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]''), ''self-hosted'') && fromJSON(''["self-hosted", "linux", "arm64"]'') || fromJSON(''["ubuntu-latest"]'') }}'
# The unsharded smoke ran 44-46 minutes, about 33 of them in the 208
# smoke tests at two workers; each shard runs a quarter of them.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
# Slow hosted mirrors kept downloading past 23 min; reserve time for
# the smoke tests and artifact cleanup after installation.
timeout-minutes: 60
permissions:
contents: 'read'
steps:
# On a reused pool 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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# Keep this sweep pinned to the test job's hardened recovery recipe.
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || 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 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
# 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}"'
# The smoke suite asserts exact text layout, which follows the host's
# default sans-serif (#15383). Checking it here turns a font-drifted
# host into a five-second actionable error instead of two failures 26
# minutes into the suite. See .github/scripts/ecs-runner/README.md.
- name: 'Check host font baseline (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
# A pull request whose head predates the guard does not carry the
# script in its checkout (this lane checks out the PR head, not the
# merge commit). Skip instead of failing that lane on a file it
# never had; every branch cut after the guard landed includes it.
if [ ! -f .github/scripts/ecs-runner/check-font-baseline.sh ]; then
echo 'font baseline check: skipped (script not present in this checkout)'
exit 0
fi
# A recoverable setup gap should not emit a failure annotation.
if GITHUB_ACTIONS='' bash .github/scripts/ecs-runner/check-font-baseline.sh; then
exit 0
fi
# Keep PR-supplied code at the runner's existing privilege level.
font_stage="$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen-fonts.XXXXXX")"
trap 'rm -rf -- "${font_stage}"' EXIT
mkdir -p "${font_stage}/lists/partial" "${font_stage}/packages"
apt-get -o "Dir::State::lists=${font_stage}/lists" update -qq
(
cd "${font_stage}/packages"
apt-get -o "Dir::State::lists=${font_stage}/lists" download fonts-dejavu-core fonts-dejavu-mono
)
for archive in "${font_stage}"/packages/*.deb; do
dpkg-deb --extract "${archive}" "${font_stage}/extracted"
done
font_dir="${XDG_DATA_HOME:-${HOME}/.local/share}/fonts/qwen-ci"
mkdir -p "${font_dir}"
cp -R "${font_stage}/extracted/usr/share/fonts/." "${font_dir}/"
fc-cache -f "${font_dir}"
bash .github/scripts/ecs-runner/check-font-baseline.sh
# The build comes from test_build's tree below, not from `prepare`.
- name: 'Install dependencies'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# Same handoff as the unit-test shards (see `test`).
- name: 'Download the built tree'
id: 'download_tree'
timeout-minutes: 15
continue-on-error: true
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Retry downloading the built tree'
if: "${{ steps.download_tree.outcome == 'failure' }}"
timeout-minutes: 15
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Unpack the built tree'
run: |-
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
rm -rf "${RUNNER_TEMP}/unit-test-tree"
# PR checkouts may predate the build-cache host-assets command.
if [ -f dist/cli.js ]; then
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
fi
- name: 'Install Playwright Chromium and WebKit (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
timeout-minutes: 30
run: |-
printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null
node node_modules/playwright/cli.js install --with-deps chromium webkit
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
if [ -f "${nested_cli}" ]; then
node "${nested_cli}" install chromium webkit
fi
- name: 'Install Playwright Chromium and WebKit (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
# Pool hosts carry Chromium's system libraries but not WebKit's: the
# first pool run of this job lacked GTK 4, GStreamer, libwoff2 and
# about 40 more. Install them only when Playwright's own host
# validation reports them missing, so each host installs them once and
# later jobs skip apt. sudo -n fails fast instead of prompting, and the
# lock timeout waits out another job's apt run on the same host.
timeout-minutes: 30
run: |-
install_log="${RUNNER_TEMP}/playwright-install.log"
node node_modules/playwright/cli.js install chromium webkit 2>&1 | tee "${install_log}"
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
if [ -f "${nested_cli}" ]; then
node "${nested_cli}" install chromium webkit 2>&1 | tee -a "${install_log}"
fi
if grep -q 'Host system is missing dependencies' "${install_log}"; then
apt_config="${RUNNER_TEMP}/apt-playwright.conf"
printf '%s\n' 'DPkg::Lock::Timeout "300";' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' > "${apt_config}"
sudo -n env APT_CONFIG="${apt_config}" "$(command -v node)" node_modules/playwright/cli.js install-deps chromium webkit
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'
if: '${{ matrix.shard == 1 }}'
env:
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
TMPDIR: '${{ runner.temp }}'
run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0'
- name: 'Run web-shell browser smoke'
env:
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
TMPDIR: '${{ runner.temp }}'
# Two workers is the hosted default (half of 4 vCPUs). Unpinned, a pool
# job would start browsers for half the host's cores while the host
# runs its other jobs.
run: 'npm run test:e2e:smoke --workspace=packages/web-shell -- --shard=${{ matrix.shard }}/4 --workers=2'
- name: 'Upload web-shell Playwright artifacts'
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-e2e-smoke-shard-${{ matrix.shard }}'
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.
#
# Preserve the original macOS check name while running its complete suites
# in bounded groups. The aggregate rejects failed, skipped or cancelled
# groups, including when one group fails before the others finish.
web_shell_e2e_smoke_result:
name: 'web-shell E2E Smoke (ubuntu-latest, Node 22.x)'
needs:
- 'classify_pr'
- 'test_build'
- 'web_shell_e2e_smoke'
if: |-
${{
!cancelled() &&
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
needs.classify_pr.outputs.skip_ci != 'true' &&
(needs.classify_pr.outputs.ci_profile || 'full') == 'full' &&
needs.test_build.result == 'success' &&
!github.event.pull_request.draft
}}
# The shards' runners: a hosted runner queued this one-line check for
# 13 minutes after the last shard finished.
runs-on: '${{ contains(fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]''), ''self-hosted'') && fromJSON(''["self-hosted", "linux", "arm64"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 5
permissions:
contents: 'read'
steps:
- name: 'Require every web-shell smoke shard to pass'
env:
SMOKE_RESULT: '${{ needs.web_shell_e2e_smoke.result }}'
run: 'test "$SMOKE_RESULT" = success'
test_macos:
name: 'Test (macos-latest, Node 22.x)'
needs: 'test_macos_shards'
if: |-
${{
always() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
)
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
steps:
- name: 'Require every macOS test group to pass'
env:
MACOS_RESULT: '${{ needs.test_macos_shards.result }}'
run: 'test "$MACOS_RESULT" = success'
test_macos_shards:
name: 'macOS unit tests (${{ matrix.group }} ${{ matrix.shard }})'
needs: 'classify_pr'
if: |-
${{
!cancelled() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
)
}}
strategy:
fail-fast: false
matrix:
include:
- group: 'cli'
shard: '1/3'
- group: 'cli'
shard: '2/3'
- group: 'cli'
shard: '3/3'
- group: 'core'
shard: '1/2'
- group: 'core'
shard: '2/2'
- group: 'rest'
shard: ''
- group: 'scripts'
shard: ''
runs-on: 'macos-latest'
timeout-minutes: 60
permissions:
contents: 'read'
steps:
# merge_group/schedule/dispatch only: this lane keeps
# refs/pull/N/head; the PR lanes pin 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'
id: 'install'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Hosted portable process smoke'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && matrix.group == 'scripts' }}"
timeout-minutes: 5
run: |-
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
- name: 'Run tests and generate reports'
if: "${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
MACOS_TEST_GROUP: '${{ matrix.group }}'
MACOS_TEST_SHARD: '${{ matrix.shard }}'
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
node scripts/run-macos-ci-group.js "$MACOS_TEST_GROUP" "$MACOS_TEST_SHARD"
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('::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('::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 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'
id: 'install'
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:workspaces
WORKSPACE_RC=$?
if [ "$WORKSPACE_RC" -ne 0 ]; then
echo '::warning::The Windows workspace suite has known baseline failures; its full result remains visible above.'
fi
npm run test:scripts
SCRIPTS_RC=$?
if [ "$SCRIPTS_RC" -ne 0 ]; then
echo '::warning::The complete Windows script suite has known baseline failures; its full result remains visible above.'
fi
npx vitest run scripts/tests/install-script.test.js scripts/tests/opentui-preview-install.test.js --config scripts/tests/vitest.config.ts --retry=0 --bail=1 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false
INSTALLER_RC=$?
set -e
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
kill "$SAMPLER_PID" 2>/dev/null || true
if [ "$INSTALLER_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 "$INSTALLER_RC"
# Unlike macOS, whose E2E legs run this file in full, no other Windows
# lane runs the Hosted cases, so this one runs all of them and fails if
# any is skipped. It runs after the unit tests so that a Hosted failure
# cannot hide their results, but only after a successful install: without
# node_modules and dist the suite cannot even collect and the red is then
# attributed to a test file that never ran.
- name: 'Hosted process suite'
if: "${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}"
timeout-minutes: 10
run: |-
npm run test:integration:hosted:sandbox:none -- --reporter=default --reporter=json --outputFile.json=hosted-process-suite.json
node -e "const r = require('./integration-tests/hosted-process-suite.json'); if (r.numTotalTests === 0 || r.numPassedTests !== r.numTotalTests) { console.error('::error::' + r.numPassedTests + ' of ' + r.numTotalTests + ' Hosted cases passed; Windows must run every case'); process.exit(1); }"
# 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, shard ${{ matrix.shard }}/3)'
# Since #16133 test_build uploads the tree this gate tests. Installing
# with `prepare` here rebuilt and rebundled it from scratch — about ten
# minutes, the longest single step on the required path — and the ~50
# files then ran one at a time (one fork on the pool, below), seventeen
# more minutes. The shards unpack the shared tree and split the files
# three ways; integration_no_ak_result keeps the required check name.
needs:
- 'classify_pr'
- 'test_build'
# 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.
#
# A draft pull request skips it (and the browser smoke): it cannot merge,
# and `ready_for_review` starts the full run that gates the merge.
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && !github.event.pull_request.draft }}"
# Shares lint's hosted/disposable routing for untrusted forks. Model keys
# are empty; the job receives only the read-only checkout token.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
# Dependency installation alone can exceed 30 minutes on a contended shared
# ECS host, before the separately bounded 20-minute integration step starts.
# Same self-hosted scoping as lint_and_static (60/30 like its 90/45):
# hosted fallback routing keeps the pre-contention ceiling, and the ARM64
# fleet — which carries no `ecs-qwen` label — gets the contended-pool
# allowance it runs under.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}'
permissions:
contents: 'read'
steps:
# This lane's fork code starts at the local checkout action and the
# install (lifecycle scripts AND scripts/prepare.js — build+bundle),
# not at the gate step, so isolate HOME from the FIRST step like
# lint_and_static: publish a run-unique HOME via GITHUB_ENV (job env
# cannot read the runner context), keep the content-verified linter
# cache shared, and keep COREPACK_HOME inside the per-run home
# (corepack does not re-verify entries on cache hit — a shared home is
# a cross-job poisoning channel). The gate step's key scrub stays.
- name: 'Create isolated HOME'
run: |-
home="${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -rf -- "$home"
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
- 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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (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,
# java_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|java_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'
# HOME is per-run (isolated above), so this cache dies with the job —
# the name must not promise persistence. The cold install is the
# accepted cost (review round-3); a shared pnpm store is a follow-up.
- name: 'Configure per-run 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 per-run 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}"'
# The build comes from test_build's tree below, not from `prepare`.
- name: 'Install Dependencies'
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
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
# Same handoff as the unit-test shards (see `test`).
- name: 'Download the built tree'
id: 'download_tree'
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 15
continue-on-error: true
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Retry downloading the built tree'
if: "${{ steps.download_tree.outcome == 'failure' }}"
timeout-minutes: 15
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'unit-test-tree'
path: '${{ runner.temp }}/unit-test-tree'
- name: 'Unpack the built tree'
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
rm -rf "${RUNNER_TEMP}/unit-test-tree"
# PR checkouts may predate the build-cache host-assets command.
if [ -f dist/cli.js ]; then
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
fi
- name: 'Run required no-AK integration gate'
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
timeout-minutes: 20
# HOME/USERPROFILE come from the first step's GITHUB_ENV publication
# (run-unique, cleared before use). QWEN_HOME rides the same home.
# The key scrub below is the zero-secrets contract: pinned by the
# routing battery (every non-path env value must be '').
env:
QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen'
API_KEY: ''
ANTHROPIC_API_KEY: ''
# The SDK auth resolver back-fills this from env; a runner-account
# residue would survive into fork PR code (review round-3).
ANTHROPIC_AUTH_TOKEN: ''
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: ''
QWENCLOUD_API_KEY: ''
QWENCLOUD_TOKEN_PLAN_API_KEY: ''
QWEN_DEFAULT_AUTH_TYPE: ''
run: |-
mkdir -p "${QWEN_HOME}"
if [ "${{ matrix.shard }}" = 1 ]; then
npm run typecheck:integration
fi
npm run test:integration:no-ak:sandbox:none -- --shard=${{ matrix.shard }}/3
# Reclaim the run-unique HOME (constructed path, never $HOME) so it
# does not accumulate in RUNNER_TEMP on this persistent pool.
- name: 'Clean isolated HOME'
if: '${{ always() }}'
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
# The required check. It passes only when the build and every shard pass,
# so a failed test_build (whose shards skip their steps) still fails it.
integration_no_ak_result:
name: 'Integration Tests (no-AK, No Sandbox)'
needs:
- 'classify_pr'
- 'test_build'
- 'integration_no_ak'
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && !github.event.pull_request.draft }}"
# ubuntu_runner, not the shards' lint_runner: the routing battery keeps
# the fork-admitting route to the lanes that isolate HOME, and this job
# checks out nothing.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
timeout-minutes: 5
permissions:
contents: 'read'
steps:
- name: 'Require the build and every no-AK integration shard to pass'
env:
BUILD_RESULT: '${{ needs.test_build.result }}'
SHARDS_RESULT: '${{ needs.integration_no_ak.result }}'
run: 'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success'
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"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# 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: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- 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 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