qwen-code/.github/workflows/ci.yml
Shaojin Wen 02c79beb62
feat(web-shell): auto-post visual previews (screenshots + flow GIFs) on PRs (#6880)
* feat(web-shell): auto-post visual previews (screenshots + flow GIFs) on PRs

PRs that touch the web-shell UI now get an auto-updated comment with
light/dark screenshots of key views (transcript, slash menu, model/theme
dialogs, permission panel) and short GIF recordings of common flows,
rendered against the existing mock daemon — no real backend, no secrets.

Split into two workflows for security, since capture runs untrusted PR code:

- web-shell-visuals.yml (pull_request): checks out the PR head, builds and
  renders it with Playwright, captures PNGs + webm, converts webm->GIF with
  ffmpeg, and uploads an artifact. `contents: read` only, references no
  secrets — fork PRs run with a read-only token and no secrets.
- web-shell-visuals-publish.yml (workflow_run): downloads the artifact,
  binds it to its real PR by requiring the PR head SHA to equal the run's
  authenticated head SHA, hosts the images on a per-PR `pr-assets/*` branch
  (referenced by immutable commit SHA), and posts/updates one inline
  comment. Never checks out or runs PR code; the write token lives only here.

Capture infra is self-contained in packages/web-shell
(playwright.visuals.config.ts + client/e2e/visuals/*), reusing the mock
daemon harness. Run locally with:
`npm run test:e2e:visuals --workspace=packages/web-shell`.

* fix(web-shell): guard empty gh api response in visuals publish

Addresses review feedback on #6880: if `gh api` returns empty (network
error / rate limit), jq on empty stdin errors and `set -e` kills the
publish job. Skip gracefully instead.

* fix(web-shell): address review nits on visuals capture

- harness recordFlow: wrap video saveAs/delete in try/catch so a video
  I/O error (e.g. drive failed before navigation) can't mask the real
  driveError.
- capture workflow: drop the unused head_sha.txt artifact field; the
  publish job binds to the authenticated workflow_run.head_sha, and an
  artifact-sourced SHA would be untrusted.

* fix(web-shell): address second review round on visuals capture

- context.close() in recordFlow's finally is now best-effort (try/catch)
  so a close/crash error can't mask the real driveError.
- add a flows spec that asserts a throwing drive propagates its own error.
- trigger the capture workflow on playwright.visuals.config.ts changes too.

* fix(web-shell): address third review round on visuals capture

- harness: log (don't silently swallow) a video save/null when drive
  succeeded; keep masking-suppression only when driveError is set.
- publish: HTML-escape interpolated values in the comment builder (defense
  in depth, independent of the upstream filename sanitization); fix the
  stale 'single pr-assets branch' comment and key concurrency on source
  repo+branch so different PRs (incl. same-named fork branches) parallelize.
- capture: bump checkout to v6.0.3 (repo standard); surface ffmpeg's stderr
  on GIF-conversion failure instead of discarding it.

* fix(web-shell): harden visuals publish/capture (review round 4)

Publish (privileged workflow_run):
- CRITICAL: capture basename before `tr` so its trailing newline isn't
  turned into `_` (which broke the .png/.gif filter -> empty preview).
- dedup only against the bot's OWN comment (author + marker), not any
  marker-bearing comment a participant can post.
- bound the pr-assets branch: force-push a single orphan snapshot per run
  (previous snapshot GC'd) instead of appending unbounded untrusted content;
  this also removes the rebase/retry path.
- cap EXAMINED candidates (not just accepted) before validation; tighten
  per-file (3MiB) and accepted-image (14) caps.
- re-validate PR open + head-SHA immediately before the comment write
  (TOCTOU); retry the comment listing and abort rather than POST a duplicate
  when listing fails.
- esc() the runUrl for consistency with the self-defending HTML.

Capture (pull_request):
- upload raw recordings as a SEPARATE artifact the publisher never downloads,
  so an untrusted multi-GB video can't exhaust the privileged job.
- also trigger on packages/webui/src and packages/sdk-typescript/src (the
  visuals dev server aliases them).
- create screenshots/gifs dirs before the metadata counts (defensive).

Harness recordFlow:
- track drive failure with an explicit boolean (handles `throw undefined`);
  discard the recording on failure so a failed flow leaves no bogus webm.

* refactor(web-shell): extract + unit-test the visuals publish staging/comment

Addresses the review's testability gap (the class of bug that let the
filename sanitizer break the whole preview slip through green CI). The image
validation (magic bytes, filename sanitization, examined/accepted/size caps)
and the comment builder (light/dark pairing, flow labels, HTML escaping) move
from inline workflow bash/node into .github/scripts/web-shell-visuals-publish
.mjs, covered by web-shell-visuals-publish.test.mjs (run in ci.yml's
node --test line). The publish workflow sparse-checks-out and calls the
script instead. Behaviour is unchanged; it just gained a test surface.

* fix(web-shell): retry the visuals asset force-push; drop stale comment

Round-4 switched hosting to a force-push but left a comment referencing a
'push-retry loop' that no longer existed, and the force-push was a single
call that set -e would abort on a transient failure. Add a bounded retry and
correct the comment.

* fix(web-shell): harden visuals publish/capture (review round 6)

Script (unit-tested):
- flow labels: own-property lookup so `toString.gif`/`constructor.gif` can't
  leak Object.prototype members into the comment.
- per-kind image caps (screenshots vs gifs) so a large screenshot set can't
  silently starve the flow GIFs from the preview.
- tests for both, plus the per-kind cap.

Publish:
- bind the artifact PR number to the run's authenticated head repo+branch
  (not just head SHA), rejecting a sibling PR that shares the same commit.
- re-validate before the force-push and again right before the comment write
  (close the download/stage/lookup TOCTOU windows).

Capture:
- bound artifact contents before upload (drop oversized / excess files) so an
  untrusted spec can't bloat the published or video artifact.
- trigger on the capture workflow file itself.

- new close-trigger cleanup workflow deletes a PR's asset branch on close, so
  pr-assets/* refs don't accumulate without bound.
- single-source the capture viewport (constants.ts) shared by config + harness.
- model-switch flow asserts the daemon model request actually fired.

* fix(web-shell): stricter visuals error handling (review round 7)

Harness recordFlow:
- when the drive SUCCEEDS, a failed context.close() or video.saveAs() (or a
  missing recording) now FAILS the flow instead of a swallowed console.warn —
  a silent pass with no .webm makes the downstream GIF step fail confusingly.
  A drive FAILURE still discards the partial video and rethrows the original
  error (unchanged).

Publish:
- validate_pr distinguishes a transient API failure (empty after retries ->
  exit 1, re-triggerable) from a genuine invalid state (closed / head mismatch
  -> skip), via a `gate` wrapper used at all three checkpoints.
- add a 2s backoff between comment-listing retries (matching the push retry).

---------

Co-authored-by: wenshao <wenshao@example.com>
2026-07-15 06:48:52 +00:00

743 lines
35 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:
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'
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 and for the merge queue (which runs in the
# 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.
runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || 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 PR (head branch in this repo => author has write access) and the
# merge queue (base-repo context) run the Linux jobs on ECS; 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 }}'
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"]'
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" || "${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}"
#
# 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' }}"
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:
# 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') }}"
env:
EXPECTED_SHA: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
run: |-
if ! git merge-base --is-ancestor "${EXPECTED_SHA}" HEAD; then
echo "::error::Checked out ref does not contain expected head ${EXPECTED_SHA}."
git log --oneline --decorate -5
exit 1
fi
- 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
changed_files="${RUNNER_TEMP}/changed-files.jsonl"
if gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[] | {filename, status, previous_filename}' > "${changed_files}"; then
if ! profile="$(node .github/scripts/ci/classify-profile.mjs "${changed_files}")"; then
echo "::error::CI profile classifier exited non-zero; running full CI."
profile=full
fi
else
echo "::warning::Unable to list PR changed files; running full CI."
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 .github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs
# Self-hosted can't reach nodejs.org reliably; reuse the machine's Node.
- 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' }}"
run: |-
if ! command -v node >/dev/null 2>&1; then
echo "::error::Node.js is not on PATH for this self-hosted runner. Provision Node 22.x or set the MAINTAINER_ECS_RUNNER_DISABLED repository variable to 'true' to route PRs back to hosted runners."
exit 1
fi
echo "Using pre-installed Node $(node -v) / npm $(npm -v)"
if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "22" ]]; then
echo "::warning::Expected Node 22.x but found $(node -v); tests will run against the runner's Node."
fi
- 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: '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"
# 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'
- 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 }); }"
npm run test:ci
- name: 'Run no-AK integration smoke tests'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name == 'pull_request' }}"
run: '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:
- 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' }}"
env:
EXPECTED_SHA: '${{ github.event.pull_request.head.sha }}'
run: |-
if ! git merge-base --is-ancestor "${EXPECTED_SHA}" HEAD; then
echo "::error::Checked out ref does not contain expected head ${EXPECTED_SHA}."
git log --oneline --decorate -5
exit 1
fi
# 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' }}"
run: |-
if ! command -v node >/dev/null 2>&1; then
echo "::error::Node.js is not on PATH for this self-hosted runner. Provision Node 22.x or set the MAINTAINER_ECS_RUNNER_DISABLED repository variable to 'true' to route PRs back to hosted runners."
exit 1
fi
echo "Using pre-installed Node $(node -v) / npm $(npm -v)"
if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "22" ]]; then
echo "::warning::Expected Node 22.x but found $(node -v); web-shell smoke tests will run against the runner's Node."
fi
- 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, rare platform regressions — run
# only in the merge queue. Skipped on PR (ubuntu is the fast PR signal) and on
# push (the queue already tested the merged tree, so a post-merge re-run is
# redundant). Two named jobs, not a matrix: a skipped matrix job reports one
# collapsed check name, never the per-OS required contexts, so PRs would sit
# "Expected" forever and never enter the queue. A skipped named job reports
# under its exact name and satisfies the required check (same as the
# Integration Tests job).
test_macos:
name: 'Test (macos-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
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 }); }"
npm run test:ci
# Windows counterpart of test_macos (see that job's note). Runner is
# windows-2022; the check name keeps the windows-latest label so it matches
# the required-status-check context.
test_windows:
name: 'Test (windows-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
runs-on: 'windows-2022'
permissions:
contents: 'read'
steps:
- 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 }); }"
npm run test:ci
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:
- 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'
env:
EXPECTED_SHA: '${{ github.event.merge_group.head_sha }}'
run: |-
if ! git merge-base --is-ancestor "${EXPECTED_SHA}" HEAD; then
echo "::error::Checked out ref does not contain expected head ${EXPECTED_SHA}."
git log --oneline --decorate -5
exit 1
fi
# 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' }}"
run: |-
if ! command -v node >/dev/null 2>&1; then
echo "::error::Node.js is not on PATH for this self-hosted runner. Provision Node 22.x or set the MAINTAINER_ECS_RUNNER_DISABLED repository variable to 'true' to route the merge queue back to hosted runners."
exit 1
fi
echo "Using pre-installed Node $(node -v) / npm $(npm -v)"
if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "22" ]]; then
echo "::warning::Expected Node 22.x but found $(node -v); integration tests will run against the runner's Node."
fi
- 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