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