#!/usr/bin/env python3 """Enforce the GitHub Actions concurrency convention. See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules. Invoked from .github/workflows/ci-quality.yml. Runs locally too: python3 .github/scripts/check-workflow-concurrency.py .github/workflows Rules: 1. Every entry-point (non-reusable) workflow declares a top-level `concurrency:` block. 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. 3. The `concurrency.group` expression MUST reference either `${{ github.workflow }}` or one of the approved hardcoded literal prefixes for workflows that are simultaneously entry-points AND reusable (on: push/ workflow_call). Two such exceptions are currently approved: - `CI-` for ci.yml (the original canonical form) - `docker-build-push-` for docker.yml This is checked by substring containment rather than prefix match because the group value is a conditional expression that resolves to a `CI-…` or `docker-build-push-…` literal at runtime. We deliberately do not use a YAML library — keeps the script dependency-free on any vanilla runner. `on:` block parsing is line-based and handles both the flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms. """ from __future__ import annotations import pathlib import re import sys REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-") def is_reusable(lines: list[str]) -> bool: """Return True iff the workflow's `on:` block names only `workflow_call`.""" in_on = False on_indent: int | None = None keys: list[str] = [] for raw in lines: # Skip blank lines and comments stripped = raw.strip() if not stripped or stripped.startswith("#"): continue indent = len(raw) - len(raw.lstrip(" ")) if not in_on: if raw.startswith("on:"): remainder = raw[len("on:"):].strip() if not remainder: # `on:` followed by indented mapping on next lines in_on = True on_indent = indent continue if remainder.startswith("[") and remainder.endswith("]"): # Flow-style list: on: [workflow_call] items = [ item.strip() for item in remainder.strip("[]").split(",") ] return items == ["workflow_call"] # Scalar form: on: workflow_call (or a single other event) return remainder == "workflow_call" continue # Inside the `on:` block; stop when indentation returns to <= on_indent if on_indent is not None and indent <= on_indent: break # Only consider keys at on_indent + indentation step (anything deeper # is nested config like `types:`) if ":" not in stripped: continue # Heuristic: first-level event keys are those with indent == on_indent + 2 # (the canonical step for a 2-space YAML doc). We collect all first-level # keys by tracking the smallest indent seen inside the block. keys.append((indent, stripped.split(":", 1)[0].strip())) if not keys: return False # Take only the outermost-indented keys as the event list min_indent = min(i for i, _ in keys) events = [name for i, name in keys if i == min_indent] return events == ["workflow_call"] CONCURRENCY_RE = re.compile(r"^concurrency:\s*$") GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$") def extract_group_key(lines: list[str]) -> str | None: """Return the `group:` value of the top-level `concurrency:` block, or None.""" for idx, raw in enumerate(lines): if CONCURRENCY_RE.match(raw): # Scan forward until we leave the concurrency block (next top-level key # is at column 0 and ends with `:`). for follow in lines[idx + 1:]: if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"): break m = GROUP_RE.match(follow) if m: return m.group(1).strip().strip("'").strip('"') break return None def has_top_level_concurrency(lines: list[str]) -> bool: return any(CONCURRENCY_RE.match(raw) for raw in lines) def check(workflows_dir: pathlib.Path) -> int: fail = 0 files = sorted( list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml")) ) for path in files: lines = path.read_text(encoding="utf-8").splitlines() reusable = is_reusable(lines) has_conc = has_top_level_concurrency(lines) if reusable: if has_conc: print( f"::error file={path}::Reusable workflow (on: workflow_call) " "must NOT declare its own concurrency block — it inherits " "from the caller. See CONTRIBUTING.md -> GitHub Actions — " "Concurrency Convention." ) fail = 1 continue if not has_conc: print( f"::error file={path}::Missing top-level concurrency block. " "See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention." ) fail = 1 continue group = extract_group_key(lines) if group is None: print( f"::error file={path}::concurrency block is missing a " "`group:` key." ) fail = 1 continue if not any(token in group for token in REQUIRED_TOKENS): print( f"::error file={path}::concurrency.group `{group}` must " f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} " "for normal entry-point workflows; use an approved literal prefix " "only for workflows that are both entry-points AND reusable — " "see CONTRIBUTING.md -> GitHub Actions — Concurrency Convention)." ) fail = 1 return fail def main(argv: list[str]) -> int: if len(argv) != 2: print(f"usage: {argv[0]} ", file=sys.stderr) return 2 workflows_dir = pathlib.Path(argv[1]) if not workflows_dir.is_dir(): print(f"not a directory: {workflows_dir}", file=sys.stderr) return 2 return check(workflows_dir) if __name__ == "__main__": sys.exit(main(sys.argv))