From d0e14d18be80681b307af29804fa73192befef9e Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:30:20 +0000 Subject: [PATCH 1/4] fix(ci): fail tests-ok when workspace discovery fails Require successful member discovery before accepting a successful or skipped pytest matrix. Exercise the workflow's actual Bash assertion across all job results and preserve empty-selection behavior. Generated-by: Codex (GPT-6) --- .github/workflows/tests.yml | 26 +++++---- tools/dev/tests/test_ci_tests_gate.py | 78 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 tools/dev/tests/test_ci_tests_gate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bc50a534f..42a07a9db 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -95,8 +95,8 @@ jobs: # pull request touching a few projects gets one job per project, so # per-project pass/fail shows in the checks list; a full run batches # them, and each batch job prints a group and a summary row per - # member. The `prek` workflow still exercises pytest as a hook, so - # this workflow is the visible signal — not the gate. + # member. The required `tests-ok` job below aggregates discovery + # and matrix results; the `prek` workflow skips workspace pytest. pytest: name: "pytest (${{ matrix.batch.name }})" runs-on: ubuntu-slim @@ -204,27 +204,31 @@ jobs: # with N entries would otherwise force N `.asf.yaml` lines that # must be kept in lock-step with the matrix. This single # `tests-ok` job is the only pytest-related context required by - # branch protection — it passes iff every entry in the `pytest` - # matrix above passed. Adding, renaming, or removing matrix - # entries no longer touches `.asf.yaml`. + # branch protection — it requires successful member discovery and + # a passing matrix (or an empty selection). Adding, renaming, or + # removing matrix entries no longer touches `.asf.yaml`. tests-ok: name: tests-ok runs-on: ubuntu-slim - needs: pytest + needs: [members, pytest] # `always()` — without it, this job would be skipped (not # failed) when an upstream matrix entry fails, and a skipped - # required-check is treated as missing by branch protection, - # which leaves the PR mergeable. Run unconditionally and assert - # on the result instead. + # required check does not block merging. Run unconditionally and + # assert on discovery as well: its failure skips the matrix too. if: always() steps: - - name: Assert all pytest matrix entries succeeded + - name: Assert member discovery and pytest matrix results # `success` — every selected member passed. # `skipped` — the path filter selected no members (the PR # changed no project's Python source/test files), so there - # was nothing to run; that is a pass, not a gap. + # was nothing to run; after successful discovery, that is a pass. # Anything else (`failure` / `cancelled`) fails the gate. run: | + members_result="${{ needs.members.result }}" + if [ "${members_result}" != "success" ]; then + echo "workspace member discovery result: ${members_result}" + exit 1 + fi result="${{ needs.pytest.result }}" if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then echo "pytest matrix result: ${result}" diff --git a/tools/dev/tests/test_ci_tests_gate.py b/tools/dev/tests/test_ci_tests_gate.py new file mode 100644 index 000000000..ecd8ea2e7 --- /dev/null +++ b/tools/dev/tests/test_ci_tests_gate.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Exercise the actual tests-ok shell gate, including discovery failures.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path +from textwrap import dedent + +import pytest + +_WORKFLOW = Path(__file__).resolve().parents[3] / ".github/workflows/tests.yml" +_RESULTS = ("success", "failure", "cancelled", "skipped") + + +@pytest.fixture +def gate_job() -> str: + # Read only this job's literal block; no YAML dependency is needed. + match = re.search(r"(?ms)^ tests-ok:\n(.*?)(?=^ \S|\Z)", _WORKFLOW.read_text(encoding="utf-8")) + assert match is not None, "tests-ok job is missing" + return match[1] + + +@pytest.fixture +def bash() -> str: + executable = shutil.which("bash") + if executable is None: + pytest.skip("the workflow gate needs Bash") + return executable + + +def test_gate_waits_for_both_jobs_and_runs_after_failure(gate_job: str) -> None: + needs = re.search(r"(?m)^ needs: (.+)$", gate_job) + assert needs is not None + assert {job.strip() for job in needs[1].strip("[]").split(",")} == {"members", "pytest"} + assert re.search(r"(?m)^ if: always\(\)$", gate_job) + + +@pytest.mark.parametrize("members_result", _RESULTS) +@pytest.mark.parametrize("pytest_result", _RESULTS) +def test_gate_results(gate_job: str, bash: str, members_result: str, pytest_result: str) -> None: + run = re.search(r"(?m)^ run: \|\n((?: .*\n|\n)+)", gate_job) + assert run is not None, "tests-ok shell assertion is missing" + script = dedent(run[1]) + for job, result in (("members", members_result), ("pytest", pytest_result)): + script = script.replace("${{ needs." + job + ".result }}", result) + assert "${{" not in script, "unresolved Actions expression in the gate" + completed = subprocess.run( + [bash, "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + passing = {("success", "success"), ("success", "skipped")} + assert (completed.returncode == 0) == ((members_result, pytest_result) in passing), ( + f"members={members_result}, pytest={pytest_result}, exit={completed.returncode}\n" + f"{completed.stdout}{completed.stderr}" + ) From 2c0beae49185760074282080f57ee77b0a513e1d Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:01:17 +0000 Subject: [PATCH 2/4] fix(ci): confirm empty selection before accepting skipped tests Require the discovery output to be [] before accepting a skipped matrix. Probe Bash before executing gate tests, check failure diagnostics, and accept equivalent literal dependency declarations without new dependencies. Align the workflow and branch-protection comments with the selection rules. Generated-by: Codex (GPT-6) --- .asf.yaml | 6 +- .github/workflows/tests.yml | 30 ++-- tools/dev/tests/test_ci_tests_gate.py | 220 +++++++++++++++++++++++--- 3 files changed, 221 insertions(+), 35 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index a1e7489b4..3b432d413 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -169,9 +169,9 @@ github: # protection and rulesets both require exact-match status # check names, so listing matrix entries here would force # an `.asf.yaml` change on every matrix add / rename / - # removal. `tests-ok` `needs:` every matrix entry and - # fails unless all succeed, so the gate semantics are the - # same. + # removal. `tests-ok` requires successful member discovery + # and a passing matrix (or a confirmed empty selection + # when the matrix is skipped). - "tests-ok" # TEMPORARILY DISABLED — see the note above. Restore by removing # the three leading `# ` markers; the values are the ones that were diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 42a07a9db..f07174dda 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,10 +60,12 @@ jobs: # - Applicability mirrors `tools/dev/run-workspace-check.sh`: a # member with `[tool.pytest.ini_options]` and no # `[tool.magpie.checks] skip = [..., "pytest"]`. - # - Path filter (PRs only): a member is picked when its own - # Python source/test files changed. On push to `main`, and when - # a shared build input changes (root `pyproject.toml`, - # `uv.lock`, the helpers, this workflow), every member is. + # - Path filter (PRs only): the helper selects members whose + # eligible files or linked sources changed, plus their + # dependents; `member_changed` documents the exclusions. + # On push to `main`, and when a shared build input changes + # (root `pyproject.toml`, `uv.lock`, the helpers, this workflow), + # every member is selected. # - Grouping: most of a job is setup, so up to six members get a # job each (named after the member, as before); more are split # into at most eight jobs balanced by test size. @@ -102,9 +104,8 @@ jobs: runs-on: ubuntu-slim needs: members # Guard against an empty selection. When the path filter selects no - # members (a PR that changed no project's Python), the batches output - # is `[]`; feeding an empty array to `matrix` makes GitHub mark this - # job `failure` ("matrix vector does not contain any values"), not + # members, the batches output is `[]`. An empty `matrix` makes GitHub + # mark this job `failure` ("matrix vector does not contain any values"), not # `skipped` — which would fail `tests-ok`. Skipping the job on an # empty list yields the `skipped` result `tests-ok` treats as a pass. if: ${{ needs.members.outputs.batches != '[]' }} @@ -205,8 +206,8 @@ jobs: # must be kept in lock-step with the matrix. This single # `tests-ok` job is the only pytest-related context required by # branch protection — it requires successful member discovery and - # a passing matrix (or an empty selection). Adding, renaming, or - # removing matrix entries no longer touches `.asf.yaml`. + # a passing matrix (or a confirmed empty selection). Matrix changes + # no longer require `.asf.yaml` updates. tests-ok: name: tests-ok runs-on: ubuntu-slim @@ -218,10 +219,11 @@ jobs: if: always() steps: - name: Assert member discovery and pytest matrix results + env: + BATCHES: ${{ needs.members.outputs.batches }} # `success` — every selected member passed. - # `skipped` — the path filter selected no members (the PR - # changed no project's Python source/test files), so there - # was nothing to run; after successful discovery, that is a pass. + # `skipped` — accepted only after successful discovery with an + # explicitly empty batches output, confirming nothing was selected. # Anything else (`failure` / `cancelled`) fails the gate. run: | members_result="${{ needs.members.result }}" @@ -230,6 +232,10 @@ jobs: exit 1 fi result="${{ needs.pytest.result }}" + if [ "${result}" = "skipped" ] && [ "${BATCHES:-}" != "[]" ]; then + echo "pytest matrix skipped without an empty selection" + exit 1 + fi if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then echo "pytest matrix result: ${result}" exit 1 diff --git a/tools/dev/tests/test_ci_tests_gate.py b/tools/dev/tests/test_ci_tests_gate.py index ecd8ea2e7..0d22b1970 100644 --- a/tools/dev/tests/test_ci_tests_gate.py +++ b/tools/dev/tests/test_ci_tests_gate.py @@ -16,11 +16,18 @@ # specific language governing permissions and limitations # under the License. -"""Exercise the actual tests-ok shell gate, including discovery failures.""" +"""Exercise the actual tests-ok shell gate, including discovery failures. + +The field reader supports literal needs lists (inline or block), scalar +conditions, quotes, and comments. It is not a general YAML parser: aliases, +tags, and folded scalars are outside this workflow test's supported syntax. +""" from __future__ import annotations +import os import re +import shlex import shutil import subprocess from pathlib import Path @@ -30,6 +37,69 @@ _WORKFLOW = Path(__file__).resolve().parents[3] / ".github/workflows/tests.yml" _RESULTS = ("success", "failure", "cancelled", "skipped") +_BASH_FLAGS = ("--noprofile", "--norc", "-e", "-o", "pipefail") + + +def _field(job: str, name: str, indent: int = 4) -> str: + lines = job.splitlines() + for index, line in enumerate(lines): + match = re.fullmatch(rf" {{{indent}}}{re.escape(name)}:[ \t]*(.*)", line) + if match is None: + continue + value = [match[1]] + for following in lines[index + 1 :]: + stripped = following.lstrip() + if not stripped or stripped.startswith("#"): + continue + depth = len(following) - len(stripped) + if depth < indent or (depth == indent and not stripped.startswith("- ")): + break + value.append(stripped) + return "\n".join(value) + raise AssertionError(f"tests-ok is missing {name}") + + +def _tokens(value: str, sequence: bool = False) -> list[str]: + lexer = shlex.shlex(value, posix=True) + lexer.whitespace_split = True + if sequence: + lexer.whitespace += ",[]" + return list(lexer) + + +def _expression(value: str, require_wrapper: bool = False) -> str: + value = " ".join(_tokens(value)).strip() + wrapped = value.startswith("${{") and value.endswith("}}") + if require_wrapper: + assert wrapped, "the environment value must evaluate an Actions expression" + if wrapped: + value = value[3:-2].strip() + return value + + +def _assert_dependencies(job: str) -> None: + needs = [token for token in _tokens(_field(job, "needs"), sequence=True) if token != "-"] + assert set(needs) == {"members", "pytest"} + assert _expression(_field(job, "if")) == "always()" + + +def _working_bash() -> str: + executable = shutil.which("bash") + if executable is None: + pytest.skip("the workflow gate needs a working Bash (not found)") + try: + probe = subprocess.run( + [executable, *_BASH_FLAGS, "-c", "exit 0"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + pytest.skip(f"the workflow gate needs a working Bash ({type(error).__name__})") + if probe.returncode != 0: + pytest.skip(f"the workflow gate needs a working Bash (probe exit {probe.returncode})") + return executable @pytest.fixture @@ -40,39 +110,149 @@ def gate_job() -> str: return match[1] -@pytest.fixture +@pytest.fixture(scope="module") def bash() -> str: - executable = shutil.which("bash") - if executable is None: - pytest.skip("the workflow gate needs Bash") - return executable + return _working_bash() def test_gate_waits_for_both_jobs_and_runs_after_failure(gate_job: str) -> None: - needs = re.search(r"(?m)^ needs: (.+)$", gate_job) - assert needs is not None - assert {job.strip() for job in needs[1].strip("[]").split(",")} == {"members", "pytest"} - assert re.search(r"(?m)^ if: always\(\)$", gate_job) + _assert_dependencies(gate_job) -@pytest.mark.parametrize("members_result", _RESULTS) -@pytest.mark.parametrize("pytest_result", _RESULTS) -def test_gate_results(gate_job: str, bash: str, members_result: str, pytest_result: str) -> None: +def test_gate_reads_batches_from_discovery(gate_job: str) -> None: + value = _field(gate_job, "BATCHES", indent=10) + assert _expression(value, require_wrapper=True) == "needs.members.outputs.batches" + + +@pytest.mark.parametrize( + "value", ["[]", "needs.members.outputs.batches", "${{ needs.pytest.outputs.batches }}"] +) +def test_incorrect_batches_bindings_are_rejected(value: str) -> None: + with pytest.raises(AssertionError): + assert _expression(value, require_wrapper=True) == "needs.members.outputs.batches" + + +def _run_gate( + gate_job: str, bash: str, members_result: str, pytest_result: str, batches: str | None = "[]" +) -> subprocess.CompletedProcess[str]: run = re.search(r"(?m)^ run: \|\n((?: .*\n|\n)+)", gate_job) assert run is not None, "tests-ok shell assertion is missing" script = dedent(run[1]) for job, result in (("members", members_result), ("pytest", pytest_result)): script = script.replace("${{ needs." + job + ".result }}", result) assert "${{" not in script, "unresolved Actions expression in the gate" - completed = subprocess.run( - [bash, "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + env = os.environ.copy() + env.pop("BATCHES", None) + if batches is not None: + env["BATCHES"] = batches + return subprocess.run( + [bash, *_BASH_FLAGS, "-c", script], + env=env, capture_output=True, text=True, timeout=20, check=False, ) - passing = {("success", "success"), ("success", "skipped")} - assert (completed.returncode == 0) == ((members_result, pytest_result) in passing), ( - f"members={members_result}, pytest={pytest_result}, exit={completed.returncode}\n" - f"{completed.stdout}{completed.stderr}" - ) + + +def _assert_result(completed: subprocess.CompletedProcess[str], code: int, diagnostic: str) -> None: + details = f"exit={completed.returncode}\n{completed.stdout}{completed.stderr}" + assert completed.returncode == code, details + assert diagnostic in completed.stdout, details + + +@pytest.mark.parametrize("members_result", _RESULTS) +@pytest.mark.parametrize("pytest_result", _RESULTS) +def test_gate_results(gate_job: str, bash: str, members_result: str, pytest_result: str) -> None: + completed = _run_gate(gate_job, bash, members_result, pytest_result) + if members_result != "success": + _assert_result(completed, 1, f"workspace member discovery result: {members_result}") + elif pytest_result not in ("success", "skipped"): + _assert_result(completed, 1, f"pytest matrix result: {pytest_result}") + else: + _assert_result(completed, 0, f"pytest gate satisfied (matrix result: {pytest_result})") + + +@pytest.mark.parametrize( + "batches", + [None, "", "[ ]", '[{"name":"dev"}]', "not-json", "$(exit 23)", "`exit 24`"], + ids=["missing", "empty", "noncanonical-empty", "nonempty", "malformed", "substitution", "backticks"], +) +def test_skipped_matrix_needs_a_confirmed_empty_selection( + gate_job: str, bash: str, batches: str | None +) -> None: + completed = _run_gate(gate_job, bash, "success", "skipped", batches) + _assert_result(completed, 1, "pytest matrix skipped without an empty selection") + + +@pytest.mark.parametrize("members_result", ["failure", "cancelled", "skipped"]) +def test_discovery_failure_is_reported_before_invalid_skip( + gate_job: str, bash: str, members_result: str +) -> None: + completed = _run_gate(gate_job, bash, members_result, "skipped", '[{"name":"dev"}]') + _assert_result(completed, 1, f"workspace member discovery result: {members_result}") + + +def test_successful_matrix_accepts_a_nonempty_selection(gate_job: str, bash: str) -> None: + completed = _run_gate(gate_job, bash, "success", "success", '[{"name":"dev"}]') + _assert_result(completed, 0, "pytest gate satisfied (matrix result: success)") + + +@pytest.mark.parametrize( + ("needs", "condition"), + [ + ("[members, pytest]", "always()"), + ("[members, pytest] # comment", "always()"), + ("['members', 'pytest']", "always()"), + ('["members", "pytest"]', "always()"), + ("\n - members\n - pytest", "always()"), + ("\n - 'members'\n - 'pytest' # comment", "${{ always() }}"), + ("[members, pytest]", "${{ always() }}"), + ("[members, pytest]", "'${{ always() }}' # comment"), + ], +) +def test_equivalent_dependency_declarations(needs: str, condition: str) -> None: + _assert_dependencies(f" needs: {needs}\n if: {condition}\n") + + +@pytest.mark.parametrize( + ("needs", "condition"), + [ + ("[pytest]", "always()"), + ("[members]", "always()"), + ("[members, pytest]", "success()"), + ("[members, pytest]", "al ways()"), + ("[members, pytest, other]", "always()"), + ], +) +def test_incorrect_dependency_declarations_are_rejected(needs: str, condition: str) -> None: + with pytest.raises(AssertionError): + _assert_dependencies(f" needs: {needs}\n if: {condition}\n") + + +@pytest.mark.parametrize("mode", ["missing", "launch-error", "timeout", "nonzero"]) +def test_unusable_bash_is_skipped(monkeypatch: pytest.MonkeyPatch, mode: str) -> None: + monkeypatch.setattr(shutil, "which", lambda name: None if mode == "missing" else "/probe/bash") + + def probe(*args, **kwargs): + if mode == "launch-error": + raise OSError("cannot launch Bash") + if mode == "timeout": + raise subprocess.TimeoutExpired("bash", 5) + return subprocess.CompletedProcess(args[0], 1, "", "WSL launcher unavailable") + + monkeypatch.setattr(subprocess, "run", probe) + with pytest.raises(pytest.skip.Exception, match="needs a working Bash"): + _working_bash() + + +def test_bash_probe_uses_the_gate_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "which", lambda name: "/probe/bash") + + def probe(args, **kwargs): + assert args == ["/probe/bash", *_BASH_FLAGS, "-c", "exit 0"] + assert kwargs["timeout"] == 5 + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(subprocess, "run", probe) + assert _working_bash() == "/probe/bash" From 3987c7ad857e4b161a28e28de6017e16235f7d24 Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:51:52 +0000 Subject: [PATCH 3/4] test(ci): check gate environment when probing Bash A Bash launcher can start without forwarding the environment needed by the tests-ok gate. Probe the real BATCHES input path and reject a shell that drops it, while keeping unavailable-launcher skips explicit. Replace implementation-detail assertions with a real-shell environment loss regression and remove synthetic bindings already checked against the workflow. Gate logic and workflow configuration are unchanged. Generated-by: Codex (GPT-6) --- tools/dev/tests/test_ci_tests_gate.py | 29 +++++++++++---------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/tools/dev/tests/test_ci_tests_gate.py b/tools/dev/tests/test_ci_tests_gate.py index 0d22b1970..c4e5ecb7e 100644 --- a/tools/dev/tests/test_ci_tests_gate.py +++ b/tools/dev/tests/test_ci_tests_gate.py @@ -89,7 +89,8 @@ def _working_bash() -> str: pytest.skip("the workflow gate needs a working Bash (not found)") try: probe = subprocess.run( - [executable, *_BASH_FLAGS, "-c", "exit 0"], + [executable, *_BASH_FLAGS, "-c", '[ "${BATCHES:-}" = "[]" ]'], + env={**os.environ, "BATCHES": "[]"}, capture_output=True, text=True, timeout=5, @@ -124,14 +125,6 @@ def test_gate_reads_batches_from_discovery(gate_job: str) -> None: assert _expression(value, require_wrapper=True) == "needs.members.outputs.batches" -@pytest.mark.parametrize( - "value", ["[]", "needs.members.outputs.batches", "${{ needs.pytest.outputs.batches }}"] -) -def test_incorrect_batches_bindings_are_rejected(value: str) -> None: - with pytest.raises(AssertionError): - assert _expression(value, require_wrapper=True) == "needs.members.outputs.batches" - - def _run_gate( gate_job: str, bash: str, members_result: str, pytest_result: str, batches: str | None = "[]" ) -> subprocess.CompletedProcess[str]: @@ -246,13 +239,15 @@ def probe(*args, **kwargs): _working_bash() -def test_bash_probe_uses_the_gate_arguments(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(shutil, "which", lambda name: "/probe/bash") +def test_bash_probe_skips_shell_without_gate_environment(bash: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "which", lambda name: bash) + run = subprocess.run - def probe(args, **kwargs): - assert args == ["/probe/bash", *_BASH_FLAGS, "-c", "exit 0"] - assert kwargs["timeout"] == 5 - return subprocess.CompletedProcess(args, 0, "", "") + def without_batches(*args, **kwargs): + env = kwargs.get("env", os.environ).copy() + env.pop("BATCHES", None) + return run(*args, **{**kwargs, "env": env}) - monkeypatch.setattr(subprocess, "run", probe) - assert _working_bash() == "/probe/bash" + monkeypatch.setattr(subprocess, "run", without_batches) + with pytest.raises(pytest.skip.Exception, match="needs a working Bash"): + _working_bash() From a301d0df4b4b2d524285a87ef4e83bc55c14f0be Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:16:19 +0000 Subject: [PATCH 4/4] test(ci): guard the successful Bash probe path Keep return-code inspection in the try-else branch, where the probe is known to have completed. This makes assignment explicit for CodeQL without relying on its model of pytest.skip raising an exception. Launcher failures still skip the gate tests with the same diagnostics. The workflow gate, modelled outcomes and environment contract are unchanged. Generated-by: Codex (GPT-6) --- tools/dev/tests/test_ci_tests_gate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/dev/tests/test_ci_tests_gate.py b/tools/dev/tests/test_ci_tests_gate.py index c4e5ecb7e..2860b437f 100644 --- a/tools/dev/tests/test_ci_tests_gate.py +++ b/tools/dev/tests/test_ci_tests_gate.py @@ -98,8 +98,9 @@ def _working_bash() -> str: ) except (OSError, subprocess.TimeoutExpired) as error: pytest.skip(f"the workflow gate needs a working Bash ({type(error).__name__})") - if probe.returncode != 0: - pytest.skip(f"the workflow gate needs a working Bash (probe exit {probe.returncode})") + else: + if probe.returncode != 0: + pytest.skip(f"the workflow gate needs a working Bash (probe exit {probe.returncode})") return executable