Skip to content

Commit e251ec3

Browse files
authored
release canaries: run each command under the step's bash, by path (#731)
The first release run of the canaries (36363585412) failed every GalTranslPP command before building: 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 gate held; no tag was created. The workflow names the step's bash (CANARY_BASH, cygpath -w on Windows) and the runner uses it; tests/scripts/test_release_canaries.py covers the runner and runs in the Linux static checks. Dispatched on this branch, the canaries built xlings and mcppls with the candidate, and the GalTranslPP build ran instead of failing at its first command.
1 parent f2df8db commit e251ec3

4 files changed

Lines changed: 124 additions & 1 deletion

File tree

‎.github/tools/release_canaries.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
`set -eo pipefail`, so a failure is the command's own; a command listed under
1111
`expect` must also print the given text. The whole list runs, and the exit
1212
status says whether every command held.
13+
14+
The bash is `$CANARY_BASH` when the workflow names one. On Windows it must: a
15+
Windows program that starts `bash` by name gets `System32\bash.exe`, the WSL
16+
launcher, because the loader searches the system directory before PATH (the
17+
first release run of these canaries printed "Windows Subsystem for Linux has
18+
no installed distributions" for every command).
1319
"""
1420
from __future__ import annotations
1521

@@ -67,6 +73,12 @@ def cmd_unpin(checkout: str) -> int:
6773
return 0
6874

6975

76+
def shell_argv(command: str) -> list[str]:
77+
"""The argv that runs one canary command: the named bash, else `bash`."""
78+
shell = os.environ.get("CANARY_BASH") or "bash"
79+
return [shell, "-c", f"set -eo pipefail\n{command}"]
80+
81+
7082
def cmd_run(name: str) -> int:
7183
mcpp = os.environ.get("MCPP", "")
7284
if not mcpp:
@@ -79,7 +91,7 @@ def cmd_run(name: str) -> int:
7991
failed = []
8092
for command in c["commands"]:
8193
print(f"::group::{command}", flush=True)
82-
proc = subprocess.run(["bash", "-c", f"set -eo pipefail\n{command}"],
94+
proc = subprocess.run(shell_argv(command),
8395
capture_output=True, text=True, check=False,
8496
env={**os.environ, "MCPP": mcpp})
8597
sys.stdout.write(proc.stdout)

‎.github/workflows/ci-linux.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ jobs:
107107
python3 tests/scripts/test_check_workflow_assertions.py
108108
python3 .github/tools/check_workflow_assertions.py --check-open
109109
110+
- name: The release canary runner runs each command under the named bash
111+
run: python3 tests/scripts/test_release_canaries.py
112+
110113
# Text-only, like the two steps around it, and it belongs here rather
111114
# than in the target-matrix workflow: that workflow runs the matrix, and
112115
# this asserts a property of the TABLE, which is readable without a

‎.github/workflows/release-canaries.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ jobs:
8181
run: |
8282
export MCPP="$CANDIDATE"
8383
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
84+
# The bash running this step, by path (release_canaries.py says why).
85+
if [ "$RUNNER_OS" = Windows ]; then
86+
export CANARY_BASH="$(cygpath -w "$BASH")"
87+
else
88+
export CANARY_BASH="$BASH"
89+
fi
8490
"$MCPP" self config --mirror GLOBAL
8591
tool="$GITHUB_WORKSPACE/.github/tools/release_canaries.py"
8692
py=python3; command -v python3 >/dev/null 2>&1 || py=python
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
"""Tests for .github/tools/release_canaries.py (WS10 of the 2026-09-28 design).
3+
4+
The canary runner runs each command under a named bash (`CANARY_BASH`): on
5+
Windows, `bash` by name is System32's WSL launcher, and the first release run
6+
of the canaries failed every command that way. The runner also reports every
7+
command, enforces `expect`, and removes only the project's own mcpp pin.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import importlib.util
13+
import json
14+
import os
15+
import shutil
16+
import sys
17+
import tempfile
18+
import unittest
19+
from pathlib import Path
20+
21+
REPO_ROOT = Path(__file__).resolve().parents[2]
22+
SCRIPT = REPO_ROOT / ".github" / "tools" / "release_canaries.py"
23+
24+
spec = importlib.util.spec_from_file_location("release_canaries", SCRIPT)
25+
rc = importlib.util.module_from_spec(spec)
26+
spec.loader.exec_module(rc)
27+
28+
29+
class ShellArgv(unittest.TestCase):
30+
def test_the_named_bash_runs_the_command(self) -> None:
31+
os.environ["CANARY_BASH"] = "/opt/git/usr/bin/bash.exe"
32+
try:
33+
argv = rc.shell_argv("echo hi")
34+
finally:
35+
del os.environ["CANARY_BASH"]
36+
self.assertEqual(argv[0], "/opt/git/usr/bin/bash.exe")
37+
self.assertEqual(argv[1], "-c")
38+
self.assertTrue(argv[2].startswith("set -eo pipefail\n"))
39+
self.assertTrue(argv[2].endswith("echo hi"))
40+
41+
def test_without_a_name_the_runner_uses_bash(self) -> None:
42+
os.environ.pop("CANARY_BASH", None)
43+
self.assertEqual(rc.shell_argv("true")[0], "bash")
44+
45+
46+
@unittest.skipIf(shutil.which("bash") is None, "no bash on this host")
47+
class Run(unittest.TestCase):
48+
def run_list(self, toml: str) -> int:
49+
with tempfile.TemporaryDirectory() as d:
50+
listing = Path(d) / "canaries.toml"
51+
listing.write_text(toml, encoding="utf-8")
52+
saved = rc.LIST
53+
rc.LIST = listing
54+
os.environ["MCPP"] = "/bin/true"
55+
try:
56+
return rc.cmd_run("probe")
57+
finally:
58+
rc.LIST = saved
59+
del os.environ["MCPP"]
60+
61+
def test_every_command_held(self) -> None:
62+
self.assertEqual(self.run_list(
63+
'[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n'
64+
'commands = ["true", "echo canary-ok"]\n'
65+
'expect = { "echo canary-ok" = "canary-ok" }\n'), 0)
66+
67+
def test_a_failing_command_and_a_missing_expectation_fail(self) -> None:
68+
self.assertEqual(self.run_list(
69+
'[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n'
70+
'commands = ["false"]\n'), 1)
71+
self.assertEqual(self.run_list(
72+
'[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n'
73+
'commands = ["echo other"]\nexpect = { "echo other" = "canary-ok" }\n'), 1)
74+
75+
def test_a_pipeline_fails_on_its_first_command(self) -> None:
76+
self.assertEqual(self.run_list(
77+
'[[canary]]\nname = "probe"\nrepo = "a/b"\nref = "main"\nos = "x"\n'
78+
'commands = ["false | cat"]\n'), 1)
79+
80+
81+
class Unpin(unittest.TestCase):
82+
def test_only_the_mcpp_pin_is_removed(self) -> None:
83+
with tempfile.TemporaryDirectory() as d:
84+
path = Path(d) / ".xlings.json"
85+
path.write_text(json.dumps({"workspace": {"mcpp": "2026.9.28.1", "cmake": "4.0"},
86+
"mirror": "GLOBAL"}), encoding="utf-8")
87+
self.assertEqual(rc.cmd_unpin(d), 0)
88+
data = json.loads(path.read_text(encoding="utf-8"))
89+
self.assertNotIn("mcpp", data["workspace"])
90+
self.assertEqual(data["workspace"]["cmake"], "4.0")
91+
self.assertEqual(data["mirror"], "GLOBAL")
92+
93+
94+
class Matrix(unittest.TestCase):
95+
def test_the_repository_list_is_well_formed(self) -> None:
96+
names = [c["name"] for c in rc.canaries()]
97+
self.assertEqual(len(names), len(set(names)))
98+
self.assertIn("GalTranslPP", names)
99+
100+
101+
if __name__ == "__main__":
102+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)