Skip to content

Commit 8cc0ebc

Browse files
Separate Python docs PR checks from Pages write authority
1 parent eb418fc commit 8cc0ebc

4 files changed

Lines changed: 190 additions & 13 deletions

File tree

.github/workflows/docs-pr.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Docs PR checks
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
paths:
7+
- 'src/**'
8+
- 'docs/**'
9+
- 'mkdocs.yml'
10+
- 'pyproject.toml'
11+
- 'scripts/check-docs-analytics.py'
12+
- 'scripts/check-docs-layout.py'
13+
- '.github/workflows/docs.yml'
14+
- '.github/workflows/docs-pr.yml'
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: docs-pr-${{ github.event.pull_request.number }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
validate:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
28+
29+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
30+
with:
31+
python-version: "3.12"
32+
33+
- name: Install package + docs deps
34+
run: pip install -e '.[docs]'
35+
36+
- name: Install browser for responsive layout checks
37+
run: python -m playwright install --with-deps chromium
38+
39+
- name: Build site
40+
run: |
41+
mkdocs build --strict
42+
python scripts/check-docs-analytics.py site
43+
python scripts/check-docs-layout.py site

.github/workflows/docs.yml

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Docs
1+
name: Docs deployment
22

33
on:
44
push:
@@ -11,23 +11,13 @@ on:
1111
- 'scripts/check-docs-analytics.py'
1212
- 'scripts/check-docs-layout.py'
1313
- '.github/workflows/docs.yml'
14-
pull_request:
15-
branches: [main]
16-
paths:
17-
- 'src/**'
18-
- 'docs/**'
19-
- 'mkdocs.yml'
20-
- 'pyproject.toml'
21-
- 'scripts/check-docs-analytics.py'
22-
- 'scripts/check-docs-layout.py'
23-
- '.github/workflows/docs.yml'
24-
workflow_dispatch:
14+
- '.github/workflows/docs-pr.yml'
2515

2616
permissions:
2717
contents: read
2818

2919
concurrency:
30-
group: docs
20+
group: docs-deployment
3121
cancel-in-progress: false
3222

3323
jobs:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dev = [
4040
"pytest>=8.0",
4141
"pytest-asyncio>=0.23",
4242
"mypy>=1.10",
43+
"PyYAML>=6.0",
4344
"ruff>=0.4",
4445
]
4546
prometheus = [

tests/test_docs_workflow_policy.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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

Comments
 (0)