mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-21 17:57:24 -05:00
adds checks for two bug classes that each produced silent, long-lived failures: 1. workflow fork-safety (from #1344). any pull_request-triggered workflow that references ${{ vars.* }} or non-GITHUB_TOKEN ${{ secrets.* }} breaks silently on fork PRs because repo vars/secrets are not exposed in that context. a week of broken fork CI on #1306, #1331, #1339 before anyone noticed. 2. marimo widget-in-gated-cell (exposed by lab_01's broken state, fixed in #1339). when an @app.cell has mo.stop() AND defines mo.ui.* widgets that appear in its return tuple, those widgets don't exist until the gate unblocks, cascading "undefined dependency" failures through every cell that depends on them. ## changes - `.github/scripts/check_workflow_fork_safety.py`: standalone python + pyyaml, parses each workflow, identifies those triggered by pull_request, flags unsafe vars/secrets references with file:line:token pointers and a fix hint. exempts secrets.GITHUB_TOKEN which is always available. - `.github/workflows/ci-sanity.yml`: new workflow triggered on .github/ changes that runs the fork-safety check. catches contributors who don't have pre-commit installed. - `.pre-commit-config.yaml`: wires the fork-safety script as a local pre-commit hook under a new "SECTION 3.5: CI SANITY" so it runs on workflow edits. - `labs/tests/test_static.py`: new TestMarimoDataflow class with test_no_widget_defined_in_gated_cell. ast-based, flags cells that are both gated and define returned widgets. marked xfail for now because 32 of 33 labs currently have the pattern; the systematic refactor is separate scope. once labs are converted to the proper pattern (widget in own cell, gate cell is pure mo.stop, see vol2/lab_05_dist_train), remove the xfail. - `labs-validate-dev.yml`, `kits-validate-dev.yml`, `mlsysim-validate-dev.yml`: the "validate build output" step now echoes the resolved env var and fails loudly with an explanation if the var is empty, rather than silently checking a wrong path. would have diagnosed #1344 in 30 seconds instead of a week. ## verification - fork-safety check: ok, 47 workflows scanned, 8 pull_request-exposed, 0 violations - marimo check: ok across all 33 labs - pytest labs/tests/test_static.py: 656 passed, 4 skipped, 33 xfailed, 1 xpassed - vol2/lab_05_dist_train is xpassed (the canonical reference lab with the proper pattern)
224 lines
7.4 KiB
Python
Executable File
224 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Check that workflows triggered by pull_request events do not reference
|
|
repository variables that won't be available on fork PRs.
|
|
|
|
Why this exists
|
|
---------------
|
|
GitHub does not expose repository variables to workflows triggered by
|
|
``pull_request`` events from forks. This is a security default. When a
|
|
workflow references ``${{ vars.* }}`` and runs on a fork PR, the
|
|
reference resolves to an empty string, which almost always leads to
|
|
silent build failures that look like "file missing" errors instead of
|
|
the real configuration problem.
|
|
|
|
Secrets are an even stronger case: ``${{ secrets.* }}`` is stripped for
|
|
fork PRs with the sole exception of ``secrets.GITHUB_TOKEN``, which is
|
|
always available (GitHub provides it automatically). Workflows that
|
|
need real secrets on PR should use ``pull_request_target`` (which runs
|
|
with target-branch context) and understand the security implications.
|
|
|
|
Incident: PR #1344. Three validate workflows used
|
|
``${{ vars.LABS_ROOT }}`` / ``${{ vars.KITS_ROOT }}`` /
|
|
``${{ vars.MLSYSIM_DOCS }}``. Every fork PR that touched those
|
|
directories silently failed its CI for a week before the pattern was
|
|
identified. The fix was to move the constants into workflow-level
|
|
``env:`` blocks, which ARE available in all contexts.
|
|
|
|
What this script does
|
|
---------------------
|
|
1. Parse every workflow file under ``.github/workflows/`` as YAML.
|
|
2. If its ``on:`` section declares a ``pull_request`` trigger (not
|
|
``pull_request_target``, which has target-branch context), scan the
|
|
raw file text for ``${{ vars.* }}`` and for ``${{ secrets.* }}``
|
|
references other than ``secrets.GITHUB_TOKEN``.
|
|
3. Skip comment lines and YAML block-scalar comments.
|
|
4. Fail with file:line:token pointers.
|
|
|
|
How to fix a violation
|
|
----------------------
|
|
Replace the ``vars.*`` reference with a workflow-level ``env:`` block::
|
|
|
|
env:
|
|
LABS_ROOT: labs
|
|
|
|
jobs:
|
|
...
|
|
steps:
|
|
- working-directory: ${{ env.LABS_ROOT }}
|
|
|
|
Workflow-level env vars ARE exposed to fork PRs.
|
|
|
|
Exit codes
|
|
----------
|
|
0 All clean.
|
|
1 At least one violation found.
|
|
2 Script error (could not parse a workflow, missing directory, etc).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print(
|
|
"error: PyYAML is required. install with: pip install pyyaml",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
|
|
WORKFLOW_DIR = Path(".github/workflows")
|
|
|
|
# Match ${{ vars.NAME }} or ${{ secrets.NAME }} with optional whitespace.
|
|
UNSAFE_PATTERN = re.compile(r"\$\{\{\s*(vars|secrets)\.(\w+)\s*\}\}")
|
|
|
|
# GITHUB_TOKEN is the one secret that IS available on fork PRs.
|
|
ALWAYS_AVAILABLE_SECRETS = {"GITHUB_TOKEN"}
|
|
|
|
|
|
def triggers_on_pull_request(workflow_yaml: dict) -> bool:
|
|
"""True if the workflow's on: section declares a pull_request trigger.
|
|
|
|
We treat pull_request_target as safe because it runs with
|
|
target-branch context and DOES have access to repo vars/secrets.
|
|
Only plain pull_request is fork-unsafe.
|
|
"""
|
|
on_section = workflow_yaml.get("on", workflow_yaml.get(True))
|
|
# PyYAML parses the bare keyword `on` as the Python boolean True
|
|
# in some YAML contexts, so we also check the True key.
|
|
|
|
if on_section is None:
|
|
return False
|
|
if isinstance(on_section, str):
|
|
return on_section == "pull_request"
|
|
if isinstance(on_section, list):
|
|
return "pull_request" in on_section
|
|
if isinstance(on_section, dict):
|
|
return "pull_request" in on_section
|
|
return False
|
|
|
|
|
|
def strip_comment(line: str) -> str:
|
|
"""Remove YAML inline comment. Preserves # inside quoted strings.
|
|
|
|
Good enough for our purposes - we're scanning for ${{ ... }}
|
|
patterns and any # inside a ${{ }} would be unusual.
|
|
"""
|
|
in_single_quote = False
|
|
in_double_quote = False
|
|
for i, ch in enumerate(line):
|
|
if ch == "'" and not in_double_quote:
|
|
in_single_quote = not in_single_quote
|
|
elif ch == '"' and not in_single_quote:
|
|
in_double_quote = not in_double_quote
|
|
elif ch == "#" and not in_single_quote and not in_double_quote:
|
|
return line[:i]
|
|
return line
|
|
|
|
|
|
def find_violations(path: Path) -> list[tuple[int, str, str]]:
|
|
"""Return [(line_number, token, context), ...] for fork-unsafe refs.
|
|
|
|
Returns empty list if the workflow does not trigger on pull_request.
|
|
"""
|
|
text = path.read_text()
|
|
try:
|
|
parsed = yaml.safe_load(text)
|
|
except yaml.YAMLError as e:
|
|
raise RuntimeError(f"could not parse {path} as YAML: {e}")
|
|
|
|
if not isinstance(parsed, dict):
|
|
return []
|
|
if not triggers_on_pull_request(parsed):
|
|
return []
|
|
|
|
violations: list[tuple[int, str, str]] = []
|
|
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
|
# Skip lines that are entirely comment. Inline comments after
|
|
# the match still get scanned (but strip_comment removes the
|
|
# comment portion first).
|
|
stripped_leading = raw_line.lstrip()
|
|
if stripped_leading.startswith("#"):
|
|
continue
|
|
code_part = strip_comment(raw_line)
|
|
for match in UNSAFE_PATTERN.finditer(code_part):
|
|
kind = match.group(1) # "vars" or "secrets"
|
|
name = match.group(2)
|
|
if kind == "secrets" and name in ALWAYS_AVAILABLE_SECRETS:
|
|
continue
|
|
token = f"{kind}.{name}"
|
|
violations.append((lineno, token, raw_line.strip()))
|
|
return violations
|
|
|
|
|
|
def format_violation(path: Path, lineno: int, token: str, context: str) -> str:
|
|
kind = token.split(".", 1)[0]
|
|
return (
|
|
f" {path}:{lineno}\n"
|
|
f" token: ${{{{ {token} }}}}\n"
|
|
f" context: {context}\n"
|
|
f" fix: replace {kind}.* with a workflow-level env: block.\n"
|
|
f" see .github/scripts/check_workflow_fork_safety.py for why.\n"
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
if not WORKFLOW_DIR.is_dir():
|
|
print(f"error: {WORKFLOW_DIR} not found", file=sys.stderr)
|
|
return 2
|
|
|
|
total_violations = 0
|
|
exposed_workflows = 0
|
|
scanned = 0
|
|
|
|
workflow_paths = sorted(
|
|
list(WORKFLOW_DIR.glob("*.yml")) + list(WORKFLOW_DIR.glob("*.yaml"))
|
|
)
|
|
|
|
for workflow_path in workflow_paths:
|
|
scanned += 1
|
|
try:
|
|
violations = find_violations(workflow_path)
|
|
except RuntimeError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return 2
|
|
except OSError as e:
|
|
print(f"error: could not read {workflow_path}: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
parsed = yaml.safe_load(workflow_path.read_text())
|
|
if isinstance(parsed, dict) and triggers_on_pull_request(parsed):
|
|
exposed_workflows += 1
|
|
except yaml.YAMLError:
|
|
continue
|
|
|
|
if violations:
|
|
print(f"\nfork-unsafe references in {workflow_path}:")
|
|
for lineno, token, context in violations:
|
|
print(format_violation(workflow_path, lineno, token, context))
|
|
total_violations += 1
|
|
|
|
if total_violations == 0:
|
|
print(
|
|
f"ok: scanned {scanned} workflows; "
|
|
f"{exposed_workflows} trigger on pull_request; "
|
|
f"none reference vars.* or (non-GITHUB_TOKEN) secrets.*"
|
|
)
|
|
return 0
|
|
|
|
print(
|
|
f"\nfound {total_violations} fork-unsafe reference(s) across "
|
|
f"{exposed_workflows} pull_request-exposed workflow(s).",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|