|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import re |
| 4 | +from pathlib import Path |
| 5 | +from typing import Any |
| 6 | + |
| 7 | +import yaml |
| 8 | + |
| 9 | +REPO_ROOT = Path(__file__).resolve().parents[1] |
| 10 | +WORKFLOW_ROOT = REPO_ROOT / ".github" / "workflows" |
| 11 | +DOCS_CHECKS = WORKFLOW_ROOT / "docs-pr.yml" |
| 12 | +DOCS_DEPLOYMENT = WORKFLOW_ROOT / "docs.yml" |
| 13 | + |
| 14 | +DOCS_PATHS = [ |
| 15 | + "src/**", |
| 16 | + "docs/**", |
| 17 | + "mkdocs.yml", |
| 18 | + "pyproject.toml", |
| 19 | + "scripts/check-docs-analytics.py", |
| 20 | + "scripts/check-docs-layout.py", |
| 21 | + ".github/workflows/docs.yml", |
| 22 | + ".github/workflows/docs-pr.yml", |
| 23 | +] |
| 24 | +CHECKOUT_ACTION = "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803" |
| 25 | +SETUP_PYTHON_ACTION = "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1" |
| 26 | +UPLOAD_PAGES_ACTION = "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9" |
| 27 | +DEPLOY_PAGES_ACTION = "actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128" |
| 28 | + |
| 29 | + |
| 30 | +def load_workflow(path: Path) -> dict[str, Any]: |
| 31 | + document = yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) |
| 32 | + assert isinstance(document, dict), f"{path.name} must contain a workflow mapping" |
| 33 | + return document |
| 34 | + |
| 35 | + |
| 36 | +def write_permissions(value: object) -> set[str]: |
| 37 | + if isinstance(value, str): |
| 38 | + return {"*"} if value == "write-all" else set() |
| 39 | + if not isinstance(value, dict): |
| 40 | + return set() |
| 41 | + return {str(name) for name, access in value.items() if access == "write"} |
| 42 | + |
| 43 | + |
| 44 | +def action_references(job: dict[str, Any]) -> list[str]: |
| 45 | + steps = job.get("steps") |
| 46 | + assert isinstance(steps, list) |
| 47 | + return [step["uses"] for step in steps if isinstance(step, dict) and "uses" in step] |
| 48 | + |
| 49 | + |
| 50 | +def run_commands(job: dict[str, Any]) -> str: |
| 51 | + steps = job.get("steps") |
| 52 | + assert isinstance(steps, list) |
| 53 | + return "\n".join(str(step["run"]) for step in steps if isinstance(step, dict) and "run" in step) |
| 54 | + |
| 55 | + |
| 56 | +def assert_actions_are_pinned(references: list[str]) -> None: |
| 57 | + for reference in references: |
| 58 | + assert re.fullmatch(r"[^@\s]+@[0-9a-f]{40}", reference), f"action is not commit-pinned: {reference}" |
| 59 | + |
| 60 | + |
| 61 | +def test_pull_request_workflows_do_not_grant_write_permissions() -> None: |
| 62 | + checked: list[str] = [] |
| 63 | + for path in sorted(WORKFLOW_ROOT.glob("*.y*ml")): |
| 64 | + workflow = load_workflow(path) |
| 65 | + triggers = workflow.get("on") |
| 66 | + assert isinstance(triggers, dict), f"{path.name} must declare explicit triggers" |
| 67 | + if "pull_request" not in triggers: |
| 68 | + continue |
| 69 | + |
| 70 | + checked.append(path.name) |
| 71 | + assert not write_permissions(workflow.get("permissions")), ( |
| 72 | + f"{path.name} grants workflow-level write permission to pull requests" |
| 73 | + ) |
| 74 | + jobs = workflow.get("jobs") |
| 75 | + assert isinstance(jobs, dict) and jobs, f"{path.name} must declare jobs" |
| 76 | + for job_name, job in jobs.items(): |
| 77 | + assert isinstance(job, dict) |
| 78 | + assert not write_permissions(job.get("permissions")), ( |
| 79 | + f"{path.name} job {job_name} grants write permission to pull requests" |
| 80 | + ) |
| 81 | + |
| 82 | + assert "docs-pr.yml" in checked |
| 83 | + |
| 84 | + |
| 85 | +def test_docs_pull_request_checks_are_read_only_and_complete() -> None: |
| 86 | + workflow = load_workflow(DOCS_CHECKS) |
| 87 | + assert workflow["on"] == { |
| 88 | + "pull_request": { |
| 89 | + "branches": ["main"], |
| 90 | + "paths": DOCS_PATHS, |
| 91 | + } |
| 92 | + } |
| 93 | + assert workflow["permissions"] == {"contents": "read"} |
| 94 | + assert set(workflow["jobs"]) == {"validate"} |
| 95 | + |
| 96 | + validate = workflow["jobs"]["validate"] |
| 97 | + references = action_references(validate) |
| 98 | + assert references == [CHECKOUT_ACTION, SETUP_PYTHON_ACTION] |
| 99 | + assert_actions_are_pinned(references) |
| 100 | + commands = run_commands(validate) |
| 101 | + assert "mkdocs build --strict" in commands |
| 102 | + assert "python scripts/check-docs-analytics.py site" in commands |
| 103 | + assert "python scripts/check-docs-layout.py site" in commands |
| 104 | + assert "upload-pages-artifact" not in DOCS_CHECKS.read_text(encoding="utf-8") |
| 105 | + assert "deploy-pages" not in DOCS_CHECKS.read_text(encoding="utf-8") |
| 106 | + |
| 107 | + |
| 108 | +def test_pages_deployment_is_push_only_and_has_exact_authority() -> None: |
| 109 | + workflow = load_workflow(DOCS_DEPLOYMENT) |
| 110 | + assert workflow["on"] == { |
| 111 | + "push": { |
| 112 | + "branches": ["main"], |
| 113 | + "paths": DOCS_PATHS, |
| 114 | + } |
| 115 | + } |
| 116 | + assert workflow["permissions"] == {"contents": "read"} |
| 117 | + assert set(workflow["jobs"]) == {"build", "deploy"} |
| 118 | + |
| 119 | + build = workflow["jobs"]["build"] |
| 120 | + assert "permissions" not in build |
| 121 | + build_references = action_references(build) |
| 122 | + assert build_references == [CHECKOUT_ACTION, SETUP_PYTHON_ACTION, UPLOAD_PAGES_ACTION] |
| 123 | + assert_actions_are_pinned(build_references) |
| 124 | + commands = run_commands(build) |
| 125 | + assert "mkdocs build --strict" in commands |
| 126 | + assert "python scripts/check-docs-analytics.py site" in commands |
| 127 | + assert "python scripts/check-docs-layout.py site" in commands |
| 128 | + |
| 129 | + deploy = workflow["jobs"]["deploy"] |
| 130 | + assert deploy["needs"] == "build" |
| 131 | + assert deploy["permissions"] == { |
| 132 | + "contents": "read", |
| 133 | + "id-token": "write", |
| 134 | + "pages": "write", |
| 135 | + } |
| 136 | + deploy_references = action_references(deploy) |
| 137 | + assert deploy_references == [DEPLOY_PAGES_ACTION] |
| 138 | + assert_actions_are_pinned(deploy_references) |
| 139 | + |
| 140 | + source = DOCS_DEPLOYMENT.read_text(encoding="utf-8") |
| 141 | + assert "actions/download-artifact@" not in source |
| 142 | + assert (REPO_ROOT / "docs" / "CNAME").read_text(encoding="utf-8").strip() == "python.durable-workflow.com" |
| 143 | + assert "site_url: https://python.durable-workflow.com/" in (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8") |
0 commit comments