From 65e3c9b7cb69be11f2a05d3e07e89fb886253383 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:06:01 +0800 Subject: [PATCH] release canaries: run each command under the step's bash, by path The first release run of the canaries (release run 36363585412) failed every GalTranslPP command with "Windows Subsystem for Linux has no installed distributions": release_canaries.py started `bash` by name, and a Windows program that does so gets System32's WSL launcher, which the loader finds before PATH. The workflow now names the bash running the step (CANARY_BASH, a Windows path from cygpath on Windows) and the runner uses it. The runner's tests (tests/scripts/test_release_canaries.py) run in the Linux static checks. --- .github/tools/release_canaries.py | 14 +++- .github/workflows/ci-linux.yml | 3 + .github/workflows/release-canaries.yml | 6 ++ tests/scripts/test_release_canaries.py | 102 +++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/test_release_canaries.py diff --git a/.github/tools/release_canaries.py b/.github/tools/release_canaries.py index a7275819..071acf27 100644 --- a/.github/tools/release_canaries.py +++ b/.github/tools/release_canaries.py @@ -10,6 +10,12 @@ `set -eo pipefail`, so a failure is the command's own; a command listed under `expect` must also print the given text. The whole list runs, and the exit status says whether every command held. + +The bash is `$CANARY_BASH` when the workflow names one. On Windows it must: a +Windows program that starts `bash` by name gets `System32\bash.exe`, the WSL +launcher, because the loader searches the system directory before PATH (the +first release run of these canaries printed "Windows Subsystem for Linux has +no installed distributions" for every command). """ from __future__ import annotations @@ -67,6 +73,12 @@ def cmd_unpin(checkout: str) -> int: return 0 +def shell_argv(command: str) -> list[str]: + """The argv that runs one canary command: the named bash, else `bash`.""" + shell = os.environ.get("CANARY_BASH") or "bash" + return [shell, "-c", f"set -eo pipefail\n{command}"] + + def cmd_run(name: str) -> int: mcpp = os.environ.get("MCPP", "") if not mcpp: @@ -79,7 +91,7 @@ def cmd_run(name: str) -> int: failed = [] for command in c["commands"]: print(f"::group::{command}", flush=True) - proc = subprocess.run(["bash", "-c", f"set -eo pipefail\n{command}"], + proc = subprocess.run(shell_argv(command), capture_output=True, text=True, check=False, env={**os.environ, "MCPP": mcpp}) sys.stdout.write(proc.stdout) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index d1338b5a..a37baafb 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -107,6 +107,9 @@ jobs: python3 tests/scripts/test_check_workflow_assertions.py python3 .github/tools/check_workflow_assertions.py --check-open + - name: The release canary runner runs each command under the named bash + run: python3 tests/scripts/test_release_canaries.py + # Text-only, like the two steps around it, and it belongs here rather # than in the target-matrix workflow: that workflow runs the matrix, and # this asserts a property of the TABLE, which is readable without a diff --git a/.github/workflows/release-canaries.yml b/.github/workflows/release-canaries.yml index 867a28db..922fe899 100644 --- a/.github/workflows/release-canaries.yml +++ b/.github/workflows/release-canaries.yml @@ -81,6 +81,12 @@ jobs: run: | export MCPP="$CANDIDATE" export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # The bash running this step, by path (release_canaries.py says why). + if [ "$RUNNER_OS" = Windows ]; then + export CANARY_BASH="$(cygpath -w "$BASH")" + else + export CANARY_BASH="$BASH" + fi "$MCPP" self config --mirror GLOBAL tool="$GITHUB_WORKSPACE/.github/tools/release_canaries.py" py=python3; command -v python3 >/dev/null 2>&1 || py=python diff --git a/tests/scripts/test_release_canaries.py b/tests/scripts/test_release_canaries.py new file mode 100644 index 00000000..25e7685b --- /dev/null +++ b/tests/scripts/test_release_canaries.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Tests for .github/tools/release_canaries.py (WS10 of the 2026-09-28 design). + +The canary runner runs each command under a named bash (`CANARY_BASH`): on +Windows, `bash` by name is System32's WSL launcher, and the first release run +of the canaries failed every command that way. The runner also reports every +command, enforces `expect`, and removes only the project's own mcpp pin. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".github" / "tools" / "release_canaries.py" + +spec = importlib.util.spec_from_file_location("release_canaries", SCRIPT) +rc = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rc) + + +class ShellArgv(unittest.TestCase): + def test_the_named_bash_runs_the_command(self) -> None: + os.environ["CANARY_BASH"] = "/opt/git/usr/bin/bash.exe" + try: + argv = rc.shell_argv("echo hi") + finally: + del os.environ["CANARY_BASH"] + self.assertEqual(argv[0], "/opt/git/usr/bin/bash.exe") + self.assertEqual(argv[1], "-c") + self.assertTrue(argv[2].startswith("set -eo pipefail\n")) + self.assertTrue(argv[2].endswith("echo hi")) + + def test_without_a_name_the_runner_uses_bash(self) -> None: + os.environ.pop("CANARY_BASH", None) + self.assertEqual(rc.shell_argv("true")[0], "bash") + + +@unittest.skipIf(shutil.which("bash") is None, "no bash on this host") +class Run(unittest.TestCase): + def run_list(self, toml: str) -> int: + with tempfile.TemporaryDirectory() as d: + listing = Path(d) / "canaries.toml" + listing.write_text(toml, encoding="utf-8") + saved = rc.LIST + rc.LIST = listing + os.environ["MCPP"] = "/bin/true" + try: + return rc.cmd_run("probe") + finally: + rc.LIST = saved + del os.environ["MCPP"] + + def test_every_command_held(self) -> None: + self.assertEqual(self.run_list( + '[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n' + 'commands = ["true", "echo canary-ok"]\n' + 'expect = { "echo canary-ok" = "canary-ok" }\n'), 0) + + def test_a_failing_command_and_a_missing_expectation_fail(self) -> None: + self.assertEqual(self.run_list( + '[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n' + 'commands = ["false"]\n'), 1) + self.assertEqual(self.run_list( + '[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n' + 'commands = ["echo other"]\nexpect = { "echo other" = "canary-ok" }\n'), 1) + + def test_a_pipeline_fails_on_its_first_command(self) -> None: + self.assertEqual(self.run_list( + '[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n' + 'commands = ["false | cat"]\n'), 1) + + +class Unpin(unittest.TestCase): + def test_only_the_mcpp_pin_is_removed(self) -> None: + with tempfile.TemporaryDirectory() as d: + path = Path(d) / ".xlings.json" + path.write_text(json.dumps({"workspace": {"mcpp": "2026.9.28.1", "cmake": "4.0"}, + "mirror": "GLOBAL"}), encoding="utf-8") + self.assertEqual(rc.cmd_unpin(d), 0) + data = json.loads(path.read_text(encoding="utf-8")) + self.assertNotIn("mcpp", data["workspace"]) + self.assertEqual(data["workspace"]["cmake"], "4.0") + self.assertEqual(data["mirror"], "GLOBAL") + + +class Matrix(unittest.TestCase): + def test_the_repository_list_is_well_formed(self) -> None: + names = [c["name"] for c in rc.canaries()] + self.assertEqual(len(names), len(set(names))) + self.assertIn("GalTranslPP", names) + + +if __name__ == "__main__": + unittest.main(verbosity=2)