diff --git a/.github/scripts/ci-disk-pressure.test.mjs b/.github/scripts/ci-disk-pressure.test.mjs index 3186fdcb7fb..c62b34ee577 100644 --- a/.github/scripts/ci-disk-pressure.test.mjs +++ b/.github/scripts/ci-disk-pressure.test.mjs @@ -19,7 +19,8 @@ const workflowPath = join( 'workflows', 'ci.yml', ); -const testSteps = parse(readFileSync(workflowPath, 'utf8')).jobs.test.steps; +const ciJobs = parse(readFileSync(workflowPath, 'utf8')).jobs; +const testSteps = ciJobs.test.steps; function step(name) { const value = testSteps.find((candidate) => candidate.name === name); @@ -27,6 +28,18 @@ function step(name) { return value; } +// lint_and_static duplicates the sampling install step; it must also carry +// its own failure()-gated collector, or the lane produces the #10035 +// telemetry and destroys it with the runner temp dir on the exact ENOSPC +// death the sampler exists to explain. +function lintStep(name) { + const value = ciJobs.lint_and_static.steps.find( + (candidate) => candidate.name === name, + ); + assert.ok(value, `missing ${name} step in lint_and_static`); + return value; +} + describe('ci.yml disk-pressure evidence', () => { it('starts sampling before npm ci and preserves those samples for upload', () => { const install = step('Install dependencies').run; @@ -77,6 +90,31 @@ describe('ci.yml disk-pressure evidence', () => { ); }); + it('gives lint_and_static the same sampler and its own collector', () => { + // The install step is pinned byte-identical to test's by + // ci-platform-lanes.test.js's shared-prelude equality; what that pin + // cannot see is the collector, which deliberately diverges by artifact + // name (upload-artifact v4+ rejects duplicate names when both jobs fail + // in one run). Pin the collector's contract here. + const install = lintStep('Install dependencies').run; + assert.match( + install, + /DISK_SAMPLES="\$\{RUNNER_TEMP\}\/disk-pressure-samples\.log"/, + ); + const upload = lintStep('Upload disk-pressure samples'); + assert.equal(upload.if, '${{ failure() }}'); + assert.equal(upload.with['if-no-files-found'], 'ignore'); + assert.equal( + upload.with.path, + '${{ runner.temp }}/disk-pressure-samples.log', + ); + assert.notEqual( + upload.with.name, + step('Upload disk-pressure samples').with.name, + 'artifact names must differ or the second failing job cannot upload', + ); + }); + it('keeps install failure status while writing the pre-install sample', () => { const root = mkdtempSync(join(tmpdir(), 'ci-disk-pressure-')); const npm = join(root, 'npm'); diff --git a/.github/scripts/qwen-triage-workflow.test.mjs b/.github/scripts/qwen-triage-workflow.test.mjs index fe5a6ebae8b..aaaf6e6d2a7 100644 --- a/.github/scripts/qwen-triage-workflow.test.mjs +++ b/.github/scripts/qwen-triage-workflow.test.mjs @@ -112,6 +112,10 @@ const ciIntegrationJob = ciDoc.jobs.integration_cli; const ciIntegrationOwnershipStep = ciIntegrationJob.steps.find( (s) => s.name === 'Restore workspace ownership', ); +const ciLintJob = ciDoc.jobs.lint_and_static; +const ciLintOwnershipStep = ciLintJob.steps.find( + (s) => s.name === 'Restore workspace ownership', +); // A probe that only checks .qwen/.git reports "healthy" on workspace-wide // poisoning (root-owned node_modules/dist, no .qwen/.git) and skips the @@ -371,7 +375,10 @@ describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => { // included. The step must therefore truncate all four after the agent, // on every exit path. const statusIdx = run.indexOf('status=$?'); - assert.ok(statusIdx > -1, 'the run block must capture the qwen exit status'); + assert.ok( + statusIdx > -1, + 'the run block must capture the qwen exit status', + ); for (const file of [ 'GITHUB_ENV', 'GITHUB_PATH', @@ -380,7 +387,10 @@ describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => { ]) { const trunc = `: > "\${${file}:?}" || true`; const idx = run.indexOf(trunc); - assert.ok(idx > -1, `${file} must be truncated after the qwen invocation`); + assert.ok( + idx > -1, + `${file} must be truncated after the qwen invocation`, + ); assert.ok( idx > statusIdx, `${file} truncation must run on the exit path, after status=$?`, @@ -400,7 +410,7 @@ describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => { 'the workspace settings removal must precede the qwen invocation', ); assert.ok(run.includes('echo "::stop-commands::${stop_token}"')); - assert.ok(run.includes("printf '\\n::%s::\\n' \"$stop_token\"")); + assert.ok(run.includes('printf \'\\n::%s::\\n\' "$stop_token"')); assert.ok(run.includes('--approval-mode yolo')); for (const key of Object.keys(resolveConflictsStep.env)) { assert.ok( @@ -1024,6 +1034,18 @@ describe('ci.yml: self-hosted checkout jobs restore ownership unconditionally', ); }); + it('lint_and_static restores ownership unconditionally', () => { + // The split copied the recovery prelude into the new lane; a + // poisoning-recovery edit landing only in this copy would leave every + // other pin green while the future required check fails checkout with + // EACCES on the next contaminated runner. + assertUnconditional( + ciLintJob.steps, + ciLintOwnershipStep, + 'ci.yml lint_and_static', + ); + }); + it('cleanup step removes .qwen but no longer any .stale.* dirs', () => { assert.ok( ciCleanStep, @@ -1428,13 +1450,24 @@ describe('qwen-triage: flakiness gate (#9125)', () => { /^\s*cp "\$\{RUNNER_TEMP:\?\}\/flake-record-files-all" "\$GATE_HOME\/files-all"$/m, 'the scrubbed child must copy the parent-recorded diff, never re-run git under env -i', ); - const recordDiffAt = recordStep.run.search(/^\s*\/usr\/bin\/git -c core\.quotePath=false diff -z/m); + const recordDiffAt = recordStep.run.search( + /^\s*\/usr\/bin\/git -c core\.quotePath=false diff -z/m, + ); const recordReExecAt = recordStep.run.search(/exec \/usr\/bin\/env -i/); - const recordCpAt = recordStep.run.search(/^\s*cp "\$\{RUNNER_TEMP:\?\}\/flake-record-files-all" "\$GATE_HOME\/files-all"$/m); - const recordInstallAt = recordStep.run.search(/^\s*install -d -m 0700 -o root -g root "\$GATE_HOME"$/m); + const recordCpAt = recordStep.run.search( + /^\s*cp "\$\{RUNNER_TEMP:\?\}\/flake-record-files-all" "\$GATE_HOME\/files-all"$/m, + ); + const recordInstallAt = recordStep.run.search( + /^\s*install -d -m 0700 -o root -g root "\$GATE_HOME"$/m, + ); assert.ok( - recordDiffAt !== -1 && recordReExecAt !== -1 && recordCpAt !== -1 && recordInstallAt !== -1 && - recordDiffAt < recordReExecAt && recordReExecAt < recordInstallAt && recordInstallAt < recordCpAt, + recordDiffAt !== -1 && + recordReExecAt !== -1 && + recordCpAt !== -1 && + recordInstallAt !== -1 && + recordDiffAt < recordReExecAt && + recordReExecAt < recordInstallAt && + recordInstallAt < recordCpAt, 'the diff must be recorded in the parent arm before the env -i re-exec, and copied into the recreated root-only home', ); // The scrubbed child must never re-run git under env -i: the ordering @@ -1442,7 +1475,10 @@ describe('qwen-triage: flakiness gate (#9125)', () => { // second git in the child. Strip comments first (the child's own docs // name `git diff` when describing what NOT to do) before asserting. assert.doesNotMatch( - recordStep.run.slice(recordReExecAt).replace(/^\s*#.*$/gm, '').replace(/\\\n/g, ' '), + recordStep.run + .slice(recordReExecAt) + .replace(/^\s*#.*$/gm, '') + .replace(/\\\n/g, ' '), /\bgit\b[^\n]*\b(diff|log|show|whatchanged)\b/, 'the scrubbed child must never re-run git under env -i — that is the failure shape of run 32227155960', ); @@ -3846,12 +3882,19 @@ describe('qwen-triage: flakiness gate — behavioral, under the production wrapp timeout: 30_000, }, ); - assert.equal(res.status, 0, `the gate refusal is fail-open: ${res.stderr}`); + assert.equal( + res.status, + 0, + `the gate refusal is fail-open: ${res.stderr}`, + ); const outputs = Object.fromEntries( readFileSync(out, 'utf8') .split('\n') .filter((l) => l.includes('=')) - .map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]), + .map((l) => [ + l.slice(0, l.indexOf('=')), + l.slice(l.indexOf('=') + 1), + ]), ); assert.equal(outputs.flake_verdict, 'error'); assert.match( @@ -4218,7 +4261,11 @@ describe('qwen-triage: flakiness gate — behavioral, under the production wrapp undefined, 'no verdict may be written on a poisoned startup', ); - assert.equal(counts('a.test.js'), 0, 'no round may run on a poisoned startup'); + assert.equal( + counts('a.test.js'), + 0, + 'no round may run on a poisoned startup', + ); }); it('a BASH_FUNC_exec%% import cannot skip the env -i re-exec — bash refuses it at startup', () => { @@ -4246,7 +4293,11 @@ describe('qwen-triage: flakiness gate — behavioral, under the production wrapp undefined, 'no verdict may be written on a poisoned startup', ); - assert.equal(counts('a.test.js'), 0, 'the body must never run on a poisoned startup'); + assert.equal( + counts('a.test.js'), + 0, + 'the body must never run on a poisoned startup', + ); }); it('a same-stem sibling (X.test.tsx next to changed X.test.ts) runs in ONE merged group, never attributed separately', () => { diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 37197307dad..82e45b82312 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -19,7 +19,7 @@ 9256 build-and-publish-image.yml 49610 cd-cua-driver.yml 2076 cd-mobile-mcp.yml -111021 ci.yml +128681 ci.yml 1482 codeql.yml 9389 comment-attachment-guard.yml 31677 desktop-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1711b2d35a..326dcd70561 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,13 +7,13 @@ on: # 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. - # Only `test` and the `classify_pr` it depends on accept it (see their - # `if`s); every other job excludes push, so a post-merge run is exactly - # Classify PR + Test. The no-AK integration gate is its own + # `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 BOTH those - # `if`s: a half-removal leaves every merge running Classify PR alone, + # 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: @@ -76,9 +76,9 @@ env: SHELLCHECK_VERSION: '0.11.0' YAMLLINT_VERSION: '1.35.1' # Single source of truth for the .github/scripts node:test suites — run by - # BOTH the github_ci_only helper step and the full-profile Test step, so a - # new helper test can't be added to one path and silently dropped from the - # other. + # 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 @@ -352,17 +352,18 @@ jobs: 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`: this is the only post-merge check faster than the - # ~40-minute E2E, 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. + # 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 @@ -556,29 +557,10 @@ jobs: 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' - - name: 'Docs-only CI' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'docs_only' }}" run: 'echo "Docs-only change; full CI skipped."' - - name: 'GitHub CI helper checks' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'github_ci_only' }}" - timeout-minutes: 5 - run: |- - # Keep this path dependency-free; script formatting is checked when those files hit full CI. - node scripts/lint.js --setup - node scripts/lint.js --actionlint - node scripts/lint.js --yamllint - node --test --test-concurrency=1 ${{ env.HELPER_TESTS }} - # Avoid setup-node downloads on ECS, where nodejs.org may be unreachable # through the egress proxy; reuse the machine's Node instead. - name: 'Set up Node.js 22.x (hosted)' @@ -643,103 +625,6 @@ jobs: echo "npm cache: ${cache_dir}" du -sh "${cache_dir}" 2>/dev/null || true - - name: 'Audit critical runtime dependencies' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" - run: 'npm run audit:runtime:critical' - - - name: 'Check lockfile' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" - run: 'npm run check:lockfile' - - - name: 'Check desktop workspace isolation' - if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" - run: 'npm run check:desktop-isolation' - - - name: 'Check 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 }}' - # 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 @@ -897,6 +782,403 @@ jobs: 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}]" > "$DISK_SAMPLES" 2>/dev/null || true + sample_disk() { + sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] 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: diff --git a/README.md b/README.md index aa311c74dd4..e851513538b 100644 --- a/README.md +++ b/README.md @@ -139,27 +139,27 @@ If you know Claude Code, you already know Qwen Code — and then some. We've put ### Evaluation Configuration -| Configuration | Value | -| ------------- | ----- | -| Dataset | `princeton-nlp/SWE-bench_Verified`, 500 cases | -| Runs | 3 trials per version, 1,500 jobs per version; 7 Qwen Code versions | -| Model | `Qwen 3.7 Max` | -| Sampling | `temperature=1`, `top_p=0.95` | -| Thinking / Reasoning | `interleaved_thinking=true`, `thinking_type=adaptive`, `reasoning_effort=high` | +| Configuration | Value | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| Dataset | `princeton-nlp/SWE-bench_Verified`, 500 cases | +| Runs | 3 trials per version, 1,500 jobs per version; 7 Qwen Code versions | +| Model | `Qwen 3.7 Max` | +| Sampling | `temperature=1`, `top_p=0.95` | +| Thinking / Reasoning | `interleaved_thinking=true`, `thinking_type=adaptive`, `reasoning_effort=high` | | Context / Output Limits | `context_1m=true`, `model_context_length=1,000,000`, `max_tokens=64,000`, `model_max_tokens=65,535` | -| Harness Limits | `max_iterations=500`, `runtime_timeout_sec=7,200`, `agent_timeout_multiplier=3.0` | +| Harness Limits | `max_iterations=500`, `runtime_timeout_sec=7,200`, `agent_timeout_multiplier=3.0` | ### Scores by Qwen Code Version -| Version | Avg Score | Pass Rate | pass@3 | pass^3 | -| ------- | --------: | --------: | -----: | -----: | -| 0.0.14 | **77.80%** | 84.6% | 84.6% | 69.2% | -| 0.1.0 | **76.73%** | 84.6% | 84.6% | 68.0% | -| 0.5.0 | **76.60%** | 83.0% | 83.0% | 68.4% | -| 0.10.0 | **76.47%** | 84.4% | 84.4% | 67.4% | -| 0.15.0 | **77.67%** | 84.6% | 84.6% | 68.6% | -| 0.19.0 | **76.40%** | 85.0% | 85.0% | 65.8% | -| 0.22.0 | **77.33%** | 84.4% | 84.4% | 68.0% | +| Version | Avg Score | Pass Rate | pass@3 | pass^3 | +| ------- | ---------: | --------: | -----: | -----: | +| 0.0.14 | **77.80%** | 84.6% | 84.6% | 69.2% | +| 0.1.0 | **76.73%** | 84.6% | 84.6% | 68.0% | +| 0.5.0 | **76.60%** | 83.0% | 83.0% | 68.4% | +| 0.10.0 | **76.47%** | 84.4% | 84.4% | 67.4% | +| 0.15.0 | **77.67%** | 84.6% | 84.6% | 68.6% | +| 0.19.0 | **76.40%** | 85.0% | 85.0% | 65.8% | +| 0.22.0 | **77.33%** | 84.4% | 84.4% | 68.0% | ### Avg Score Trend diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 76706927a84..ef7a21eda28 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1085,7 +1085,9 @@ export const AppContainer = (props: AppContainerProps) => { const durableTasks = await readCronTasks(config.getProjectRoot()); activeScheduledTaskCount = countActiveScheduledTasks(durableTasks); } catch (error) { - debugLogger.warn(`Failed to read scheduled tasks at startup: ${error}`); + debugLogger.warn( + `Failed to read scheduled tasks at startup: ${error}`, + ); } } const scheduledTasksWarning = getScheduledTasksStartupWarning( diff --git a/scripts/tests/ci-platform-lanes.test.js b/scripts/tests/ci-platform-lanes.test.js index 855ec71c948..fa75218f870 100644 --- a/scripts/tests/ci-platform-lanes.test.js +++ b/scripts/tests/ci-platform-lanes.test.js @@ -37,7 +37,7 @@ const failureIssue = parse( // `on:` parses as the boolean true in YAML 1.1. const triggers = ci[true] ?? ci['on']; const LANES = ['test_macos', 'test_windows']; -const PUSH_JOBS = ['classify_pr', 'test']; +const PUSH_JOBS = ['classify_pr', 'test', 'lint_and_static']; const condOf = (job) => String(ci.jobs[job].if ?? ''); // The extended ceilings are scoped to the ECS pool: `runs-on` falls back to @@ -89,6 +89,19 @@ it('keeps the no-AK gate on its pre-contention ceiling when hosted', () => { expect(timeoutMinutesOn('integration_no_ak', '')).toBe(30); }); +it('keeps lint_and_static sized for cold-cache pool runs', () => { + // Both numbers are measured, not predicted: a flat 45 was tried first and + // the lane was beheaded twice running honestly on cold-cache hk3 pool + // runners — at 30 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). Routed like test's 120/60 and no_ak's 60/30 for the same + // pool-contention reason; hosted keeps the tighter ceiling so a genuine + // hang there does not burn the ECS allowance. + expect(timeoutMinutesOn('lint_and_static', ECS_RUNNER)).toBe(90); + expect(timeoutMinutesOn('lint_and_static', HOSTED_RUNNER)).toBe(45); + expect(timeoutMinutesOn('lint_and_static', '')).toBe(45); +}); + // One helper for both "an run reaches exactly these jobs" invariants. // // It decides by EVALUATING each gate for the event, not by looking for tokens @@ -159,7 +172,8 @@ const assertEventReachesOnly = (event, allowedJobs) => { describe('platform lanes — triggers', () => { it('gives the workflow a scheduled trigger', () => { // Without it the lanes have no path to `main` at all: `ci.yml`'s push - // trigger is accepted only by `test` and the `classify_pr` it depends on, + // trigger is accepted only by `test`, `lint_and_static`, and the + // `classify_pr` they depend on, // so a merge-queue-only gate on a repository with no merge queue is an off // switch for these two. expect(triggers.schedule).toBeDefined(); @@ -278,11 +292,12 @@ describe('platform lanes — triggers', () => { // `ci.yml` carried no push trigger while no merge queue was enabled, so // nothing validated the merged tree before it landed and the only check after // it was the ~40-minute E2E — a regression could sit in `main` for hours. The -// trigger is back, taken by `test` and the `classify_pr` it depends on. +// trigger is back, taken by `test`, `lint_and_static`, and the `classify_pr` +// they depend on. // // The runner-routing tests in `.github/scripts/ci-runner-routing.test.mjs` // cannot reach any of this: they drive `pick_runner`'s shell directly with -// EVENT_NAME=push, bypassing the trigger and both job gates, so they stay +// EVENT_NAME=push, bypassing the trigger and all three job gates, so they stay // green if either loses its push arm. What is pinned here is the YAML half — // the same class of invariant as `keeps a nightly run to exactly the two // lanes` above, and for the same reason: every mutation below leaves the whole @@ -305,6 +320,60 @@ describe('post-merge push lane', () => { expect(triggers.push).toEqual({ branches: ['main'] }); }); + it("lint_and_static shares test's exact gate", () => { + // The split exists so this job can become a required check on its own; + // its gate is therefore as security-relevant as test's. One equality + // rides on test's whole-literal pin below: if the two gates ever + // diverge — someone widening or narrowing the lint lane independently — + // this fails before the divergence ships, and the literal pin still + // guards the shared shape. + expect(condOf('lint_and_static')).toBe(condOf('test')); + }); + + it('lint_and_static keeps the minimal token its required-check role needs', () => { + // This block overrides the workflow-level `checks: write` / + // `statuses: write` grant. The lane runs contributor code (`npm ci` + // executes the PR's lifecycle scripts) on same-repo PRs, merge_group, + // dispatch and push; widening this — say by copying `test`'s block when + // adding a reporter — hands write tokens to that code. `test`'s own + // wider block is legitimate: dorny/test-reporter needs `checks: write`. + expect(ci.jobs.lint_and_static.permissions).toEqual({ contents: 'read' }); + }); + + it('keeps every shared prelude step identical between test and lint_and_static', () => { + // The split copied the checkout/node/install prelude into the new job, + // and copies drift: the first merge after the split brought #10799's + // trusted-classifier rework, which updated `test`'s copy and left the + // lint one stale — resolved by hand that time. Field-for-field equality + // over every same-named step turns the next drift into a red test + // instead of a review archaeology exercise. + const byName = (job) => + new Map( + (ci.jobs[job].steps ?? []).map((s) => [ + s.name, + JSON.stringify(s, Object.keys(s).sort()), + ]), + ); + const t = byName('test'); + const l = byName('lint_and_static'); + // The one same-named step that MUST differ: both jobs can fail in one + // run, and upload-artifact v4+ rejects duplicate artifact names, so the + // collector carries a per-job name. Everything else that shares a name + // must be byte-identical. + const deliberatelyDivergent = new Set(['Upload disk-pressure samples']); + const shared = [...t.keys()].filter( + (n) => l.has(n) && !deliberatelyDivergent.has(n), + ); + // The prelude is what is duplicated; if this floor ever drops, steps + // were renamed apart and the guard is no longer guarding anything. + expect(shared.length).toBeGreaterThanOrEqual(12); + for (const n of shared) { + expect(l.get(n), `step "${n}" drifted between the two jobs`).toBe( + t.get(n), + ); + } + }); + it('classify_pr admits push in its event allowlist', () => { // `test` reads `needs.classify_pr.outputs.ubuntu_runner`. If classify_pr // stops accepting push, `test` still runs but that output is empty and @@ -336,7 +405,7 @@ describe('post-merge push lane', () => { ); }); - it('keeps a post-merge run to exactly classify_pr and test', () => { + it('keeps a post-merge run to exactly its expected jobs', () => { // The mirror of the nightly assertion, through the same helper: a `push:` // trigger fires the whole workflow, so every other job must exclude push // outright or gate on an allowlist that cannot contain it. Otherwise a @@ -353,13 +422,15 @@ describe('post-merge push lane', () => { // pull_request / merge_group, and its `merge-base --is-ancestor` check // passes for a newer tip regardless. Order matters as much as presence — // the arm has to sit before the `github.ref` fallback to ever be reached. - const checkout = (ci.jobs.test.steps ?? []).find((s) => - String(s.uses ?? '').startsWith('actions/checkout'), - ); - expect(checkout).toBeDefined(); - expect(String(checkout.with?.ref ?? '')).toBe( - "${{ 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 }}", - ); + for (const job of ['test', 'lint_and_static']) { + const checkout = (ci.jobs[job].steps ?? []).find((s) => + String(s.uses ?? '').startsWith('actions/checkout'), + ); + expect(checkout, job).toBeDefined(); + expect(String(checkout.with?.ref ?? ''), job).toBe( + "${{ 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 }}", + ); + } }); it('does not upload coverage from a post-merge run', () => { diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index def1679792c..947640e4a38 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -476,8 +476,10 @@ describe('no-AK integration CI wiring', () => { // stale ones this guard must reject, while the pin above stays green. expect(guardAction).toContain("EXPECTED_SHA: '${{ inputs.expected_sha }}'"); + const lintJob = getWorkflowJob(workflow, 'lint_and_static'); const guardCalls = { test: getWorkflowStep(ubuntuJob, GUARD_STEP), + lint_and_static: getWorkflowStep(lintJob, GUARD_STEP), web_shell_e2e_smoke: getWorkflowStep(webShellJob, GUARD_STEP), test_windows: getWorkflowStep(windowsJob, GUARD_STEP), integration_cli: getWorkflowStep(integrationJob, GUARD_STEP), @@ -491,6 +493,11 @@ describe('no-AK integration CI wiring', () => { expect(guardCalls.test).toContain( 'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"', ); + // Byte-identical to test's event-aware shape: the lint lane replicates + // the same checkout contract on the same event surface. + expect(guardCalls.lint_and_static).toContain( + 'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"', + ); expect(guardCalls.web_shell_e2e_smoke).toContain( "expected_sha: '${{ github.event.pull_request.head.sha }}'", ); diff --git a/scripts/tests/workflow-size.test.js b/scripts/tests/workflow-size.test.js index 1e4b331da59..26c67c9178a 100644 --- a/scripts/tests/workflow-size.test.js +++ b/scripts/tests/workflow-size.test.js @@ -49,6 +49,23 @@ describe('workflow file size', () => { expect(bytes).toBeLessThan(gateBytes); }); + it('fails fast: the size gate precedes the dependency install', () => { + // In the pre-split test job the gate deliberately ran right after the + // profile step — dependency-free bash, seconds into the job. The split + // initially parked it after `npm ci`, which delays the verdict by ~6 + // minutes warm and ~15 cold, and (worse) hides a size violation behind + // any unrelated install failure since the step has no always() gate. + const lintJob = ciWorkflow.match( + /^ {2}lint_and_static:[\s\S]*?(?=^ {2}[a-z_]+:)/m, + )?.[0]; + expect(lintJob).toBeDefined(); + const gate = lintJob.indexOf("- name: 'Check workflow file size'"); + const install = lintJob.indexOf("- name: 'Install dependencies'"); + expect(gate).toBeGreaterThan(-1); + expect(install).toBeGreaterThan(-1); + expect(gate).toBeLessThan(install); + }); + it('runs the gate on every CI profile, not just full', () => { // A .github-only PR classifies as `github_ci_only`; gating the check on the // `full` profile would skip it for exactly the changes that can trip it.