mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 02:06:21 +00:00
The first thing the revived triggers hit was not a test failure but the lane's own plumbing. `test_windows` verifies its checkout with `verify-checkout-head`, and the input was written when this lane ran in the merge queue alone: `expected_sha: github.event.merge_group.head_sha`, with no event gate. On a pull request that expression is empty, the action refuses an empty SHA, and the lane went red in 63 seconds without running a test — the first Windows run in six weeks, failing on the trigger rather than on the code. Give it the event-aware shape the Ubuntu gate already uses, and skip it where there is nothing to verify: the scheduled and dispatch runs check out a branch by name, not a head commit. Pinned generally rather than by name: for both lanes, any step whose inputs read a `github.event.<event>` context must be gated to that event, in the step's own `if` or in the expression itself. Restoring the old spelling turns that test red.
1403 lines
73 KiB
YAML
1403 lines
73 KiB
YAML
# .github/workflows/ci.yml
|
|
|
|
name: 'Qwen Code CI'
|
|
|
|
on:
|
|
# No `push` trigger: every job here is gated to pull_request / merge_group, so
|
|
# a push to `main` ran nothing (CodeQL was the last push job and moved to its
|
|
# own scheduled codeql.yml). The merge queue validates the merged tree before
|
|
# it lands, so there is nothing left to run on the post-merge push.
|
|
pull_request:
|
|
branches:
|
|
- 'main'
|
|
- 'release/**'
|
|
merge_group:
|
|
# A daily heartbeat for the macOS and Windows lanes ONLY. Those two are
|
|
# gated on `merge_group`, and the merge queue is not enabled on this
|
|
# repository — no queue run since 2026-07-02 — so they had stopped running
|
|
# entirely: skipped on every pull request, and never reached afterwards. The
|
|
# pull-request gate below catches the diffs a path list can recognise; this
|
|
# catches everything it cannot, one day later, on `main`. Every other job
|
|
# here excludes `schedule` explicitly, so a nightly run is exactly two jobs.
|
|
schedule:
|
|
- cron: '17 19 * * *'
|
|
workflow_dispatch:
|
|
inputs:
|
|
branch_ref:
|
|
description: 'Branch to run on'
|
|
required: true
|
|
default: 'main'
|
|
type: 'string'
|
|
linux_runner:
|
|
description: 'Linux runner to use for manual validation'
|
|
required: true
|
|
default: 'self-hosted'
|
|
type: 'choice'
|
|
options:
|
|
- 'self-hosted'
|
|
- 'hosted'
|
|
|
|
concurrency:
|
|
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
|
cancel-in-progress: |-
|
|
${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}
|
|
|
|
permissions:
|
|
checks: 'write'
|
|
contents: 'read'
|
|
statuses: 'write'
|
|
|
|
defaults:
|
|
run:
|
|
shell: 'bash'
|
|
|
|
env:
|
|
ACTIONLINT_VERSION: '1.7.12'
|
|
SHELLCHECK_VERSION: '0.11.0'
|
|
YAMLLINT_VERSION: '1.35.1'
|
|
# Single source of truth for the .github/scripts node:test suites — run by
|
|
# BOTH the github_ci_only helper step and the full-profile Test step, so a
|
|
# new helper test can't be added to one path and silently dropped from the
|
|
# other.
|
|
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/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'
|
|
|
|
jobs:
|
|
classify_pr:
|
|
name: 'Classify PR'
|
|
if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }}"
|
|
# Gate runs on ECS for in-repo PRs, fork PRs whose author has write access
|
|
# (OWNER/MEMBER/COLLABORATOR association — a write-access author is as
|
|
# trusted as an in-repo branch), and the merge queue (base-repo context),
|
|
# else a busy hosted pool delays it and blocks the ECS-bound jobs. The
|
|
# kill-switch is read here, so flipping it reverts everything to hosted.
|
|
# This runs-on and the pick_runner step below are the canonical home of
|
|
# the association routing; sdk-java.yml and serve-ab.yml mirror it.
|
|
runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
|
continue-on-error: true
|
|
outputs:
|
|
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
|
|
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
|
|
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), and the merge queue
|
|
# (base-repo context) run the Linux jobs on ECS; other fork PRs stay
|
|
# hosted. Disable via repo var MAINTAINER_ECS_RUNNER_DISABLED=true.
|
|
- name: 'Select Linux runner'
|
|
id: 'pick_runner'
|
|
env:
|
|
SAME_REPO: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
|
|
AUTHOR_ASSOCIATION: '${{ github.event.pull_request.author_association }}'
|
|
ECS_DISABLED: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED }}'
|
|
EVENT_NAME: '${{ github.event_name }}'
|
|
DISPATCH_LINUX_RUNNER: '${{ github.event.inputs.linux_runner }}'
|
|
run: |-
|
|
ubuntu_runner='["ubuntu-latest"]'
|
|
trusted_author=false
|
|
case "${AUTHOR_ASSOCIATION}" in
|
|
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
|
|
esac
|
|
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
|
if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then
|
|
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
|
fi
|
|
elif [[ "${ECS_DISABLED}" != "true" && ( "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" ) ]]; then
|
|
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
|
fi
|
|
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
|
|
echo "Selected Linux runner: ${ubuntu_runner}"
|
|
|
|
# Does this pull request need the macOS and Windows lanes? They are the only
|
|
# signal this repository has about a host that is not Linux with a GNU
|
|
# userland, and they are expensive, so they run on the diffs whose behaviour
|
|
# the HOST decides — shell, CI definitions, the script layer, the runner
|
|
# config, the platform-coupled subtrees. The classifier is a net, not a
|
|
# proof; the scheduled run on `main` is what covers everything a path list
|
|
# cannot see.
|
|
#
|
|
# Its own job, not a step in classify_pr: that job's outputs pick the Linux
|
|
# runner for everything else, and a new failure mode there (this one needs a
|
|
# checkout, on a pool whose workspace other jobs can poison) would take the
|
|
# whole run's routing with it. Here a failure costs one skipped
|
|
# classification, which the gate reads as "run the lanes".
|
|
#
|
|
# Hosted, and the checkout is of the pull request's BASE commit: this runs
|
|
# before any review, so checking out the contributor's head would run their
|
|
# classifier with this job's token, and staying off the ECS pool keeps it
|
|
# away from the poisoned-workspace class entirely.
|
|
classify_platform:
|
|
name: 'Classify platform sensitivity'
|
|
if: "${{ github.event_name == 'pull_request' }}"
|
|
runs-on: 'ubuntu-latest'
|
|
timeout-minutes: 5
|
|
continue-on-error: true
|
|
permissions:
|
|
contents: 'read'
|
|
pull-requests: 'read'
|
|
outputs:
|
|
platform_sensitive: '${{ steps.platform.outputs.platform_sensitive }}'
|
|
steps:
|
|
- name: 'Check out the classifier from the base branch'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: '${{ github.event.pull_request.base.sha }}'
|
|
persist-credentials: false
|
|
sparse-checkout: '.github/scripts/ci'
|
|
|
|
- name: 'Classify platform sensitivity'
|
|
id: 'platform'
|
|
env:
|
|
GH_TOKEN: '${{ github.token }}'
|
|
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
|
IS_SAME_REPO_PR: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
|
|
run: |-
|
|
set -uo pipefail
|
|
# Fail-safe in every direction: only a confident `false` from the
|
|
# classifier skips the lanes. A classifier error, an unexpected
|
|
# word, a fork PR, or this whole job failing all end as "run" — a
|
|
# gate that fails open stops testing without ever saying so.
|
|
sensitive=true
|
|
if [ "${IS_SAME_REPO_PR}" = 'true' ]; then
|
|
classified="$(.github/scripts/ci/classify-pr-profile.sh "${GITHUB_REPOSITORY}" "${PR_NUMBER}" platform)"
|
|
rc=$?
|
|
case "${rc}:${classified}" in
|
|
0:true|0:false) sensitive="${classified}" ;;
|
|
*) echo "::warning::Platform-sensitivity classifier returned rc=${rc} '${classified}'; running the macOS and Windows lanes." ;;
|
|
esac
|
|
else
|
|
echo "Fork PR detected; running the macOS and Windows lanes."
|
|
fi
|
|
echo "platform_sensitive=${sensitive}" >> "${GITHUB_OUTPUT}"
|
|
echo "Platform-sensitive: ${sensitive}"
|
|
|
|
#
|
|
# Test: Node
|
|
#
|
|
test:
|
|
name: 'Test (ubuntu-latest, Node 22.x)'
|
|
needs: 'classify_pr'
|
|
# Stay running on release-sync PRs so the required Test contexts still
|
|
# report; the per-step skip_ci guards below make them no-op (pass) there.
|
|
# Not on push: the merge queue already tested the merged tree, so a
|
|
# post-merge re-run on `main` would be redundant.
|
|
if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' }}"
|
|
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
|
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"
|
|
|
|
# Remove .qwen/ left by a previous job so actions/checkout doesn't trip
|
|
# on restrictive permissions.
|
|
- name: 'Clean stale .qwen before checkout'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
run: |-
|
|
set -uo pipefail
|
|
if [ -d "$GITHUB_WORKSPACE/.qwen" ] && [ ! -L "$GITHUB_WORKSPACE/.qwen" ]; then
|
|
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
|
|
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
|
|
fi
|
|
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
|
# and qwen-review/* branches behind. prune drops registrations whose
|
|
# directories the rm above removed; worktree remove --force then
|
|
# clears any still-registered leftover directory (--force tolerates
|
|
# dirty contents), since a branch checked out in a live worktree
|
|
# cannot be deleted. If removal still fails, the registration
|
|
# survives and the branch delete below warns. The sweep deletes all
|
|
# review artifacts, not just the current PR's: safe because a runner
|
|
# executes one job at a time. Kept inline rather than a shared
|
|
# script: this runs pre-checkout on shared runners, where leftover
|
|
# workspace files are untrusted.
|
|
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
|
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" worktree list --porcelain \
|
|
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
|
| while read -r worktree; do
|
|
[ -n "$worktree" ] || continue
|
|
# Registered paths come from leftover git metadata and are
|
|
# untrusted: the awk filter above matched by substring, so reject
|
|
# `..` traversal and re-anchor to the review prefix before the
|
|
# destructive remove.
|
|
case "$worktree" in
|
|
*/../*|../*|*/..)
|
|
echo "::warning::skipping suspicious review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
|
*)
|
|
echo "::warning::skipping unexpected review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
esac
|
|
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
|
echo "::warning::could not remove review worktree: $worktree"
|
|
done || true
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
|
| while read -r stale_ref; do
|
|
if [ -n "$stale_ref" ]; then
|
|
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
|
echo "::warning::could not remove review branch: $stale_ref"
|
|
fi
|
|
done || true
|
|
fi
|
|
|
|
# On PRs, check out refs/pull/N/head (the immutable PR head, published the
|
|
# instant the branch is pushed) instead of github.ref. github.ref is the
|
|
# merge ref (refs/pull/N/merge), which GitHub rebuilds asynchronously and
|
|
# can serve stale for minutes after a push, repeatedly flaking this gate.
|
|
# Merge queue refs are ephemeral; check out the event head SHA directly so
|
|
# slow hosted runners do not fail after the queue branch is removed.
|
|
# Non-PR/non-queue events keep github.ref.
|
|
- name: 'Checkout'
|
|
id: 'checkout'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
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 }}"
|
|
# Shallow: nothing here walks git history (the verify guard below checks
|
|
# head.sha == HEAD, schema/tests touch 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: 'Classify CI profile'
|
|
id: 'ci_profile'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
env:
|
|
GH_TOKEN: '${{ github.token }}'
|
|
PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}"
|
|
IS_SAME_REPO_PR: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}"
|
|
run: |-
|
|
profile=full
|
|
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then
|
|
if [[ "${IS_SAME_REPO_PR}" == "true" ]]; then
|
|
# Fetch + classify through the shared wrapper (also used by the
|
|
# review workflow's docs-only gate) so the classifier's input
|
|
# contract lives in one place. Exit 2 = listing failed,
|
|
# 3 = classifier failed.
|
|
set +e
|
|
profile="$(.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::CI profile classifier exited non-zero; running full CI."
|
|
profile=full
|
|
fi
|
|
else
|
|
echo "Fork PR detected; running full CI."
|
|
fi
|
|
fi
|
|
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."'
|
|
|
|
- name: 'GitHub CI helper checks'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'github_ci_only' }}"
|
|
timeout-minutes: 5
|
|
run: |-
|
|
# Keep this path dependency-free; script formatting is checked when those files hit full CI.
|
|
node scripts/lint.js --setup
|
|
node scripts/lint.js --actionlint
|
|
node scripts/lint.js --yamllint
|
|
node --test ${{ env.HELPER_TESTS }}
|
|
|
|
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
|
# through the egress proxy; reuse the machine's Node instead.
|
|
- name: 'Set up Node.js 22.x (hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'github-hosted' }}"
|
|
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version: '22.x'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'package-lock.json'
|
|
registry-url: 'https://registry.npmjs.org/'
|
|
|
|
- name: 'Use pre-installed Node.js (self-hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
|
uses: './.github/actions/self-hosted-node'
|
|
|
|
- name: 'Configure persistent npm cache (self-hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${HOME}/.cache/qwen-code/npm"
|
|
mkdir -p "${cache_dir}"
|
|
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
|
echo "Using persistent npm cache at ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- name: 'Configure npm for rate limiting'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: |-
|
|
npm config set fetch-retry-mintimeout 20000
|
|
npm config set fetch-retry-maxtimeout 120000
|
|
npm config set fetch-retries 5
|
|
npm config set fetch-timeout 300000
|
|
|
|
- name: 'Install dependencies'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: |-
|
|
npm ci --prefer-offline --no-audit --progress=false
|
|
|
|
- name: 'Report npm cache usage (self-hosted)'
|
|
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
|
echo "npm cache: ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- 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 voice guard mirror sync'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'npm run check:voice-guard-sync'
|
|
|
|
- name: 'Install linters'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --setup'
|
|
|
|
- name: 'Run ESLint'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --eslint'
|
|
|
|
- name: 'Run actionlint'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
timeout-minutes: 5
|
|
run: 'node scripts/lint.js --actionlint'
|
|
|
|
- name: 'Run shellcheck'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --shellcheck'
|
|
|
|
- name: 'Run yamllint'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --yamllint'
|
|
|
|
- name: 'Run Prettier'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --prettier'
|
|
|
|
- name: 'Run sensitive keyword linter'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node scripts/lint.js --sensitive-keywords'
|
|
|
|
- name: 'Run i18n check'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'npm run check-i18n'
|
|
|
|
- name: 'Generate settings schema'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'npm run generate:settings-schema'
|
|
|
|
- name: 'Check settings schema is up-to-date'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: |-
|
|
if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then
|
|
echo "Error: settings.schema.json is out of date."
|
|
echo "Please run: npm run generate:settings-schema"
|
|
echo "Then commit the updated schema file."
|
|
git diff packages/vscode-ide-companion/schemas/settings.schema.json
|
|
exit 1
|
|
fi
|
|
echo "Settings schema is up-to-date"
|
|
|
|
- name: 'Generate VS Code companion notices'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'npm run generate:notices --workspace=qwen-code-vscode-ide-companion'
|
|
|
|
- name: 'Check VS Code companion notices are up-to-date'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: |-
|
|
if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt) ]]; then
|
|
echo "Error: NOTICES.txt is out of date."
|
|
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
|
|
echo "Then commit the updated file."
|
|
git diff --stat packages/vscode-ide-companion/NOTICES.txt
|
|
exit 1
|
|
fi
|
|
echo "NOTICES.txt is up-to-date"
|
|
|
|
# Keep this Linux-only PR gate explicit. macOS/Windows merge-queue jobs run
|
|
# npm run test:ci only, so they intentionally do not repeat this
|
|
# platform-independent bundle closure check.
|
|
- name: 'Check serve fast-path bundle closure'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'npm run check:serve-fast-path-bundle'
|
|
|
|
# The `github_ci_only` profile runs the .github/scripts helper tests, but a
|
|
# `full` PR that touches those scripts skips that path and `npm run
|
|
# test:ci` (vitest) does not collect `node:test` files — so run them here
|
|
# too, or a compositor/publisher change could pass CI without its
|
|
# regression tests. Linux-only (they're platform-independent).
|
|
- name: 'Run .github/scripts helper tests'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: 'node --test ${{ env.HELPER_TESTS }}'
|
|
|
|
# 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 60-minute cap nor red the required check —
|
|
# continue-on-error absorbs the step-level timeout. The apt calls
|
|
# carry their OWN shorter bound (140 s + 140 s = 280 s < the 300 s
|
|
# step cap) so the guard below still runs: when the step-level
|
|
# timeout fires there is no `|| echo`, and the lane loses its
|
|
# tooling with no annotation to say so.
|
|
timeout-minutes: 5
|
|
continue-on-error: true
|
|
run: |-
|
|
if command -v tmux > /dev/null 2>&1 && command -v zip > /dev/null 2>&1 && command -v unzip > /dev/null 2>&1; then
|
|
# Advisory here too: a broken-but-installed tmux (dangling
|
|
# symlink, missing lib) must not fail the required Test check
|
|
# before a single test has run.
|
|
tmux -V || echo '::warning::tmux present but not answering; real-tmux capture tests will be skipped.'
|
|
zip -v > /dev/null 2>&1 || echo '::warning::zip present but not answering; the zip-packaging suite will throw on CI.'
|
|
unzip -v > /dev/null 2>&1 || echo '::warning::unzip present but not answering; the zip-packaging suite will throw on CI.'
|
|
elif [ "$(id -u)" = '0' ] && command -v apt-get > /dev/null 2>&1; then
|
|
# Root-container lanes have no sudo (and need none): apt-get
|
|
# runs as-is. Advisory, not a gate: an apt hiccup must not red
|
|
# this step before a single test has run — the suites carry
|
|
# their own signal (the real-tmux suite skips; the zip suite
|
|
# throws on CI). ::warning:: (not a plain echo) so a lane
|
|
# where the install PERMANENTLY fails shows the loss in the
|
|
# check UI instead of hiding it in a multi-thousand-line log.
|
|
timeout 140 apt-get update -qq && timeout 140 apt-get install -y -qq --no-install-recommends tmux zip unzip \
|
|
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
|
elif sudo -n true > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
|
|
# Same advisory contract as the root branch; sudo -n here so a
|
|
# runner without passwordless sudo fails fast instead of
|
|
# hanging on a password prompt toward the step's bound.
|
|
timeout 140 sudo -n apt-get update -qq && timeout 140 sudo -n apt-get install -y -qq --no-install-recommends tmux zip unzip \
|
|
|| echo '::warning::tmux/zip install failed; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
|
else
|
|
echo '::warning::tmux/zip unavailable; real-tmux capture tests will be skipped and the zip-packaging suite will throw on CI.'
|
|
fi
|
|
|
|
- name: 'Run tests and generate reports'
|
|
id: 'unit_tests'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
env:
|
|
NO_COLOR: true
|
|
HOME: '${{ runner.temp }}/qwen-ci-home'
|
|
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
|
OPENAI_API_KEY: ''
|
|
DASHSCOPE_API_KEY: ''
|
|
QWEN_API_KEY: ''
|
|
GEMINI_API_KEY: ''
|
|
QWEN_DEFAULT_AUTH_TYPE: ''
|
|
run: |-
|
|
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
|
# ENOSPC has failed test steps mid-suite while the host looks
|
|
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
|
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
|
# failing run captures the spike, and dump full state on failure.
|
|
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
|
# Symlink aliases break tests that intentionally compare real paths.
|
|
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
|
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
|
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
|
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
|
TMPDIR="$QWEN_CI_TMPDIR"
|
|
export TMPDIR
|
|
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
|
fi
|
|
fi
|
|
( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
|
SAMPLER_PID=$!
|
|
set +e
|
|
npm run test:ci
|
|
RC=$?
|
|
set -e
|
|
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
|
kill "$SAMPLER_PID" 2>/dev/null || true
|
|
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
|
exit "$RC"
|
|
|
|
# Release guard for the Chrome extension: build, package, and scan real
|
|
# artifacts for forbidden adapter signatures instead of leaving
|
|
# `scan:artifacts` manual-only. Linux leg only — packaging shells out to
|
|
# the POSIX `zip`; skip (not fail) where it is missing.
|
|
- name: 'Package and scan Chrome extension artifacts'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
run: |-
|
|
if ! command -v zip >/dev/null 2>&1; then
|
|
echo "::warning::zip is not installed; skipping the Chrome extension artifact scan"
|
|
exit 0
|
|
fi
|
|
npm -w packages/chrome-extension run package
|
|
npm -w packages/chrome-extension run scan:artifacts
|
|
|
|
- name: 'Run required no-AK integration gate'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
|
timeout-minutes: 20
|
|
env:
|
|
HOME: '${{ runner.temp }}/qwen-no-ak-home'
|
|
USERPROFILE: '${{ runner.temp }}/qwen-no-ak-home'
|
|
QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home/.qwen'
|
|
API_KEY: ''
|
|
ANTHROPIC_API_KEY: ''
|
|
ANTHROPIC_BASE_URL: ''
|
|
ANTHROPIC_MODEL: ''
|
|
BAILIAN_CODING_PLAN_API_KEY: ''
|
|
BAILIAN_TOKEN_PLAN_API_KEY: ''
|
|
DEEPSEEK_API_KEY: ''
|
|
OPENAI_API_KEY: ''
|
|
OPENAI_BASE_URL: ''
|
|
OPENAI_MODEL: ''
|
|
DASHSCOPE_API_KEY: ''
|
|
GOOGLE_API_KEY: ''
|
|
GOOGLE_MODEL: ''
|
|
QWEN_API_KEY: ''
|
|
QWEN_MODEL: ''
|
|
GEMINI_API_KEY: ''
|
|
GEMINI_MODEL: ''
|
|
IDEALAB_API_KEY: ''
|
|
MINIMAX_API_KEY: ''
|
|
MODELSCOPE_API_KEY: ''
|
|
OPENROUTER_API_KEY: ''
|
|
REQUESTY_API_KEY: ''
|
|
XAI_API_KEY: ''
|
|
ZAI_API_KEY: ''
|
|
QWEN_DEFAULT_AUTH_TYPE: ''
|
|
run: |-
|
|
mkdir -p "${HOME}" "${QWEN_HOME}"
|
|
npm run test:integration:no-ak:sandbox:none
|
|
|
|
- name: 'Publish Test Report (for non-forks)'
|
|
if: |-
|
|
${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && steps.unit_tests.outcome != 'skipped' && (github.event.pull_request.head.repo.full_name == github.repository) }}
|
|
uses: 'dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2' # ratchet:dorny/test-reporter@v3
|
|
with:
|
|
name: 'Test Results (ubuntu-latest, Node 22.x)'
|
|
path: 'packages/*/junit.xml'
|
|
reporter: 'java-junit'
|
|
fail-on-error: 'false'
|
|
|
|
- name: 'Upload Test Results Artifact (for forks)'
|
|
if: |-
|
|
${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
|
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
|
with:
|
|
name: 'test-results-fork-22.x-ubuntu-latest'
|
|
path: 'packages/*/junit.xml'
|
|
|
|
- name: 'Upload coverage reports'
|
|
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
|
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
|
with:
|
|
name: 'coverage-reports-22.x-ubuntu-latest'
|
|
path: 'packages/*/coverage'
|
|
|
|
web_shell_e2e_smoke:
|
|
name: 'web-shell E2E Smoke (ubuntu-latest, Node 22.x)'
|
|
needs:
|
|
- 'classify_pr'
|
|
- 'test'
|
|
if: |-
|
|
${{
|
|
!cancelled() &&
|
|
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
|
needs.classify_pr.outputs.skip_ci != 'true' &&
|
|
needs.test.outputs.ci_profile == 'full'
|
|
}}
|
|
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
|
timeout-minutes: 20
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
# Self-hosted runners reuse the workspace; a prior containerised job can
|
|
# leave root-owned, read-only files anywhere in it. Restore ownership and
|
|
# write permission unconditionally before checkout — see the test job's
|
|
# 'Restore workspace ownership' step for why probing first is unsafe.
|
|
- name: 'Restore workspace ownership'
|
|
run: |-
|
|
set -uo pipefail
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
if [ "$RUNNER_UID" != "0" ]; then
|
|
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
|
fi
|
|
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
|
|
|
# Same pre-checkout recovery as the test job: this job lands on the
|
|
# same reused pool, so leftover review worktrees and branches from an
|
|
# interrupted review would break this checkout too.
|
|
- name: 'Clean stale .qwen before checkout'
|
|
run: |-
|
|
set -uo pipefail
|
|
if [ -d "$GITHUB_WORKSPACE/.qwen" ] && [ ! -L "$GITHUB_WORKSPACE/.qwen" ]; then
|
|
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
|
|
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
|
|
fi
|
|
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
|
# and qwen-review/* branches behind. prune drops registrations whose
|
|
# directories the rm above removed; worktree remove --force then
|
|
# clears any still-registered leftover directory (--force tolerates
|
|
# dirty contents), since a branch checked out in a live worktree
|
|
# cannot be deleted. If removal still fails, the registration
|
|
# survives and the branch delete below warns. The sweep deletes all
|
|
# review artifacts, not just the current PR's: safe because a runner
|
|
# executes one job at a time. Kept inline rather than a shared
|
|
# script: this runs pre-checkout on shared runners, where leftover
|
|
# workspace files are untrusted.
|
|
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
|
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" worktree list --porcelain \
|
|
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
|
| while read -r worktree; do
|
|
[ -n "$worktree" ] || continue
|
|
# Registered paths come from leftover git metadata and are
|
|
# untrusted: the awk filter above matched by substring, so reject
|
|
# `..` traversal and re-anchor to the review prefix before the
|
|
# destructive remove.
|
|
case "$worktree" in
|
|
*/../*|../*|*/..)
|
|
echo "::warning::skipping suspicious review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
|
*)
|
|
echo "::warning::skipping unexpected review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
esac
|
|
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
|
echo "::warning::could not remove review worktree: $worktree"
|
|
done || true
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
|
| while read -r stale_ref; do
|
|
if [ -n "$stale_ref" ]; then
|
|
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
|
echo "::warning::could not remove review branch: $stale_ref"
|
|
fi
|
|
done || true
|
|
fi
|
|
|
|
- name: 'Checkout'
|
|
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
|
with:
|
|
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || github.ref }}"
|
|
fetch-depth: 1
|
|
|
|
- name: 'Verify checkout includes expected head commit'
|
|
if: "${{ github.event_name == 'pull_request' }}"
|
|
uses: './.github/actions/verify-checkout-head'
|
|
with:
|
|
expected_sha: '${{ github.event.pull_request.head.sha }}'
|
|
|
|
# Self-hosted can't reach nodejs.org reliably; reuse the machine's Node.
|
|
- name: 'Set up Node.js 22.x (hosted)'
|
|
if: "${{ runner.environment == 'github-hosted' }}"
|
|
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version: '22.x'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'package-lock.json'
|
|
registry-url: 'https://registry.npmjs.org/'
|
|
|
|
- name: 'Use pre-installed Node.js (self-hosted)'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
uses: './.github/actions/self-hosted-node'
|
|
|
|
- name: 'Configure persistent npm cache (self-hosted)'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${HOME}/.cache/qwen-code/npm"
|
|
mkdir -p "${cache_dir}"
|
|
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
|
echo "Using persistent npm cache at ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- name: 'Configure npm for rate limiting'
|
|
run: |-
|
|
npm config set fetch-retry-mintimeout 20000
|
|
npm config set fetch-retry-maxtimeout 120000
|
|
npm config set fetch-retries 5
|
|
npm config set fetch-timeout 300000
|
|
|
|
- name: 'Install dependencies'
|
|
run: |-
|
|
npm ci --prefer-offline --no-audit --progress=false
|
|
|
|
- name: 'Install Playwright Chromium (hosted)'
|
|
if: "${{ runner.environment == 'github-hosted' }}"
|
|
run: 'npx playwright install --with-deps chromium'
|
|
|
|
- name: 'Install Playwright Chromium (self-hosted)'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
# Self-hosted ECS runners already include system deps; --with-deps can race apt locks.
|
|
run: 'npx playwright install chromium'
|
|
|
|
- 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 web-shell browser smoke'
|
|
run: 'npm run test:e2e:smoke --workspace=packages/web-shell'
|
|
|
|
- name: 'Upload web-shell Playwright artifacts'
|
|
if: '${{ always() }}'
|
|
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
|
with:
|
|
name: 'web-shell-e2e-smoke'
|
|
path: |-
|
|
packages/web-shell/client/e2e/test-results
|
|
packages/web-shell/client/e2e/playwright-report
|
|
if-no-files-found: 'ignore'
|
|
|
|
# macOS/Windows: slowest/costliest runners, and the only signal this
|
|
# repository has about a host that is not Linux with a GNU userland.
|
|
#
|
|
# They used to run in the merge queue alone. That queue is not enabled here —
|
|
# no `merge_group` run since 2026-07-02, and merges land as squashes — so the
|
|
# gate meant they never ran at all: reported as "skipped" on every pull
|
|
# request, and never reached afterwards. A macOS-only failure could ship and
|
|
# sit in `main` indefinitely, which is what happened in #9220 (a GNU-only
|
|
# `realpath -m` in a workflow guard, with the suite that pinned it red on
|
|
# every Mac).
|
|
#
|
|
# Three triggers now, in cost order: a pull request whose diff the
|
|
# platform-sensitivity classifier recognises (shell, CI definitions, the
|
|
# script layer, the runner config, the platform-coupled subtrees), the merge
|
|
# queue if it is ever enabled again, and a nightly run on `main` for
|
|
# everything a path list cannot see. The pull-request gate is fail-safe —
|
|
# only a confident `false` skips, so a broken classifier costs runner minutes
|
|
# rather than coverage.
|
|
#
|
|
# Two named jobs, not a matrix: a skipped matrix job reports one collapsed
|
|
# check name, never the per-OS contexts, so a required-check configuration
|
|
# would sit "Expected" forever. A skipped named job reports under its exact
|
|
# name. (No status check is required on `main` today — the ruleset carries
|
|
# only deletion, non-fast-forward and pull_request rules — so this shape is
|
|
# currently insurance, not a live constraint.)
|
|
test_macos:
|
|
name: 'Test (macos-latest, Node 22.x)'
|
|
needs:
|
|
- 'classify_pr'
|
|
- 'classify_platform'
|
|
if: |-
|
|
${{
|
|
!cancelled() && (
|
|
github.event_name == 'merge_group' ||
|
|
github.event_name == 'schedule' ||
|
|
github.event_name == 'workflow_dispatch' ||
|
|
(github.event_name == 'pull_request' &&
|
|
needs.classify_platform.outputs.platform_sensitive != 'false')
|
|
)
|
|
}}
|
|
runs-on: 'macos-latest'
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
# See the Ubuntu gate's checkout: PRs use the immutable refs/pull/N/head
|
|
# and merge queue uses the event head SHA.
|
|
- name: 'Checkout'
|
|
id: 'checkout'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
|
|
|
- name: 'Set up Node.js 22.x'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version: '22.x'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'package-lock.json'
|
|
registry-url: 'https://registry.npmjs.org/'
|
|
|
|
- name: 'Configure npm for rate limiting'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
run: |-
|
|
npm config set fetch-retry-mintimeout 20000
|
|
npm config set fetch-retry-maxtimeout 120000
|
|
npm config set fetch-retries 5
|
|
npm config set fetch-timeout 300000
|
|
|
|
- name: 'Install dependencies'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
run: |-
|
|
npm ci --prefer-offline --no-audit --progress=false
|
|
|
|
- name: 'Run tests and generate reports'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
env:
|
|
NO_COLOR: true
|
|
HOME: '${{ runner.temp }}/qwen-ci-home'
|
|
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
|
OPENAI_API_KEY: ''
|
|
DASHSCOPE_API_KEY: ''
|
|
QWEN_API_KEY: ''
|
|
GEMINI_API_KEY: ''
|
|
QWEN_DEFAULT_AUTH_TYPE: ''
|
|
run: |-
|
|
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
|
# ENOSPC has failed test steps mid-suite while the host looks
|
|
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
|
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
|
# failing run captures the spike, and dump full state on failure.
|
|
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
|
# Symlink aliases break tests that intentionally compare real paths.
|
|
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
|
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
|
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
|
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
|
TMPDIR="$QWEN_CI_TMPDIR"
|
|
export TMPDIR
|
|
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
|
fi
|
|
fi
|
|
( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
|
SAMPLER_PID=$!
|
|
set +e
|
|
npm run test:ci
|
|
RC=$?
|
|
set -e
|
|
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
|
kill "$SAMPLER_PID" 2>/dev/null || true
|
|
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
|
exit "$RC"
|
|
|
|
# Windows counterpart of test_macos (see that job's note). ECS is the default
|
|
# with a windows-2022 kill-switch fallback; the check name stays unchanged so
|
|
# it matches the required-status-check context. The job is merge_group-only,
|
|
# so code reaching it is post-approval; maintainers can still queue fork PRs.
|
|
# The runs-on expression therefore needs only the kill switch. 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'
|
|
- 'classify_platform'
|
|
if: |-
|
|
${{
|
|
!cancelled() && (
|
|
github.event_name == 'merge_group' ||
|
|
github.event_name == 'schedule' ||
|
|
github.event_name == 'workflow_dispatch' ||
|
|
(github.event_name == 'pull_request' &&
|
|
needs.classify_platform.outputs.platform_sensitive != 'false')
|
|
)
|
|
}}
|
|
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && 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'
|
|
|
|
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS,
|
|
# so fail loud if the checkout lacks the merge-queue head rather than
|
|
# silently testing the wrong tree into a merge.
|
|
# Written when this lane ran in the merge queue alone, so its expected
|
|
# SHA named only the queue's event: on any other trigger the input is
|
|
# empty and the step fails the whole lane before a single test runs.
|
|
# That is what the revived triggers hit first. Same event-aware shape as
|
|
# the Ubuntu gate now, and skipped where there is no head to verify —
|
|
# the scheduled and dispatch runs check out a branch by name.
|
|
- name: 'Verify checkout includes expected head commit'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
|
|
uses: './.github/actions/verify-checkout-head'
|
|
with:
|
|
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
|
|
|
|
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
|
|
# through the egress proxy; reuse the machine's Node instead.
|
|
- name: 'Set up Node.js 22.x (hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
|
|
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version: '22.x'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'package-lock.json'
|
|
registry-url: 'https://registry.npmjs.org/'
|
|
|
|
- name: 'Use pre-installed Node.js (self-hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
|
uses: './.github/actions/self-hosted-node'
|
|
|
|
- name: 'Configure persistent npm cache (self-hosted)'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${HOME}/.cache/qwen-code/npm"
|
|
# Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH,
|
|
# so create the directory through the Node the preflight verified.
|
|
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
|
|
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
|
echo "Using persistent npm cache at ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- name: 'Configure npm for rate limiting'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
run: |-
|
|
npm config set fetch-retry-mintimeout 20000
|
|
npm config set fetch-retry-maxtimeout 120000
|
|
npm config set fetch-retries 5
|
|
npm config set fetch-timeout 300000
|
|
|
|
- name: 'Install dependencies'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
run: |-
|
|
npm ci --prefer-offline --no-audit --progress=false
|
|
|
|
- name: 'Run tests and generate reports'
|
|
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
env:
|
|
NO_COLOR: true
|
|
HOME: '${{ runner.temp }}/qwen-ci-home'
|
|
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
|
OPENAI_API_KEY: ''
|
|
DASHSCOPE_API_KEY: ''
|
|
QWEN_API_KEY: ''
|
|
GEMINI_API_KEY: ''
|
|
QWEN_DEFAULT_AUTH_TYPE: ''
|
|
run: |-
|
|
node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }"
|
|
# ENOSPC has failed test steps mid-suite while the host looks
|
|
# healthy afterwards — a transient spike, likely /tmp inodes or a
|
|
# tmpfs cap. Sample the routed temp filesystem every 10s so the
|
|
# failing run captures the spike, and dump full state on failure.
|
|
# Keep Linux temp paths real and short on disk-backed /var/tmp.
|
|
# Symlink aliases break tests that intentionally compare real paths.
|
|
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
|
if [ "${RUNNER_OS:-}" = "Linux" ]; then
|
|
QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)"
|
|
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
|
TMPDIR="$QWEN_CI_TMPDIR"
|
|
export TMPDIR
|
|
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
|
fi
|
|
fi
|
|
( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) &
|
|
SAMPLER_PID=$!
|
|
set +e
|
|
npm run test:ci
|
|
RC=$?
|
|
set -e
|
|
pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true
|
|
kill "$SAMPLER_PID" 2>/dev/null || true
|
|
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
|
|
exit "$RC"
|
|
|
|
post_coverage_comment:
|
|
name: 'Post Coverage Comment'
|
|
runs-on: 'ubuntu-latest'
|
|
needs:
|
|
- 'classify_pr'
|
|
- 'test'
|
|
# !cancelled() not always(): don't let a cancelled run hold the concurrency slot here.
|
|
if: |-
|
|
${{
|
|
!cancelled() &&
|
|
needs.classify_pr.outputs.skip_ci != 'true' &&
|
|
needs.test.outputs.ci_profile == 'full' &&
|
|
github.event_name == 'pull_request' &&
|
|
github.event.pull_request.head.repo.full_name == github.repository
|
|
}}
|
|
continue-on-error: true
|
|
permissions:
|
|
contents: 'read' # For checkout
|
|
pull-requests: 'write' # For commenting
|
|
strategy:
|
|
matrix:
|
|
# Reduce noise by only posting the comment once
|
|
os:
|
|
- 'ubuntu-latest'
|
|
node-version:
|
|
- '22.x'
|
|
steps:
|
|
- name: 'Checkout'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
|
|
- name: 'Download coverage reports artifact'
|
|
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
|
with:
|
|
name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}'
|
|
path: 'coverage_artifact' # Download to a specific directory
|
|
|
|
- name: 'Post Coverage Comment using Composite Action'
|
|
uses: './.github/actions/post-coverage-comment' # Path to the composite action directory
|
|
with:
|
|
cli_json_file: 'coverage_artifact/cli/coverage/coverage-summary.json'
|
|
core_json_file: 'coverage_artifact/core/coverage/coverage-summary.json'
|
|
cli_full_text_summary_file: 'coverage_artifact/cli/coverage/full-text-summary.txt'
|
|
core_full_text_summary_file: 'coverage_artifact/core/coverage/full-text-summary.txt'
|
|
node_version: '${{ matrix.node-version }}'
|
|
os: '${{ matrix.os }}'
|
|
github_token: '${{ secrets.GITHUB_TOKEN }}'
|
|
|
|
# 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_cli:
|
|
name: 'Integration Tests (CLI, No Sandbox)'
|
|
needs: 'classify_pr'
|
|
# Same ECS routing as the Ubuntu gate (via classify_pr): the merge queue runs
|
|
# in the base-repo context, so use the self-hosted ECS pool and keep the
|
|
# scarce hosted Linux runners free. Falls back to hosted if classify_pr is
|
|
# skipped or the ECS kill-switch is set.
|
|
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
|
|
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
|
permissions:
|
|
contents: 'read'
|
|
env:
|
|
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
|
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
|
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
|
steps:
|
|
# Self-hosted runners reuse the workspace; a prior containerised job can
|
|
# leave root-owned, read-only files anywhere in it. Restore ownership and
|
|
# write permission unconditionally before checkout — see the test job's
|
|
# 'Restore workspace ownership' step for why probing first is unsafe.
|
|
- name: 'Restore workspace ownership'
|
|
run: |-
|
|
set -uo pipefail
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
if [ "$RUNNER_UID" != "0" ]; then
|
|
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
|
fi
|
|
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
|
|
|
# Same pre-checkout recovery as the test job: this job lands on the
|
|
# same reused pool, so leftover review worktrees and branches from an
|
|
# interrupted review would break this checkout too.
|
|
- name: 'Clean stale .qwen before checkout'
|
|
run: |-
|
|
set -uo pipefail
|
|
if [ -d "$GITHUB_WORKSPACE/.qwen" ] && [ ! -L "$GITHUB_WORKSPACE/.qwen" ]; then
|
|
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
|
|
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
|
|
fi
|
|
# Interrupted reviews leave worktree registrations under .qwen/tmp/
|
|
# and qwen-review/* branches behind. prune drops registrations whose
|
|
# directories the rm above removed; worktree remove --force then
|
|
# clears any still-registered leftover directory (--force tolerates
|
|
# dirty contents), since a branch checked out in a live worktree
|
|
# cannot be deleted. If removal still fails, the registration
|
|
# survives and the branch delete below warns. The sweep deletes all
|
|
# review artifacts, not just the current PR's: safe because a runner
|
|
# executes one job at a time. Kept inline rather than a shared
|
|
# script: this runs pre-checkout on shared runners, where leftover
|
|
# workspace files are untrusted.
|
|
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
|
|
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" worktree list --porcelain \
|
|
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
|
|
| while read -r worktree; do
|
|
[ -n "$worktree" ] || continue
|
|
# Registered paths come from leftover git metadata and are
|
|
# untrusted: the awk filter above matched by substring, so reject
|
|
# `..` traversal and re-anchor to the review prefix before the
|
|
# destructive remove.
|
|
case "$worktree" in
|
|
*/../*|../*|*/..)
|
|
echo "::warning::skipping suspicious review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
|
|
*)
|
|
echo "::warning::skipping unexpected review worktree path: $worktree"
|
|
continue
|
|
;;
|
|
esac
|
|
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
|
|
echo "::warning::could not remove review worktree: $worktree"
|
|
done || true
|
|
"${GIT_SAFE[@]}" worktree prune -v || true
|
|
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
|
|
| while read -r stale_ref; do
|
|
if [ -n "$stale_ref" ]; then
|
|
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
|
|
echo "::warning::could not remove review branch: $stale_ref"
|
|
fi
|
|
done || true
|
|
fi
|
|
|
|
- name: 'Checkout'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
|
# Shallow, mirroring the Ubuntu gate: nothing here walks git history,
|
|
# and a full-history clone is the heaviest transfer on the ECS runner.
|
|
fetch-depth: 1
|
|
|
|
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS
|
|
# via classify_pr, so fail loud if the checkout lacks the merge-queue head
|
|
# rather than silently testing the wrong tree into a merge.
|
|
- name: 'Verify checkout includes expected head commit'
|
|
uses: './.github/actions/verify-checkout-head'
|
|
with:
|
|
expected_sha: '${{ github.event.merge_group.head_sha }}'
|
|
|
|
# Hosted downloads Node; self-hosted ECS reuses its pre-installed Node 22
|
|
# (it can't reach nodejs.org reliably). Mirrors the Ubuntu gate.
|
|
- name: 'Setup Node.js (hosted)'
|
|
if: "${{ runner.environment == 'github-hosted' }}"
|
|
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version-file: '.nvmrc'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'package-lock.json'
|
|
|
|
- name: 'Use pre-installed Node.js (self-hosted)'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
uses: './.github/actions/self-hosted-node'
|
|
|
|
- name: 'Configure persistent npm cache (self-hosted)'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${HOME}/.cache/qwen-code/npm"
|
|
mkdir -p "${cache_dir}"
|
|
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
|
echo "Using persistent npm cache at ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- name: 'Install Dependencies'
|
|
env:
|
|
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
|
run: |-
|
|
npm ci --no-audit --progress=false
|
|
|
|
- name: 'Report npm cache usage (self-hosted)'
|
|
if: "${{ always() && runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
cache_dir="${NPM_CONFIG_CACHE:-$(npm config get cache)}"
|
|
echo "npm cache: ${cache_dir}"
|
|
du -sh "${cache_dir}" 2>/dev/null || true
|
|
|
|
- name: 'Run CLI Integration Tests'
|
|
run: |-
|
|
npm run test:integration:cli:sandbox:none
|
|
|
|
#
|
|
# Desktop Shell: compile + test the Tauri crate in PR CI.
|
|
#
|
|
# The desktop-release workflow (workflow_dispatch only) is otherwise the sole
|
|
# place this crate is built, so a compile error can land on a PR and stay
|
|
# invisible until release time. This job compiles the crate and runs its
|
|
# release-config tests on every PR that touches the shell. It does not need
|
|
# the bundled runtime, so it is cheap. `cargo test` builds the crate and thus
|
|
# catches compile failures (e.g. a moved-value error); fmt/clippy are not run
|
|
# here because the release pipeline does not gate on them either.
|
|
desktop_shell:
|
|
name: 'Desktop Shell (${{ matrix.os }})'
|
|
needs: 'classify_pr'
|
|
if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' && needs.classify_pr.outputs.skip_ci != 'true' }}"
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: ['ubuntu-22.04', 'windows-2022']
|
|
runs-on: '${{ matrix.os }}'
|
|
timeout-minutes: 45
|
|
permissions:
|
|
contents: 'read'
|
|
pull-requests: 'read'
|
|
steps:
|
|
- name: 'Checkout'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
|
|
fetch-depth: 1
|
|
|
|
# Fail open: any uncertainty (non-PR event, API failure) runs the job.
|
|
#
|
|
# Ask GitHub which files the PR changed, the same call the CI profile
|
|
# classifier makes above. The two-tree `git diff BASE HEAD` this replaced
|
|
# is not the PR's diff: it reports everything the base gained since the
|
|
# branch point as a change on the PR's side. #8132 added
|
|
# packages/desktop-shell, so every branch older than it saw 78 phantom
|
|
# desktop-shell paths, ran this job, and failed in a checkout of
|
|
# refs/pull/N/head that has no such directory.
|
|
- name: 'Detect desktop-shell changes'
|
|
id: 'filter'
|
|
env:
|
|
GH_TOKEN: '${{ github.token }}'
|
|
PR_NUMBER: "${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}"
|
|
run: |-
|
|
changed=true
|
|
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${PR_NUMBER}" ]]; then
|
|
# `previous_filename` too: renaming a file out of the crate changes
|
|
# it, and only the old path says so.
|
|
if files="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[] | .filename, (.previous_filename // empty)')"; then
|
|
if grep -Eq '^(packages/desktop-shell/|\.github/scripts/create-desktop-update-manifest\.mjs|\.github/workflows/ci\.yml)' <<<"${files}"; then
|
|
changed=true
|
|
else
|
|
changed=false
|
|
fi
|
|
else
|
|
echo "::warning::Unable to list the PR's changed files; compiling the crate."
|
|
fi
|
|
fi
|
|
# Whatever the filter decided, a tree without the crate cannot have
|
|
# regressed it, and cargo has no manifest to run against — the job
|
|
# would report a missing working directory as a failure of the PR.
|
|
if [[ "${changed}" == "true" && ! -f packages/desktop-shell/src-tauri/Cargo.toml ]]; then
|
|
echo "::notice::packages/desktop-shell/src-tauri is absent from this head; nothing to compile."
|
|
changed=false
|
|
fi
|
|
echo "changed=${changed}" >> "${GITHUB_OUTPUT}"
|
|
echo "desktop-shell changed: ${changed}"
|
|
|
|
# cargo test links the Tauri/wry webview, so the WebKit/GTK dev headers
|
|
# must be present (mirrors the Linux build job in desktop-release.yml).
|
|
- name: 'Install Linux dependencies'
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils
|
|
|
|
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
with:
|
|
node-version: '22.x'
|
|
|
|
- uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
|
|
- uses: 'Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae' # v2
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
with:
|
|
workspaces: 'packages/desktop-shell/src-tauri -> target'
|
|
|
|
- name: 'Compile and test the desktop crate'
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
working-directory: 'packages/desktop-shell'
|
|
run: 'cargo test --manifest-path src-tauri/Cargo.toml'
|
|
|
|
- name: 'Run desktop release tests'
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
working-directory: 'packages/desktop-shell'
|
|
run: 'node scripts/test-release.js'
|