diff --git a/.github/scripts/ci-runner-routing.test.mjs b/.github/scripts/ci-runner-routing.test.mjs index a1773eb5706..66c2301cf38 100644 --- a/.github/scripts/ci-runner-routing.test.mjs +++ b/.github/scripts/ci-runner-routing.test.mjs @@ -224,6 +224,46 @@ describe('ci.yml classify_pr runner routing', () => { ); }); + it('a push to main reaches ECS, and the kill-switch still wins', () => { + // The post-merge fast signal (Classify PR + Test on `main`) runs the same + // ten-plus-minute Test job a pull request does, in the most trusted + // context there is: the code is already merged and the YAML is main's + // own. Route it to the pool instead of spending a scarce hosted Linux + // runner on every merge. A push carries no author association, so this + // arm is necessarily event-based. + assert.equal( + runPickRunner({ + ecsDisabled: false, + sameRepo: false, + assoc: '', + eventName: 'push', + }), + ECS, + ); + assert.equal( + runPickRunner({ + ecsDisabled: true, + sameRepo: false, + assoc: '', + eventName: 'push', + }), + HOSTED, + 'kill-switch must revert post-merge runs to hosted', + ); + }); + + it('keeps the push arm out of the classify runs-on expression', () => { + // classify_pr routes twice, and on a push the two halves deliberately + // disagree: the shell step sends downstream Linux jobs to ECS while this + // expression leaves classify_pr itself hosted. Pin the asymmetry so a + // future "drift fix" has to read why first — this expression is the + // canonical association-routing text that sdk-java.yml and serve-ab.yml + // mirror, a push has no association to route on, and classify_pr is a + // seconds-long job whose pool costs nothing either way. The drift guard + // above evaluates both halves on pull_request, where they must agree. + assert.doesNotMatch(classifyRunsOn, /github\.event_name == 'push'/); + }); + it('the runs-on expression keeps the trusted clause and kill-switch', () => { // Structural pins for the expression half of the drift guard — the // simulation above re-implements it, so pin the real text too. diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 2d9baebaaf8..515a080ccf6 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -18,7 +18,7 @@ 9256 build-and-publish-image.yml 49610 cd-cua-driver.yml 2076 cd-mobile-mcp.yml -91370 ci.yml +95501 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 ee2117ee557..d133f3f53f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,21 @@ name: 'Qwen Code CI' on: - # No `push` trigger: every job here is gated to pull_request / merge_group, so - # a push to `main` ran nothing (CodeQL was the last push job and moved to its - # own scheduled codeql.yml). The merge queue validates the merged tree before - # it lands, so there is nothing left to run on the post-merge push. + # 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. + # 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 + # `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, + # publishing outputs nothing consumes. + push: + branches: + - 'main' pull_request: branches: - 'main' @@ -40,7 +51,14 @@ on: - 'hosted' concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + # 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/') }} @@ -67,12 +85,20 @@ env: # 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. - WORKFLOW_SIZE_BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}' + # `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' }}" + 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), @@ -127,9 +153,17 @@ jobs: echo "skip_ci=${skip_ci}" # In-repo PRs, fork PRs whose author has write access - # (OWNER/MEMBER/COLLABORATOR association), and the merge queue - # (base-repo context) run the Linux jobs on ECS; other fork PRs stay - # hosted. Disable via repo var MAINTAINER_ECS_RUNNER_DISABLED=true. + # (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: @@ -148,7 +182,7 @@ jobs: if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]' fi - elif [[ "${ECS_DISABLED}" != "true" && ( "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" ) ]]; then + 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}" @@ -162,9 +196,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. - # Not on push: the merge queue already tested the merged tree, so a - # post-merge re-run on `main` would be redundant. - if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' }}" + # 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. + if: "${{ !cancelled() && github.event_name != 'schedule' }}" runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}' timeout-minutes: 60 outputs: @@ -281,7 +324,17 @@ jobs: 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 }}" + # 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 @@ -619,8 +672,11 @@ jobs: name: 'test-results-fork-22.x-ubuntu-latest' path: 'packages/*/junit.xml' + # Excludes push: the only consumer, `post_coverage_comment`, is gated to + # pull_request and nothing else reads `coverage-reports-*`, so uploading + # on every merge spends egress and retention on a lane with no reader. - name: 'Upload coverage reports' - if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" + 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' diff --git a/.github/workflows/main-ci-failure-issue.yml b/.github/workflows/main-ci-failure-issue.yml index 8615ce03e9b..a6d693d1e8f 100644 --- a/.github/workflows/main-ci-failure-issue.yml +++ b/.github/workflows/main-ci-failure-issue.yml @@ -4,10 +4,15 @@ name: 'Main CI Failure Issue' on: workflow_run: - # 'Qwen Code CI' is watched for its SCHEDULED run only (see the event - # filter below): that nightly is the macOS and Windows lanes' only - # trigger outside a pull request, and a lane nobody is told about is a - # lane nobody looks at — which is how those two came to stop running. + # 'Qwen Code CI' is watched for its SCHEDULED run and its post-merge PUSH + # run (see the event filter below — the push arm is not name-scoped, so it + # covers this workflow too). The nightly is the macOS and Windows lanes' + # only trigger outside a pull request, and a lane nobody is told about is a + # lane nobody looks at — which is how those two came to stop running. The + # push arm covers ci.yml's post-merge lane on `main`, whose whole point is + # to surface a regression as it lands; a failure there files an issue with + # `type/bug`, `status/ready-for-agent` and `autofix/approved`, so expect + # one per red merge, not per red nightly only. workflows: ['E2E Tests', 'SDK Python', 'Qwen Code CI'] types: ['completed'] # Trigger-level branch filter, not only the `if` below: 'Qwen Code CI' diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 98bf4c1483f..79875058f0a 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -975,8 +975,12 @@ signal, NOT proof that main is healthy. MAIN_GREEN_CHECKS is sourced from the last-merged PR's PRE-MERGE check-runs, which ran against that PR merged with main-as-of-then — -never the tree now on main (ci.yml has no push trigger, so main's -squash commits carry no check-runs to read). main breaks here by +never the tree now on main. (ci.yml's post-merge push lane DOES put +check-runs on main's squash commits now, but that lane is lint, static +analysis and unit tests only — the no-AK integration gate and every +platform lane stay off the push trigger — so it is a strictly narrower +signal than a PR's full matrix, and this deliberately stays on the +pre-merge runs.) main breaks here by SEMANTIC CONFLICT: two PRs green apart but broken together. In exactly that state the last-merged PR is green, this signal reads green, and the update would merge a currently-broken main into a healthy PR. The diff --git a/scripts/tests/ci-platform-lanes.test.js b/scripts/tests/ci-platform-lanes.test.js index 2a1cd9c7ad1..bc37d202872 100644 --- a/scripts/tests/ci-platform-lanes.test.js +++ b/scripts/tests/ci-platform-lanes.test.js @@ -37,13 +37,82 @@ 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 condOf = (job) => String(ci.jobs[job].if ?? ''); +// 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 +// in it — the same substitute-then-evaluate technique +// `.github/scripts/ci-runner-routing.test.mjs` uses on `runs-on`. Token +// probes are connective-blind: an `&&`→`||` flip inside an excluded job, or an +// allowlisted equality paired with an event-neutral disjunct under `||`, +// changes which jobs run while every substring stays exactly where it was. +// Evaluation sees the change because it computes the answer instead of +// pattern-matching the question, and it needs no expression parser of our own. +// +// Any context term the substitutions do not model throws rather than reading +// as false, so a gate that grows a new input is re-read here instead of +// silently degrading this guard. +const gateRunsOn = (cond, event) => { + if (String(cond ?? '').trim() === '') return true; + let e = String(cond) + .replace(/\$\{\{/g, ' ') + .replace(/\}\}/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const subs = [ + [/!cancelled\(\)/g, 'true'], + [/cancelled\(\)/g, 'false'], + [/always\(\)/g, 'true'], + [/github\.event_name/g, `"${event}"`], + // These pins model a normal run: a gate keyed on any other value of a + // modeled output (e.g. `ci_profile != 'full'`, `skip_ci == 'true'`) + // evaluates here at the pin and may mis-report its reachability — + // re-derive this helper's verdict before adding such a gate. + [/needs\.classify_pr\.outputs\.skip_ci/g, '"false"'], + [/needs\.test\.outputs\.ci_profile/g, '"full"'], + [ + /github\.event\.pull_request\.head\.repo\.full_name/g, + event === 'pull_request' ? '"QwenLM/qwen-code"' : '""', + ], + [/github\.repository/g, '"QwenLM/qwen-code"'], + ]; + for (const [re, v] of subs) e = e.replace(re, v); + e = e + .replace(/!==/g, '@NE@') + .replace(/===/g, '@EQ@') + .replace(/!=/g, '@NE@') + .replace(/==/g, '@EQ@') + .replace(/@NE@/g, '!==') + .replace(/@EQ@/g, '==='); + if (/github\.|needs\.|steps\.|vars\.|inputs\./.test(e)) { + throw new Error(`gate carries a term this guard does not model: ${e}`); + } + return Boolean(new Function(`return (${e});`)()); +}; + +// Asserts both directions: the jobs that must run on `event` do, and every +// other job does not. The positive half matters as much — a gate that stops +// admitting its own event turns the lane off with nothing else going red. +const assertEventReachesOnly = (event, allowedJobs) => { + for (const [name, job] of Object.entries(ci.jobs)) { + const reached = gateRunsOn(job.if, event); + expect( + reached, + allowedJobs.includes(name) + ? `${name} no longer runs on ${event}` + : `${name} would also run on ${event}`, + ).toBe(allowedJobs.includes(name)); + } +}; + describe('platform lanes — triggers', () => { it('gives the workflow a scheduled trigger', () => { - // Without it the lanes have no path to `main` at all: `ci.yml` has no - // push trigger by design, so a merge-queue-only gate on a repository with - // no merge queue is an off switch. + // 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, + // so a merge-queue-only gate on a repository with no merge queue is an off + // switch for these two. expect(triggers.schedule).toBeDefined(); expect(Array.isArray(triggers.schedule)).toBe(true); expect(triggers.schedule[0].cron).toMatch(/^\S+ \S+ \S+ \S+ \S+$/); @@ -151,26 +220,136 @@ describe('platform lanes — triggers', () => { // therefore either exclude `schedule` outright or gate on an event // allowlist that cannot contain it — otherwise the nightly quietly // becomes a full CI run every day. - for (const [name, job] of Object.entries(ci.jobs)) { - if (LANES.includes(name)) continue; - const cond = String(job.if ?? ''); - const excluded = - cond.includes("github.event_name != 'schedule'") || - /github\.event_name == '(pull_request|merge_group|workflow_dispatch)'/.test( - cond, - ); - expect(excluded, `${name} would also run on the nightly schedule`).toBe( - true, - ); - // Mentioning an allowlisted event is not the same as excluding this - // one: `event == 'pull_request' || event == 'schedule'` satisfies the - // check above while running nightly. Require the impossibility. - expect( - cond, - `${name} admits the schedule event explicitly`, - ).not.toContain("github.event_name == 'schedule'"); - expect(cond, `${name} has no event gate at all`).not.toBe(''); - } + assertEventReachesOnly('schedule', LANES); + }); +}); + +// The post-merge push lane on `main`. +// +// `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. +// +// 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 +// 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 +// suite green while silently turning the lane off, widening it, or pointing it +// at the wrong tree. +describe('post-merge push lane', () => { + const stepOf = (job, name) => + (ci.jobs[job].steps ?? []).find((s) => s.name === name); + + it('is triggered on main and nowhere else', () => { + // Drop the trigger and the post-merge signal is gone with it; widen the + // branch list and every dev branch push spends a pool runner. + // The whole object, not just `branches`: a sibling `tags:` key would + // widen the lane to every tag push while `branches` still read `['main']`. + // That also breaks the size ratchet — on a tag-creation push + // `github.event.before` is all zeros, so WORKFLOW_SIZE_BASE_SHA is + // unresolvable and both enforcers take their strict arm — and + // main-ci-failure-issue.yml's `head_branch == 'main'` filter means no + // issue is filed for tag runs: a silent red on release day. + expect(triggers.push).toEqual({ branches: ['main'] }); + }); + + 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 + // `fromJSON(... || '["ubuntu-latest"]')` silently demotes every + // post-merge run to a scarce hosted runner. + // Whole-literal, not a substring: `||`→`&&` anywhere in this allowlist + // keeps every token in place while skipping classify_pr on a push. `test` + // would still run (its own gate is `!cancelled()`-shaped), but with + // `ubuntu_runner` empty it falls back through + // `fromJSON(... || '["ubuntu-latest"]')` — every post-merge run silently + // demoted to a scarce hosted runner, and `skip_ci`/`ci_profile` empty. + expect(condOf('classify_pr')).toBe( + "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}", + ); + }); + + it('test accepts push while still excluding the nightly', () => { + // The allowlist rewrite this guard exists to foreclose — `!cancelled() + // && event != 'schedule' && (event == 'pull_request' || event == + // 'merge_group' || event == 'workflow_dispatch')` — contains no + // `!= 'push'`, so it passes any exclusion-shaped check; the exclusivity + // helper's positive half catches it instead (test is in PUSH_JOBS, so it + // must reach push). The literal pin below covers what the evaluator + // cannot: `!cancelled()` is substituted to a constant, so a gate that + // loses that clause evaluates identically on every event and only the + // whole-literal pin catches the change. + expect(condOf('test')).toBe( + "${{ !cancelled() && github.event_name != 'schedule' }}", + ); + }); + + it('keeps a post-merge run to exactly classify_pr and test', () => { + // 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 + // merge quietly becomes a full CI run — desktop_shell's cargo build + // included. + assertEventReachesOnly('push', PUSH_JOBS); + }); + + it('checks out the commit the push reported, not the branch tip', () => { + // `github.ref` resolves `refs/heads/main` at fetch time — a moving tip — + // while the check run attaches to `github.sha`. Without a push arm the + // lane can validate a tree it does not report on, and nothing detects it: + // `Verify checkout includes expected head commit` is gated to + // 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 }}", + ); + }); + + it('does not upload coverage from a post-merge run', () => { + // The artifact's only consumer, post_coverage_comment, is gated to + // pull_request; nothing else under .github/ reads coverage-reports-*. + // Without this exclusion every merge spends pool egress and artifact + // retention on a lane with no reader. + const step = stepOf('test', 'Upload coverage reports'); + expect(step).toBeDefined(); + expect(String(step.if)).toBe( + "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name != 'push' }}", + ); + }); + + it('scopes the concurrency group and keeps main uncancellable', () => { + // All three components, the way e2e-workflow.test.js pins its own. + // + // Event: unscoped, the group collapses onto `Qwen Code CI-refs/heads/main` + // and the post-merge run shares it with the nightly (60-minute lanes, no + // cancellation on main) and with any PR from a fork branch named literally + // `refs/heads/main` — a legal ref name. + // + // Repo + ref: `head_ref` alone is not unique. Two PRs with the same head + // branch name — including from different forks, which evaluate groups in + // the base repo's namespace — share a group, and PR runs DO cancel in + // progress, so they cancel each other. A fork's default branch is `main`, + // the most common head_ref there is. Dropping the ref half entirely would + // collapse every PR into one group and leave this suite green. + // + // cancel-in-progress: flipping it to `true` was measured to be invisible + // to all 1854 test:scripts tests, while consecutive merges would cancel + // each other — the history this repo already recorded once, when 67 of + // 100 push runs were cancelled and only 25 ever reported. + expect(String(ci.concurrency.group)).toBe( + '${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref }}', + ); + expect(String(ci.concurrency['cancel-in-progress']).trim()).toBe( + "${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}", + ); }); }); diff --git a/scripts/tests/main-ci-failure-issue-workflow.test.js b/scripts/tests/main-ci-failure-issue-workflow.test.js index d7a75f5c8ca..52bbb12e6be 100644 --- a/scripts/tests/main-ci-failure-issue-workflow.test.js +++ b/scripts/tests/main-ci-failure-issue-workflow.test.js @@ -36,7 +36,10 @@ describe('main CI failure issue workflow', () => { expect(workflow).toContain( "github.event.workflow_run.head_branch == 'main'", ); - // Push covers the other two watched workflows; schedule is scoped to + // Push covers the other two watched workflows AND 'Qwen Code CI''s own + // post-merge lane on `main` — ci.yml restored that trigger, so main's + // squash commits now carry Test check-runs and a red one files an issue + // here. Schedule is scoped to // 'Qwen Code CI' — that nightly is the platform lanes' only trigger // outside a pull request, and the other watched workflows' own // nightlies must not dispatch the autofix agent through this watcher. diff --git a/scripts/tests/workflow-size.test.js b/scripts/tests/workflow-size.test.js index e0d93459848..1e4b331da59 100644 --- a/scripts/tests/workflow-size.test.js +++ b/scripts/tests/workflow-size.test.js @@ -72,11 +72,17 @@ describe('workflow file size', () => { // forget. const workflowEnv = ciWorkflow.match(/^env:[\s\S]*?\njobs:/m)?.[0]; // Anchored to the whole line: substring checks still pass a `||` → `&&` - // mutation (empty on both events — the #9904 red wall returns) and an + // mutation (empty on every event — the #9904 red wall returns) and an // appended `|| github.sha` fallback (workflow_dispatch resolves the base // to the checked-out commit, failing the ratchet open on that lane). + // `github.event.before` is the push lane's arm and is safe where + // `github.sha` is not: on a squash-merge push it is main's PREVIOUS tip, + // so the comparison is against a different tree the way a PR's base is, + // and an all-zeros `before` (branch creation, force push) still fails + // closed. Without it the push lane has no base at all and the ratchet + // fails closed on `main` while every PR stays green on leniency. expect(workflowEnv).toMatch( - /^\s*WORKFLOW_SIZE_BASE_SHA: '\$\{\{ github\.event\.pull_request\.base\.sha \|\| github\.event\.merge_group\.base_sha \}\}'$/m, + /^\s*WORKFLOW_SIZE_BASE_SHA: '\$\{\{ github\.event\.pull_request\.base\.sha \|\| github\.event\.merge_group\.base_sha \|\| github\.event\.before \}\}'$/m, ); }); });