Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/tap-syntax-skip-allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Matrix legs of .github/workflows/tests.yml that skip `brew test-bot --only-tap-syntax`.
# Reviewed list: one leg per line, then the reason. scripts/tap_syntax_legs.py reads it
# and fails the run if an entry has no reason, names a leg not in the matrix, or if
# every leg would skip. A leg not listed here runs tap-syntax.
#
# What tap-syntax runs: `brew style <tap>` (RuboCop, shellcheck, actionlint) and
# `brew readall --os=all --arch=all <tap>` do not depend on the host. The full-tap
# `brew audit` evaluates the formula on the host OS; on a macOS leg that host-OS audit
# still runs on every PR that changes a formula, as `brew audit --formula --online --git`
# plus `brew style --formula` inside the --only-formulae step. ubuntu-latest keeps the
# full tap-syntax step on every run (hq#58, SM-HB-3).
macos-15-intel Host-independent checks already run on ubuntu-latest; this leg's host-OS formula audit runs in --only-formulae on formula PRs. Was 314 s p50 of the 717 s critical-path leg.
macos-latest Host-independent checks already run on ubuntu-latest; this leg's host-OS formula audit runs in --only-formulae on formula PRs. Was 27 s p50.
11 changes: 11 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ jobs:
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@848c272c7bfe984a197bd4d16383f4c106ec61e6 # 2026.09.21.1

- name: Decide whether this leg runs tap-syntax
id: tap-syntax-leg
env:
MATRIX_OS: ${{ matrix.os }}
run: |
python3 -B scripts/test_tap_syntax_legs.py
python3 -B scripts/tap_syntax_legs.py "$MATRIX_OS"

- name: Cache Homebrew Bundler RubyGems
uses: actions/cache@v4
with:
Expand All @@ -31,7 +39,10 @@ jobs:
- name: Run brew test-bot --only-setup
run: brew test-bot --only-setup

# Runs on every leg not named in .github/tap-syntax-skip-allowlist.txt;
# a missing decision runs it (!= 'false', not == 'true').
- name: Run brew test-bot --only-tap-syntax
if: steps.tap-syntax-leg.outputs.tap_syntax != 'false'
run: brew test-bot --only-tap-syntax

- name: Run brew test-bot (formula)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ Approve pending brew test-bot runs if GitHub requests approval on the generated
PR, then merge after the formula checks pass. GitHub documents this
[approval requirement for token-created PRs](https://docs.github.com/en/actions/concepts/security/github_token#when-github_token-triggers-workflow-runs).
The test-bot's manual dispatch runs syntax/setup checks; its formula checks run
on `pull_request`.
on `pull_request`. The tap-syntax step (`brew style`, `brew readall --os=all
--arch=all`, full-tap `brew audit`) runs on every matrix leg except those named,
with a reason, in `.github/tap-syntax-skip-allowlist.txt`; today that is the two
macOS legs, so it runs once, on `ubuntu-latest`. Each macOS leg still runs
`brew style --formula` and `brew audit --formula` in the formula step of every PR
that changes the formula. `scripts/tap_syntax_legs.py` makes the per-leg decision
and fails the run if every leg would skip, an entry has no reason, or an entry
names a leg that is not in the matrix; `python3 -B scripts/test_tap_syntax_legs.py`
checks it.

To validate the entire bump path without changing `main`, create a temporary
`automation/validation-1297-*` branch from current `main` and restore a historical
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci_plant.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
# hq#58 hb-s2 CI plant: SC2086 (unquoted expansion). Never merge.
echo $1

Check failure on line 3 in scripts/ci_plant.sh

View workflow job for this annotation

GitHub Actions / test-bot (ubuntu-latest)

Double quote to prevent globbing and word splitting.
135 changes: 135 additions & 0 deletions scripts/tap_syntax_legs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Decide whether this brew test-bot matrix leg runs --only-tap-syntax.

`brew test-bot --only-tap-syntax` runs `brew style`, `brew readall --os=all
--arch=all` and the full-tap `brew audit`. The first two do not depend on the
host, so running them on every matrix leg repeats the same check; on
macos-15-intel it cost about 5 of the leg's 11.5 minutes (hq#58, SM-HB-3).

A leg skips the step only when it is named, with a reason, in
.github/tap-syntax-skip-allowlist.txt. Any leg not named there runs it, so a
renamed or added leg runs tap-syntax by default.

The script exits 1 instead of deciding when:
- the matrix os list cannot be read unambiguously from the workflow, or the
matrix uses include/exclude (which this model of the legs cannot see);
- the allowlist is missing, or an entry has no reason, is duplicated, or
names an os that is not in the matrix (a stale entry);
- every matrix leg is allowlisted, so tap-syntax would run nowhere;
- the leg being decided is not in the matrix.

Usage: tap_syntax_legs.py [--workflow PATH] [--allowlist PATH] LEG
Writes tap_syntax=true|false to $GITHUB_OUTPUT when that is set.
"""
import os
import re
import sys

WORKFLOW = ".github/workflows/tests.yml"
ALLOWLIST = ".github/tap-syntax-skip-allowlist.txt"
JOB = "test-bot"
NAME = re.compile(r"^[A-Za-z0-9._-]+$")


class LegError(Exception):
pass


def matrix_legs(text):
"""Return the test-bot job's matrix os list, refusing any ambiguity."""
if re.search(r"^\s+(include|exclude):", text, re.M):
raise LegError("matrix include/exclude is not supported; list every leg in 'os: [...]'")
lists = re.findall(r"^\s+os:\s*\[([^\]]*)\]\s*(?:#.*)?$", text, re.M)
if len(lists) != 1:
raise LegError(f"expected exactly one flow-style 'os: [...]' line, found {len(lists)}")
legs = [item.strip().strip("'\"") for item in lists[0].split(",")]
if not legs or any(not NAME.match(leg) for leg in legs):
raise LegError(f"unreadable matrix os list: [{lists[0]}]")
if len(set(legs)) != len(legs):
raise LegError(f"duplicate leg in matrix os list: {legs}")
try:
import yaml
except ImportError:
return legs
doc = yaml.safe_load(text)
try:
matrix = doc["jobs"][JOB]["strategy"]["matrix"]
except (KeyError, TypeError):
raise LegError(f"no jobs.{JOB}.strategy.matrix in the workflow") from None
if not isinstance(matrix, dict) or set(matrix) != {"os"} or matrix["os"] != legs:
raise LegError(f"jobs.{JOB}.strategy.matrix is {matrix!r}, not os: {legs}")
return legs


def allowlisted(text, legs):
"""Return {leg: reason} from the allowlist text."""
entries = {}
for number, line in enumerate(text.splitlines(), 1):
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
if len(parts) < 2 or not parts[1].strip():
raise LegError(f"allowlist line {number}: '{parts[0]}' has no reason")
leg, reason = parts[0], parts[1].strip()
if leg in entries:
raise LegError(f"allowlist line {number}: '{leg}' is listed twice")
if leg not in legs:
raise LegError(f"allowlist line {number}: '{leg}' is not a matrix leg {legs} (stale entry)")
entries[leg] = reason
return entries


def decide(leg, workflow_text, allowlist_text):
legs = matrix_legs(workflow_text)
skips = allowlisted(allowlist_text, legs)
running = [name for name in legs if name not in skips]
if not running:
raise LegError(f"every matrix leg {legs} is allowlisted: tap-syntax would run nowhere")
if leg not in legs:
raise LegError(f"leg '{leg}' is not in the matrix {legs}")
return leg not in skips, skips.get(leg, ""), legs, running


def main(argv):
workflow, allowlist, leg = WORKFLOW, ALLOWLIST, None
args = list(argv)
while args:
arg = args.pop(0)
if arg in ("--workflow", "--allowlist") and args:
if arg == "--workflow":
workflow = args.pop(0)
else:
allowlist = args.pop(0)
elif leg is None and not arg.startswith("--"):
leg = arg
else:
raise LegError(f"unexpected argument '{arg}'")
if not leg:
raise LegError("usage: tap_syntax_legs.py [--workflow PATH] [--allowlist PATH] LEG")
try:
with open(workflow) as handle:
workflow_text = handle.read()
with open(allowlist) as handle:
allowlist_text = handle.read()
except OSError as error:
raise LegError(str(error)) from None
run, reason, legs, running = decide(leg, workflow_text, allowlist_text)
if run:
print(f"tap-syntax runs on {leg}")
else:
print(f"::notice title=tap-syntax skipped on {leg}::allowlisted in {allowlist}: {reason}")
output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a") as handle:
handle.write(f"tap_syntax={'true' if run else 'false'}\n")
print(f"TAP_SYNTAX_LEGS leg={leg} run={'true' if run else 'false'} "
f"legs={len(legs)} running={','.join(running)}")


if __name__ == "__main__":
try:
main(sys.argv[1:])
except LegError as error:
print(f"::error title=tap-syntax leg check::{error}")
sys.exit(1)
119 changes: 119 additions & 0 deletions scripts/test_tap_syntax_legs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Checks for scripts/tap_syntax_legs.py (hq#58, SM-HB-3).

Every case runs the script as the workflow does (a subprocess with a leg
argument and GITHUB_OUTPUT set) and checks its exit code and the decision it
wrote. The error cases are the ways an allowlist can make tap-syntax run
nowhere or describe legs that no longer exist.
"""
import os
from pathlib import Path
import subprocess
import sys
import tempfile

HERE = Path(__file__).resolve().parent
SCRIPT = HERE / "tap_syntax_legs.py"
REPO = HERE.parent

WORKFLOW = """name: brew test-bot
on:
pull_request:
jobs:
test-bot:
strategy:
fail-fast: false
matrix:
os: {matrix}
runs-on: ${{{{ matrix.os }}}}
steps:
- run: brew test-bot --only-tap-syntax
"""
MATRIX = "[ubuntu-latest, macos-15-intel, macos-latest]"
SKIP_MACOS = "# comment\n\nmacos-15-intel host-independent\nmacos-latest host-independent\n"

# (name, leg, workflow matrix text, allowlist text or None for a missing file, expected decision or "error")
CASES = [
("listed leg skips", "macos-15-intel", MATRIX, SKIP_MACOS, "false"),
("unlisted leg runs", "ubuntu-latest", MATRIX, SKIP_MACOS, "true"),
("empty allowlist runs everywhere", "macos-latest", MATRIX, "# nothing skipped\n", "true"),
("quoted matrix entries", "ubuntu-latest", "['ubuntu-latest', \"macos-latest\"] # legs",
"macos-latest host-independent\n", "true"),
("renamed ubuntu leg still runs", "ubuntu-24.04", "[ubuntu-24.04, macos-15-intel, macos-latest]",
SKIP_MACOS, "true"),
("every leg allowlisted", "macos-latest", MATRIX,
SKIP_MACOS + "ubuntu-latest host-independent\n", "error"),
("ubuntu leg dropped from matrix", "macos-latest", "[macos-15-intel, macos-latest]", SKIP_MACOS, "error"),
("entry without reason", "ubuntu-latest", MATRIX, "macos-latest\n", "error"),
("stale entry", "ubuntu-latest", MATRIX, "macos-14 retired runner\n", "error"),
("duplicate entry", "ubuntu-latest", MATRIX, "macos-latest a\nmacos-latest b\n", "error"),
("leg not in matrix", "windows-latest", MATRIX, SKIP_MACOS, "error"),
("missing allowlist", "ubuntu-latest", MATRIX, None, "error"),
("matrix exclude", "ubuntu-latest", MATRIX + "\n exclude:\n - os: ubuntu-latest",
SKIP_MACOS, "error"),
("block-style matrix list", "ubuntu-latest", "\n - ubuntu-latest\n - macos-latest",
"", "error"),
("empty leg argument", "", MATRIX, SKIP_MACOS, "error"),
]


def run(leg, workflow, allowlist, output):
env = dict(os.environ, GITHUB_OUTPUT=str(output))
args = [sys.executable, "-B", str(SCRIPT), "--workflow", str(workflow), "--allowlist", str(allowlist)]
return subprocess.run(args + [leg], env=env, capture_output=True, text=True)


def decision(output):
lines = output.read_text().splitlines() if output.exists() else []
return lines[-1].split("=", 1)[1] if lines else None


passed = 0
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
for name, leg, matrix, allowlist_text, expected in CASES:
workflow = tmp / "tests.yml"
allowlist = tmp / "allowlist.txt"
output = tmp / "output"
workflow.write_text(WORKFLOW.format(matrix=matrix))
if allowlist.exists():
allowlist.unlink()
if allowlist_text is not None:
allowlist.write_text(allowlist_text)
if output.exists():
output.unlink()
result = run(leg, workflow, allowlist, output)
detail = (name, result.returncode, result.stdout, result.stderr)
if expected == "error":
assert result.returncode == 1, detail
assert "::error title=tap-syntax leg check::" in result.stdout, detail
assert decision(output) is None, detail
else:
assert result.returncode == 0, detail
assert decision(output) == expected, detail
assert f"TAP_SYNTAX_LEGS leg={leg} run={expected} " in result.stdout, detail
passed += 1
print(f"PASS {name}")

# The tree's own workflow and allowlist: every leg decides, and at least one runs.
real_workflow = REPO / ".github/workflows/tests.yml"
real_allowlist = REPO / ".github/tap-syntax-skip-allowlist.txt"
sys.path.insert(0, str(HERE))
import tap_syntax_legs
legs = tap_syntax_legs.matrix_legs(real_workflow.read_text())
runs = []
for leg in legs:
output = tmp / f"output-{leg}"
result = run(leg, real_workflow, real_allowlist, output)
assert result.returncode == 0, (leg, result.stdout, result.stderr)
runs.append(decision(output))
assert set(runs) <= {"true", "false"} and "true" in runs, (legs, runs)
passed += 1
print(f"PASS repository legs {dict(zip(legs, runs))}")

# Pinned population: deleting a CASES row must fail here, not shrink the count silently.
EXPECTED_EXAMINED = 16
examined = len(CASES) + 1
assert examined == EXPECTED_EXAMINED, (examined, EXPECTED_EXAMINED)
assert passed == examined, (passed, examined)
print(f"TAP_SYNTAX_LEGS_CHECKS_DONE passed={passed} examined={examined}")
Loading