mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(ci): add disposable external PR runners * test(ci): align platform lanes with disposable runner routing --------- Co-authored-by: yiliang114 <[email protected]>
3492 lines
202 KiB
YAML
3492 lines
202 KiB
YAML
# .github/workflows/ci.yml
|
||
|
||
name: 'Qwen Code CI'
|
||
|
||
on:
|
||
# This trigger was removed on the premise that the merge queue validates the
|
||
# merged tree before it lands. No queue is enabled (none since 2026-07-02),
|
||
# so nothing validated that tree on either side of a merge: `main` had no
|
||
# gate before one and nothing faster than the ~40-minute E2E after one.
|
||
# `test_build`, `test`, `test_ubuntu`, `lint_and_static`, and the
|
||
# `classify_pr` they depend on accept it (see their `if`s); every other job
|
||
# excludes push, so a post-merge run is exactly those five. The no-AK
|
||
# integration gate is its own `integration_no_ak` job on pull_request /
|
||
# merge_group, so this lane is lint, static analysis and unit tests.
|
||
# Removing it once the queue is enabled means dropping the trigger AND the
|
||
# push arm from ALL FIVE `if`s: a partial removal leaves every merge
|
||
# running the survivors alone, publishing outputs nothing consumes.
|
||
push:
|
||
branches:
|
||
- 'main'
|
||
pull_request:
|
||
branches:
|
||
- 'main'
|
||
- 'release/**'
|
||
- 'omni-experiment'
|
||
# The default three plus ready_for_review: a draft skips the no-AK
|
||
# integration gate and the browser smoke, so leaving draft has to start
|
||
# the run that includes them.
|
||
types:
|
||
- 'opened'
|
||
- 'synchronize'
|
||
- 'reopened'
|
||
- 'ready_for_review'
|
||
merge_group:
|
||
# The macOS and Windows lanes' ONLY remaining trigger, and therefore this
|
||
# repository's only signal about a host that is not Linux with a GNU
|
||
# userland. Those two are otherwise gated on `merge_group`, and the merge
|
||
# queue is not enabled here — no queue run since 2026-07-02 — while their
|
||
# pull-request trigger is off until the standing Windows failures are fixed
|
||
# (see test_macos). A regression therefore surfaces here, one day later, on
|
||
# `main`, and nowhere else: treat a red nightly as a blocker, not as noise.
|
||
# Every other job here excludes `schedule` explicitly, so a nightly run is
|
||
# exactly two jobs.
|
||
schedule:
|
||
- cron: '17 19 * * *'
|
||
workflow_dispatch:
|
||
inputs:
|
||
branch_ref:
|
||
description: 'Branch to run on'
|
||
required: true
|
||
default: 'main'
|
||
type: 'string'
|
||
linux_runner:
|
||
description: 'Linux runner to use for manual validation'
|
||
required: true
|
||
default: 'self-hosted'
|
||
type: 'choice'
|
||
options:
|
||
- 'self-hosted'
|
||
- 'self-hosted-arm64'
|
||
- 'hosted'
|
||
|
||
concurrency:
|
||
# Event + source repo + ref, not ref alone. Without the event the group
|
||
# collapses onto `Qwen Code CI-refs/heads/main`, shared with the nightly
|
||
# (60-minute lanes, neither cancelling on `main`). Without the source repo,
|
||
# same-named branches across forks collide — they evaluate in the base
|
||
# repo's namespace and PR runs DO cancel in progress, so a fork whose
|
||
# default branch is `main` cancels a victim PR's run on every push.
|
||
# Supersede within a PR is kept: one branch backs one open PR per fork.
|
||
# A ref-only group replaces older pending main pushes even when cancellation
|
||
# is disabled. Give each push-tip SHA its own group so every merged tree can
|
||
# report independently. Repeated pushes/reruns of one SHA still share a group.
|
||
# Keep running main and release validations uncancellable.
|
||
group: |-
|
||
${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.sha || github.head_ref || github.ref }}
|
||
cancel-in-progress: |-
|
||
${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}
|
||
|
||
permissions:
|
||
checks: 'write'
|
||
contents: 'read'
|
||
statuses: 'write'
|
||
|
||
defaults:
|
||
run:
|
||
shell: 'bash'
|
||
|
||
env:
|
||
ACTIONLINT_VERSION: '1.7.12'
|
||
SHELLCHECK_VERSION: '0.11.0'
|
||
YAMLLINT_VERSION: '1.35.1'
|
||
# Single source of truth for the .github/scripts node:test suites — run by
|
||
# BOTH helper steps in `lint_and_static` (the github_ci_only fast lane and
|
||
# the full-profile one), so a new helper test can't be added to one path
|
||
# and silently dropped from the other. The fast lane runs before ANY
|
||
# dependency install in that job — its setup-node, self-hosted node and
|
||
# `npm ci` steps are all gated on the full profile — so it takes only
|
||
# HELPER_TESTS_DEP_FREE, suites whose import closure is node: builtins
|
||
# alone. HELPER_TESTS keeps the full list: the full-profile helper step
|
||
# re-runs every suite after `npm ci`, and a push to main always classifies
|
||
# `full` (classify_pr computes a profile only for pull_request), so what
|
||
# the fast lane skips is still re-checked post-merge.
|
||
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.test.mjs'
|
||
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.test.mjs'
|
||
# The growth ratchet and its vitest mirror compare each workflow against
|
||
# the PR's base commit to tell "this PR grew the file" apart from "the
|
||
# baseline went stale on main" (#9904). Wired once here so every lane
|
||
# inherits it — the gate step and every `npm run test:ci` step, whatever
|
||
# it is named — instead of each step hand-wiring a copy.
|
||
# `github.event.before` is the push lane's base: on a squash-merge push it is
|
||
# main's previous tip, restoring the PR lane's leniency for a baseline that
|
||
# went stale on main. Without it both enforcers see an empty base and fail
|
||
# closed, so the next stale baseline reddens every merge while every PR stays
|
||
# green — telling you to "update .size-baseline in this PR" when no PR
|
||
# exists. `github.sha` is deliberately not used: on workflow_dispatch it
|
||
# resolves to the checked-out commit and would compare the file against
|
||
# itself, failing open. An all-zeros `before` still fails closed.
|
||
WORKFLOW_SIZE_BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}'
|
||
|
||
jobs:
|
||
classify_pr:
|
||
name: 'Classify PR'
|
||
if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}"
|
||
# Gate runs on ECS for EVERY event it accepts — pull requests (untrusted
|
||
# forks included), merge queue, pushes to main, and dispatch — unless the
|
||
# kill-switch forces hosted. classify executes only trusted base-SHA code
|
||
# with a read-only token; the residual PR-YAML risk is the same class
|
||
# accepted for lint_and_static (2026-10-31 decision, see
|
||
# docs/design/2026-10-31-lint-static-ecs-routing.md). Keeping the gate on
|
||
# hosted for forks queued the entire fork CI chain behind a scarce pool.
|
||
# The association routing for the downstream lanes lives in pick_runner
|
||
# (ubuntu_runner) below; sdk-java.yml and serve-ab.yml mirror that form
|
||
# (the routing tests hold the mirrors to it). test_windows deliberately
|
||
# does not: a pull_request run executes the PR's own YAML, so no runs-on
|
||
# trust clause is enforceable there and its lane never admits pull
|
||
# requests to the pool.
|
||
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||
continue-on-error: true
|
||
permissions:
|
||
contents: 'read'
|
||
pull-requests: 'read'
|
||
outputs:
|
||
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
|
||
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
|
||
pr_test_runner: '${{ steps.pick_runner.outputs.pr_test_runner }}'
|
||
# Untrusted forks use hosted runners unless the disposable pool is enabled.
|
||
lint_runner: '${{ steps.pick_runner.outputs.lint_runner }}'
|
||
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
|
||
steps:
|
||
- name: 'Detect release version-sync PR'
|
||
id: 'release_sync'
|
||
env:
|
||
# Repository variables can override these defaults if release naming
|
||
# or the CI bot account changes.
|
||
HEAD_REPO: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || '' }}"
|
||
HEAD_REF: "${{ github.event_name == 'pull_request' && github.head_ref || '' }}"
|
||
PR_TITLE: "${{ github.event_name == 'pull_request' && github.event.pull_request.title || '' }}"
|
||
RELEASE_SYNC_HEAD_PREFIX: "${{ vars.RELEASE_SYNC_HEAD_PREFIX || 'release/' }}"
|
||
RELEASE_SYNC_TITLE_PREFIX: "${{ vars.RELEASE_SYNC_TITLE_PREFIX || 'chore(release):' }}"
|
||
RELEASE_SYNC_ACTOR: "${{ vars.RELEASE_SYNC_ACTOR || 'qwen-code-ci-bot' }}"
|
||
run: |-
|
||
skip_ci=false
|
||
repo_match=false
|
||
actor_match=false
|
||
head_match=false
|
||
title_match=false
|
||
[[ "${HEAD_REPO}" == "${GITHUB_REPOSITORY}" ]] && repo_match=true
|
||
[[ "${GITHUB_ACTOR}" == "${RELEASE_SYNC_ACTOR}" ]] && actor_match=true
|
||
[[ "${HEAD_REF}" == "${RELEASE_SYNC_HEAD_PREFIX}"* ]] && head_match=true
|
||
[[ "${PR_TITLE}" == "${RELEASE_SYNC_TITLE_PREFIX}"* ]] && title_match=true
|
||
|
||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" &&
|
||
"${repo_match}" == "true" &&
|
||
"${actor_match}" == "true" &&
|
||
"${head_match}" == "true" &&
|
||
"${title_match}" == "true" ]]; then
|
||
skip_ci=true
|
||
echo "Release sync PR detected: actor=${GITHUB_ACTOR}, head_ref=${HEAD_REF}, title=${PR_TITLE}"
|
||
else
|
||
echo "Not a release sync PR: event=${GITHUB_EVENT_NAME}, actor=${GITHUB_ACTOR}, expected_actor=${RELEASE_SYNC_ACTOR}, repo_match=${repo_match}, head_match=${head_match}, title_match=${title_match}"
|
||
fi
|
||
|
||
echo "skip_ci=${skip_ci}" >> "${GITHUB_OUTPUT}"
|
||
echo "skip_ci=${skip_ci}"
|
||
|
||
# In-repo PRs, fork PRs whose author has write access
|
||
# (OWNER/MEMBER/COLLABORATOR association), the merge queue (base-repo
|
||
# context) and a push to `main` run the Linux jobs on ECS; other fork
|
||
# PRs stay hosted. Disable via repo var
|
||
# MAINTAINER_ECS_RUNNER_DISABLED=true.
|
||
#
|
||
# Keep push routing aligned with the classifier above so a hosted queue
|
||
# does not delay the downstream ECS jobs.
|
||
#
|
||
# Untrusted forks opt into the disposable pool; otherwise their build,
|
||
# test, lint and no-AK integration jobs stay hosted.
|
||
- name: 'Select Linux runner'
|
||
id: 'pick_runner'
|
||
env:
|
||
SAME_REPO: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
|
||
AUTHOR_ASSOCIATION: '${{ github.event.pull_request.author_association }}'
|
||
ECS_DISABLED: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED }}'
|
||
EPHEMERAL_ENABLED: '${{ vars.EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED }}'
|
||
EPHEMERAL_ARCH: '${{ vars.EXTERNAL_PR_EPHEMERAL_ARCH }}'
|
||
# Optional fleet selector: 'hosted' forces github.com-hosted runners,
|
||
# 'arm64' routes to the experimental ARM64 ECS fleet, anything else
|
||
# keeps the x64 ecs-qwen fleet. Empty/unset keeps current behavior.
|
||
LINUX_RUNNER_FLEET: '${{ vars.LINUX_RUNNER_FLEET }}'
|
||
EVENT_NAME: '${{ github.event_name }}'
|
||
DISPATCH_LINUX_RUNNER: '${{ github.event.inputs.linux_runner }}'
|
||
run: |-
|
||
ubuntu_runner='["ubuntu-latest"]'
|
||
trusted_author=false
|
||
case "${AUTHOR_ASSOCIATION}" in
|
||
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
|
||
esac
|
||
# Resolve fleet labels once; both outputs assign from this.
|
||
fleet_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
|
||
fleet_runner='["self-hosted", "linux", "arm64"]'
|
||
fi
|
||
# Dispatch input wins over the fleet variable; MAINTAINER_ECS_RUNNER_
|
||
# DISABLED=true still hard-forces hosted runners everywhere.
|
||
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||
if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then
|
||
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||
elif [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted-arm64" ]]; then
|
||
# Experimental ARM64 ECS fleet (Hangzhou, ecs.q8y). No arch label
|
||
# set is required by any production lane; this only selects the
|
||
# fleet that self-identifies as ARM64.
|
||
ubuntu_runner='["self-hosted", "linux", "arm64"]'
|
||
fi
|
||
elif [[ "${ECS_DISABLED}" == "true" || "${LINUX_RUNNER_FLEET}" == "hosted" ]]; then
|
||
ubuntu_runner='["ubuntu-latest"]'
|
||
elif [[ "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ]]; then
|
||
ubuntu_runner="${fleet_runner}"
|
||
fi
|
||
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected Linux runner: ${ubuntu_runner}"
|
||
pr_test_runner="${ubuntu_runner}"
|
||
lint_runner="${ubuntu_runner}"
|
||
if [[ "${EPHEMERAL_ENABLED}" == "true" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" && "${EVENT_NAME}" == "pull_request" && "${SAME_REPO}" == "false" && "${trusted_author}" == "false" ]]; then
|
||
pr_test_runner='["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"]'
|
||
if [[ "${EPHEMERAL_ARCH}" == "arm64" ]]; then
|
||
pr_test_runner='["self-hosted", "linux", "arm64", "ecs-qwen-ephemeral"]'
|
||
fi
|
||
lint_runner="${pr_test_runner}"
|
||
fi
|
||
echo "pr_test_runner=${pr_test_runner}" >> "${GITHUB_OUTPUT}"
|
||
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected lint runner: ${lint_runner}"
|
||
|
||
# The product jobs later check out the PR head. Classify before that
|
||
# from an explicit base-SHA checkout so no fork can replace the
|
||
# classifier implementation it asks CI to execute. The event token only
|
||
# needs PR read access.
|
||
- name: 'Restore workspace ownership'
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# actions/checkout reuses a leftover directory whose origin URL
|
||
# matches and runs git (hooks included) inside it, so the trusted
|
||
# classifier checkout below must not inherit a residue from an
|
||
# earlier run on the shared pool; the sweep removes it like the
|
||
# .qwen names (see the test job's sweep comment for the rule).
|
||
- name: 'Clean stale .qwen before checkout'
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
- name: 'Verify trusted classifier checkout is clean'
|
||
if: "${{ github.event_name == 'pull_request' }}"
|
||
run: |-
|
||
if [ -e "$GITHUB_WORKSPACE/trusted-ci-classifier" ] ||
|
||
[ -L "$GITHUB_WORKSPACE/trusted-ci-classifier" ]; then
|
||
echo "::error::trusted-ci-classifier survived cleanup; refusing to reuse it"
|
||
exit 1
|
||
fi
|
||
|
||
- name: 'Checkout trusted CI classifier'
|
||
if: "${{ github.event_name == 'pull_request' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
repository: '${{ github.repository }}'
|
||
ref: '${{ github.event.pull_request.base.sha }}'
|
||
path: 'trusted-ci-classifier'
|
||
sparse-checkout: '.github/scripts/ci'
|
||
fetch-depth: 1
|
||
persist-credentials: false
|
||
|
||
- name: 'Classify CI profile from trusted base'
|
||
id: 'ci_profile'
|
||
env:
|
||
GH_TOKEN: '${{ github.token }}'
|
||
PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}"
|
||
run: |-
|
||
profile=full
|
||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then
|
||
set +e
|
||
profile="$(trusted-ci-classifier/.github/scripts/ci/classify-pr-profile.sh "${GITHUB_REPOSITORY}" "${PR_NUMBER}")"
|
||
classify_rc=$?
|
||
set -e
|
||
if [ "$classify_rc" -eq 2 ]; then
|
||
echo "::warning::Unable to list PR changed files; running full CI."
|
||
profile=full
|
||
elif [ "$classify_rc" -ne 0 ]; then
|
||
echo "::error::Trusted CI profile classifier exited non-zero; running full CI."
|
||
profile=full
|
||
fi
|
||
fi
|
||
case "${profile}" in
|
||
docs_only|github_ci_only|java_only|full) ;;
|
||
*)
|
||
echo "::error::Trusted CI profile classifier returned '${profile}'; running full CI."
|
||
profile=full
|
||
;;
|
||
esac
|
||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected trusted CI profile: ${profile}"
|
||
|
||
#
|
||
# Test: Node
|
||
#
|
||
# Builds the tree every unit-test shard runs against. The `prepare` build
|
||
# takes most of ten minutes on the shared pool; doing it once here lets
|
||
# each shard install without it and unpack this archive. A pull request
|
||
# also starts from the newest cached build and rebuilds only the
|
||
# workspaces whose inputs changed (scripts/ci-build-cache.mjs).
|
||
test_build:
|
||
name: 'Build unit-test tree (ubuntu-latest, Node 22.x)'
|
||
needs: 'classify_pr'
|
||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || ''["ubuntu-latest"]'') }}'
|
||
timeout-minutes: 45
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
|
||
# verify) may leave root-owned, read-only files anywhere in the workspace.
|
||
# Restore ownership and write permission unconditionally so the checkout
|
||
# below can wipe the tree without EACCES. Do not gate this behind a
|
||
# .qwen/.git probe: poisoning is workspace-wide (root-owned node_modules/
|
||
# dist with no .qwen/.git), so a probe reports "healthy" and skips the
|
||
# recovery exactly when it is needed.
|
||
- name: 'Restore workspace ownership'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Remove .qwen/ and its recovery backup left by a previous job so
|
||
# actions/checkout doesn't trip on restrictive permissions.
|
||
# `.qwen.root-orig` is emitted by recovery tooling OUTSIDE this repo —
|
||
# nothing here produces it (git grep matches only these sweep copies
|
||
# and their pins in scripts/tests/review-worktree-cleanup-workflow.
|
||
# test.js). It is the backup name a cancelled verify's recovery leaves
|
||
# after renaming an unreadable, root-owned `.qwen` aside (observed on
|
||
# the shared pool; first recorded around review run 33146730771). If
|
||
# that producer's naming changes or a third residue name appears,
|
||
# update the for-loop list in every sweep copy or the checkout
|
||
# poisoning this sweep exists for silently recurs.
|
||
# `trusted-ci-classifier` is the third residue name: classify_pr's
|
||
# base-SHA checkout path. actions/checkout reuses a leftover copy
|
||
# whose origin URL matches and runs git (hooks included) inside it,
|
||
# so it is swept rather than warmed.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
|
||
# retry, while github.ref is the asynchronously rebuilt merge ref and
|
||
# can lag behind a push. Neither mutable ref identifies this run's head.
|
||
# Merge queue refs are ephemeral; check out the event head SHA directly so
|
||
# slow hosted runners do not fail after the queue branch is removed.
|
||
# Non-PR/non-queue events keep github.ref.
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
id: 'checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
# Pin push runs to the event SHA: the `github.ref` fallback resolves
|
||
# `refs/heads/main` at fetch time — a moving tip — while the check run
|
||
# attaches to the triggering commit, so the lane would validate a tree
|
||
# it does not report on. Nothing detects that (the checkout-head
|
||
# verifier is gated to pull_request / merge_group, and its ancestor
|
||
# check passes for a newer tip regardless), and merges land close
|
||
# enough together to hit it routinely. Regressions are still caught —
|
||
# the tested tree is a descendant — but attribution breaks: red on a
|
||
# clean commit, autofix filed against the wrong merge. `e2e.yml` pins
|
||
# the event SHA by taking actions/checkout's default.
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}"
|
||
# Shallow: nothing here walks git history except on demand (the
|
||
# verify guard below checks head.sha == HEAD; the size gate and its
|
||
# vitest mirror fetch the PR's base commit at depth 1 when the
|
||
# baseline went stale; everything else touches only the working
|
||
# tree). On the in-repo ECS runner a full-history clone is the
|
||
# heaviest transfer and chokes the squid egress proxy, flaking
|
||
# checkout. depth 1 is enough.
|
||
fetch-depth: 1
|
||
|
||
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
|
||
# ref) silently testing the wrong tree. Cheap: one merge-base, sub-second.
|
||
# Also runs in the merge queue — now that the queue's Ubuntu checkout is on
|
||
# ECS/squid, a wrong-tree pass would merge bad code.
|
||
- name: 'Verify checkout includes expected head commit'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
||
|
||
- name: 'Use trusted CI profile'
|
||
id: 'ci_profile'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
env:
|
||
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
|
||
run: |-
|
||
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
|
||
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
|
||
fi
|
||
profile="${TRUSTED_CI_PROFILE:-full}"
|
||
case "${profile}" in
|
||
docs_only|github_ci_only|java_only|full) ;;
|
||
*) profile=full ;;
|
||
esac
|
||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected CI profile: ${profile}"
|
||
|
||
- name: 'Docs-only CI'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'docs_only' }}"
|
||
run: 'echo "Docs-only change; full CI skipped."'
|
||
|
||
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
||
# through the egress proxy; reuse the machine's Node instead.
|
||
- name: 'Set up Node.js 22.x (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
# Fail fast on a saturated self-hosted host instead of dying on ENOSPC
|
||
# mid-install (#10035): the workspace cleaner skips busy runners, so a
|
||
# busy fleet can hit 100% while jobs keep being admitted. Gating before
|
||
# `npm ci` turns that into a clear, retryable reschedule onto a host
|
||
# with headroom, instead of a corrupted half-run.
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
# scripts/ci-build-cache.mjs runs the build after the install.
|
||
- name: 'Install dependencies'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
# Every cap below stays under the job's 45 so a stalled host fails the
|
||
# step and leaves the failure-gated collectors at the end reachable. A
|
||
# job timeout cancels the run and skips them, which is the case those
|
||
# collectors exist for.
|
||
timeout-minutes: 30
|
||
env:
|
||
QWEN_SKIP_PREPARE: '1'
|
||
run: |-
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
|
||
sample_disk() {
|
||
hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true)
|
||
sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true
|
||
echo "$sample"
|
||
echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true
|
||
}
|
||
sample_disk
|
||
( while sleep 10; do sample_disk; done ) &
|
||
SAMPLER_PID=$!
|
||
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
|
||
# Observed: the pinned cache entry existed with its pnpm binary
|
||
# missing, corepack trusts an existing entry without re-verifying
|
||
# its files, and the install died instantly with MODULE_NOT_FOUND
|
||
# (main run 41927887452). What left the entry incomplete is
|
||
# undetermined; _work/_temp survives across jobs on self-hosted
|
||
# runners, so such state persists. Start the shard-owned cache
|
||
# empty — and only that exact path, so a runner-exported shared
|
||
# cache and lint's already-warmed run-unique home stay untouched.
|
||
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
|
||
rm -rf -- "${COREPACK_HOME}"
|
||
fi
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Report npm cache usage (self-hosted)'
|
||
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
||
echo "npm cache: ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
# base_key is the key the base commit's own push run saved under. A
|
||
# bare prefix restore returns the NEWEST entry, which can be many main
|
||
# commits away from this branch; once any root file or build script
|
||
# differs, every workspace rebuilds ("29 of 29 workspaces changed").
|
||
# The base commit is fetched treeless-of-blobs: the key reads blob ids
|
||
# from its trees, never file contents. A failed fetch only drops back
|
||
# to the prefix restore.
|
||
- name: 'Hash the build inputs'
|
||
id: 'build_key'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
env:
|
||
BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}'
|
||
run: |-
|
||
echo "key=$(node scripts/ci-build-cache.mjs key)" >> "${GITHUB_OUTPUT}"
|
||
base_key=''
|
||
if [ -n "${BASE_SHA}" ] &&
|
||
git fetch --quiet --no-tags --depth=1 --filter=blob:none origin "${BASE_SHA}"; then
|
||
base_key="$(node scripts/ci-build-cache.mjs key --at "${BASE_SHA}")" || base_key=''
|
||
fi
|
||
echo "base_key=${base_key}" >> "${GITHUB_OUTPUT}"
|
||
echo "Base ${BASE_SHA:-none} build key: ${base_key:-none}"
|
||
|
||
# A push to main always builds from scratch, so every cache a pull
|
||
# request starts from is a clean build of main; a pull request whose
|
||
# restore found nothing saves its own clean build for its next run.
|
||
# The path stays relative: actions/cache versions an entry by its path,
|
||
# and runner.temp differs between pool hosts, so one host could never
|
||
# restore what another saved.
|
||
#
|
||
# The key carries no runner.arch. The x64-labelled pool includes ARM64
|
||
# hosts, and every built path is JavaScript except the bundle's opentui
|
||
# native library, which `build` recopies for the host after restoring
|
||
# and every consumer recopies after unpacking. Split
|
||
# by arch, each half of the pool restored the other half's misses.
|
||
- name: 'Restore the build cache'
|
||
id: 'build_cache'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name != 'push' }}"
|
||
timeout-minutes: 5
|
||
continue-on-error: true
|
||
uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
|
||
with:
|
||
path: '.qwen-build-cache'
|
||
key: 'qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.key }}'
|
||
restore-keys: |-
|
||
qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.base_key || 'no-base-key' }}
|
||
qwen-build-v2-${{ runner.os }}-
|
||
|
||
# DEV=true makes the bundle write dist/esbuild.json, the metafile the
|
||
# serve fast-path closure check below reads; nothing else in the build
|
||
# reads DEV.
|
||
- name: 'Build'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 25
|
||
env:
|
||
DEV: 'true'
|
||
run: 'node scripts/ci-build-cache.mjs build .qwen-build-cache'
|
||
|
||
# These checks read the built tree. They used to run in Lint & Static,
|
||
# which built and bundled the CLI a second time for them (~9 minutes);
|
||
# here they read the tree every other lane tests, in seconds. They stay
|
||
# required: `Test (ubuntu-latest, Node 22.x)` passes only when this job
|
||
# does. Linux-only on purpose — macOS/Windows run `test:ci` alone.
|
||
- name: 'Check serve fast-path bundle closure'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 5
|
||
run: 'node scripts/check-serve-fast-path-bundle.js'
|
||
|
||
# cli sources name core modules by path (`.../config/storage.js`). In the
|
||
# repo those resolve through tsconfig paths or vitest aliases; in the
|
||
# published package neither exists and Node resolves them against core's
|
||
# `exports`, where one `./*` entry carries all of them. Nothing else
|
||
# exercises that entry, so without this a change to it — or to the
|
||
# dist layout — leaves every suite green and breaks `qwen` at startup.
|
||
- name: 'Check core subpath exports resolve'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 5
|
||
run: 'npm run check:core-subpath-exports'
|
||
|
||
# The generator imports core's dist, so it lives with the build too.
|
||
- name: 'Generate settings schema'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run generate:settings-schema'
|
||
|
||
- name: 'Check settings schema is up-to-date'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then
|
||
echo "Error: settings.schema.json is out of date."
|
||
echo "Please run: npm run generate:settings-schema"
|
||
echo "Then commit the updated schema file."
|
||
git diff packages/vscode-ide-companion/schemas/settings.schema.json
|
||
exit 1
|
||
fi
|
||
echo "Settings schema is up-to-date"
|
||
|
||
- name: 'Pack the build cache'
|
||
id: 'pack_build_cache'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && (github.event_name == 'push' || steps.build_cache.outputs.cache-matched-key == '') }}"
|
||
timeout-minutes: 8
|
||
run: |-
|
||
rm -rf .qwen-build-cache
|
||
node scripts/ci-build-cache.mjs pack .qwen-build-cache
|
||
|
||
- name: 'Save the build cache'
|
||
if: "${{ steps.pack_build_cache.outcome == 'success' }}"
|
||
timeout-minutes: 5
|
||
continue-on-error: true
|
||
uses: 'actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
|
||
with:
|
||
path: '.qwen-build-cache'
|
||
key: 'qwen-build-v2-${{ runner.os }}-${{ steps.build_key.outputs.key }}'
|
||
|
||
# Everything the build wrote: the ignored, untracked paths outside
|
||
# node_modules, which each shard's own install recreates. The
|
||
# checkout's clean leaves nothing else ignored in the workspace. The
|
||
# path list comes from `ci-build-cache.mjs` so `pack` and this archive
|
||
# cannot disagree about what the build produces.
|
||
- name: 'Archive the built tree'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 8
|
||
run: |-
|
||
node scripts/ci-build-cache.mjs paths |
|
||
tar --null -T - -czf "${RUNNER_TEMP}/unit-test-tree.tgz"
|
||
du -h "${RUNNER_TEMP}/unit-test-tree.tgz"
|
||
|
||
- name: 'Upload the built tree'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree.tgz'
|
||
compression-level: 0
|
||
overwrite: true
|
||
retention-days: 7
|
||
|
||
# This job carries the same sampling install as the shards but builds for
|
||
# up to its whole timeout afterwards, and its sampler dies with that
|
||
# step's trap … EXIT. Without a collector here, a build that dies on a
|
||
# saturated host (the #10035 class, and the OOM that took hk2-11) keeps
|
||
# no evidence at all — the samples go with the runner temp dir.
|
||
- name: 'Dump disk state on failure'
|
||
if: '${{ failure() }}'
|
||
run: |-
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
{
|
||
echo "DISKCONTEXT failure-dump $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
df -hT || df -h || true
|
||
df -i || true
|
||
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo || true
|
||
} 2>/dev/null | tee -a "$DISK_SAMPLES" || true
|
||
|
||
# Last step, and a name distinct from the test and lint collectors:
|
||
# upload-artifact v4+ rejects duplicate names when several jobs fail in
|
||
# one run.
|
||
- name: 'Upload disk-pressure samples'
|
||
if: '${{ failure() }}'
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'disk-pressure-build-${{ github.run_id }}-attempt-${{ github.run_attempt }}'
|
||
path: '${{ runner.temp }}/disk-pressure-samples.log'
|
||
if-no-files-found: 'ignore'
|
||
retention-days: 14
|
||
|
||
# The required `Test (ubuntu-latest, Node 22.x)` check is `test_ubuntu`,
|
||
# which passes only when the build and every shard below pass.
|
||
test:
|
||
name: 'Linux unit tests (shard ${{ matrix.shard }}/8)'
|
||
needs: ['classify_pr', 'test_build']
|
||
# Stay running on release-sync PRs so the required Test contexts still
|
||
# report; the per-step skip_ci guards below make them no-op (pass) there.
|
||
# Accepts push on `main`: one of the two post-merge checks faster than the
|
||
# ~40-minute E2E — the lint/static half lives in `lint_and_static` — and
|
||
# with no merge queue enabled nothing validates the merged tree before it
|
||
# lands either. The expensive and PR-shaped steps stay off it on their own
|
||
# gates, each by its own mechanism — the checkout-head verifier is
|
||
# restricted to pull_request / merge_group, while the test reporter
|
||
# carries no event gate at all: its same-repo clause (head.repo.full_name
|
||
# == github.repository) is empty on push. Do not "normalise" that clause
|
||
# into the event allowlist — it also keeps fork PRs out, and
|
||
# dorny/test-reporter needs `checks: write`, which fork-event runs do not
|
||
# have. So a push run is lint, static analysis and unit tests —
|
||
# lint/static in `lint_and_static`, unit tests here.
|
||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || ''["ubuntu-latest"]'') }}'
|
||
# Every Vitest run takes one eighth of its suite's files (`--shard`): the
|
||
# unsharded lane ran 45-59 minutes, almost all of it the CLI and core
|
||
# suites one after the other. A shard keeps the old ceiling's headroom
|
||
# for a contended ECS host; the test step's own cap bounds a genuine hang.
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||
timeout-minutes: 60
|
||
outputs:
|
||
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
|
||
permissions:
|
||
contents: 'read'
|
||
checks: 'write'
|
||
pull-requests: 'write'
|
||
steps:
|
||
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
|
||
# verify) may leave root-owned, read-only files anywhere in the workspace.
|
||
# Restore ownership and write permission unconditionally so the checkout
|
||
# below can wipe the tree without EACCES. Do not gate this behind a
|
||
# .qwen/.git probe: poisoning is workspace-wide (root-owned node_modules/
|
||
# dist with no .qwen/.git), so a probe reports "healthy" and skips the
|
||
# recovery exactly when it is needed.
|
||
- name: 'Restore workspace ownership'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Remove .qwen/ and its recovery backup left by a previous job so
|
||
# actions/checkout doesn't trip on restrictive permissions.
|
||
# `.qwen.root-orig` is emitted by recovery tooling OUTSIDE this repo —
|
||
# nothing here produces it (git grep matches only these sweep copies
|
||
# and their pins in scripts/tests/review-worktree-cleanup-workflow.
|
||
# test.js). It is the backup name a cancelled verify's recovery leaves
|
||
# after renaming an unreadable, root-owned `.qwen` aside (observed on
|
||
# the shared pool; first recorded around review run 33146730771). If
|
||
# that producer's naming changes or a third residue name appears,
|
||
# update the for-loop list in every sweep copy or the checkout
|
||
# poisoning this sweep exists for silently recurs.
|
||
# `trusted-ci-classifier` is the third residue name: classify_pr's
|
||
# base-SHA checkout path. actions/checkout reuses a leftover copy
|
||
# whose origin URL matches and runs git (hooks included) inside it,
|
||
# so it is swept rather than warmed.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
|
||
# retry, while github.ref is the asynchronously rebuilt merge ref and
|
||
# can lag behind a push. Neither mutable ref identifies this run's head.
|
||
# Merge queue refs are ephemeral; check out the event head SHA directly so
|
||
# slow hosted runners do not fail after the queue branch is removed.
|
||
# Non-PR/non-queue events keep github.ref.
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
id: 'checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
# Pin push runs to the event SHA: the `github.ref` fallback resolves
|
||
# `refs/heads/main` at fetch time — a moving tip — while the check run
|
||
# attaches to the triggering commit, so the lane would validate a tree
|
||
# it does not report on. Nothing detects that (the checkout-head
|
||
# verifier is gated to pull_request / merge_group, and its ancestor
|
||
# check passes for a newer tip regardless), and merges land close
|
||
# enough together to hit it routinely. Regressions are still caught —
|
||
# the tested tree is a descendant — but attribution breaks: red on a
|
||
# clean commit, autofix filed against the wrong merge. `e2e.yml` pins
|
||
# the event SHA by taking actions/checkout's default.
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}"
|
||
# Shallow: nothing here walks git history except on demand (the
|
||
# verify guard below checks head.sha == HEAD; the size gate and its
|
||
# vitest mirror fetch the PR's base commit at depth 1 when the
|
||
# baseline went stale; everything else touches only the working
|
||
# tree). On the in-repo ECS runner a full-history clone is the
|
||
# heaviest transfer and chokes the squid egress proxy, flaking
|
||
# checkout. depth 1 is enough.
|
||
fetch-depth: 1
|
||
|
||
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
|
||
# ref) silently testing the wrong tree. Cheap: one merge-base, sub-second.
|
||
# Also runs in the merge queue — now that the queue's Ubuntu checkout is on
|
||
# ECS/squid, a wrong-tree pass would merge bad code.
|
||
- name: 'Verify checkout includes expected head commit'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
||
|
||
- name: 'Use trusted CI profile'
|
||
id: 'ci_profile'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
env:
|
||
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
|
||
run: |-
|
||
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
|
||
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
|
||
fi
|
||
profile="${TRUSTED_CI_PROFILE:-full}"
|
||
case "${profile}" in
|
||
docs_only|github_ci_only|java_only|full) ;;
|
||
*) profile=full ;;
|
||
esac
|
||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected CI profile: ${profile}"
|
||
|
||
- name: 'Docs-only CI'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'docs_only' }}"
|
||
run: 'echo "Docs-only change; full CI skipped."'
|
||
|
||
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
||
# through the egress proxy; reuse the machine's Node instead.
|
||
- name: 'Set up Node.js 22.x (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
# The test step's side lane (.github/scripts/ci/run-unit-test-lanes.sh)
|
||
# runs the standalone runtime regression against pinned Node and Bun
|
||
# archives. Keep them on the persistent host so only the first run per
|
||
# host downloads them: the download cost minutes per run, and a 502 from
|
||
# npm once reddened an otherwise green run. Exported here because the
|
||
# test step overrides HOME.
|
||
- name: 'Configure persistent standalone runtime cache (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/standalone-runtimes"
|
||
echo "QWEN_STANDALONE_RUNTIME_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using persistent standalone runtime cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
# Fail fast on a saturated self-hosted host instead of dying on ENOSPC
|
||
# mid-install (#10035): the workspace cleaner skips busy runners, so a
|
||
# busy fleet can hit 100% while jobs keep being admitted. Gating before
|
||
# `npm ci` turns that into a clear, retryable reschedule onto a host
|
||
# with headroom, instead of a corrupted half-run.
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
# `test_build` already ran the `prepare` build; its archive below
|
||
# brings the outputs this install skips.
|
||
- name: 'Install dependencies'
|
||
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 30
|
||
env:
|
||
QWEN_SKIP_PREPARE: '1'
|
||
QWEN_SKIP_NOTICE_GENERATION: '1'
|
||
# Pin COREPACK_HOME to a fixed path under runner.temp so the
|
||
# corepack cache written here is visible to the test step below,
|
||
# which overrides HOME for isolation. runner.temp survives across
|
||
# jobs on self-hosted runners, and corepack does not re-verify
|
||
# extracted entries on cache hit, so the install body below clears
|
||
# this exact path before use; the test step then reuses the cache
|
||
# this step warms within the same job.
|
||
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack'
|
||
run: |-
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
|
||
sample_disk() {
|
||
hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true)
|
||
sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true
|
||
echo "$sample"
|
||
echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true
|
||
}
|
||
sample_disk
|
||
( while sleep 10; do sample_disk; done ) &
|
||
SAMPLER_PID=$!
|
||
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
|
||
# Observed: the pinned cache entry existed with its pnpm binary
|
||
# missing, corepack trusts an existing entry without re-verifying
|
||
# its files, and the install died instantly with MODULE_NOT_FOUND
|
||
# (main run 41927887452). What left the entry incomplete is
|
||
# undetermined; _work/_temp survives across jobs on self-hosted
|
||
# runners, so such state persists. Start the shard-owned cache
|
||
# empty — and only that exact path, so a runner-exported shared
|
||
# cache and lint's already-warmed run-unique home stay untouched.
|
||
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
|
||
rm -rf -- "${COREPACK_HOME}"
|
||
fi
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
# Eight shards pull this same artifact at once, so the cap has to clear
|
||
# the slow tail rather than the median: on main four shards finished
|
||
# inside a minute, three needed between 2m40s and just under five
|
||
# minutes, and one exceeded the old five-minute cap on two consecutive
|
||
# runs, which skipped the unpack and failed the required check. Fifteen
|
||
# minutes leaves that measured tail about three times the headroom; if
|
||
# main still expires, the fix is fewer concurrent fetches, not a bigger
|
||
# cap.
|
||
- name: 'Download the built tree'
|
||
id: 'download_tree'
|
||
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 15
|
||
continue-on-error: true
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Retry downloading the built tree'
|
||
if: "${{ steps.download_tree.outcome == 'failure' }}"
|
||
timeout-minutes: 15
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Unpack the built tree'
|
||
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
|
||
rm -rf "${RUNNER_TEMP}/unit-test-tree"
|
||
# PR checkouts may predate the build-cache host-assets command.
|
||
if [ -f dist/cli.js ]; then
|
||
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
|
||
fi
|
||
|
||
- name: 'Report npm cache usage (self-hosted)'
|
||
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
||
echo "npm cache: ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
# The install-script packaging suite needs zip/unzip, and throws on a
|
||
# CI host that ships neither, so a silent skip there is impossible.
|
||
# tmux is PRE-LANDED for #8388: that PR's capture-tui carries a
|
||
# describe.skipIf(!hasTmux)-gated real-tmux suite which, on a runner
|
||
# without tmux (the hosted ubuntu-latest image ships none), would
|
||
# silently skip every real-tmux behaviour — holder survival, matching,
|
||
# server reaping, refusal contracts — inside a green required check.
|
||
# Until it lands, the tmux half installs a tool nothing here uses;
|
||
# that is deliberate, so the tooling is in place before the suite that
|
||
# depends on it (qwen-autofix.yml already installs tmux the same way).
|
||
- name: 'Install tmux and zip tooling'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.os == 'Linux' }}"
|
||
# Bounded AND advisory: a stalled mirror or dpkg lock must neither
|
||
# hang the job toward its 120-minute cap nor red the required check —
|
||
# continue-on-error absorbs the step-level timeout. The apt calls
|
||
# carry their OWN shorter bound (140 s + 140 s = 280 s < the 300 s
|
||
# step cap) so the guard below still runs: when the step-level
|
||
# timeout fires there is no `|| echo`, and the lane loses its
|
||
# tooling with no annotation to say so.
|
||
timeout-minutes: 5
|
||
continue-on-error: true
|
||
run: |-
|
||
if command -v tmux > /dev/null 2>&1 && command -v zip > /dev/null 2>&1 && command -v unzip > /dev/null 2>&1; then
|
||
# Advisory here too: a broken-but-installed tmux (dangling
|
||
# symlink, missing lib) must not fail the required Test check
|
||
# before a single test has run.
|
||
tmux -V || echo '::warning::tmux present but not answering; real-tmux capture tests will be skipped.'
|
||
zip -v > /dev/null 2>&1 || echo '::warning::zip present but not answering; the zip-packaging suite will throw on CI.'
|
||
unzip -v > /dev/null 2>&1 || echo '::warning::unzip present but not answering; the zip-packaging suite will throw on CI.'
|
||
elif [ "$(id -u)" = '0' ] && command -v apt-get > /dev/null 2>&1; then
|
||
# Root-container lanes have no sudo (and need none): apt-get
|
||
# runs as-is. Advisory, not a gate: an apt hiccup must not red
|
||
# this step before a single test has run — the suites carry
|
||
# their own signal (the real-tmux suite skips; the zip suite
|
||
# throws on CI). ::warning:: (not a plain echo) so a lane
|
||
# where the install PERMANENTLY fails shows the loss in the
|
||
# check UI instead of hiding it in a multi-thousand-line log.
|
||
timeout 140 apt-get update -qq && timeout 140 apt-get install -y -qq --no-install-recommends tmux zip unzip \
|
||
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
||
elif sudo -n true > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
|
||
# Same advisory contract as the root branch; sudo -n here so a
|
||
# runner without passwordless sudo fails fast instead of
|
||
# hanging on a password prompt toward the step's bound.
|
||
timeout 140 sudo -n apt-get update -qq && timeout 140 sudo -n apt-get install -y -qq --no-install-recommends tmux zip unzip \
|
||
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
||
else
|
||
echo '::warning::tmux/zip unavailable; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
||
fi
|
||
|
||
- name: 'Provision linters for real-binary tests'
|
||
# The script-lint real-binary regressions (the #15974 unloadable
|
||
# workflow, the PR-planted ignore-all config, the merge-base trust
|
||
# boundary) are skipIf-gated on actionlint/shellcheck being on PATH;
|
||
# without this step they silently skip on every CI run (PR #16001
|
||
# review). Same pinned, SHA-256-verified installer the lint lane
|
||
# uses, promoted onto GITHUB_PATH ahead of anything a shared runner
|
||
# left there. Advisory: a failed install degrades to the skips, not
|
||
# a red required check. Windows is excluded — the installer ships
|
||
# linux/darwin archives only.
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.os != 'Windows' }}"
|
||
timeout-minutes: 5
|
||
continue-on-error: true
|
||
run: |-
|
||
node --input-type=module -e '
|
||
import { execSync } from "node:child_process";
|
||
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||
import { getLinters, getLinterTempDir } from "./scripts/lint.js";
|
||
const tempDir = getLinterTempDir();
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
mkdirSync(tempDir, { recursive: true });
|
||
const linters = getLinters();
|
||
for (const name of ["actionlint", "shellcheck"]) {
|
||
execSync(linters[name].installer, { stdio: "inherit" });
|
||
// Verify the install produced its binary BEFORE promoting the
|
||
// dir: a silent no-op install must fail this step visibly
|
||
// (continue-on-error tolerates it, the UI shows it), not
|
||
// degrade to skipped tests nobody notices (PR #16001 F2).
|
||
const bin = tempDir + "/" + name + "/" + name;
|
||
if (!existsSync(bin)) throw new Error(name + " installer produced no binary at " + bin);
|
||
appendFileSync(process.env.GITHUB_PATH, tempDir + "/" + name + "\n");
|
||
}
|
||
console.log("PROVISIONED actionlint + shellcheck into " + tempDir);
|
||
'
|
||
|
||
- name: 'Run tests and generate reports'
|
||
id: 'unit_tests'
|
||
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
# A hung suite used to burn the whole job budget: #10490's run 3 was
|
||
# cancelled at ~60 minutes with the runner terminating orphaned test
|
||
# processes, which reads as a timeout rather than a failure.
|
||
#
|
||
# The cap has to clear the slowest honest shard, not the healthy one:
|
||
# the same 1/8 slice measures ~7 minutes on an idle host and ~36 on a
|
||
# contended one, and a failing run adds its retries on top. 45 leaves
|
||
# room for that while still firing before this job's 60-minute cap,
|
||
# which this step enters around minute 10 — a step timeout keeps the
|
||
# diagnostic steps below alive, a job timeout does not.
|
||
timeout-minutes: 45
|
||
env:
|
||
NO_COLOR: true
|
||
# Every attempt of a contended shard is a fresh roll: the same
|
||
# commit run three times on this fleet failed three disjoint test
|
||
# sets (#10490), and a shard of identical work measures 6.7min or
|
||
# 36min depending only on which host it lands on. A retry lets a
|
||
# contention flake pass; a real break still fails all three
|
||
# attempts. Operators can retune or disable ('off') without a PR.
|
||
VITEST_RETRY: "${{ vars.QWEN_CI_VITEST_RETRY || '2' }}"
|
||
# Coverage is collected only on the post-merge main run: nothing
|
||
# read the pull-request reports, and v8 instrumentation cost about
|
||
# a fifth of the suite's wall time on every push. The vitest configs
|
||
# of cli, core, web-shell and vscode-ide-companion read this switch.
|
||
QWEN_CI_COVERAGE: "${{ github.event_name == 'push' && '1' || '' }}"
|
||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||
# HOME above hides the runner's corepack cache; without this the
|
||
# vitest globalSetup (scripts/tests/corepack-warmup.js) spawns
|
||
# `corepack pnpm --version` on a cold cache and downloads the pinned
|
||
# pnpm from npmjs, which the ECS egress can time out on. Must match
|
||
# the Install dependencies step's COREPACK_HOME so it hits the cache
|
||
# install already warmed.
|
||
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack'
|
||
# Bound each Vitest process on the shared ECS host. Operators can
|
||
# tune the maximum without changing this workflow.
|
||
VITEST_MAX_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
|
||
VITEST_MIN_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
|
||
VITEST_MAX_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
|
||
VITEST_MIN_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
|
||
OPENAI_API_KEY: ''
|
||
DASHSCOPE_API_KEY: ''
|
||
QWEN_API_KEY: ''
|
||
GEMINI_API_KEY: ''
|
||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||
# A millisecond budget written on a developer machine measures the
|
||
# code; on the shared pool it measures the neighbours, where the same
|
||
# work runs 5x apart depending on placement (#10490). Skip those
|
||
# assertions here — they still run locally and on the hosted lanes.
|
||
QWEN_SKIP_LATENCY_BUDGETS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
|
||
# The lane script's side-lane log, outside the TMPDIR this step
|
||
# removes. The script deletes it once replayed, so it is left only
|
||
# when this step is killed first; the next step prints it.
|
||
SIDE_LANE_LOG: '${{ runner.temp }}/side-lane-tests.log'
|
||
# This shard's slice of every Vitest run. A small suite has fewer
|
||
# files than shards, hence --passWithNoTests beside it. The
|
||
# standalone regression is no Vitest run; the first shard runs it.
|
||
UNIT_TEST_SHARD: '${{ matrix.shard }}/8'
|
||
QWEN_CI_SKIP_STANDALONE_RUNTIMES: "${{ matrix.shard != 1 && '1' || '' }}"
|
||
run: |-
|
||
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
||
test_runner=()
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
# Permission-denial fixtures must also work on root-run ECS lanes.
|
||
chown -R 0 "$GITHUB_WORKSPACE" "$HOME"
|
||
echo 'Dropping DAC override capabilities for root unit tests'
|
||
test_runner=(setpriv '--bounding-set=-dac_override,-dac_read_search' '--inh-caps=-dac_override,-dac_read_search')
|
||
fi
|
||
# ENOSPC has failed test steps mid-suite while the host looks
|
||
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
||
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
||
# failing run captures the spike, and dump full state on failure.
|
||
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
||
# Symlink aliases break tests that intentionally compare real paths.
|
||
# The sample-file setup sits BEFORE the TMPDIR routing block (#10035)
|
||
# because no-ak-integration-ci pins that block byte-identical across
|
||
# the test/test_macos/test_windows legs; each DFSAMPLE line already
|
||
# carries the routed tmpdir, so the header only needs job/runner ids.
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
if [ ! -s "$DISK_SAMPLES" ]; then
|
||
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
|
||
fi
|
||
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
||
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
||
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
||
TMPDIR="$QWEN_CI_TMPDIR"
|
||
export TMPDIR
|
||
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
||
fi
|
||
fi
|
||
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]"; echo "$sample"; echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true; sleep 10; done ) &
|
||
SAMPLER_PID=$!
|
||
retry_arg=(--shard="${UNIT_TEST_SHARD}" --passWithNoTests)
|
||
if [ -n "${VITEST_RETRY:-}" ] && [ "${VITEST_RETRY}" != 'off' ]; then
|
||
retry_arg+=(--retry="${VITEST_RETRY}")
|
||
fi
|
||
set +e
|
||
# Every workspace suite, the standalone regression and scripts/tests,
|
||
# in two concurrent lanes; the script says why. Each command it runs
|
||
# inherits the capabilities test_runner drops.
|
||
"${test_runner[@]}" .github/scripts/ci/run-unit-test-lanes.sh "${retry_arg[@]}"
|
||
RC=$?
|
||
set -e
|
||
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
||
kill "$SAMPLER_PID" 2>/dev/null || true
|
||
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
||
exit "$RC"
|
||
|
||
# A timeout or a cancel kills the test step before the lane script
|
||
# replays its side lane, which runs every suite but the CLI and core
|
||
# ones. Print what the side lane got through.
|
||
- name: 'Print the side lane of a killed test step'
|
||
if: '${{ failure() || cancelled() }}'
|
||
run: |-
|
||
side_log="${RUNNER_TEMP}/side-lane-tests.log"
|
||
if [ -f "${side_log}" ]; then
|
||
cat "${side_log}"
|
||
fi
|
||
|
||
# The host looks healthy after cleanup, so persist the disk-pressure
|
||
# timeline from a failed run (#10035): the artifact keeps the samples
|
||
# around long enough to correlate them with this job and runner.
|
||
- name: 'Upload disk-pressure samples'
|
||
if: '${{ failure() }}'
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'disk-pressure-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}-shard-${{ matrix.shard }}'
|
||
path: '${{ runner.temp }}/disk-pressure-samples.log'
|
||
if-no-files-found: 'ignore'
|
||
retention-days: 14
|
||
|
||
# Release guard for the Chrome extension: build, package, and scan real
|
||
# artifacts for forbidden adapter signatures instead of leaving
|
||
# `scan:artifacts` manual-only. Linux leg only — packaging shells out to
|
||
# the POSIX `zip`; skip (not fail) where it is missing.
|
||
- name: 'Package and scan Chrome extension artifacts'
|
||
if: "${{ needs.test_build.result == 'success' && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && matrix.shard == 1 }}"
|
||
run: |-
|
||
if ! command -v zip >/dev/null 2>&1; then
|
||
echo "::warning::zip is not installed; skipping the Chrome extension artifact scan"
|
||
exit 0
|
||
fi
|
||
npm -w packages/chrome-extension run package
|
||
npm -w packages/chrome-extension run scan:artifacts
|
||
|
||
- name: 'Publish Test Report (for non-forks)'
|
||
if: |-
|
||
${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && steps.unit_tests.outcome != 'skipped' && (github.event.pull_request.head.repo.full_name == github.repository) }}
|
||
# The parser rejects the whole step when any matched file is not
|
||
# well-formed XML, and vitest's JUnit writer can leave
|
||
# packages/cli/junit.xml truncated at the tail when the suite exits
|
||
# mid-write: #15305 (attempt 1) failed at line 47397 and #15261 at
|
||
# 47232 with "Invalid character in entity name", while both runs'
|
||
# tests had passed. The report is advisory (`fail-on-error: false`),
|
||
# so a broken artifact must not turn a green suite red.
|
||
continue-on-error: true
|
||
uses: 'dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2' # ratchet:dorny/test-reporter@v3
|
||
with:
|
||
name: 'Test Results (ubuntu-latest, Node 22.x, shard ${{ matrix.shard }})'
|
||
path: 'packages/*/junit.xml'
|
||
reporter: 'java-junit'
|
||
fail-on-error: 'false'
|
||
|
||
- name: 'Upload Test Results Artifact'
|
||
if: |-
|
||
${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}
|
||
# Advisory like the publisher above: a transient artifact-service
|
||
# failure must not turn a green suite red. `always()` also fires on a
|
||
# run cancelled mid-suite, so a partial report can be retained under
|
||
# the complete run's name.
|
||
continue-on-error: true
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'test-results-fork-22.x-ubuntu-latest' || 'test-results-22.x-ubuntu-latest' }}-shard-${{ matrix.shard }}"
|
||
path: 'packages/*/junit.xml'
|
||
if-no-files-found: 'ignore'
|
||
retention-days: 7
|
||
|
||
# Push only: that is the one lane where QWEN_CI_COVERAGE turns
|
||
# collection on, so a pull-request run has nothing to upload.
|
||
- name: 'Upload coverage reports'
|
||
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name == 'push' }}"
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'coverage-reports-22.x-ubuntu-latest-shard-${{ matrix.shard }}'
|
||
path: 'packages/*/coverage'
|
||
|
||
#
|
||
# Lint & Static: the checks whose verdict depends only on the tree.
|
||
#
|
||
|
||
test_ubuntu:
|
||
name: 'Test (ubuntu-latest, Node 22.x)'
|
||
needs: ['classify_pr', 'test_build', 'test']
|
||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||
# The shards' runners: a hosted runner queued this one-line check for
|
||
# 9 minutes after the last shard finished.
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||
timeout-minutes: 5
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
- name: 'Require the build and every Linux test shard to pass'
|
||
env:
|
||
BUILD_RESULT: '${{ needs.test_build.result }}'
|
||
SHARDS_RESULT: '${{ needs.test.result }}'
|
||
run: 'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success'
|
||
|
||
lint_and_static:
|
||
name: 'Lint & Static (ubuntu-latest, Node 22.x)'
|
||
needs: 'classify_pr'
|
||
# Split out of `test` so this half can become a required status check on
|
||
# its own. The property that justifies that role is deterministic
|
||
# OUTCOME — no vitest unit-test run, whose flake was measured failing
|
||
# 41% of post-merge runs in the week of the split — not lightness: the
|
||
# bundle-closure check builds and bundles the CLI, the helper-suite
|
||
# steps execute the 26-file HELPER_TESTS battery (most spawn
|
||
# subprocesses), and the lane runs on the same contended pool as
|
||
# everything else, which is what the routed 90/45 ceiling below prices.
|
||
# One honest limit, recorded so the required-check follow-up is argued
|
||
# on true premises: the incidents that motivated the split all failed in
|
||
# `Run tests and generate reports`, which stayed in `test` — this lane
|
||
# would have been green on those merges. What it gates is the class a
|
||
# required check can hold without flake hostages, not that class.
|
||
# One red gate must not hold the other's hostages.
|
||
# Same event surface as `test` (see its `if` for the reasoning), and the
|
||
# same release-sync convention: stay running on skip_ci PRs so a required
|
||
# context still reports, with the per-step guards making it a no-op pass.
|
||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||
# Shares the external-PR hosted/disposable switch with test and integration_no_ak.
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
|
||
# Pool-scoped like test's, not a bare constant: this lane was beheaded twice
|
||
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
|
||
# (install 892s vs the sibling Test job's 347s) and again at 45 (install
|
||
# 940s, bundle closure 681s vs a warm 171s, every step 2–4x). The pool's
|
||
# contention is why test runs 120/60 and no_ak 60/30; a flat 45 priced
|
||
# only the warm half of that reality. Hosted runners keep the tighter
|
||
# ceiling so a genuine hang there does not burn the ECS allowance.
|
||
# Keyed on `self-hosted`, not on the x64 fleet's `ecs-qwen` label: the
|
||
# ARM64 fleet renders ["self-hosted", "linux", "arm64"], so a
|
||
# fleet-specific substring priced those reserved machines as hosted.
|
||
# Reads the same lint_runner as runs-on so the ceiling always prices the
|
||
# pool the job actually lands on.
|
||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
|
||
env:
|
||
LINT_BASE_SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}"
|
||
permissions:
|
||
contents: 'read'
|
||
pull-requests: 'read'
|
||
steps:
|
||
# Untrusted fork code runs here; job env cannot read the runner context
|
||
# and RUNNER_TEMP survives across jobs, so publish a run-unique HOME via
|
||
# GITHUB_ENV. Rationale: docs/design/2026-10-31-lint-static-ecs-routing.md.
|
||
- name: 'Create isolated HOME'
|
||
run: |-
|
||
home="${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||
rm -rf -- "$home"
|
||
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
|
||
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
|
||
# Linter archives are SHA-256-verified on every cache hit, so sharing
|
||
# them is safe; corepack does NOT re-verify extracted entries on hit,
|
||
# so its home stays per-run (a shared one is a poisoning channel).
|
||
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
|
||
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
|
||
|
||
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
|
||
# verify) may leave root-owned, read-only files anywhere in the workspace.
|
||
# Restore ownership and write permission unconditionally so the checkout
|
||
# below can wipe the tree without EACCES. Do not gate this behind a
|
||
# .qwen/.git probe: poisoning is workspace-wide (root-owned node_modules/
|
||
# dist with no .qwen/.git), so a probe reports "healthy" and skips the
|
||
# recovery exactly when it is needed.
|
||
- name: 'Restore workspace ownership'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Remove .qwen/ and its recovery backup left by a previous job so
|
||
# actions/checkout doesn't trip on restrictive permissions.
|
||
# `.qwen.root-orig` is emitted by recovery tooling OUTSIDE this repo —
|
||
# nothing here produces it (git grep matches only these sweep copies
|
||
# and their pins in scripts/tests/review-worktree-cleanup-workflow.
|
||
# test.js). It is the backup name a cancelled verify's recovery leaves
|
||
# after renaming an unreadable, root-owned `.qwen` aside (observed on
|
||
# the shared pool; first recorded around review run 33146730771). If
|
||
# that producer's naming changes or a third residue name appears,
|
||
# update the for-loop list in every sweep copy or the checkout
|
||
# poisoning this sweep exists for silently recurs.
|
||
# `trusted-ci-classifier` is the third residue name: classify_pr's
|
||
# base-SHA checkout path. actions/checkout reuses a leftover copy
|
||
# whose origin URL matches and runs git (hooks included) inside it,
|
||
# so it is swept rather than warmed.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
# Pin PRs to the event head SHA: refs/pull/N/head can advance before a
|
||
# retry, while github.ref is the asynchronously rebuilt merge ref and
|
||
# can lag behind a push. Neither mutable ref identifies this run's head.
|
||
# Merge queue refs are ephemeral; check out the event head SHA directly so
|
||
# slow hosted runners do not fail after the queue branch is removed.
|
||
# Non-PR/non-queue events keep github.ref.
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
id: 'checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
# Pin push runs to the event SHA: the `github.ref` fallback resolves
|
||
# `refs/heads/main` at fetch time — a moving tip — while the check run
|
||
# attaches to the triggering commit, so the lane would validate a tree
|
||
# it does not report on. Nothing detects that (the checkout-head
|
||
# verifier is gated to pull_request / merge_group, and its ancestor
|
||
# check passes for a newer tip regardless), and merges land close
|
||
# enough together to hit it routinely. Regressions are still caught —
|
||
# the tested tree is a descendant — but attribution breaks: red on a
|
||
# clean commit, autofix filed against the wrong merge. `e2e.yml` pins
|
||
# the event SHA by taking actions/checkout's default.
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}"
|
||
# Shallow: nothing here walks git history except on demand (the
|
||
# verify guard below checks head.sha == HEAD; the size gate and its
|
||
# vitest mirror fetch the PR's base commit at depth 1 when the
|
||
# baseline went stale; everything else touches only the working
|
||
# tree). On the in-repo ECS runner a full-history clone is the
|
||
# heaviest transfer and chokes the squid egress proxy, flaking
|
||
# checkout. depth 1 is enough.
|
||
fetch-depth: 1
|
||
|
||
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
|
||
# ref) silently testing the wrong tree. Cheap: one merge-base, sub-second.
|
||
# Also runs in the merge queue — now that the queue's Ubuntu checkout is on
|
||
# ECS/squid, a wrong-tree pass would merge bad code.
|
||
- name: 'Verify checkout includes expected head commit'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
||
|
||
- name: 'Use trusted CI profile'
|
||
id: 'ci_profile'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
env:
|
||
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
|
||
run: |-
|
||
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
|
||
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
|
||
fi
|
||
profile="${TRUSTED_CI_PROFILE:-full}"
|
||
scripts_unit=false
|
||
case "${profile}" in
|
||
docs_only|github_ci_only|full) ;;
|
||
# A change confined to the Java packages skips the TypeScript
|
||
# build and test lanes, so this job runs its whole payload (the
|
||
# Flyway and contract checks included) plus the scripts/tests
|
||
# suites, seventeen of which read the Java trees.
|
||
java_only)
|
||
profile=full
|
||
scripts_unit=true
|
||
;;
|
||
*) profile=full ;;
|
||
esac
|
||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||
echo "scripts_unit=${scripts_unit}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected CI profile: ${profile}"
|
||
|
||
# Deliberately gated on skip_ci ONLY, not on the CI profile: a PR that
|
||
# touches nothing but .github/ classifies as `github_ci_only`, and that
|
||
# is exactly the PR most likely to push a workflow file past GitHub's
|
||
# 500 KB start-runs limit — the limit that silently killed the autofix
|
||
# loop for a day on 2026-08-19. Costs one `wc -c` per workflow file.
|
||
- name: 'Check workflow file size'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: '.github/scripts/check-workflow-size.sh'
|
||
|
||
- name: 'Check PR-stage CI budget'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: 'node .github/scripts/check-ci-budget.mjs'
|
||
|
||
# A green lane proves the branch passes the gate AS THE BRANCH defines
|
||
# it — the checkout above is the event PR head by deliberate trade-off —
|
||
# not as the base defines it. #11117 flipped Prettier --write →
|
||
# --check on main while #11216's branch still ran --write; it merged
|
||
# green and reddened every later full-profile PR (#11378 cleaned up).
|
||
# Fail a branch whose base has since changed a gate-defining file.
|
||
# PRs only: merge_group validates the prospective merged tree and push
|
||
# validates the base itself. docs_only runs no lint lane, so there is
|
||
# no stale green to catch; github_ci_only runs lint.js lanes in the
|
||
# helper step below, so it is covered. Seconds of API calls before the
|
||
# expensive steps; details in the script's header.
|
||
- name: 'Check lint gate freshness'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
|
||
env:
|
||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||
BASE_REF: '${{ github.event.pull_request.base.ref }}'
|
||
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
|
||
run: 'node .github/scripts/check-lint-gate-freshness.mjs'
|
||
|
||
# Keep the cheap schema guards on PRs and main while Java runs daily.
|
||
- name: 'Check Flyway migration versions are unique'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
|
||
run: 'node scripts/check-flyway-migrations.js packages/managed-agent-server packages/sdk-java/qwencode'
|
||
|
||
- name: 'Check the API contract version advances'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
|
||
env:
|
||
BASE_REF: '${{ github.event.pull_request.base.ref }}'
|
||
GH_TOKEN: '${{ github.token }}'
|
||
REPO: '${{ github.repository }}'
|
||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||
run: |-
|
||
set -euo pipefail
|
||
DOCUMENT='packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json'
|
||
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | .filename, .previous_filename // empty' > "${RUNNER_TEMP}/contract-pr-files.txt"
|
||
if ! grep -qxF "${DOCUMENT}" "${RUNNER_TEMP}/contract-pr-files.txt"; then
|
||
exit 0
|
||
fi
|
||
BASE_DOCUMENT="${RUNNER_TEMP}/contract-base.json"
|
||
git fetch --quiet --depth=1 --filter=blob:none origin "refs/heads/${BASE_REF}"
|
||
if ! git show "FETCH_HEAD:${DOCUMENT}" > "${BASE_DOCUMENT}" 2>/dev/null; then
|
||
PREVIOUS_DOCUMENT="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[] | select(.filename == "packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json") | .previous_filename // empty')"
|
||
if [ -n "${PREVIOUS_DOCUMENT}" ]; then
|
||
git show "FETCH_HEAD:${PREVIOUS_DOCUMENT}" > "${BASE_DOCUMENT}"
|
||
else
|
||
: > "${BASE_DOCUMENT}"
|
||
fi
|
||
fi
|
||
node scripts/check-contract-version.js "${BASE_DOCUMENT}" "${DOCUMENT}"
|
||
|
||
# Advisory test-growth summary and flag; see the script header.
|
||
- name: 'Report test growth'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' }}"
|
||
continue-on-error: true
|
||
timeout-minutes: 5
|
||
env:
|
||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
|
||
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
|
||
run: 'node .github/scripts/check-test-growth.mjs'
|
||
|
||
# Stays below the two steps above that refetch shallow history: a
|
||
# depth-1 fetch of the base ref grafts the commit LINT_BASE_SHA names and
|
||
# `merge-base` then fails closed to a silent full scan (design doc,
|
||
# "PR-lane history"). The helper-checks step that runs lint.js is below.
|
||
- name: 'Fetch incremental lint history'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' && steps.ci_profile.outputs.ci_profile != 'docs_only' }}"
|
||
timeout-minutes: 2
|
||
continue-on-error: true
|
||
env:
|
||
LINT_HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
|
||
run: |-
|
||
# Blobless: an earlier blobless merge-base fetch leaves this
|
||
# checkout without those blobs, and lint-scope reads only commits and
|
||
# trees anyway.
|
||
partial=(-c extensions.partialClone=origin -c remote.origin.promisor=true)
|
||
git "${partial[@]}" fetch --no-tags --depth=100 --filter=blob:none \
|
||
origin "${LINT_BASE_SHA}" "${LINT_HEAD_SHA}"
|
||
# Match lint-scope's merge-base and shallow-boundary checks.
|
||
# Ambiguity stops deepening but still announces full-lint fallback.
|
||
history_usable() {
|
||
local merge_base above shallow
|
||
merge_base="$(git merge-base --all "${LINT_BASE_SHA}" HEAD 2>/dev/null)" || return 1
|
||
case "${merge_base}" in
|
||
'') return 1 ;;
|
||
*$'\n'*) return 2 ;;
|
||
esac
|
||
[ "$(git rev-parse --is-shallow-repository)" = 'true' ] || return 0
|
||
shallow="$(git rev-parse --git-path shallow)"
|
||
above="$(git rev-list "${merge_base}..HEAD" "${merge_base}..${LINT_BASE_SHA}")" || return 1
|
||
! grep -qxF -f "${shallow}" <<< "${above}"
|
||
}
|
||
deepens=0
|
||
reason='deepen cap reached'
|
||
for _attempt in 1 2 3; do
|
||
state=0
|
||
history_usable || state=$?
|
||
case "${state}" in 0|2) break ;; esac
|
||
git "${partial[@]}" fetch --no-tags --deepen=300 --filter=blob:none \
|
||
origin "${LINT_BASE_SHA}" "${LINT_HEAD_SHA}" || { reason='deepen fetch aborted'; break; }
|
||
deepens=$((deepens + 1))
|
||
done
|
||
# Later fetches in this checkout must stay ordinary.
|
||
git config --unset remote.origin.partialclonefilter || true
|
||
state=0
|
||
history_usable || state=$?
|
||
if [ "${state}" != 0 ]; then
|
||
[ "${state}" != 2 ] || reason='ambiguous merge base'
|
||
message="Incremental lint history: ${deepens} deepen(s), base depth $(git rev-list --count "${LINT_BASE_SHA}") — ${reason}"
|
||
echo "::warning::${message}"
|
||
echo "- ${message}" >>"${GITHUB_STEP_SUMMARY}"
|
||
fi
|
||
|
||
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
||
# through the egress proxy; reuse the machine's Node instead.
|
||
- name: 'Set up Node.js 22.x (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
# Fail fast on a saturated self-hosted host instead of dying on ENOSPC
|
||
# mid-install (#10035): the workspace cleaner skips busy runners, so a
|
||
# busy fleet can hit 100% while jobs keep being admitted. Gating before
|
||
# `npm ci` turns that into a clear, retryable reschedule onto a host
|
||
# with headroom, instead of a corrupted half-run.
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
- name: 'Install dependencies'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 30
|
||
env:
|
||
QWEN_SKIP_PREPARE: '1'
|
||
QWEN_SKIP_NOTICE_GENERATION: '1'
|
||
run: |-
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true
|
||
sample_disk() {
|
||
hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true)
|
||
sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true
|
||
echo "$sample"
|
||
echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true
|
||
}
|
||
sample_disk
|
||
( while sleep 10; do sample_disk; done ) &
|
||
SAMPLER_PID=$!
|
||
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
|
||
# Observed: the pinned cache entry existed with its pnpm binary
|
||
# missing, corepack trusts an existing entry without re-verifying
|
||
# its files, and the install died instantly with MODULE_NOT_FOUND
|
||
# (main run 41927887452). What left the entry incomplete is
|
||
# undetermined; _work/_temp survives across jobs on self-hosted
|
||
# runners, so such state persists. Start the shard-owned cache
|
||
# empty — and only that exact path, so a runner-exported shared
|
||
# cache and lint's already-warmed run-unique home stay untouched.
|
||
if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then
|
||
rm -rf -- "${COREPACK_HOME}"
|
||
fi
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Report npm cache usage (self-hosted)'
|
||
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
||
echo "npm cache: ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'GitHub CI helper checks'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'github_ci_only' }}"
|
||
timeout-minutes: 5
|
||
run: |-
|
||
# Keep this path dependency-free: it runs before any dependency install in this job (setup-node, self-hosted node and `npm ci` above are all gated on the full profile), so only the node-builtin-only HELPER_TESTS_DEP_FREE suites run here. The full-profile helper step below re-runs the complete HELPER_TESTS list after `npm ci`, and script formatting is checked when those files hit full CI.
|
||
node scripts/lint.js --setup
|
||
node scripts/lint.js --actionlint
|
||
node scripts/lint.js --yamllint
|
||
node --test --test-concurrency=1 ${{ env.HELPER_TESTS_DEP_FREE }}
|
||
|
||
- name: 'Audit critical runtime dependencies'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run audit:runtime:critical'
|
||
|
||
- name: 'Check lockfile'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run check:lockfile'
|
||
|
||
- name: 'Check desktop workspace isolation'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run check:desktop-isolation'
|
||
|
||
- name: 'Check TUI dependency direction'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run check:tui-dep-direction'
|
||
|
||
- name: 'Install linters'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --setup'
|
||
|
||
- name: 'Run ESLint'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --eslint'
|
||
|
||
- name: 'Run actionlint'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 5
|
||
run: 'node scripts/lint.js --actionlint'
|
||
|
||
- name: 'Run shellcheck'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --shellcheck'
|
||
|
||
- name: 'Run yamllint'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --yamllint'
|
||
|
||
- name: 'Run Prettier'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --prettier'
|
||
|
||
- name: 'Run sensitive keyword linter'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'node scripts/lint.js --sensitive-keywords'
|
||
|
||
- name: 'Run i18n check'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: 'npm run check-i18n'
|
||
|
||
- name: 'Generate Web Shell and VS Code companion notices'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
npm run generate:notices --workspace=@qwen-code/web-shell
|
||
npm run generate:notices --workspace=qwen-code-vscode-ide-companion
|
||
|
||
- name: 'Check Web Shell and VS Code companion notices are up-to-date'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt) ]]; then
|
||
echo "Error: NOTICES.txt is out of date."
|
||
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
|
||
echo "And: npm run generate:notices --workspace=@qwen-code/web-shell"
|
||
echo "Then commit the updated file."
|
||
git diff --stat packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt
|
||
exit 1
|
||
fi
|
||
echo "NOTICES.txt is up-to-date"
|
||
|
||
# The `github_ci_only` profile runs the .github/scripts helper tests, but a
|
||
# `full` PR that touches those scripts skips that path and `npm run
|
||
# test:ci` (vitest) does not collect `node:test` files — so run them here
|
||
# too, or a compositor/publisher change could pass CI without its
|
||
# regression tests. Linux-only (they're platform-independent).
|
||
# One retry, the unit lane's contention doctrine (#10868): the battery
|
||
# spawns real subprocess loops on the shared ECS pool, and a main push
|
||
# has no flaky-rerun patrol — a single host hiccup failed the lane on a
|
||
# CSS-only commit (#12772). A real break still fails both attempts.
|
||
# Attempt 1 tees to a log so the warning can name the failed suites;
|
||
# pipefail (defaults.run.shell: bash) is what keeps node's status
|
||
# through that pipe. The warning is gated on the retry's success, so
|
||
# only an absorbed flake is annotated — a double failure reddens the
|
||
# lane directly (the #11364 doctrine from e2e.yml).
|
||
- name: 'Run .github/scripts helper tests'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
# Route os.tmpdir() off the host /tmp tmpfs: its inode quota is
|
||
# small and shared across the pool, and e2e-build.test.mjs creates
|
||
# thousands of chunk files there (#15965 ENOSPC with 202G free).
|
||
# Same routing as the unit lanes (#10035), private per-run dir
|
||
# included: RUNNER_TEMP is shared, and a cancelled battery never
|
||
# reaches its own after() cleanup, so the fixture tree would stick
|
||
# around in _work/_temp. A hard kill stays uncovered either way.
|
||
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||
HELPER_TMPDIR="$(mktemp -d "${TMPDIR}/helper-tests-XXXXXX" 2>/dev/null)" || HELPER_TMPDIR=""
|
||
if [ -n "$HELPER_TMPDIR" ]; then
|
||
TMPDIR="$HELPER_TMPDIR"
|
||
export TMPDIR
|
||
trap 'rm -rf "$HELPER_TMPDIR"' EXIT
|
||
fi
|
||
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} 2>&1 | tee "${RUNNER_TEMP}/helper-attempt1.log" || {
|
||
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} &&
|
||
echo "::warning::helper tests failed once and the retry absorbed it; attempt-1 failures: $(grep -E '^not ok' "${RUNNER_TEMP}/helper-attempt1.log" | head -5 | paste -sd ';' -) — count this toward the #12772 helper-battery recurrence threshold"
|
||
}
|
||
|
||
# Only on a java_only change, whose unit-test shards skip: the
|
||
# scripts/tests suites that name the Java trees. Selected by content so
|
||
# a new suite joins without editing this step; the rest of
|
||
# scripts/tests reads the TypeScript build, which this job never makes.
|
||
- name: 'Run scripts/tests for a Java-only change'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.scripts_unit == 'true' }}"
|
||
run: |-
|
||
mapfile -t suites < <(git grep -l -E 'managed-agent-server/|sdk-java/' -- 'scripts/tests/*.test.*')
|
||
if [ "${#suites[@]}" -eq 0 ]; then
|
||
echo '::error::Found no scripts/tests suite naming the Java trees.'
|
||
exit 1
|
||
fi
|
||
npm run test:scripts:unit -- "${suites[@]}"
|
||
|
||
# Reclaim the run-unique HOME (constructed path, never $HOME) before the
|
||
# failure collector, which must stay last.
|
||
- name: 'Clean isolated HOME'
|
||
if: '${{ always() }}'
|
||
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
|
||
|
||
# State-at-failure dump for the 19 substantive steps after install: the
|
||
# install sampler is reaped by that step's `trap … EXIT`, so a death in
|
||
# ESLint or the bundle-closure build would otherwise upload an artifact
|
||
# holding only install-window samples. `tee -a` appends — a bare `>`
|
||
# would truncate exactly those samples — and mirrors to the job log,
|
||
# because the file it appends to is the one whose writability is under
|
||
# investigation: redirected straight into it, an ENOSPC death loses the
|
||
# dump and its own diagnostic together, leaving oncall unable to tell
|
||
# "dumped, nothing interesting" from "could not write". tee still emits
|
||
# the data to stdout when the file write fails, and reports why on the
|
||
# stderr this block does not swallow; it exits 1 there, hence `|| true`.
|
||
- name: 'Dump disk state on failure'
|
||
if: '${{ failure() }}'
|
||
run: |-
|
||
DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log"
|
||
{
|
||
echo "DISKCONTEXT failure-dump $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
df -hT || df -h || true
|
||
df -i || true
|
||
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo || true
|
||
} 2>/dev/null | tee -a "$DISK_SAMPLES" || true
|
||
# Collector for the #10035 telemetry, kept as the LAST step: failure()
|
||
# is evaluated when the step is reached and never revisited, so at its
|
||
# old slot just after install it had already been passed (and skipped,
|
||
# nothing yet failed) by the time any lint/static step could die — it
|
||
# could only ever collect a prelude failure. `test` is NOT mirrored here
|
||
# and does not get that treatment: its collector sits four steps from the
|
||
# end, so a failure in the packaging or upload steps that follow it is
|
||
# never collected in that job. Same loss class, deliberately left to a
|
||
# follow-up rather than widened into this change. Distinct artifact name:
|
||
# both jobs can fail in one run and upload-artifact v4+ rejects
|
||
# duplicate names.
|
||
- name: 'Upload disk-pressure samples'
|
||
if: '${{ failure() }}'
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'disk-pressure-lint-${{ github.run_id }}-attempt-${{ github.run_attempt }}'
|
||
path: '${{ runner.temp }}/disk-pressure-samples.log'
|
||
if-no-files-found: 'ignore'
|
||
retention-days: 14
|
||
|
||
# The `web-shell E2E Smoke (ubuntu-latest, Node 22.x)` check is
|
||
# `web_shell_e2e_smoke_result`, which passes only when every shard passes.
|
||
web_shell_e2e_smoke:
|
||
name: 'web-shell E2E Smoke (shard ${{ matrix.shard }}/4)'
|
||
# Gate on the classifier's own profile output instead of the copy `test`
|
||
# re-derives: it is the same value (the classifier already forces an
|
||
# unknown one to `full`), and dropping the `test` edge lets this
|
||
# 35-minute lane run beside Test instead of behind it. `|| 'full'`
|
||
# extends that fail-safe to a classifier that emitted nothing at all.
|
||
#
|
||
# It tests test_build's tree instead of building its own. Each shard
|
||
# used to rebuild every workspace (~10 minutes, four times per run):
|
||
# this lane's ARM64 cache was saved once and never refreshed, so it
|
||
# restored a stale build every time. The tree is JavaScript apart from
|
||
# the opentui native library, which is recopied for ARM64 after unpacking.
|
||
# Drafts skip it, as they skip the no-AK integration gate.
|
||
needs:
|
||
- 'classify_pr'
|
||
- 'test_build'
|
||
if: |-
|
||
${{
|
||
!cancelled() &&
|
||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
||
needs.classify_pr.outputs.skip_ci != 'true' &&
|
||
(needs.classify_pr.outputs.ci_profile || 'full') == 'full' &&
|
||
needs.test_build.result == 'success' &&
|
||
!github.event.pull_request.draft
|
||
}}
|
||
# Keep the classifier's trust and hosted overrides, but use ARM64 for
|
||
# this lane independently of the ordinary Linux CI fleet. The history
|
||
# below is x64-specific, not a verdict on ARM64, but it is why this lane
|
||
# is fragile: #11250 moved it back to hosted after four pool attempts
|
||
# failed (run 34081409590). Three ran the smoke unpinned at 8 workers,
|
||
# half the host's 16 cores, against hosted's 2, and timed out broadly on
|
||
# visibility and clock waits. The fourth's transcript gate took 73 s
|
||
# against its 60 s budget while npm install alone took 28 min on that
|
||
# host. The smoke step pins the hosted worker count.
|
||
runs-on: '${{ contains(fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]''), ''self-hosted'') && fromJSON(''["self-hosted", "linux", "arm64"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||
# The unsharded smoke ran 44-46 minutes, about 33 of them in the 208
|
||
# smoke tests at two workers; each shard runs a quarter of them.
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3, 4]
|
||
# Slow hosted mirrors kept downloading past 23 min; reserve time for
|
||
# the smoke tests and artifact cleanup after installation.
|
||
timeout-minutes: 60
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
# On a reused pool workspace a prior containerised job can leave
|
||
# root-owned, read-only files. Restore them unconditionally before
|
||
# checkout, as in the test job.
|
||
- name: 'Restore workspace ownership'
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Keep this sweep pinned to the test job's hardened recovery recipe.
|
||
# The `.qwen.root-orig` name's provenance (an external recovery tool)
|
||
# is documented on the test job's copy.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||
with:
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.ref }}"
|
||
fetch-depth: 1
|
||
|
||
- name: 'Verify checkout includes expected head commit'
|
||
if: "${{ github.event_name == 'pull_request' }}"
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: '${{ github.event.pull_request.head.sha }}'
|
||
|
||
# Self-hosted can't reach nodejs.org reliably; reuse the machine's Node.
|
||
- name: 'Set up Node.js 22.x (hosted)'
|
||
if: "${{ runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ runner.environment == 'github-hosted' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
# The test job's pre-install admission check (#10035), before
|
||
# dependency installation.
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
# The smoke suite asserts exact text layout, which follows the host's
|
||
# default sans-serif (#15383). Checking it here turns a font-drifted
|
||
# host into a five-second actionable error instead of two failures 26
|
||
# minutes into the suite. See .github/scripts/ecs-runner/README.md.
|
||
- name: 'Check host font baseline (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
# A pull request whose head predates the guard does not carry the
|
||
# script in its checkout (this lane checks out the PR head, not the
|
||
# merge commit). Skip instead of failing that lane on a file it
|
||
# never had; every branch cut after the guard landed includes it.
|
||
if [ ! -f .github/scripts/ecs-runner/check-font-baseline.sh ]; then
|
||
echo 'font baseline check: skipped (script not present in this checkout)'
|
||
exit 0
|
||
fi
|
||
# A recoverable setup gap should not emit a failure annotation.
|
||
if GITHUB_ACTIONS='' bash .github/scripts/ecs-runner/check-font-baseline.sh; then
|
||
exit 0
|
||
fi
|
||
# Keep PR-supplied code at the runner's existing privilege level.
|
||
font_stage="$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen-fonts.XXXXXX")"
|
||
trap 'rm -rf -- "${font_stage}"' EXIT
|
||
mkdir -p "${font_stage}/lists/partial" "${font_stage}/packages"
|
||
apt-get -o "Dir::State::lists=${font_stage}/lists" update -qq
|
||
(
|
||
cd "${font_stage}/packages"
|
||
apt-get -o "Dir::State::lists=${font_stage}/lists" download fonts-dejavu-core fonts-dejavu-mono
|
||
)
|
||
for archive in "${font_stage}"/packages/*.deb; do
|
||
dpkg-deb --extract "${archive}" "${font_stage}/extracted"
|
||
done
|
||
font_dir="${XDG_DATA_HOME:-${HOME}/.local/share}/fonts/qwen-ci"
|
||
mkdir -p "${font_dir}"
|
||
cp -R "${font_stage}/extracted/usr/share/fonts/." "${font_dir}/"
|
||
fc-cache -f "${font_dir}"
|
||
bash .github/scripts/ecs-runner/check-font-baseline.sh
|
||
|
||
# The build comes from test_build's tree below, not from `prepare`.
|
||
- name: 'Install dependencies'
|
||
env:
|
||
QWEN_SKIP_PREPARE: '1'
|
||
run: |-
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
# Same handoff as the unit-test shards (see `test`).
|
||
- name: 'Download the built tree'
|
||
id: 'download_tree'
|
||
timeout-minutes: 15
|
||
continue-on-error: true
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Retry downloading the built tree'
|
||
if: "${{ steps.download_tree.outcome == 'failure' }}"
|
||
timeout-minutes: 15
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Unpack the built tree'
|
||
run: |-
|
||
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
|
||
rm -rf "${RUNNER_TEMP}/unit-test-tree"
|
||
# PR checkouts may predate the build-cache host-assets command.
|
||
if [ -f dist/cli.js ]; then
|
||
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
|
||
fi
|
||
|
||
- name: 'Install Playwright Chromium and WebKit (hosted)'
|
||
if: "${{ runner.environment == 'github-hosted' }}"
|
||
timeout-minutes: 30
|
||
run: |-
|
||
printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null
|
||
node node_modules/playwright/cli.js install --with-deps chromium webkit
|
||
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
|
||
if [ -f "${nested_cli}" ]; then
|
||
node "${nested_cli}" install chromium webkit
|
||
fi
|
||
|
||
- name: 'Install Playwright Chromium and WebKit (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
# Pool hosts carry Chromium's system libraries but not WebKit's: the
|
||
# first pool run of this job lacked GTK 4, GStreamer, libwoff2 and
|
||
# about 40 more. Install them only when Playwright's own host
|
||
# validation reports them missing, so each host installs them once and
|
||
# later jobs skip apt. sudo -n fails fast instead of prompting, and the
|
||
# lock timeout waits out another job's apt run on the same host.
|
||
timeout-minutes: 30
|
||
run: |-
|
||
install_log="${RUNNER_TEMP}/playwright-install.log"
|
||
node node_modules/playwright/cli.js install chromium webkit 2>&1 | tee "${install_log}"
|
||
nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'
|
||
if [ -f "${nested_cli}" ]; then
|
||
node "${nested_cli}" install chromium webkit 2>&1 | tee -a "${install_log}"
|
||
fi
|
||
if grep -q 'Host system is missing dependencies' "${install_log}"; then
|
||
apt_config="${RUNNER_TEMP}/apt-playwright.conf"
|
||
printf '%s\n' 'DPkg::Lock::Timeout "300";' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' > "${apt_config}"
|
||
sudo -n env APT_CONFIG="${apt_config}" "$(command -v node)" node_modules/playwright/cli.js install-deps chromium webkit
|
||
fi
|
||
|
||
- name: 'Choose web-shell Playwright port'
|
||
run: |-
|
||
port="$(node -e "const net=require('node:net');const server=net.createServer();server.listen(0,'127.0.0.1',()=>{console.log(server.address().port);server.close();});")"
|
||
echo "PLAYWRIGHT_PORT=${port}" >> "${GITHUB_ENV}"
|
||
echo "Using web-shell Playwright port ${port}"
|
||
|
||
- name: 'Run transcript document browser gate'
|
||
if: '${{ matrix.shard == 1 }}'
|
||
env:
|
||
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
|
||
TMPDIR: '${{ runner.temp }}'
|
||
run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0'
|
||
|
||
- name: 'Run web-shell browser smoke'
|
||
env:
|
||
# Avoid the pool's shared /tmp tmpfs for Chromium temporary files.
|
||
TMPDIR: '${{ runner.temp }}'
|
||
# Two workers is the hosted default (half of 4 vCPUs). Unpinned, a pool
|
||
# job would start browsers for half the host's cores while the host
|
||
# runs its other jobs.
|
||
run: 'npm run test:e2e:smoke --workspace=packages/web-shell -- --shard=${{ matrix.shard }}/4 --workers=2'
|
||
|
||
- name: 'Upload web-shell Playwright artifacts'
|
||
if: '${{ always() }}'
|
||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||
with:
|
||
name: 'web-shell-e2e-smoke-shard-${{ matrix.shard }}'
|
||
path: |-
|
||
packages/web-shell/client/e2e/test-results
|
||
packages/web-shell/client/e2e/playwright-report
|
||
if-no-files-found: 'ignore'
|
||
|
||
# macOS/Windows: slowest/costliest runners, and the only signal this
|
||
# repository has about a host that is not Linux with a GNU userland.
|
||
#
|
||
# They used to run in the merge queue alone. That queue is not enabled here —
|
||
# no `merge_group` run since 2026-07-02, and merges land as squashes — so the
|
||
# gate meant they never ran at all: reported as "skipped" on every pull
|
||
# request, and never reached afterwards. A macOS-only failure could ship and
|
||
# sit in `main` indefinitely, which is what happened in #9220 (a GNU-only
|
||
# `realpath -m` in a workflow guard, with the suite that pinned it red on
|
||
# every Mac).
|
||
#
|
||
# The pull-request trigger is OFF, and this is the whole reason the nightly
|
||
# above exists. Over the 18 hours measured before it was pulled, the Windows
|
||
# lane reported 13 failures and 0 successes on pull requests — one standing
|
||
# set of Windows-only path and symlink failures (resolved paths into
|
||
# read_many_files, releaseWorktree through an ancestor symlink, the SHA-256
|
||
# review worktree) repeating across unrelated PRs — and macOS queued for a
|
||
# p90 of 42 minutes, 159 at worst, on a hosted pool this repository does not
|
||
# saturate by itself. Neither lane gates a merge: the `main` ruleset carries
|
||
# no required status check. So the cost landed entirely on contributors, who
|
||
# read a red X no diff of theirs caused and waited on checks that never had
|
||
# to pass.
|
||
#
|
||
# Two triggers remain, in cost order: the merge queue if it is ever enabled
|
||
# again, and a nightly run on `main` — which is now the only thing keeping
|
||
# macOS and Windows visible at all, so it is load-bearing rather than a
|
||
# backstop. Read a red nightly as a blocker; nothing else will report it.
|
||
#
|
||
# Restoring the pull-request trigger is the last step of fixing those
|
||
# failures, not a separate decision — and it is a REVERT of the commit that
|
||
# carried this change, not an edit: the classifier job, its `if` arms on
|
||
# both lanes, and the contract pins in scripts/tests/ci-platform-lanes.
|
||
# test.js all moved together, and that suite now fails a half-restoration
|
||
# (a trigger without its classifier, or the reverse). The classifier script
|
||
# `classify-platform-sensitivity.mjs`, the `platform` mode of
|
||
# `classify-pr-profile.sh` and both their test files were left untouched
|
||
# precisely so the revert stays clean. Worth revisiting at the same time:
|
||
# that classifier calls every fork pull request platform-sensitive, which
|
||
# is most of the traffic here and most of what the macOS queue was spent
|
||
# on.
|
||
#
|
||
# Preserve the original macOS check name while running its complete suites
|
||
# in bounded groups. The aggregate rejects failed, skipped or cancelled
|
||
# groups, including when one group fails before the others finish.
|
||
|
||
web_shell_e2e_smoke_result:
|
||
name: 'web-shell E2E Smoke (ubuntu-latest, Node 22.x)'
|
||
needs:
|
||
- 'classify_pr'
|
||
- 'test_build'
|
||
- 'web_shell_e2e_smoke'
|
||
if: |-
|
||
${{
|
||
!cancelled() &&
|
||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
||
needs.classify_pr.outputs.skip_ci != 'true' &&
|
||
(needs.classify_pr.outputs.ci_profile || 'full') == 'full' &&
|
||
needs.test_build.result == 'success' &&
|
||
!github.event.pull_request.draft
|
||
}}
|
||
# The shards' runners: a hosted runner queued this one-line check for
|
||
# 13 minutes after the last shard finished.
|
||
runs-on: '${{ contains(fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]''), ''self-hosted'') && fromJSON(''["self-hosted", "linux", "arm64"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||
timeout-minutes: 5
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
- name: 'Require every web-shell smoke shard to pass'
|
||
env:
|
||
SMOKE_RESULT: '${{ needs.web_shell_e2e_smoke.result }}'
|
||
run: 'test "$SMOKE_RESULT" = success'
|
||
|
||
test_macos:
|
||
name: 'Test (macos-latest, Node 22.x)'
|
||
needs: 'test_macos_shards'
|
||
if: |-
|
||
${{
|
||
always() && (
|
||
github.event_name == 'merge_group' ||
|
||
github.event_name == 'schedule' ||
|
||
github.event_name == 'workflow_dispatch'
|
||
)
|
||
}}
|
||
runs-on: 'ubuntu-latest'
|
||
timeout-minutes: 5
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
- name: 'Require every macOS test group to pass'
|
||
env:
|
||
MACOS_RESULT: '${{ needs.test_macos_shards.result }}'
|
||
run: 'test "$MACOS_RESULT" = success'
|
||
|
||
test_macos_shards:
|
||
name: 'macOS unit tests (${{ matrix.group }} ${{ matrix.shard }})'
|
||
needs: 'classify_pr'
|
||
if: |-
|
||
${{
|
||
!cancelled() && (
|
||
github.event_name == 'merge_group' ||
|
||
github.event_name == 'schedule' ||
|
||
github.event_name == 'workflow_dispatch'
|
||
)
|
||
}}
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- group: 'cli'
|
||
shard: '1/3'
|
||
- group: 'cli'
|
||
shard: '2/3'
|
||
- group: 'cli'
|
||
shard: '3/3'
|
||
- group: 'core'
|
||
shard: '1/2'
|
||
- group: 'core'
|
||
shard: '2/2'
|
||
- group: 'rest'
|
||
shard: ''
|
||
- group: 'scripts'
|
||
shard: ''
|
||
runs-on: 'macos-latest'
|
||
timeout-minutes: 60
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
# merge_group/schedule/dispatch only: this lane keeps
|
||
# refs/pull/N/head; the PR lanes pin the event head SHA.
|
||
- name: 'Checkout'
|
||
id: 'checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
||
|
||
- name: 'Set up Node.js 22.x'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
- name: 'Install dependencies'
|
||
id: 'install'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Hosted portable process smoke'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && matrix.group == 'scripts' }}"
|
||
timeout-minutes: 5
|
||
run: |-
|
||
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
|
||
|
||
- name: 'Run tests and generate reports'
|
||
if: "${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
env:
|
||
MACOS_TEST_GROUP: '${{ matrix.group }}'
|
||
MACOS_TEST_SHARD: '${{ matrix.shard }}'
|
||
NO_COLOR: true
|
||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||
OPENAI_API_KEY: ''
|
||
DASHSCOPE_API_KEY: ''
|
||
QWEN_API_KEY: ''
|
||
GEMINI_API_KEY: ''
|
||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||
run: |-
|
||
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
||
# ENOSPC has failed test steps mid-suite while the host looks
|
||
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
||
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
||
# failing run captures the spike, and dump full state on failure.
|
||
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
||
# Symlink aliases break tests that intentionally compare real paths.
|
||
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
||
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
||
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
||
TMPDIR="$QWEN_CI_TMPDIR"
|
||
export TMPDIR
|
||
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
||
fi
|
||
fi
|
||
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
||
SAMPLER_PID=$!
|
||
set +e
|
||
node scripts/run-macos-ci-group.js "$MACOS_TEST_GROUP" "$MACOS_TEST_SHARD"
|
||
RC=$?
|
||
set -e
|
||
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
||
kill "$SAMPLER_PID" 2>/dev/null || true
|
||
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
||
exit "$RC"
|
||
|
||
# Windows counterpart of test_macos (see that job's note). ECS is the default
|
||
# with a windows-2022 kill-switch fallback; the check name stays unchanged so
|
||
# it matches the required-status-check context. The `!= 'pull_request'` arm
|
||
# of `runs-on` is inert while the pull-request trigger is off, and stays for
|
||
# when it returns: a pull_request run executes the workflow YAML from the
|
||
# PR's own merge commit, so any PR this lane admits could rewrite `runs-on`
|
||
# in the same diff that reaches it. A gate the gated tree controls is no
|
||
# gate; the pool is reached only by triggers an unreviewed PR cannot open —
|
||
# the post-approval merge queue, schedule and dispatch. ECS-only tuning is
|
||
# gated on runner.environment; the hosted fallback is the pre-ECS job plus
|
||
# the checkout guard and a job-level timeout-minutes.
|
||
test_windows:
|
||
name: 'Test (windows-latest, Node 22.x)'
|
||
needs: 'classify_pr'
|
||
if: |-
|
||
${{
|
||
!cancelled() && (
|
||
github.event_name == 'merge_group' ||
|
||
github.event_name == 'schedule' ||
|
||
github.event_name == 'workflow_dispatch'
|
||
)
|
||
}}
|
||
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event_name != ''pull_request'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'
|
||
timeout-minutes: 60
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
# Belt-and-braces alongside .gitattributes' `eol=lf`: turn autocrlf off
|
||
# before the checkout so even a freshly provisioned runner that defaults
|
||
# autocrlf on checks out LF-only files. Repository-local `./` actions
|
||
# resolve from the job workspace, so the checkout must precede them;
|
||
# the rest of the self-hosted tuning runs after the checkout via the
|
||
# configure-windows-runner action, shared verbatim with
|
||
# windows-runner-smoke.yml so the runner-validation smoke exercises
|
||
# exactly what this gate uses. LC_ALL mirrors the Linux gates' locale
|
||
# env (inert on Windows, where Node collates through ICU), and Git Bash
|
||
# goes on PATH so the remaining steps can run under the workflow-level
|
||
# bash default.
|
||
- name: 'Disable Git CRLF conversion (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
||
shell: 'powershell'
|
||
run: 'git config --global core.autocrlf false'
|
||
|
||
- name: 'Checkout'
|
||
id: 'checkout'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
||
|
||
- name: 'Configure self-hosted Windows test environment'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/configure-windows-runner'
|
||
|
||
# Hosted runners expose TEMP through an 8.3 short alias. Self-hosted
|
||
# runners keep their configured, alias-free RUNNER_TEMP path.
|
||
- name: 'Point temp at a short-alias-free directory'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
|
||
shell: 'powershell'
|
||
run: |-
|
||
$temp = Join-Path $env:RUNNER_WORKSPACE 'qwen-code-temp'
|
||
New-Item -ItemType Directory -Force -Path $temp | Out-Null
|
||
"TEMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||
"TMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||
Write-Host "TEMP set to $temp"
|
||
|
||
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS,
|
||
# so fail loud if the checkout lacks the merge-queue head rather than
|
||
# silently testing the wrong tree into a merge.
|
||
# Written when this lane ran in the merge queue alone, so its expected
|
||
# SHA named only the queue's event: on any other trigger the input is
|
||
# empty and the step fails the whole lane before a single test runs.
|
||
# That is what the revived triggers hit first. Same event-aware shape as
|
||
# the Ubuntu gate now, and skipped where there is no head to verify —
|
||
# the scheduled and dispatch runs check out a branch by name.
|
||
- name: 'Verify checkout includes expected head commit'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
||
|
||
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
||
# through the egress proxy; reuse the machine's Node instead.
|
||
- name: 'Set up Node.js 22.x (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version: '22.x'
|
||
package-manager-cache: false
|
||
registry-url: 'https://registry.npmjs.org/'
|
||
|
||
- name: 'Cache pnpm store (hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
|
||
uses: './.github/actions/pnpm-store-cache'
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
# Compare case-insensitively: Windows paths are case-insensitive and
|
||
# realpathSync returns the on-disk casing, so a drive-letter or
|
||
# directory-case difference is the SAME directory under one spelling,
|
||
# not the 8.3 alias this guards. A strict !== there would fail the
|
||
# self-hosted lane too — it runs this step against the runner's
|
||
# pre-existing RUNNER_TEMP — for a reason unrelated to the alias. The
|
||
# alias case (RUNNER~1 -> runneradmin) differs by more than casing and
|
||
# still fails; a casing-only difference warns so it stays visible.
|
||
- name: 'Verify temp paths carry no short alias'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
node -e "const fs = require('node:fs'); for (const key of ['TEMP', 'TMP']) { const value = process.env[key]; if (!value) { console.error('::error::' + key + ' is not set'); process.exitCode = 1; continue; } const real = fs.realpathSync(value); if (real === value) continue; if (real.toLowerCase() === value.toLowerCase()) { console.log('::warning::' + key + ' differs from its real path only by casing: ' + value + ' -> ' + real); continue; } console.error('::error::' + key + ' carries a short alias: ' + value + ' -> ' + real); process.exitCode = 1; }"
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
# Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH,
|
||
# so create the directory through the Node the preflight verified.
|
||
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Configure npm for rate limiting'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
npm config set fetch-retry-mintimeout 20000
|
||
npm config set fetch-retry-maxtimeout 120000
|
||
npm config set fetch-retries 5
|
||
npm config set fetch-timeout 300000
|
||
|
||
- name: 'Install dependencies'
|
||
id: 'install'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
run: |-
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Run tests and generate reports'
|
||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
env:
|
||
NO_COLOR: true
|
||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||
OPENAI_API_KEY: ''
|
||
DASHSCOPE_API_KEY: ''
|
||
QWEN_API_KEY: ''
|
||
GEMINI_API_KEY: ''
|
||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||
run: |-
|
||
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
||
# Not a duplicate of the pre-checkout autocrlf step: HOME and
|
||
# USERPROFILE are redirected for this step, so git reads a different
|
||
# global config here — and that step is gated on self-hosted runners,
|
||
# so on the hosted windows-2022 fallback it never runs. Deleting this
|
||
# line silently returns CRLF conversion to every git operation the
|
||
# suites perform, and no test notices.
|
||
git config --global core.autocrlf false
|
||
# ENOSPC has failed test steps mid-suite while the host looks
|
||
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
||
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
||
# failing run captures the spike, and dump full state on failure.
|
||
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
||
# Symlink aliases break tests that intentionally compare real paths.
|
||
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
||
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
||
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
||
TMPDIR="$QWEN_CI_TMPDIR"
|
||
export TMPDIR
|
||
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
||
fi
|
||
fi
|
||
( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
||
SAMPLER_PID=$!
|
||
set +e
|
||
npm run test:ci:workspaces
|
||
WORKSPACE_RC=$?
|
||
if [ "$WORKSPACE_RC" -ne 0 ]; then
|
||
echo '::warning::The Windows workspace suite has known baseline failures; its full result remains visible above.'
|
||
fi
|
||
npm run test:scripts
|
||
SCRIPTS_RC=$?
|
||
if [ "$SCRIPTS_RC" -ne 0 ]; then
|
||
echo '::warning::The complete Windows script suite has known baseline failures; its full result remains visible above.'
|
||
fi
|
||
npx vitest run scripts/tests/install-script.test.js scripts/tests/opentui-preview-install.test.js --config scripts/tests/vitest.config.ts --retry=0 --bail=1 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false
|
||
INSTALLER_RC=$?
|
||
set -e
|
||
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
||
kill "$SAMPLER_PID" 2>/dev/null || true
|
||
if [ "$INSTALLER_RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
||
exit "$INSTALLER_RC"
|
||
|
||
# Unlike macOS, whose E2E legs run this file in full, no other Windows
|
||
# lane runs the Hosted cases, so this one runs all of them and fails if
|
||
# any is skipped. It runs after the unit tests so that a Hosted failure
|
||
# cannot hide their results, but only after a successful install: without
|
||
# node_modules and dist the suite cannot even collect and the red is then
|
||
# attributed to a test file that never ran.
|
||
- name: 'Hosted process suite'
|
||
if: "${{ !cancelled() && steps.install.outcome == 'success' && needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||
timeout-minutes: 10
|
||
run: |-
|
||
npm run test:integration:hosted:sandbox:none -- --reporter=default --reporter=json --outputFile.json=hosted-process-suite.json
|
||
node -e "const r = require('./integration-tests/hosted-process-suite.json'); if (r.numTotalTests === 0 || r.numPassedTests !== r.numTotalTests) { console.error('::error::' + r.numPassedTests + ' of ' + r.numTotalTests + ' Hosted cases passed; Windows must run every case'); process.exit(1); }"
|
||
|
||
# Integration tests run only in the merge queue, not on every PR push.
|
||
# They are the suite that previously ran *only* in the nightly Release
|
||
# pipeline (`release.yml`), so regressions stayed hidden until release
|
||
# time. Gating them on `merge_group` catches the failure before the PR
|
||
# lands on `main`, while keeping the per-PR critical path fast. The
|
||
# `merge_group` event runs in the base-repo context, so the same model
|
||
# secrets used by the release jobs are available here.
|
||
#
|
||
# Until merge queue is enabled on `main` this job simply never triggers,
|
||
# so adding it is a no-op for existing PR/push runs. Reuses the exact
|
||
# `test:integration:cli:sandbox:none` script from `release.yml`.
|
||
integration_no_ak:
|
||
name: 'Integration Tests (no-AK, shard ${{ matrix.shard }}/3)'
|
||
# Since #16133 test_build uploads the tree this gate tests. Installing
|
||
# with `prepare` here rebuilt and rebundled it from scratch — about ten
|
||
# minutes, the longest single step on the required path — and the ~50
|
||
# files then ran one at a time (one fork on the pool, below), seventeen
|
||
# more minutes. The shards unpack the shared tree and split the files
|
||
# three ways; integration_no_ak_result keeps the required check name.
|
||
needs:
|
||
- 'classify_pr'
|
||
- 'test_build'
|
||
# The deterministic no-credential integration set (#8313) used to run as a
|
||
# step inside the Ubuntu `test` job. That made it invisible to anything
|
||
# that reads check names: the `Integration Tests (CLI, No Sandbox)` check
|
||
# is merge_group-only and shows as skipped on every PR, and the PR review
|
||
# bot ruled from that skip that a changed integration test "never ran"
|
||
# (#9895 round 15) while this very gate had executed it and passed inside
|
||
# `test`. A check of its own carries the fact in its name. Same runner
|
||
# routing as the Ubuntu gate; same PR + merge-queue events; same profile
|
||
# gate, classified here because depending on `test` for its output would
|
||
# serialize this job behind the hour-long unit run.
|
||
#
|
||
# A draft pull request skips it (and the browser smoke): it cannot merge,
|
||
# and `ready_for_review` starts the full run that gates the merge.
|
||
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && !github.event.pull_request.draft }}"
|
||
# Shares lint's hosted/disposable routing for untrusted forks. Model keys
|
||
# are empty; the job receives only the read-only checkout token.
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3]
|
||
# Dependency installation alone can exceed 30 minutes on a contended shared
|
||
# ECS host, before the separately bounded 20-minute integration step starts.
|
||
# Same self-hosted scoping as lint_and_static (60/30 like its 90/45):
|
||
# hosted fallback routing keeps the pre-contention ceiling, and the ARM64
|
||
# fleet — which carries no `ecs-qwen` label — gets the contended-pool
|
||
# allowance it runs under.
|
||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}'
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
# This lane's fork code starts at the local checkout action and the
|
||
# install (lifecycle scripts AND scripts/prepare.js — build+bundle),
|
||
# not at the gate step, so isolate HOME from the FIRST step like
|
||
# lint_and_static: publish a run-unique HOME via GITHUB_ENV (job env
|
||
# cannot read the runner context), keep the content-verified linter
|
||
# cache shared, and keep COREPACK_HOME inside the per-run home
|
||
# (corepack does not re-verify entries on cache hit — a shared home is
|
||
# a cross-job poisoning channel). The gate step's key scrub stays.
|
||
- name: 'Create isolated HOME'
|
||
run: |-
|
||
home="${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||
rm -rf -- "$home"
|
||
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
|
||
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
|
||
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
|
||
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
|
||
|
||
- name: 'Restore workspace ownership'
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Same pre-checkout recovery as the test job: this job lands on the
|
||
# same reused pool, so leftover review worktrees and branches from an
|
||
# interrupted review would break this checkout too.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
ref: "${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
||
fetch-depth: 1
|
||
|
||
- name: 'Verify checkout includes expected head commit'
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
||
|
||
# KEEP IN SYNC with the `test` job's profile step so a docs-only,
|
||
# java_only or github-ci-only PR skips this gate exactly when it skips
|
||
# the unit run.
|
||
- name: 'Use trusted CI profile'
|
||
id: 'ci_profile'
|
||
env:
|
||
TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'
|
||
run: |-
|
||
if [ -z "${TRUSTED_CI_PROFILE}" ]; then
|
||
echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."
|
||
fi
|
||
profile="${TRUSTED_CI_PROFILE:-full}"
|
||
case "${profile}" in
|
||
docs_only|github_ci_only|java_only|full) ;;
|
||
*) profile=full ;;
|
||
esac
|
||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||
echo "Selected CI profile: ${profile}"
|
||
|
||
- name: 'Setup Node.js (hosted)'
|
||
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version-file: '.nvmrc'
|
||
package-manager-cache: false
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
# HOME is per-run (isolated above), so this cache dies with the job —
|
||
# the name must not promise persistence. The cold install is the
|
||
# accepted cost (review round-3); a shared pnpm store is a follow-up.
|
||
- name: 'Configure per-run npm cache (self-hosted)'
|
||
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using per-run npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
# The build comes from test_build's tree below, not from `prepare`.
|
||
- name: 'Install Dependencies'
|
||
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
env:
|
||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||
QWEN_SKIP_PREPARE: '1'
|
||
run: |-
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Report npm cache usage (self-hosted)'
|
||
if: "${{ always() && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
||
echo "npm cache: ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
# Same handoff as the unit-test shards (see `test`).
|
||
- name: 'Download the built tree'
|
||
id: 'download_tree'
|
||
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 15
|
||
continue-on-error: true
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Retry downloading the built tree'
|
||
if: "${{ steps.download_tree.outcome == 'failure' }}"
|
||
timeout-minutes: 15
|
||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||
with:
|
||
name: 'unit-test-tree'
|
||
path: '${{ runner.temp }}/unit-test-tree'
|
||
|
||
- name: 'Unpack the built tree'
|
||
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
run: |-
|
||
tar -xzf "${RUNNER_TEMP}/unit-test-tree/unit-test-tree.tgz"
|
||
rm -rf "${RUNNER_TEMP}/unit-test-tree"
|
||
# PR checkouts may predate the build-cache host-assets command.
|
||
if [ -f dist/cli.js ]; then
|
||
node --input-type=module -e 'import { copyOpenTuiAssets } from "./scripts/copy_bundle_assets.js"; copyOpenTuiAssets();'
|
||
fi
|
||
|
||
- name: 'Run required no-AK integration gate'
|
||
if: "${{ needs.test_build.result == 'success' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||
timeout-minutes: 20
|
||
# HOME/USERPROFILE come from the first step's GITHUB_ENV publication
|
||
# (run-unique, cleared before use). QWEN_HOME rides the same home.
|
||
# The key scrub below is the zero-secrets contract: pinned by the
|
||
# routing battery (every non-path env value must be '').
|
||
env:
|
||
QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen'
|
||
API_KEY: ''
|
||
ANTHROPIC_API_KEY: ''
|
||
# The SDK auth resolver back-fills this from env; a runner-account
|
||
# residue would survive into fork PR code (review round-3).
|
||
ANTHROPIC_AUTH_TOKEN: ''
|
||
ANTHROPIC_BASE_URL: ''
|
||
ANTHROPIC_MODEL: ''
|
||
BAILIAN_CODING_PLAN_API_KEY: ''
|
||
BAILIAN_TOKEN_PLAN_API_KEY: ''
|
||
DEEPSEEK_API_KEY: ''
|
||
OPENAI_API_KEY: ''
|
||
OPENAI_BASE_URL: ''
|
||
OPENAI_MODEL: ''
|
||
DASHSCOPE_API_KEY: ''
|
||
GOOGLE_API_KEY: ''
|
||
GOOGLE_MODEL: ''
|
||
QWEN_API_KEY: ''
|
||
QWEN_MODEL: ''
|
||
GEMINI_API_KEY: ''
|
||
GEMINI_MODEL: ''
|
||
IDEALAB_API_KEY: ''
|
||
MINIMAX_API_KEY: ''
|
||
MODELSCOPE_API_KEY: ''
|
||
MOONSHOT_API_KEY: ''
|
||
OPENROUTER_API_KEY: ''
|
||
REQUESTY_API_KEY: ''
|
||
XAI_API_KEY: ''
|
||
ZAI_API_KEY: ''
|
||
QWENCLOUD_API_KEY: ''
|
||
QWENCLOUD_TOKEN_PLAN_API_KEY: ''
|
||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||
run: |-
|
||
mkdir -p "${QWEN_HOME}"
|
||
if [ "${{ matrix.shard }}" = 1 ]; then
|
||
npm run typecheck:integration
|
||
fi
|
||
npm run test:integration:no-ak:sandbox:none -- --shard=${{ matrix.shard }}/3
|
||
|
||
# Reclaim the run-unique HOME (constructed path, never $HOME) so it
|
||
# does not accumulate in RUNNER_TEMP on this persistent pool.
|
||
- name: 'Clean isolated HOME'
|
||
if: '${{ always() }}'
|
||
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
|
||
|
||
# The required check. It passes only when the build and every shard pass,
|
||
# so a failed test_build (whose shards skip their steps) still fails it.
|
||
integration_no_ak_result:
|
||
name: 'Integration Tests (no-AK, No Sandbox)'
|
||
needs:
|
||
- 'classify_pr'
|
||
- 'test_build'
|
||
- 'integration_no_ak'
|
||
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && !github.event.pull_request.draft }}"
|
||
# ubuntu_runner, not the shards' lint_runner: the routing battery keeps
|
||
# the fork-admitting route to the lanes that isolate HOME, and this job
|
||
# checks out nothing.
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||
timeout-minutes: 5
|
||
permissions:
|
||
contents: 'read'
|
||
steps:
|
||
- name: 'Require the build and every no-AK integration shard to pass'
|
||
env:
|
||
BUILD_RESULT: '${{ needs.test_build.result }}'
|
||
SHARDS_RESULT: '${{ needs.integration_no_ak.result }}'
|
||
run: 'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success'
|
||
|
||
integration_cli:
|
||
name: 'Integration Tests (CLI, No Sandbox)'
|
||
needs: 'classify_pr'
|
||
# Same ECS routing as the Ubuntu gate (via classify_pr): the merge queue runs
|
||
# in the base-repo context, so use the self-hosted ECS pool and keep the
|
||
# scarce hosted Linux runners free. Falls back to hosted if classify_pr is
|
||
# skipped or the ECS kill-switch is set.
|
||
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
|
||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||
permissions:
|
||
contents: 'read'
|
||
env:
|
||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||
steps:
|
||
# Self-hosted runners reuse the workspace; a prior containerised job can
|
||
# leave root-owned, read-only files anywhere in it. Restore ownership and
|
||
# write permission unconditionally before checkout — see the test job's
|
||
# 'Restore workspace ownership' step for why probing first is unsafe.
|
||
- name: 'Restore workspace ownership'
|
||
run: |-
|
||
set -uo pipefail
|
||
RUNNER_UID="$(id -u)"
|
||
RUNNER_GID="$(id -g)"
|
||
if [ "$RUNNER_UID" != "0" ]; then
|
||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
||
fi
|
||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
||
# A leftover workspace owned by a different uid than the step user
|
||
# (e.g. a runner-user checkout under root-run steps) makes every git
|
||
# call fail with "detected dubious ownership". The pinned
|
||
# actions/checkout adds safe.directory only to its own temporary
|
||
# global config, so later steps are uncovered; trust the workspace
|
||
# here instead. The --get-all guard keeps a persistent runner's
|
||
# gitconfig from accumulating a duplicate entry per job.
|
||
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
|
||
|
||
# Same pre-checkout recovery as the test job: this job lands on the
|
||
# same reused pool, so leftover review worktrees and branches from an
|
||
# interrupted review would break this checkout too. The
|
||
# `.qwen.root-orig` name's provenance (an external recovery tool) is
|
||
# documented on the test job's copy.
|
||
- name: 'Clean stale .qwen before checkout'
|
||
run: |-
|
||
set -uo pipefail
|
||
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
|
||
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
continue
|
||
fi
|
||
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
|
||
chmod -R u+w "$stale_qwen" 2>/dev/null || true
|
||
fi
|
||
# A foreign-owned directory cannot always be renamed to a
|
||
# different parent: updating its .. entry can fail even when the
|
||
# workspace parent is writable. If that individual move fails,
|
||
# quarantine the runner-owned workspace itself, then recreate the
|
||
# empty checkout root. Warm contents are lost only on this
|
||
# otherwise unrecoverable path.
|
||
rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
|
||
{
|
||
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
|
||
mkdir -p "$quarantine" 2>/dev/null || true
|
||
stale_name="$(basename -- "$stale_qwen")"
|
||
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
|
||
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
else
|
||
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
|
||
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
|
||
cd "$GITHUB_WORKSPACE"; then
|
||
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
|
||
break
|
||
else
|
||
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
|
||
fi
|
||
fi
|
||
}
|
||
done
|
||
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
||
# and qwen-review/* branches behind. prune drops registrations whose
|
||
# directories the rm above removed; worktree remove --force then
|
||
# clears any still-registered leftover directory (--force tolerates
|
||
# dirty contents), since a branch checked out in a live worktree
|
||
# cannot be deleted. If removal still fails, the registration
|
||
# survives and the branch delete below warns. The sweep deletes all
|
||
# review artifacts, not just the current PR's: safe because a runner
|
||
# executes one job at a time. Kept inline rather than a shared
|
||
# script: this runs pre-checkout on shared runners, where leftover
|
||
# workspace files are untrusted.
|
||
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
||
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" worktree list --porcelain \
|
||
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
||
| while read -r worktree; do
|
||
[ -n "$worktree" ] || continue
|
||
# Registered paths come from leftover git metadata and are
|
||
# untrusted: the awk filter above matched by substring, so reject
|
||
# `..` traversal and re-anchor to the review prefix before the
|
||
# destructive remove.
|
||
case "$worktree" in
|
||
*/../*|../*|*/..)
|
||
echo "::warning::skipping suspicious review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
||
*)
|
||
echo "::warning::skipping unexpected review worktree path: $worktree"
|
||
continue
|
||
;;
|
||
esac
|
||
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
||
echo "::warning::could not remove review worktree: $worktree"
|
||
done || true
|
||
"${GIT_SAFE[@]}" worktree prune -v || true
|
||
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
||
| while read -r stale_ref; do
|
||
if [ -n "$stale_ref" ]; then
|
||
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
||
echo "::warning::could not remove review branch: $stale_ref"
|
||
fi
|
||
done || true
|
||
fi
|
||
|
||
- name: 'Recreate runner Git metadata'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
||
|
||
- name: 'Checkout'
|
||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||
with:
|
||
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
||
# Shallow, mirroring the Ubuntu gate: nothing here walks git history,
|
||
# and a full-history clone is the heaviest transfer on the ECS runner.
|
||
fetch-depth: 1
|
||
|
||
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS
|
||
# via classify_pr, so fail loud if the checkout lacks the merge-queue head
|
||
# rather than silently testing the wrong tree into a merge.
|
||
- name: 'Verify checkout includes expected head commit'
|
||
uses: './.github/actions/verify-checkout-head'
|
||
with:
|
||
expected_sha: '${{ github.event.merge_group.head_sha }}'
|
||
|
||
# Hosted downloads Node; self-hosted ECS reuses its pre-installed Node 22
|
||
# (it can't reach nodejs.org reliably). Mirrors the Ubuntu gate.
|
||
- name: 'Setup Node.js (hosted)'
|
||
if: "${{ runner.environment == 'github-hosted' }}"
|
||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||
with:
|
||
node-version-file: '.nvmrc'
|
||
package-manager-cache: false
|
||
|
||
- name: 'Use pre-installed Node.js (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
uses: './.github/actions/self-hosted-node'
|
||
|
||
- name: 'Configure persistent npm cache (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||
mkdir -p "${cache_dir}"
|
||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||
echo "Using npm cache at ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
# Same pre-install admission check as the test job (#10035): this job
|
||
# shares the self-hosted pool in the merge queue.
|
||
- name: 'Disk floor gate (self-hosted)'
|
||
if: "${{ runner.environment == 'self-hosted' }}"
|
||
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
|
||
|
||
- name: 'Install Dependencies'
|
||
env:
|
||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||
run: |-
|
||
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
||
|
||
- name: 'Report npm cache usage (self-hosted)'
|
||
if: "${{ always() && runner.environment == 'self-hosted' }}"
|
||
run: |-
|
||
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
||
echo "npm cache: ${cache_dir}"
|
||
du -sh "${cache_dir}" 2>/dev/null || true
|
||
|
||
- name: 'Run CLI Integration Tests'
|
||
run: |-
|
||
npm run test:integration:cli:sandbox:none
|