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
143 changes: 143 additions & 0 deletions .github/scripts/docs_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Decide how a ``release/*`` branch publishes its docs with mike.

Used by ``.github/workflows/docs.yml``. The docs version is the PEP 440 version
in the branch name (``release/v2.5.0`` -> ``2.5.0``, ``release/v3.0.0a2`` ->
``3.0.0a2``). The alias depends on what is already on gh-pages:

* A stable version takes ``latest`` (and becomes the site default) when it is
at least as new as every stable version already deployed; an older one, such
as a 2.4.2 patch after 2.5.0, is deployed with no alias.
* A pre-release takes the alias for its kind (``alpha``, ``beta``, ``rc`` or
``dev``) when it is at least as new as every deployed version of that kind,
and never touches ``latest`` or the default.

Usage::

python docs_version.py <ref name> <mike list --json output>

The decision is written to ``$GITHUB_OUTPUT`` as ``version``, ``alias`` (may be
empty), ``title``, ``set_default`` and ``prerelease``, and printed.
"""

from __future__ import annotations

import json
import os
import sys
from dataclasses import dataclass
from typing import Any

from packaging.version import InvalidVersion, Version

BRANCH_PREFIX = "release/"
LATEST = "latest"
_PRE_ALIASES = {"a": "alpha", "b": "beta", "rc": "rc"}


@dataclass(frozen=True)
class Decision:
"""How mike should deploy one version."""

version: str
alias: str
title: str
set_default: bool
prerelease: bool


def parse_branch(ref_name: str) -> Version:
"""Return the PEP 440 version named by a ``release/[v]X.Y.Z...`` branch.

Raises:
ValueError: If the branch is not under ``release/`` or its name is not
a public PEP 440 version.
"""
if not ref_name.startswith(BRANCH_PREFIX):
raise ValueError(f"branch {ref_name!r} is not a {BRANCH_PREFIX}* branch")
name = ref_name.removeprefix(BRANCH_PREFIX)
name = name.removeprefix("v")
try:
version = Version(name)
except InvalidVersion:
raise ValueError(
f"branch {ref_name!r} does not name a PEP 440 version (expected e.g. release/v2.5.0 or release/v3.0.0a2)"
) from None
if version.local is not None:
raise ValueError(f"branch {ref_name!r} names a local version ({version}); use a public version")
return version


def kind(version: Version) -> str | None:
"""Return the alias for a pre-release's kind, or ``None`` for a stable version.

A ``.devN`` suffix wins over the pre-release marker, so ``3.0.0a2.dev1`` is
``dev``.
"""
if version.dev is not None:
return "dev"
if version.pre is not None:
return _PRE_ALIASES[version.pre[0]]
return None


def deployed_versions(mike_list: str) -> list[Version]:
"""Return the PEP 440 versions in ``mike list --json`` output.

Empty output (no gh-pages branch yet) means nothing is deployed. Entries
whose name is not a PEP 440 version are ignored.
"""
if not mike_list.strip():
return []
entries: list[dict[str, Any]] = json.loads(mike_list)
versions = []
for entry in entries:
try:
versions.append(Version(str(entry["version"])))
except InvalidVersion:
continue
return versions


def decide(ref_name: str, mike_list: str) -> Decision:
"""Decide the docs version, alias and title for *ref_name*."""
version = parse_branch(ref_name)
this_kind = kind(version)
peers = [v for v in deployed_versions(mike_list) if kind(v) == this_kind]
newest = all(version >= v for v in peers)

if this_kind is None:
alias = LATEST if newest else ""
return Decision(str(version), alias, str(version), set_default=newest, prerelease=False)

alias = this_kind if newest else ""
return Decision(str(version), alias, f"{version} ({this_kind})", set_default=False, prerelease=True)


def main(argv: list[str]) -> int:
"""Write the decision for ``argv[1]`` (ref name) and ``argv[2]`` (mike list JSON)."""
if len(argv) != 3:
print("usage: docs_version.py <ref name> <mike list --json output>", file=sys.stderr)
return 2
try:
decision = decide(argv[1], argv[2])
except ValueError as exc:
print(f"::error::{exc}")
return 1

lines = [
f"version={decision.version}",
f"alias={decision.alias}",
f"title={decision.title}",
f"set_default={str(decision.set_default).lower()}",
f"prerelease={str(decision.prerelease).lower()}",
]
print("\n".join(lines))
output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ jobs:
run: uv sync --group docs

- name: Build docs
run: uv run mkdocs build
run: uv run mkdocs build --strict

# ── Build verification ─────────────────────────────────────────────────
build:
Expand Down
115 changes: 87 additions & 28 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,81 @@
name: Deploy docs

# Publishes the MkDocs site to GitHub Pages (https://swgoh-utils.github.io/comlink-python/).
# Publishes the versioned MkDocs site (https://swgoh-utils.github.io/comlink-python/)
# with mike. Every `release/*` branch publishes its own folder on the `gh-pages`
# branch, named after the PEP 440 version in the branch name, and Material's version
# picker reads the versions.json that mike keeps there.
#
# Deploys from `main` only, so the published docs always describe the latest release
# rather than unreleased work on `develop`. CI's `docs` job still build-checks every PR.
# .github/scripts/docs_version.py picks the alias from what is already deployed:
#
# One-time setup: Settings -> Pages -> Build and deployment -> Source: "GitHub Actions".
# branch version alias title
# release/v2.5.0 2.5.0 latest (default) 2.5.0 newest stable so far
# release/v2.4.2 2.4.2 (none) 2.4.2 older than a deployed stable
# release/v3.0.0a2 3.0.0a2 alpha 3.0.0a2 (alpha)
# release/v3.0.0b1 3.0.0b1 beta 3.0.0b1 (beta)
# release/v3.0.0rc1 3.0.0rc1 rc 3.0.0rc1 (rc)
# release/v3.0.0.dev1 3.0.0.dev1 dev 3.0.0.dev1 (dev)
#
# A stable version takes `latest` only if it is at least as new as every deployed
# stable version; a pre-release takes its kind's alias only if it is at least as new
# as every deployed version of that kind, and never touches `latest`. The root URL
# redirects to `latest`. main, develop and tags do not deploy; CI's `docs` job
# build-checks every PR.
#
# Pushes made with GITHUB_TOKEN (such as prepare-release.yml's) do not start this
# workflow; run it by hand on that release branch instead.
#
# One-time setup: Settings -> Pages -> Build and deployment -> Source:
# "Deploy from a branch", branch `gh-pages`, folder `/ (root)`.
# See "Publishing the docs" in CONTRIBUTING.md.

on:
push:
branches: [main]
branches: ["release/**"]
paths:
- "docs/**"
- "overrides/**"
- "src/**"
- "mkdocs.yml"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/docs.yml"
- ".github/scripts/docs_version.py"
# Run by hand on a release/* branch, e.g. after prepare-release.yml pushed it.
# Runs started on any other ref are skipped by the job condition below.
workflow_dispatch:

permissions:
contents: read
contents: write

# Deploys push to the gh-pages branch, so they queue behind one another instead
# of racing.
concurrency:
group: pages
group: gh-pages
cancel-in-progress: false

jobs:
build:
name: Build docs
deploy:
name: Deploy versioned docs
# Only release branches of the upstream repository publish; forks and runs on
# other refs never touch gh-pages.
if: github.repository == 'swgoh-utils/comlink-python' && startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Full history: hatch-vcs needs the tags, and mike builds on top of the
# existing origin/gh-pages history.
fetch-depth: 0
# `mike deploy --push` pushes gh-pages with the token checkout stores.
# No artifacts are uploaded, so it never leaves the runner.
persist-credentials: true

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
# This job publishes the site, so it must not restore a cache that
# other workflows (e.g. PR runs) can write.
enable-cache: false

- name: Set up Python
uses: actions/setup-python@v7
Expand All @@ -43,25 +85,42 @@ jobs:
- name: Install dependencies
run: uv sync --group docs

- name: Build docs
- name: Pick docs version
id: version
env:
REF_NAME: ${{ github.ref_name }}
run: |
# `[]` when gh-pages does not exist yet.
deployed="$(uv run mike list --json)"
uv run --with packaging python .github/scripts/docs_version.py "$REF_NAME" "$deployed"

- name: Build docs (strict)
env:
DOCS_PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: uv run mkdocs build --strict

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: site/
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v5
- name: Deploy with mike
env:
VERSION: ${{ steps.version.outputs.version }}
ALIAS: ${{ steps.version.outputs.alias }}
TITLE: ${{ steps.version.outputs.title }}
SET_DEFAULT: ${{ steps.version.outputs.set_default }}
# Shows the pre-release banner (mkdocs.yml extra.prerelease).
DOCS_PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: |
if [[ -n "$ALIAS" ]]; then
uv run mike deploy --push --update-aliases --title "$TITLE" "$VERSION" "$ALIAS"
else
uv run mike deploy --push --title "$TITLE" "$VERSION"
fi

# Only the newest stable version gets here. Re-running it when the root
# already redirects to `latest` is a no-op (mike skips the empty commit).
if [[ "$SET_DEFAULT" == "true" ]]; then
uv run mike set-default --push latest
fi
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pip-delete-this-directory.txt
# Exhaustive tests and support files (owner-only, not distributed)
tests/exhaustive/
scripts/
# ...but not the workflow helpers (docs_version.py).
!/.github/scripts/
data/
gameData.json
.pythonrc.py
Expand Down
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
# mkdocs.yml uses MkDocs' `!ENV` tag, which the safe loader rejects;
# `mkdocs build --strict` (CI's docs job) validates it instead.
exclude: ^mkdocs\.yml$
- id: check-toml
- id: check-added-large-files
- id: debug-statements
Expand Down
Loading
Loading