From ee989f85999e2edf1878182c6ee975f5fe8a947c Mon Sep 17 00:00:00 2001 From: Mahdi Shafiei Date: Wed, 30 Sep 2026 14:17:18 -0700 Subject: [PATCH] docs: build the documentation as a check; Read the Docs hosts it docs.yml built the site and pushed it to gh-pages with peaceiris/actions-gh-pages and cname: docs.avitai.bio. Every push to main deployed to a site that does not exist: the Pages settings API returns 404 for the repository, https://avitai.github.io/DiffBio returns 404, and docs.avitai.bio has no DNS records. Read the Docs builds and serves the documentation from .readthedocs.yaml, and https://diffbio.readthedocs.io returns 200. docs.yml now runs a strict mkdocs build on each pull request and each push to main that touches the docs, mkdocs.yml, the package source or the workflow, with a contents: read token and no deploy step. The concurrency group keeps cancel-in-progress: true, which tests/test_ci_concurrency.py requires of every workflow a push triggers. A contract test holds it there: no workflow uses a GitHub Pages deploy action or requests pages: write, docs.yml holds a read-only token, and it builds strictly on both events. --- .github/workflows/docs.yml | 79 ++++++++-------------------------- tests/test_ci_docs.py | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 61 deletions(-) create mode 100644 tests/test_ci_docs.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 04be147..601a5b1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,7 @@ -name: Deploy Documentation +name: Documentation + +# Read the Docs builds and hosts the documentation (.readthedocs.yaml). This workflow only checks +# that the site builds strictly, so a change that breaks it fails before it reaches Read the Docs. on: push: @@ -8,6 +11,17 @@ on: - 'docs/**' - 'mkdocs.yml' - 'pyproject.toml' + - 'src/diffbio/**' + - '.github/actions/setup-diffbio/action.yml' + - '.github/workflows/docs.yml' + pull_request: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'pyproject.toml' + - 'src/diffbio/**' - '.github/actions/setup-diffbio/action.yml' - '.github/workflows/docs.yml' workflow_dispatch: @@ -17,16 +31,15 @@ concurrency: cancel-in-progress: true permissions: - contents: write + contents: read jobs: - deploy: + build: + name: Build Docs runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up DiffBio docs environment uses: ./.github/actions/setup-diffbio @@ -37,59 +50,3 @@ jobs: - name: Build documentation run: uv run mkdocs build --clean --strict - - - name: Prepare deployment directory - run: | - mkdir -p deploy/diffbio - mv site/* deploy/diffbio/ - echo "docs.avitai.bio" > deploy/CNAME - cat > deploy/index.html << 'EOF' - - - - - Avitai Documentation - - - -

Avitai Documentation

-

Available documentation:

- - - - EOF - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./deploy - force_orphan: true - cname: docs.avitai.bio diff --git a/tests/test_ci_docs.py b/tests/test_ci_docs.py new file mode 100644 index 0000000..666c88f --- /dev/null +++ b/tests/test_ci_docs.py @@ -0,0 +1,88 @@ +"""The documentation is built as a check; Read the Docs builds and hosts it. + +``.readthedocs.yaml`` publishes the site. No workflow deploys to GitHub Pages, and ``docs.yml`` +only proves the site builds strictly, on a pull request and on a push to ``main``, with a +read-only token. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + + +WORKFLOWS = Path(__file__).resolve().parents[1] / ".github" / "workflows" +DOCS_WORKFLOW = WORKFLOWS / "docs.yml" +PAGES_ACTIONS = ( + "peaceiris/actions-gh-pages", + "actions/deploy-pages", + "actions/upload-pages-artifact", +) +READ_ONLY = {"contents": "read"} + + +def _load(path: Path) -> dict[str, Any]: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _triggers(document: dict[str, Any]) -> dict[str, Any]: + """The ``on`` mapping; PyYAML reads the bare key ``on`` as the boolean ``True``.""" + keys: dict[Any, Any] = document + return keys.get("on") or keys.get(True) or {} + + +def _permission_blocks(document: dict[str, Any]) -> list[Any]: + """Every ``permissions`` block: the workflow's own, then each job's.""" + blocks = [document["permissions"]] if "permissions" in document else [] + blocks += [job["permissions"] for job in document["jobs"].values() if "permissions" in job] + return blocks + + +def _steps(document: dict[str, Any]) -> list[dict[str, Any]]: + return [step for job in document["jobs"].values() for step in job.get("steps", [])] + + +def _workflow_paths() -> list[Path]: + return sorted(WORKFLOWS.glob("*.yml")) + + +def test_the_contract_reads_the_workflows() -> None: + """A positive control: the scan below must see docs.yml and its steps.""" + assert DOCS_WORKFLOW in _workflow_paths() + assert _steps(_load(DOCS_WORKFLOW)) + + +@pytest.mark.parametrize("path", _workflow_paths(), ids=lambda path: path.name) +def test_no_workflow_deploys_to_github_pages(path: Path) -> None: + document = _load(path) + uses = [str(step.get("uses", "")) for step in _steps(document)] + + assert [use for use in uses if use.startswith(PAGES_ACTIONS)] == [], path.name + assert [block for block in _permission_blocks(document) if "pages" in block] == [], path.name + + +def test_the_docs_workflow_holds_a_read_only_token() -> None: + blocks = _permission_blocks(_load(DOCS_WORKFLOW)) + + assert blocks, "docs.yml inherits the default token permissions" + assert all(block == READ_ONLY for block in blocks), blocks + + +@pytest.mark.parametrize("event", ["pull_request", "push"]) +def test_the_docs_build_runs_on_pull_requests_and_pushes_to_main(event: str) -> None: + triggers = _triggers(_load(DOCS_WORKFLOW)) + + assert event in triggers + assert triggers[event]["branches"] == ["main"] + assert triggers[event]["paths"] == triggers["push"]["paths"] + + +def test_the_docs_build_is_strict() -> None: + commands = [str(step.get("run", "")) for step in _steps(_load(DOCS_WORKFLOW))] + builds = [command for command in commands if "mkdocs build" in command] + + assert builds, "docs.yml never builds the site" + assert all("--strict" in command for command in builds), builds