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
79 changes: 18 additions & 61 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Avitai Documentation</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 100px auto;
padding: 20px;
text-align: center;
}
h1 { color: #333; }
ul { list-style: none; padding: 0; }
li { margin: 10px 0; }
a {
color: #0066cc;
text-decoration: none;
font-size: 18px;
padding: 10px 20px;
display: inline-block;
border: 1px solid #0066cc;
border-radius: 5px;
transition: all 0.3s;
}
a:hover {
background: #0066cc;
color: white;
}
</style>
</head>
<body>
<h1>Avitai Documentation</h1>
<p>Available documentation:</p>
<ul>
<li><a href="./diffbio/">DiffBio - Differentiable Bioinformatics</a></li>
</ul>
</body>
</html>
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
88 changes: 88 additions & 0 deletions tests/test_ci_docs.py
Original file line number Diff line number Diff line change
@@ -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
Loading