Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,38 @@ jobs:
- name: Run tests
run: make test
- uses: codecov/codecov-action@v6
Docs:
runs-on: ubuntu-latest
# Deploy only a release that reached PyPI. As its own job, a failed
# deploy cannot fail Test or Publish.
needs: [Test, Publish]
if: |
(github.repository == 'PolicyEngine/policyengine-core')
&& (github.event.head_commit.message == 'Update PolicyEngine Core')
permissions:
contents: write
# One deploy at a time; a newly queued deploy replaces one still waiting.
concurrency:
group: gh-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
- name: Install package
run: make install
- name: Generate documentation
run: make documentation
- name: Deploy documentation
if: matrix.os == 'ubuntu-latest'
uses: JamesIves/github-pages-deploy-action@v4
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: docs/_build/html # The folder the action should deploy.
branch: gh-pages
folder: docs/_build/html
Publish:
runs-on: ubuntu-latest
if: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fix-docs-deploy.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The release workflow deploys the documentation site again, from a Docs job that runs once the tests pass and the release reaches PyPI; the deploy step had been skipped on every release since September 2024 because it checked a matrix the Test job no longer has.
1 change: 1 addition & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ sphinx:
html_css_files:
- style.css
extra_extensions:
- "sphinx.ext.githubpages"
- "sphinx.ext.autodoc"
- "sphinxarg.ext"
- "sphinx.ext.viewcode"
Expand Down
143 changes: 143 additions & 0 deletions tests/core/test_docs_deployment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import re
from pathlib import Path

import pytest
import yaml


ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS = ROOT / ".github" / "workflows"
PUSH_WORKFLOW = WORKFLOWS / "push.yaml"
DOCS_CONFIG = ROOT / "docs" / "_config.yml"
MAKEFILE = ROOT / "Makefile"
DEPLOY_ACTION = "JamesIves/github-pages-deploy-action@"
PYPI_ACTION = "pypa/gh-action-pypi-publish@"


def load_jobs(workflow: Path) -> dict:
return yaml.safe_load(workflow.read_text())["jobs"]


def as_list(value) -> list:
if value is None:
return []
return [value] if isinstance(value, str) else list(value)


def strings_in(node):
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for value in node.values():
yield from strings_in(value)
elif isinstance(node, list):
for item in node:
yield from strings_in(item)


def matrix_keys(job: dict) -> set | None:
"""Keys a job's matrix defines, or None when an expression builds it."""
matrix = (job.get("strategy") or {}).get("matrix") or {}
if not isinstance(matrix, dict):
return None
keys = set(matrix) - {"include", "exclude"}
include = matrix.get("include", [])
if not isinstance(include, list):
return None
for entry in include:
if not isinstance(entry, dict):
return None
keys |= set(entry)
return keys


def jobs_using(jobs: dict, action: str) -> list:
return [
name
for name, job in jobs.items()
if any(step.get("uses", "").startswith(action) for step in job.get("steps", []))
]


def upstream_jobs(jobs: dict, name: str) -> set:
seen = set()
pending = as_list(jobs[name].get("needs"))
while pending:
needed = pending.pop()
if needed not in seen:
seen.add(needed)
pending.extend(as_list(jobs[needed].get("needs")))
return seen


@pytest.mark.parametrize(
"workflow", sorted(WORKFLOWS.glob("*.y*ml")), ids=lambda path: path.name
)
def test_workflow_matrix_references_have_a_matrix(workflow: Path):
# The Test job lost its matrix in September 2024 but kept a step guarded
# by ``matrix.os == 'ubuntu-latest'``, so that condition never held and
# the documentation deploy was skipped on every release afterwards.
for name, job in load_jobs(workflow).items():
defined = matrix_keys(job)
if defined is None:
continue
referenced = {
key
for text in strings_in(job)
for key in re.findall(r"\bmatrix\.([A-Za-z_][\w-]*)", text)
}
missing = referenced - defined
assert not missing, (
f"{workflow.name} job {name} references matrix keys "
f"{sorted(missing)} that its strategy does not define"
)


def test_release_deploys_documentation_after_publishing():
jobs = load_jobs(PUSH_WORKFLOW)
assert jobs_using(jobs, DEPLOY_ACTION) == ["Docs"]
docs = jobs["Docs"]
steps = docs["steps"]
deploy = next(
step for step in steps if step.get("uses", "").startswith(DEPLOY_ACTION)
)
build = next(step for step in steps if step.get("run") == "make documentation")

assert docs["if"] == jobs["Test"]["if"]
assert {"Test", "Publish"} <= set(as_list(docs.get("needs")))
# A condition on the deploy step is how it went unnoticed for two years.
assert "if" not in deploy
assert steps.index(build) < steps.index(deploy)
# GitHub Pages serves the gh-pages branch root. The action's ``token``
# input defaults to the job token, which needs write access to push.
assert deploy["with"]["branch"] == "gh-pages"
assert deploy["with"]["folder"] == "docs/_build/html"
assert docs["permissions"].get("contents") == "write"
# A constant group serializes deploys across release runs.
concurrency = docs["concurrency"]
if isinstance(concurrency, str):
concurrency = {"group": concurrency}
assert "${{" not in concurrency["group"]
assert concurrency.get("cancel-in-progress", False) is False


def test_documentation_build_matches_deploy_folder():
recipe = re.search(
r"^documentation:.*\n((?:\t.*\n?)+)", MAKEFILE.read_text(), re.MULTILINE
)
extensions = yaml.safe_load(DOCS_CONFIG.read_text())["sphinx"]["extra_extensions"]

# ``jb build docs`` writes HTML to docs/_build/html, the deployed folder.
assert recipe is not None
assert "jb build docs" in [line.strip() for line in recipe.group(1).splitlines()]
# Pages builds a branch source with Jekyll unless its root has .nojekyll,
# which this extension writes into the HTML output.
assert "sphinx.ext.githubpages" in extensions


def test_documentation_deploy_cannot_block_pypi_publishing():
jobs = load_jobs(PUSH_WORKFLOW)

for name in jobs_using(jobs, PYPI_ACTION):
assert name != "Docs"
assert "Docs" not in upstream_jobs(jobs, name)
Loading