from __future__ import annotations
import os
import pathlib
import re
import shutil
import subprocess
import sys
import yaml
BASH32_IMAGES = ("bash:3.2", "public.ecr.aws/docker/library/bash:3.2")
VERSION_PROBE = 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'
RUNTIME_ONLY: tuple[tuple[str, str], ...] = (
(r"\b(declare|local|typeset|readonly)\s+-[A-Za-z]*[Agn]\b", "declare -A/-g/-n (bash 4)"),
(r"\b(mapfile|readarray|coproc)\b", "mapfile/readarray/coproc (bash 4)"),
(r"\$\{[A-Za-z0-9_#@*][A-Za-z0-9_\[\]@*]*(,,|\^\^|,|\^)", "case modification ${v,,} (bash 4)"),
(r"\$\{[^{}]*@[QEPAaKkUuL]\}", "parameter transformation ${v@Q} (bash 4.4)"),
(r"\bshopt\s+-s\s+(globstar|lastpipe|dirspell|autocd)\b", "bash 4 shopt option"),
(r"\bwait\s+-n\b", "wait -n (bash 4.3)"),
(r"\bread\s+-[A-Za-z]*N\b", "read -N (bash 4.1)"),
(r"\bexec\s+\{[A-Za-z_]", "exec {fd}> named descriptor (bash 4.1)"),
)
WAIVER = "bash32-ok"
EXPRESSION = re.compile(r"\$\{\{.*?\}\}", re.DOTALL)
OS_ONLY = re.compile(r"^\s*(?:\$\{\{)?\s*runner\.os\s*(==|!=)\s*'(\w+)'\s*(?:\}\})?\s*$")
def runs_on_macos(job: dict) -> bool:
runs_on = job.get("runs-on", "")
if isinstance(runs_on, list):
runs_on = " ".join(str(entry) for entry in runs_on)
runs_on = str(runs_on)
if "macos" in runs_on.lower():
return True
return "${{" in runs_on and "macos" in yaml.safe_dump(job.get("strategy", {})).lower()
def reaches_macos(step: dict) -> bool:
gate = OS_ONLY.match(str(step.get("if", "")))
if not gate:
return True
operator, name = gate.groups()
return (name == "macOS") if operator == "==" else (name != "macOS")
def is_bash(shell: str | None) -> bool:
words = [word for word in str(shell or "bash").split() if not word.startswith(("-", "{"))]
return bool(words) and os.path.basename(words[-1] if words[-1] != "env" else words[0]) == "bash"
def collect(where: str, steps: list[dict], default_shell: str | None,
root: pathlib.Path, seen: set[pathlib.Path]) -> list[tuple[str, str]]:
blocks: list[tuple[str, str]] = []
for index, step in enumerate(steps):
if not reaches_macos(step):
continue
label = f"{where}: {step.get('name', f'step {index}')}"
inputs = step.get("with") or {}
if "run" in step and is_bash(step.get("shell", default_shell)):
blocks.append((label, str(step["run"])))
if "command" in inputs and is_bash(inputs.get("shell")):
blocks.append((f"{label} (command input)", str(inputs["command"])))
uses = str(step.get("uses", ""))
if uses.startswith("./"):
action = root / uses[2:]
for name in ("action.yml", "action.yaml"):
if (action / name).exists():
action = action / name
break
if action.is_file() and action not in seen:
seen.add(action)
spec = yaml.safe_load(action.read_text(encoding="utf-8")) or {}
blocks += collect(str(action.relative_to(root)),
(spec.get("runs") or {}).get("steps") or [], None, root, seen)
return blocks
def macos_bash_blocks(root: pathlib.Path) -> list[tuple[str, str]]:
blocks: list[tuple[str, str]] = []
for workflow in sorted((root / ".github" / "workflows").glob("*.y*ml")):
spec = yaml.safe_load(workflow.read_text(encoding="utf-8")) or {}
default = ((spec.get("defaults") or {}).get("run") or {}).get("shell")
for name, job in (spec.get("jobs") or {}).items():
if not runs_on_macos(job):
continue
shell = ((job.get("defaults") or {}).get("run") or {}).get("shell") or default
blocks += collect(f"{workflow.name} :: {name}", job.get("steps") or [],
shell, root, set())
return blocks
def bash32_command() -> list[str] | None:
def probes_32(command: list[str]) -> bool:
result = subprocess.run(command + ["-c", VERSION_PROBE], capture_output=True, text=True)
return result.stdout.strip() == "3.2"
override = os.environ.get("MACOS_BASH")
if override:
return [override] if probes_32([override]) else None
if not shutil.which("docker"):
return None
for image in BASH32_IMAGES:
for probe in (["image", "inspect"], ["pull", "--quiet"]):
if subprocess.run(["docker", *probe, image], capture_output=True).returncode == 0:
command = ["docker", "run", "--rm", "-i", image, "bash"]
if probes_32(command):
return command
break
return None
def parse_check(bash: list[str], blocks: list[tuple[str, str]]) -> list[str]:
failures = []
for label, source in blocks:
result = subprocess.run(bash + ["-n", "-s"], input=source, capture_output=True, text=True)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().replace("\n", "\n ")
failures.append(f"{label}\n {detail}")
return failures
def runtime_check(blocks: list[tuple[str, str]]) -> list[str]:
failures = []
for label, source in blocks:
for number, line in enumerate(source.splitlines(), start=1):
if line.lstrip().startswith("#") or WAIVER in line:
continue
for pattern, why in RUNTIME_ONLY:
if re.search(pattern, line):
failures.append(f"{label} (line {number}): {why}\n {line.strip()}")
break
return failures
def main() -> int:
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
blocks = [(label, EXPRESSION.sub("GHA_EXPRESSION", source))
for label, source in macos_bash_blocks(root)]
if not blocks:
print("::error::No macOS bash steps found — this check has stopped checking anything.")
return 1
bash = bash32_command()
if bash is None:
print("::error::No bash 3.2 available. Install docker (for "
f"`{'` or `'.join(BASH32_IMAGES)}`), or point $MACOS_BASH at a bash 3.2 binary.")
return 1
failures = parse_check(bash, blocks) + runtime_check(blocks)
if failures:
print(f"Shell that bash 3.2 cannot run, in {len(failures)} macOS step(s):\n")
for failure in failures:
print(f" {failure}\n")
print("Rewrite the construct — a `case` inside `$(...)` needs its pattern "
f"written as `(pattern)`, or no `case` at all. A false positive takes a "
f"`{WAIVER}` comment on the line.")
return 1
print(f"macOS bash 3.2 check: clean ({len(blocks)} step(s) under {' '.join(bash)})")
return 0
if __name__ == "__main__":
raise SystemExit(main())