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)