Skip to content

feat: chat transcript mr2a html export #35779

feat: chat transcript mr2a html export

feat: chat transcript mr2a html export #35779

Workflow file for this run

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