From 3f085d0724778f07dffd8d3eff8bea13d9c08353 Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 28 Oct 2026 12:44:06 +0000 Subject: [PATCH] ci: full matrix once per PR in the merge queue; Windows/macOS non-blocking (#420) (#421) * ci: run the full matrix once per PR, in the merge queue (#420) Pull requests now run a fast subset: Python 3.12 on ubuntu-latest, the coverage gate, black/flake8, frontend modules, bandit and the Chromium/touch frontend coverage shards plus merge. The merge queue, pushes to main and manual runs keep the full matrix: Linux/Windows/macOS x Python 3.12-3.14, real ComfyUI compatibility and the Firefox/WebKit page workflows. A dropped matrix leg never reports a check, so the new required "all tests" gate requires every test.yml job to succeed outside pull requests and real ComfyUI to be skipped only on pull requests; viewerjs does the same for the browser shards. branch-protection.json drops the eight legs a pull request no longer runs and adds "all tests". * docs(ci): ruleset edit keeps every rule and integration id; find merge-queue runs * ci: move Windows and macOS out of the blocking path (#420) The maintainer deferred Windows and macOS to the end of the redesign. The merge queue now runs Linux on Python 3.12-3.14, real ComfyUI, the frontend coverage shards and the Firefox/WebKit page workflows; pull requests keep the Linux 3.12 subset. The six Windows/macOS legs move to the new portability.yml, which runs on push to main, nightly and on demand, keeps the 27-minute limit and is never a required check. Red runs there are fixed at their cause before the cutover (#334). * docs(ci): required checks live in the ruleset and the classic protection (#420) --- .github/branch-protection.json | 9 +- .github/workflows/portability.yml | 55 ++++++++++ .github/workflows/test.yml | 44 +++++++- .github/workflows/viewerjs-check.yml | 13 ++- TESTING.md | 99 +++++++++++++++--- tests/vendor/ci-workflow.test.mjs | 145 ++++++++++++++++++++++----- 6 files changed, 315 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/portability.yml diff --git a/.github/branch-protection.json b/.github/branch-protection.json index 985d4116..4aca6925 100644 --- a/.github/branch-protection.json +++ b/.github/branch-protection.json @@ -3,17 +3,10 @@ "strict": false, "contexts": [ "py3.12 on ubuntu-latest", - "py3.13 on ubuntu-latest", - "py3.14 on ubuntu-latest", - "py3.12 on windows-latest", - "py3.13 on windows-latest", - "py3.14 on windows-latest", - "py3.12 on macos-latest", - "py3.13 on macos-latest", - "py3.14 on macos-latest", "coverage gate (85%)", "black and flake8", "frontend modules", + "all tests", "viewerjs", "real ComfyUI compatibility", "bandit (high severity)" diff --git a/.github/workflows/portability.yml b/.github/workflows/portability.yml new file mode 100644 index 00000000..75122ab0 --- /dev/null +++ b/.github/workflows/portability.yml @@ -0,0 +1,55 @@ +name: Windows and macOS + +permissions: + contents: read + +# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context +# here is a required check. A red run is fixed at its cause before the workspace cutover (#334). +on: + push: + branches: [main] + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +# Every run gets its own group (run_id), so no main commit's run is ever cancelled or dropped as pending. +concurrency: + group: ${{ github.workflow }}-${{ github.run_id }} + cancel-in-progress: false + +env: + PYTHONFAULTHANDLER: "1" + +# Time limit rule as in test.yml: ceil(1.5 x the slowest of three recent green runs on main) + 3 +# minutes, never under 5. Measured 2026-10-26 in test.yml on runs 40257217681 (3be0ada), +# 40240706568 (fb9134f), 40228167328 (f9835aa), before these legs moved here in #420: +# slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min +# (macos 6m14s-8m30s, windows 11m42s-15m54s) +jobs: + test: + name: py${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 27 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + python-version: ["3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v7 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: python -m tests.run_suite -v diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43bf3d99..85194d1a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,10 +28,18 @@ env: # py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min # (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s) # 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs. +# Windows and macOS moved to portability.yml (#420), which keeps this 27-minute limit; this +# Linux-only matrix keeps it too until it is re-measured. # real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min # coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min # perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s, # frontend modules 23s / 19s / 29s -> 5 min (the floor) +# all tests: a result check without setup, 5 min (the floor) +# +# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend +# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on +# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set. +# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge. jobs: test: name: py${{ matrix.python-version }} on ${{ matrix.os }} @@ -40,8 +48,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.12", "3.13", "3.14"] + os: [ubuntu-latest] + python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.12"]') || fromJSON('["3.12", "3.13", "3.14"]') }} steps: - uses: actions/checkout@v7 @@ -63,6 +71,9 @@ jobs: comfyui: name: real ComfyUI compatibility + # Merge queue, main and manual runs only. A job skipped by its condition reports Success, so this + # required context never blocks a pull request; "all tests" fails if it is skipped anywhere else. + if: github.event_name != 'pull_request' runs-on: ubuntu-latest # Chromium and its system libraries come from the pinned Playwright image (tag = @playwright/test in # tools/vendor/package-lock.json; tests/vendor/browser-image.test.mjs checks it). The runner install @@ -253,3 +264,32 @@ jobs: - name: Run JS tests run: node --test tests/js/*.test.js + + # The required context "all tests". A dropped matrix leg never reports a check at all, so the eight + # legs that pull requests skip cannot be required by name; this gate requires them instead. Every + # job must succeed; real ComfyUI must be skipped on a pull request and succeed on every other event. + all-tests: + name: all tests + needs: [test, comfyui, coverage, lint, js-test] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require every test job for this event + env: + EVENT_NAME: ${{ github.event_name }} + TEST_RESULT: ${{ needs.test.result }} + COMFYUI_RESULT: ${{ needs.comfyui.result }} + COVERAGE_RESULT: ${{ needs.coverage.result }} + LINT_RESULT: ${{ needs.lint.result }} + JS_RESULT: ${{ needs.js-test.result }} + run: | + queue_only=success + if [ "$EVENT_NAME" = pull_request ]; then queue_only=skipped; fi + echo "event: $EVENT_NAME (real ComfyUI must be $queue_only)" + echo "test matrix: $TEST_RESULT" + echo "comfyui: $COMFYUI_RESULT" + echo "coverage: $COVERAGE_RESULT" + echo "lint: $LINT_RESULT" + echo "js-test: $JS_RESULT" + test "$TEST_RESULT" = success && test "$COVERAGE_RESULT" = success && test "$LINT_RESULT" = success && test "$JS_RESULT" = success && test "$COMFYUI_RESULT" = "$queue_only" diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml index 8582a67d..783e1b92 100644 --- a/.github/workflows/viewerjs-check.yml +++ b/.github/workflows/viewerjs-check.yml @@ -165,6 +165,8 @@ jobs: browser-workflows: name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2) + # Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards. + if: github.event_name != 'pull_request' runs-on: ubuntu-latest # Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the # same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json; @@ -219,8 +221,9 @@ jobs: retention-days: 14 # Branch protection requires the exact context "viewerjs". This job keeps that - # name and fails unless every frontend job above succeeded (every shard, the - # coverage merge and every browser shard), including when one was cancelled or skipped. + # name and fails unless every coverage shard and the coverage merge succeeded and + # the browser shards succeeded, or, on a pull request only, were skipped (#420). + # Any failure, cancellation or other skip fails it. viewerjs: needs: [frontend-coverage, frontend-coverage-merge, browser-workflows] if: always() @@ -229,11 +232,15 @@ jobs: steps: - name: Require every frontend job to succeed env: + EVENT_NAME: ${{ github.event_name }} COVERAGE_RESULT: ${{ needs.frontend-coverage.result }} MERGE_RESULT: ${{ needs.frontend-coverage-merge.result }} WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }} run: | + queue_only=success + if [ "$EVENT_NAME" = pull_request ]; then queue_only=skipped; fi + echo "event: $EVENT_NAME (browser-workflows must be $queue_only)" echo "frontend-coverage: $COVERAGE_RESULT" echo "frontend-coverage-merge: $MERGE_RESULT" echo "browser-workflows: $WORKFLOWS_RESULT" - test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success + test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = "$queue_only" diff --git a/TESTING.md b/TESTING.md index 28a1a306..e6ac7dbb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -181,10 +181,12 @@ check, so run it before pushing a new or deleted frontend file instead of waiting for the `frontend coverage` jobs. The `viewerjs` check runs on every PR, including backend-only changes. It is an -aggregate job that passes only when every frontend job succeeds: the three -`frontend coverage (shard N/3)` jobs, `frontend coverage (merge)`, and the four -`page workflows (firefox|webkit, shard N/2)` jobs. A failed, cancelled or skipped -job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public +aggregate job. It requires the three `frontend coverage (shard N/3)` jobs and +`frontend coverage (merge)` to succeed on every event. The four +`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue, +on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue"). +`viewerjs` requires them to be skipped on a pull request and to succeed +everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3` (shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3 @@ -271,6 +273,13 @@ gh run view --json jobs # shows a job that alr gh run view --job --log-failed # that job's failed log, at once ``` +Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the +merge queue, on the merge-group commit rather than the +pushed one. Find that run with `gh run list --event merge_group --workflow test.yml` +(or `viewerjs-check.yml`) and match its head branch +(`gh-readonly-queue/main/pr--...`). Windows and macOS failures show up only +after the merge, in `gh run list --workflow portability.yml`. + Start fixing as soon as a failed log shows the cause, but push the fix only after the run for that commit has finished: pull-request runs use `cancel-in-progress`, so a push cancels the running jobs and can hide a second failure. @@ -539,29 +548,95 @@ Before changing paths, SQLite, streams, restore or cleanup: open with `FirstWriterHold`) and give peers that must all succeed the ordinary busy timeout; cross-thread handoffs wait on events with generous ceilings, never a short wall clock (#242). -- Run native Windows CI early for these changes and include a Windows impact and - evidence note in the PR. All twelve OS/Python combinations and every applicable +- Run native Windows CI early for these changes. Pull requests and the merge + queue run only Linux, so start Windows and macOS on the branch with + `gh workflow run portability.yml --ref `. + Include a Windows impact and evidence note in the PR. All six Windows/macOS + combinations in that run, every Linux leg and every applicable exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath` controls do not establish native filesystem, locking or cleanup success. +## Pull requests and the merge queue + +Each PR runs the full matrix once, in the merge queue (#420). The event decides +what runs: + +| Job | `pull_request` | `merge_group`, push to `main`, `workflow_dispatch` | +| --- | --- | --- | +| `py3.12 on ubuntu-latest` | runs | runs | +| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run | +| `real ComfyUI compatibility` | skipped | runs | +| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run | +| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run | +| `page workflows (firefox\|webkit, shard N/2)` | skipped | run | +| `all tests`, `viewerjs` (gates) | run | run | +| `perf budget` | skipped | push and dispatch only (not in the queue) | +| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required | + +GitHub has one required set for pull requests and the queue. A job skipped by +its `if:` reports Success. A dropped matrix leg never reports, so it would stay +pending. That is why `all tests` (in `test.yml`) and `viewerjs` carry the +guarantee: +- On a pull request they require the subset to succeed and the queue-only jobs + to be skipped. +- On every other event they require every job to succeed. + +So a merge group cannot turn green unless all three Linux Python legs, real +ComfyUI and the four browser shards passed. + +Windows and macOS (Python 3.12-3.14) are out of the blocking path for now +(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`, +nightly and on demand, and no context there is required. A red portability run +is still a failure: fix it at its cause, not by rerunning, and have every one +green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the +event split and runs both gate scripts for each event. + ## Required merge checks -`.github/branch-protection.json` records the required checks: all twelve Python/OS -combinations, Python coverage, formatting, frontend modules, `viewerjs`, `real ComfyUI compatibility`, -and `bandit (high severity)`. -The branch must be current with main, and administrators follow the same checks. +`.github/branch-protection.json` records the required checks: +`py3.12 on ubuntu-latest`, `coverage gate (85%)`, `black and flake8`, +`frontend modules`, `all tests`, `viewerjs`, `real ComfyUI compatibility` and +`bandit (high severity)`. Each one reports on a pull request. +The same set lives in two places, and both must change together: the `main` +ruleset (25960526, which also holds the merge queue) and the classic branch +protection on `main`, which the manifest mirrors. GitHub requires the union of +the two, so a context left in either one blocks every PR that does not report it. It does not require an additional human approval. A repository administrator applies the configuration after the new workflow is passing, then verifies it: +```sh +gh api repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 \ + | jq '.rules[] | select(.type == "required_status_checks") | [.parameters.required_status_checks[].context]' +``` + +A PUT replaces the whole `rules` array, so edit the current ruleset in place. +Keep the `deletion`, `non_fast_forward` and `merge_queue` rules, and give every +context `integration_id` 15368 (GitHub Actions), so no other integration can +post a required check. Save the current ruleset first; it is the rollback: + +```sh +gh api repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 > ruleset-before.json +jq '{name, target, enforcement, conditions, bypass_actors, rules: [.rules[] + | if .type == "required_status_checks" + then .parameters.required_status_checks = [$ARGS.positional[] | {context: ., integration_id: 15368}] + else . end]}' ruleset-before.json --args "py3.12 on ubuntu-latest" "..." > ruleset-new.json +gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/rulesets/25960526 --input ruleset-new.json +``` + +Afterwards, read it back. Check that the `merge_queue` rule is still there and +that every context keeps its `integration_id`. Then apply the manifest to the +classic protection and read both back: + ```sh gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \ --input .github/branch-protection.json gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks +gh api repos/ComfyAssets/ComfyUI_PromptManager/rules/branches/main ``` - -Update the manifest whenever required job names change. Keep the frontend check + Update the manifest whenever +required job names change. Keep the frontend check free of path filters so a required check is never silently skipped. ## Unified workspace diff --git a/tests/vendor/ci-workflow.test.mjs b/tests/vendor/ci-workflow.test.mjs index cfafcaae..d8ed881a 100644 --- a/tests/vendor/ci-workflow.test.mjs +++ b/tests/vendor/ci-workflow.test.mjs @@ -67,6 +67,45 @@ const browserJobIds = [...allJobs].filter(([, body]) => /npm run --prefix tools\ const mergeJob = () => allJobs.get('frontend-coverage-merge'); const verifyStep = () => steps(mergeJob()).find(step => /name: Verify every shard's raw coverage arrived/.test(step)); +// Jobs that run only in the merge queue, on main and on demand (#420): skipped on pull_request. +const QUEUE_ONLY_IF = /^ {4}if: github\.event_name != 'pull_request'$/m; +const EVENTS = ['pull_request', 'merge_group', 'push', 'workflow_dispatch']; + +function gateVariables(jobText) { + return new Map([...jobText.matchAll(/^ {10}([A-Z_]+): \$\{\{ needs\.([\w-]+)\.result \}\}$/gm)] + .map(match => [match[2], match[1]])); +} + +function runGate(jobText, env) { + const script = runScript(steps(jobText).find(step => /run: \|/.test(step))); + try { + execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { env: { PATH: process.env.PATH, ...env }, stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +// A gate passes only when every always-on job succeeded and every queue-only job is skipped on a +// pull request and succeeded on every other event. Anything else fails it. +function assertGateBehaviour(jobText, queueOnly) { + assert.match(jobText, /^ {10}EVENT_NAME: \$\{\{ github\.event_name \}\}$/m, 'the gate knows the event'); + const variables = gateVariables(jobText); + for (const id of queueOnly) assert.ok(variables.has(id), `${id} reaches the gate`); + for (const event of EVENTS) { + const expected = id => (queueOnly.includes(id) && event === 'pull_request' ? 'skipped' : 'success'); + const green = Object.fromEntries([...variables].map(([id, name]) => [name, expected(id)])); + assert.equal(runGate(jobText, { ...green, EVENT_NAME: event }), true, `${event}: the expected results pass`); + for (const [id, name] of variables) { + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + if (result === expected(id)) continue; + assert.equal(runGate(jobText, { ...green, EVENT_NAME: event, [name]: result }), false, + `${event}: ${id}=${result} must fail the gate`); + } + } + } +} + test('viewerjs keeps its name and needs every frontend job', () => { const viewerjs = allJobs.get('viewerjs'); assert.ok(viewerjs, 'the required context is the job id viewerjs'); @@ -76,32 +115,17 @@ test('viewerjs keeps its name and needs every frontend job', () => { assert.deepEqual([...needs].sort(), [...others].sort(), 'viewerjs needs every other job in the workflow'); assert.deepEqual([...needs].sort(), ['browser-workflows', 'frontend-coverage', 'frontend-coverage-merge']); assert.match(viewerjs, /^ {4}if: always\(\)$/m, 'viewerjs runs (and fails) when a needed job failed or was skipped'); - const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step))); - for (const id of needs) { - const variable = new RegExp(`^ {10}([A-Z_]+): \\$\\{\\{ needs\\.${id}\\.result \\}\\}$`, 'm').exec(viewerjs); - assert.ok(variable, `${id}'s result reaches the check`); - assert.match(script, new RegExp(`test "\\$${variable[1]}" = success`), `${id} must be exactly success`); - } + assert.deepEqual([...gateVariables(viewerjs).keys()].sort(), [...needs].sort(), 'every needed result reaches the check'); }); -test('viewerjs fails unless every needed job succeeded', () => { - const viewerjs = allJobs.get('viewerjs'); - const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step))); - const variables = [...viewerjs.matchAll(/^ {10}([A-Z_]+): \$\{\{ needs\.[\w-]+\.result \}\}$/gm)].map(match => match[1]); - const run = env => { - try { - execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { env: { PATH: process.env.PATH, ...env }, stdio: 'pipe' }); - return true; - } catch { - return false; - } - }; - const allSuccess = Object.fromEntries(variables.map(name => [name, 'success'])); - assert.equal(run(allSuccess), true); - for (const name of variables) { - for (const result of ['failure', 'cancelled', 'skipped']) { - assert.equal(run({ ...allSuccess, [name]: result }), false, `${name}=${result} must fail viewerjs`); - } +test('viewerjs requires the browser shards everywhere but on a pull request', () => { + assertGateBehaviour(allJobs.get('viewerjs'), ['browser-workflows']); +}); + +test('Firefox/WebKit page workflows run only outside pull requests; coverage runs on every event', () => { + assert.match(allJobs.get('browser-workflows'), QUEUE_ONLY_IF); + for (const id of ['frontend-coverage', 'frontend-coverage-merge']) { + assert.doesNotMatch(allJobs.get(id), /^ {4}if:/m, `${id} runs on every event`); } }); @@ -224,7 +248,8 @@ test('browser job timeouts keep a real margin over measured runs', () => { }); // Every job is bounded (#350): a hang ends at a measured limit instead of GitHub's 6-hour default. -const BOUNDED_WORKFLOWS = ['test.yml', 'code-quality.yml', 'viewerjs-check.yml']; +const GATING_WORKFLOWS = ['test.yml', 'code-quality.yml', 'viewerjs-check.yml']; +const BOUNDED_WORKFLOWS = [...GATING_WORKFLOWS, 'portability.yml']; const readWorkflow = name => readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), 'utf8'); test('every job in the CI workflows has a numeric timeout-minutes', () => { @@ -276,3 +301,73 @@ test('the bandit pre-commit hook fails at the severity CI enforces and passes ev assert.doesNotMatch(hook, /true|exit-zero|bash -c/, 'nothing masks the exit code or drops a file'); assert.doesNotMatch(hook, /pass_filenames: false/, 'the hook scans the staged files'); }); + +// Event split (#420): pull requests run a fast subset, the merge queue and main run everything. +const testJobs = () => jobs(readWorkflow('test.yml')); + +test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on every other event', () => { + const body = testJobs().get('test'); + assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows and macOS never gate a merge'); + const match = /^ {8}python-version: \$\{\{ github\.event_name == 'pull_request' && fromJSON\('(\[[^\]]*\])'\) \|\| fromJSON\('(\[[^\]]*\])'\) \}\}$/m.exec(body); + assert.ok(match, 'python-version is chosen by event'); + assert.deepEqual({ pr: JSON.parse(match[1]), other: JSON.parse(match[2]) }, { pr: ['3.12'], other: ['3.12', '3.13', '3.14'] }); + assert.match(body, /^ {6}fail-fast: false$/m); + assert.match(body, /^ {4}name: py\$\{\{ matrix\.python-version \}\} on \$\{\{ matrix\.os \}\}$/m); +}); + +test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => { + const text = readWorkflow('portability.yml'); + const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\nconcurrency:')); + for (const event of ['push', 'schedule', 'workflow_dispatch']) assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm')); + for (const event of ['pull_request', 'merge_group']) assert.doesNotMatch(triggers, new RegExp(`^ {2}${event}`, 'm')); + assert.match(triggers, /^ {4}branches: \[main\]$/m, 'push runs only for main'); + const body = jobs(text).get('test'); + assert.deepEqual(matrixList(body, 'os'), ['windows-latest', 'macos-latest']); + assert.deepEqual(matrixList(body, 'python-version'), ['"3.12"', '"3.13"', '"3.14"']); + assert.match(body, /^ {6}fail-fast: false$/m); + assert.match(body, /^ {8}run: python -m tests\.run_suite -v$/m); + assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled'); +}); + +test('real ComfyUI runs only outside pull requests; the other test.yml checks run on every event', () => { + const found = testJobs(); + assert.match(found.get('comfyui'), QUEUE_ONLY_IF); + for (const id of ['test', 'coverage', 'lint', 'js-test']) { + assert.doesNotMatch(found.get(id), /^ {4}if:/m, `${id} runs on every event`); + } +}); + +test('all tests needs every gating test.yml job and requires the full set outside pull requests', () => { + const found = testJobs(); + const gate = found.get('all-tests'); + assert.ok(gate, 'the aggregate gate exists'); + assert.match(gate, /^ {4}name: all tests$/m, 'the required context is "all tests"'); + assert.match(gate, /^ {4}if: always\(\)$/m); + const gating = [...found.keys()].filter(id => !['all-tests', 'perf-budget'].includes(id)); + assert.deepEqual([...needsOf(gate)].sort(), gating.sort(), 'the gate needs every job but the non-gating perf budget'); + assert.deepEqual([...gateVariables(gate).keys()].sort(), gating.sort(), 'every needed result reaches the gate'); + assertGateBehaviour(gate, ['comfyui']); +}); + +test('the CI workflows run on pull_request, merge_group and push without path filters', () => { + for (const name of GATING_WORKFLOWS) { + const text = readWorkflow(name); + const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\n', text.indexOf('\non:') + 1) + 400); + for (const event of ['merge_group', 'pull_request', 'push']) { + assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm'), `${name} runs on ${event}`); + } + assert.doesNotMatch(text, /^ {4}paths(-ignore)?:/m, `${name} has no path filter that would leave a required check pending`); + } + assert.doesNotMatch(jobs(readWorkflow('code-quality.yml')).get('security'), /^ {4}if:/m, 'bandit runs on every event'); +}); + +test('every required check reports on a pull request and the gates carry the full matrix', () => { + const contexts = JSON.parse(readRepoFile('.github/branch-protection.json')).required_status_checks.contexts; + assert.deepEqual([...contexts].sort(), [ + 'all tests', 'bandit (high severity)', 'black and flake8', 'coverage gate (85%)', 'frontend modules', + 'py3.12 on ubuntu-latest', 'real ComfyUI compatibility', 'viewerjs', + ].sort()); + assert.equal(contexts.filter(context => /^py/.test(context)).length, 1, + 'a dropped matrix leg never reports on a pull request, so it cannot be required'); + assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows and macOS never gate a merge'); +});