Files
computer-playbook/cli/meta/ci/subset_roles.py
kevinveenbirkenbachandClaude Opus 5 71888d86af refactor(ci): prefix workflows by trigger
The workflow directory mixed entry points, reusable stages and scheduled jobs into one flat list sorted by topic, so the first question about any file - what starts it - was the one the name did not answer. Every workflow now carries one of three prefixes, derived from its on: block: cron- for a schedule trigger, call- for one only reachable through workflow_call, entry- for everything a repository event or a human starts. Scheduled workflows additionally carry an alarm clock as the first character of their name.

Two lints keep it that way. One derives the expected prefix from the triggers and compares it against the file name; it flagged all 17 offenders before the rename and none after. The other resolves every .github/workflows/<file> string in the repository against the directory, which is what a rename silently breaks in Markdown links, Python constants and shell API URLs alike.

Renaming pulled 69 pre-existing comments into the comment lint's changed-file scope. The vendored CodeQL template got the nocheck marker its scorecard sibling already carried, two workflow headers moved above name: where they count as file headers, and the remaining banners were deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:05:51 +02:00

102 lines
3.4 KiB
Python

"""Resolve the explicit role subset declared in a pull-request body.
Activated by the ``🧩 Subset`` label: the entry-pull-request workflow only
runs this module when the label is present. It gives fork contributors the
selective-CI capability that ``entry-manual-steer.yml`` (``workflow_dispatch``)
grants maintainers, which forks cannot trigger.
It reads the PR body from ``$PR_BODY``, extracts the first fenced code block
whose YAML defines a ``roles:`` list, validates that every listed id is an
existing ``roles/<id>`` directory, and writes ``whitelist`` (space-separated)
to ``$GITHUB_OUTPUT`` -- the same output the diff-derived resolver
produces.
Unlike the fail-safe diff resolver (which widens to ``__ALL__`` on any
problem), the subset path is strict: invalid YAML, a missing/empty ``roles:``
list, or an unknown role id each abort with a clear message and a non-zero
exit, so a mistyped subset never silently deploys the wrong set. Role ids must
match ``^[a-z0-9-]+$``, which also blocks path traversal before any filesystem
lookup.
Invoked from scripts/github/resolve/pr_affected_roles.sh via
``python -m cli.meta.ci.subset_roles``.
"""
import os
import re
import sys
from pathlib import Path
from typing import NoReturn
import yaml
from utils import PROJECT_ROOT
from utils.cache.yaml import load_yaml_str
ROLES_DIR = PROJECT_ROOT / "roles"
FENCE_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL)
NAME_RE = re.compile(r"^[a-z0-9-]+$")
def fail(message: str) -> NoReturn:
print(f"Subset roles error: {message}", file=sys.stderr)
sys.exit(1)
def extract_roles(body: str):
blocks = FENCE_RE.findall(body)
if not blocks:
fail(
"the '🧩 Subset' label is set but the PR body has no fenced "
"```yaml block. Add a '## Roles' section with a `roles:` list."
)
for block in blocks:
try:
data = load_yaml_str(block)
except yaml.YAMLError as exc:
fail(f"invalid YAML in the PR body roles block: {exc}")
if isinstance(data, dict) and "roles" in data:
return data["roles"]
fail(
"no `roles:` key found in any YAML block of the PR body. Declare the "
"subset as a `roles:` list under a '## Roles' section."
)
def main() -> None:
roles = extract_roles(os.environ.get("PR_BODY", ""))
if roles is None:
roles = []
if not isinstance(roles, list) or not all(isinstance(r, str) for r in roles):
fail("`roles:` must be a YAML list of role-id strings.")
roles = [r.strip() for r in roles if r and r.strip()]
if not roles:
fail(
"the '🧩 Subset' label is set but the `roles:` list is empty. "
"List at least one role id or remove the label."
)
invalid = sorted({r for r in roles if not NAME_RE.fullmatch(r)})
if invalid:
fail(
"invalid role id(s) (allowed: lowercase letters, digits, '-'): "
+ ", ".join(invalid)
)
unknown = sorted({r for r in roles if not (ROLES_DIR / r).is_dir()})
if unknown:
fail("unknown role id(s): " + ", ".join(unknown))
whitelist = " ".join(roles)
print(f"Subset roles resolved from PR body: {whitelist}")
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with Path(github_output).open("a", encoding="utf-8") as handle:
handle.write(f"whitelist={whitelist}\n")
if __name__ == "__main__":
main()