feat(desktop): establish OpenWork on the Qwen Tauri Web Shell #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .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' | |
| # 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-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/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/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 mirrors it. | |
| runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && 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 [[ "${GITHUB_REPOSITORY}" == "QwenLM/qwen-code" && "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then | |
| ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]' | |
| fi | |
| elif [[ "${GITHUB_REPOSITORY}" == "QwenLM/qwen-code" && "${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}" | |
| # | |
| # 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: | |
| # 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 }); }" | |
| npm run test:ci | |
| # 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, 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). 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' | |
| if: "${{ !cancelled() && github.event_name == 'merge_group' }}" | |
| 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. | |
| - name: 'Verify checkout includes expected head commit' | |
| if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" | |
| uses: './.github/actions/verify-checkout-head' | |
| with: | |
| expected_sha: '${{ github.event.merge_group.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 }); }" | |
| 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: | |
| # 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' && 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' |