From 2a467f6b8dd150a2d4f1b0526b4bba55bcfb1a3a Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Sat, 17 Oct 2026 08:45:40 -0400 Subject: [PATCH 1/3] ci(docs): publish versioned docs with mike Replace the single-version deploy-pages workflow with mike deploys to a gh-pages branch so the 2.x product line and the 3.x pre-release line can share https://swgoh-utils.github.io/comlink-python/ behind Material's version picker. This is the 2.x half; the 3.x line carries the same docs.yml and overrides, with `extra.prerelease: true` in mkdocs.yml. - docs.yml: deploy on main and release/v* (plus manual runs on v* tags); the version comes from CHANGELOG.md like release.yml: main -> X.Y + `latest`, pre-release lines -> X.Y + `dev` titled "(pre-release)"; stable release/vX.Y.Z staging branches are skipped. The first stable deploy runs `mike set-default latest` so the root keeps opening 2.x. fetch-depth 0, bot git identity, contents: write, one gh-pages concurrency group, and a guard so forks never publish. - mkdocs.yml: extra.version.provider mike, default alias `latest`. - overrides/main.html: "older version" banner (pre-release banner when a line sets extra.prerelease). - docs dependency group: add mike. - ci.yml: build-check docs with --strict, matching the deploy. - CONTRIBUTING: "Publishing the docs" with the one-time cutover steps. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 129 +++++++++++++++++++++++++++++-------- CONTRIBUTING.md | 58 ++++++++++++++++- mkdocs.yml | 11 ++++ overrides/main.html | 31 +++++++++ pyproject.toml | 2 + uv.lock | 51 ++++++++++++++- 7 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 overrides/main.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74401d4..ce425d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ca73c69..48bd7db 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,36 +1,64 @@ 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 line deploys its own folder to the `gh-pages` branch, 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. +# ref CHANGELOG/tag version docs version alias +# main X.Y.Z X.Y latest +# release/v* carrying a pre-release X.Y.ZaN / bN / rcN X.Y dev (titled "X.Y (pre-release)") +# v* tag (manual run only) the tag X.Y latest or dev, as above # -# One-time setup: Settings -> Pages -> Build and deployment -> Source: "GitHub Actions". +# Branches take their version from the first release heading in CHANGELOG.md, the +# same source release.yml uses (release tags only appear after the PyPI publish, so +# `git describe` would still name the previous release). Stable `release/vX.Y.Z` +# branches staged by prepare-release.yml are skipped: their docs go live when the +# release PR merges into main. +# +# The root URL redirects to `latest` (`mike set-default latest`), so it always shows +# the newest stable release. CI's `docs` job still build-checks every PR. +# +# 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: [main, "release/v*"] paths: - "docs/**" + - "overrides/**" - "src/**" - "mkdocs.yml" - "pyproject.toml" + - "uv.lock" - ".github/workflows/docs.yml" + # Run by hand on main, a release/v* branch or a v* tag, e.g. to publish the first + # version or to rebuild an older one. workflow_dispatch: permissions: - contents: read + contents: write +# Every line pushes to the same gh-pages branch, so deploys from all branches and +# tags 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 + # Forks (including a maintainer fork carrying release/v* branches) must not + # publish their own gh-pages. + if: github.repository == 'swgoh-utils/comlink-python' 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 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -43,25 +71,70 @@ jobs: - name: Install dependencies run: uv sync --group docs - - name: Build docs + - name: Pick docs version + id: version + env: + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + run: | + case "$REF" in + refs/tags/v*) + release="${REF_NAME#v}" ;; + refs/heads/main | refs/heads/release/v*) + release="$(grep -m1 -oE '^## \[v?[0-9]+\.[0-9]+\.[0-9]+[^]]*\]' CHANGELOG.md \ + | sed -E 's/^## \[v?([0-9][^]]*)\]$/\1/')" ;; + *) + echo "::error::Docs deploy from $REF is not supported; run it on main, release/v* or a v* tag." + exit 1 ;; + esac + + if [[ ! "$release" =~ ^([0-9]+)\.([0-9]+) ]]; then + echo "::error::Cannot read a MAJOR.MINOR version from '$release'." + exit 1 + fi + version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + + # PEP 440 pre-release markers: 3.0.0a2, 3.0.0b1, 3.0.0rc1, 3.0.0.dev1. + if [[ "$release" =~ (a|b|rc|\.dev)[0-9]+$ ]]; then + alias="dev" + title="$version (pre-release)" + elif [[ "$REF" == refs/heads/release/v* ]]; then + echo "::notice::$REF_NAME stages a stable release; its docs deploy from main once it merges." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + else + alias="latest" + title="$version" + fi + + echo "Deploying $REF as version=$version alias=$alias title='$title'" + { + echo "version=$version" + echo "alias=$alias" + echo "title=$title" + } >> "$GITHUB_OUTPUT" + + - name: Build docs (strict) + if: steps.version.outputs.skip != 'true' run: uv run mkdocs build --strict - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: site/ + - name: Configure git identity + if: steps.version.outputs.skip != 'true' + 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 + if: steps.version.outputs.skip != 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + ALIAS: ${{ steps.version.outputs.alias }} + TITLE: ${{ steps.version.outputs.title }} + run: | + uv run mike deploy --push --update-aliases --title "$TITLE" "$VERSION" "$ALIAS" + + # The first stable deploy also points the root URL at `latest`. Later + # deploys leave the default alone (moving `latest` is enough). + if [[ "$ALIAS" == "latest" ]] && ! git cat-file -e gh-pages:index.html 2>/dev/null; then + uv run mike set-default --push latest + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67d5343..750402b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,7 @@ Thanks for your interest in contributing to comlink-python! This guide covers ev - [Submitting a Pull Request](#submitting-a-pull-request) - [Issue Guidelines](#issue-guidelines) - [Release Process](#release-process) + - [Publishing the docs](#publishing-the-docs) - [Getting Help](#getting-help) --- @@ -135,7 +136,7 @@ comlink-python/ │ │ ├── backmerge.yml # Stages the main → develop back-merge after a release │ │ ├── ci.yml # CI pipeline (lint, type-check, test, docs, build) │ │ ├── commitlint.yml # Commit message validation (runs the pre-commit hook) -│ │ ├── docs.yml # Publishes the docs site to GitHub Pages from main +│ │ ├── docs.yml # Publishes the versioned docs site (mike → gh-pages) │ │ ├── integration.yml # Integration tests against live comlink services │ │ ├── issue-fixed-in-develop.yml # Comments on issues a merge into develop resolves │ │ ├── labeler.yml # Auto-label PRs by file path @@ -186,6 +187,7 @@ comlink-python/ ├── SECURITY.md # Supported versions and vulnerability reporting ├── SUPPORT.md # Where to get help / report what ├── mkdocs.yml # Docs site configuration +├── overrides/ # MkDocs Material theme overrides (version banners) ├── pyproject.toml # Project metadata, build config, tool settings └── uv.lock # Locked dependency versions ``` @@ -559,6 +561,60 @@ This is why conventional commit messages matter — they become the changelog an - **Minor** (2.x.0): New features, backward-compatible changes - **Major** (x.0.0): Breaking API changes +### Publishing the docs + +The docs site at [swgoh-utils.github.io/comlink-python](https://swgoh-utils.github.io/comlink-python/) is versioned with [mike](https://github.com/jimporter/mike). Each release line builds its own copy of the site into a folder on the `gh-pages` branch, and the version picker in the header (Material for MkDocs, `extra.version.provider: mike`) switches between them. [`docs.yml`](.github/workflows/docs.yml) runs the deploy after every push that touches the docs: + +| Pushed ref | Version | Alias | Picker title | +|---|---|---|---| +| `main` | `X.Y` of the first release in `CHANGELOG.md` (today `2.5`) | `latest` | `2.5` | +| `release/v*` whose `CHANGELOG.md` names a pre-release (`3.0.0a2`, `3.0.0rc1`, …) | `3.0` | `dev` | `3.0 (pre-release)` | +| `release/vX.Y.Z` for a stable release (from `prepare-release.yml`) | skipped; its docs deploy from `main` once the release PR merges | | | +| a `v*` tag, when the workflow is run by hand on it | from the tag | `latest` or `dev`, as above | | + +- The root URL redirects to `latest`, so it always opens the newest stable release. Patch releases redeploy their minor version (`2.5.1` replaces the `2.5` folder). +- Pre-release builds set `extra.prerelease: true` in `mkdocs.yml`, which shows a "pre-release" banner on every page ([`overrides/main.html`](overrides/main.html)). Release builds leave it unset and instead get Material's "older version" banner once a newer line becomes `latest`. +- Every deploy queues in the `gh-pages` concurrency group, so runs from different lines never race. +- `mkdocs.yml`, `overrides/` and `docs.yml` must stay the same on every line, except for `extra.prerelease` and the nav. + +**Preview the versioned site locally** without touching the remote (the deploy commits to a local `gh-pages` branch only): + +```bash +uv sync --group docs +uv run mike deploy --title "2.5" 2.5 latest # or: --title "3.0 (pre-release)" 3.0 dev +uv run mike set-default latest +uv run mike serve # http://localhost:8000/ +git branch -D gh-pages # throw the local preview away +``` + +For a quick single-version preview, `uv run mkdocs serve` is still enough; the version picker only appears under `mike serve`. + +#### One-time cutover (maintainer) + +The site used to be deployed by `actions/deploy-pages` from `main` only. To switch to the versioned layout: + +1. **Merge the versioned docs setup into `main`** (through `develop` and the normal release PR, or a docs-only PR). The deploy that runs on that push creates the `gh-pages` branch with `2.5/`, the `latest` alias and the root redirect (`mike set-default latest` runs automatically on the first stable deploy). The old site stays live while the Pages source is still "GitHub Actions". +2. **Check the first version.** If the merge did not touch any docs path, run **Actions → Deploy docs → Run workflow** on `main`. Deploy `2.5` before any pre-release: until a stable version exists there is no root redirect. To do it by hand instead: + + ```bash + git switch main && git pull + uv sync --group docs + uv run mike deploy --push --update-aliases --title "2.5" 2.5 latest + uv run mike set-default --push latest + ``` + +3. **Switch the Pages source.** In **Settings → Pages → Build and deployment**, change **Source** from "GitHub Actions" to **"Deploy from a branch"**, branch **`gh-pages`**, folder **`/ (root)`**. Check that the root URL opens the 2.5 docs. +4. **Publish the pre-release line.** Once the active `release/v3.*` branch lives in this repository, push to it (or run the workflow on it) to add `3.0` with the `dev` alias. The job runs only in `swgoh-utils/comlink-python`, so forks never publish their own `gh-pages`. +5. **Protect `gh-pages`** (optional, recommended). Add a ruleset or branch protection rule for `gh-pages` that blocks force pushes and deletion. Leave direct pushes allowed for GitHub Actions: the deploy pushes with the workflow's `GITHUB_TOKEN`, so a rule that requires pull requests or status checks would block it (or add "GitHub Actions" to the bypass list). +6. **Clean up.** The old `github-pages` environment created by `deploy-pages` can be deleted, or kept for history. + +#### Maintaining versions + +- **Remove a version** (for example an abandoned pre-release): `uv run mike delete --push 3.0`. +- **Retitle** a version: `uv run mike retitle --push 3.0 "3.0 (beta)"`. +- **When 3.0.0 is released**, `main` deploys `3.0` as `latest` and `--update-aliases` moves `latest` off `2.5`, which then shows the "older version" banner. Drop the leftover `dev` alias with `uv run mike alias --push --update-aliases dev`, or delete it, and remove `extra.prerelease` from `mkdocs.yml` on `main`. +- **Rebuild an older line** by running the workflow by hand on its tag (for example `v2.5.0`). A stable tag also moves `latest`, so only do this for the newest stable line. + --- ## Getting Help diff --git a/mkdocs.yml b/mkdocs.yml index 6ce5be0..d794d84 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,7 @@ edit_uri: edit/develop/docs/ theme: name: material + custom_dir: overrides palette: - scheme: default primary: indigo @@ -34,6 +35,16 @@ nav: - "Helpers": api/helpers.md - "Exceptions": api/exceptions.md +extra: + # Versioned with mike: each release line publishes its own folder on the + # gh-pages branch (see "Publishing the docs" in CONTRIBUTING.md). + version: + provider: mike + default: latest + alias: true + # Pre-release lines set `prerelease: true` here to show a banner on every + # page (overrides/main.html). Release lines leave it unset. + extra_css: - stylesheets/extra.css diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..3650d23 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{# + Version banners for the mike-versioned site (see "Publishing the docs" in + CONTRIBUTING.md). Each published version is built from its own branch, so a + branch decides its own banner through `extra.prerelease` in mkdocs.yml: + + - pre-release lines (extra.prerelease: true) always show the announce banner; + - release lines show Material's "outdated" banner, which its JavaScript + reveals only when the version being viewed is not the default (`latest`). + + The `-` whitespace control matters: Material renders a banner whenever the + block's output is non-empty, so stray whitespace would draw an empty one. +#} + +{% block announce -%} + {%- if config.extra.prerelease %} + You are viewing the docs for a pre-release of swgoh_comlink. + APIs may change before the final release. + Go to the docs for the latest stable release. + {% endif -%} +{%- endblock %} + +{% block outdated -%} + {%- if not config.extra.prerelease %} + You are viewing the docs for an older release of swgoh_comlink. + + Go to the docs for the latest release. + + {% endif -%} +{%- endblock %} diff --git a/pyproject.toml b/pyproject.toml index 0297699..0de4cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ include = [ "/LICENSE", "/pyproject.toml", "/mkdocs.yml", + "/overrides", ] [tool.uv.sources] @@ -131,6 +132,7 @@ dev = [ {include-group = "lint"}, ] docs = [ + "mike>=2.2.0", # MkDocs 2.0 removes the plugin system (mkdocstrings) and theme overrides that this # site depends on, with no migration path — stay on 1.x until the ecosystem settles. "mkdocs>=1.6,<2", diff --git a/uv.lock b/uv.lock index 12ccc47..aa734cd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -409,6 +409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inversion" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/b5/e2d34bdeb088e3d28f5466c7c687126bba8337dcc0997c6c0ae40d4c4add/inversion-1.0.1.tar.gz", hash = "sha256:39b0d99da7924bcb21fc3e96ef2ac85dc83346461f74dd6a758ecaff3c75bb8e", size = 27028, upload-time = "2026-10-15T16:02:08.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/78/d1caf51e136f98eef9f45fd417a21f2389bf8042e8d8f3f1ed7832a500ca/inversion-1.0.1-py3-none-any.whl", hash = "sha256:a8b7b7bb1bef85318179421ef11e5222bd492f477373f0dcb80f323f8b0595d2", size = 19846, upload-time = "2026-10-15T16:02:07.701Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -524,6 +533,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -711,6 +737,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -938,6 +973,7 @@ dev = [ { name = "ty" }, ] docs = [ + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -973,6 +1009,7 @@ dev = [ { name = "ty", specifier = ">=0.0.34" }, ] docs = [ + { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs", specifier = ">=1.6,<2" }, { name = "mkdocs-material", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, @@ -1083,6 +1120,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] +[[package]] +name = "verspec" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "inversion" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/e3/cf3dcb426ca8dcc1421478e75582e1fea1d37e11522e31670c4e1b26701e/verspec-0.2.1.tar.gz", hash = "sha256:2046ea165be01b3ff4a8c2319d0ced57b1d7673fb05b38a75367531acde3b534", size = 1893, upload-time = "2026-10-15T15:33:24.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/9d/b0f5248cdaae8c34a779db8cb282b6ebab760cfa349f05f5d0bb2530e508/verspec-0.2.1-py3-none-any.whl", hash = "sha256:bc192db9f51ba0c14cd3874167656f84aca1736bef002385299dab675b07377d", size = 2632, upload-time = "2026-10-15T15:33:24.111Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From ed3d20418725cb3c8f5d4ce564eb3f9d3c297f8e Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Sat, 17 Oct 2026 09:00:03 -0400 Subject: [PATCH 2/3] docs: transport errors arrive as SwgohComlinkException The v1 -> v2 migration guide and README told readers to replace `except requests.RequestException` with `except httpx.RequestError`, but the clients catch httpx.RequestError and re-raise SwgohComlinkException, so that handler never fires. Point readers at SwgohComlinkException and list the SwgohComlinkHTTPError family (v2.3+) in the hierarchy. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/migration.md | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fa53521..c363eff 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ For details on enabling and customizing log output, see [docs/logging.md](https: This release replaces `requests` with `httpx` and changes how logging is configured. If you are upgrading from a previous version, see the [Migration Guide](https://github.com/swgoh-utils/comlink-python/blob/main/docs/migration.md) for a walkthrough of required changes: - Replace `requests` with `httpx` in your dependencies -- Update `except requests.RequestException` to `except httpx.RequestError` (or catch `SwgohComlinkException`) +- Update `except requests.RequestException` to `except SwgohComlinkException` (network errors are wrapped in it) - Update any `get_logger(log_level=...)` calls — configure logging via the standard `logging` module instead - Use a context manager (`with SwgohComlink() ...`) or call `close()` to release connections diff --git a/docs/migration.md b/docs/migration.md index f0f769a..a188dc0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -27,25 +27,27 @@ you only need to upgrade the package itself. ## 2. Update exception handling -The exception raised on HTTP errors changed from `requests.RequestException` to -`httpx.RequestError`. The library still wraps transport errors in its own -`SwgohComlinkException`, so if you catch that you are already covered. +v1.x let `requests.RequestException` escape on network failures. The client now +catches the transport errors raised by `httpx` (`httpx.RequestError`: connection +refused, timeouts, DNS failures) and re-raises them as `SwgohComlinkException`, so +code that caught the `requests` exception must catch `SwgohComlinkException` +instead. Do not switch it to `httpx.RequestError`; that exception never reaches +your code. ```diff from swgoh_comlink import SwgohComlink - import requests -+ import httpx ++ from swgoh_comlink.exceptions import SwgohComlinkException comlink = SwgohComlink() try: player = comlink.get_player(allycode=245866537) - except requests.RequestException as e: -+ except httpx.RequestError as e: ++ except SwgohComlinkException as e: print(f"Network error: {e}") ``` -**Recommended:** Catch `SwgohComlinkException` instead of transport-level errors — this -insulates your code from future HTTP library changes: +To tell argument errors apart from service and network errors, catch the subclasses first: ```python from swgoh_comlink import SwgohComlink @@ -71,6 +73,12 @@ The exception hierarchy is: - `SwgohComlinkException` — base for all library errors - `SwgohComlinkValueError` (also inherits `ValueError`) — invalid argument values - `SwgohComlinkTypeError` (also inherits `TypeError`) — invalid argument types + - `SwgohComlinkHTTPError` — comlink answered with an HTTP error status (v2.3+) + - `SwgohComlinkRateLimitError` — HTTP 429 or the game's `Rate exceeded!` + - `SwgohComlinkUnavailableError` — HTTP 503 + - `SwgohComlinkClientError` — any other 4xx + +See [Exceptions](api/exceptions.md) for details. ## 3. Update logging configuration @@ -304,7 +312,7 @@ added together. | Area | Before (v1.x) | After | |------|---------------|-------| | HTTP library | `requests` | `httpx` | -| Transport exception | `requests.RequestException` | `httpx.RequestError` | +| Transport exception | `requests.RequestException` | `SwgohComlinkException` (wraps `httpx.RequestError`) | | Exception hierarchy | `SwgohComlinkException`, `SwgohComlinkValueError` | Added `SwgohComlinkTypeError` | | `get_logger(log_level=)` | Accepted `log_level`, auto-configured handlers | No `log_level` param, no auto-configuration | | Default log output | Console output at INFO level | Silent (NullHandler only) | From 038999d65473459837f3cd8b26f209067eb4cb40 Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Sat, 17 Oct 2026 11:22:16 -0400 Subject: [PATCH 3/3] ci(docs): deploy each release/* branch as its own docs version Per review: docs deploy from branches named release/*, each published under its own version; latest/default follows the newest stable release and pre-releases get an alias for their kind. - docs.yml: trigger on pushes to release/** (same path filters, plus the new helper) and workflow_dispatch; the job runs only in swgoh-utils/comlink-python on refs/heads/release/*. main, develop and tags no longer deploy. - .github/scripts/docs_version.py: version = PEP 440 version in the branch name (release/v2.5.0 -> 2.5.0); invalid or local versions fail with ::error::. Reads `mike list --json`: a stable version takes `latest` (and set-default) only if >= every deployed stable; a pre-release takes alpha/beta/rc/dev (dev wins) only if >= every deployed version of its kind, and never touches latest. Unit tests in tests/unit/test_docs_version.py. - mkdocs.yml: extra.prerelease reads DOCS_PRERELEASE (set from the decision), defaulting to the line's own value for local builds; check-yaml skips mkdocs.yml (the strict docs build validates it). - setup-uv cache disabled in the publishing job (zizmor cache-poisoning). - .gitignore: un-ignore .github/scripts/ (a bare `scripts/` rule hid it). - CONTRIBUTING: version/alias rules, release timing (prepare-release.yml pushes with GITHUB_TOKEN, which does not start workflows, so run the deploy by hand), and the cutover steps. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/docs_version.py | 143 ++++++++++++++++++++++++++++++++ .github/workflows/docs.yml | 116 ++++++++++++-------------- .gitignore | 2 + .pre-commit-config.yaml | 3 + CONTRIBUTING.md | 62 ++++++++------ mkdocs.yml | 8 +- overrides/main.html | 9 +- tests/unit/test_docs_version.py | 140 +++++++++++++++++++++++++++++++ 8 files changed, 384 insertions(+), 99 deletions(-) create mode 100644 .github/scripts/docs_version.py create mode 100644 tests/unit/test_docs_version.py diff --git a/.github/scripts/docs_version.py b/.github/scripts/docs_version.py new file mode 100644 index 0000000..b308232 --- /dev/null +++ b/.github/scripts/docs_version.py @@ -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 + +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 ", 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)) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 48bd7db..400f892 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,22 +1,28 @@ name: Deploy docs # Publishes the versioned MkDocs site (https://swgoh-utils.github.io/comlink-python/) -# with mike. Every release line deploys its own folder to the `gh-pages` branch, and -# Material's version picker reads the versions.json that mike keeps there: +# 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. # -# ref CHANGELOG/tag version docs version alias -# main X.Y.Z X.Y latest -# release/v* carrying a pre-release X.Y.ZaN / bN / rcN X.Y dev (titled "X.Y (pre-release)") -# v* tag (manual run only) the tag X.Y latest or dev, as above +# .github/scripts/docs_version.py picks the alias from what is already deployed: # -# Branches take their version from the first release heading in CHANGELOG.md, the -# same source release.yml uses (release tags only appear after the PyPI publish, so -# `git describe` would still name the previous release). Stable `release/vX.Y.Z` -# branches staged by prepare-release.yml are skipped: their docs go live when the -# release PR merges into main. +# 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) # -# The root URL redirects to `latest` (`mike set-default latest`), so it always shows -# the newest stable release. CI's `docs` job still build-checks every PR. +# 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)`. @@ -24,7 +30,7 @@ name: Deploy docs on: push: - branches: [main, "release/v*"] + branches: ["release/**"] paths: - "docs/**" - "overrides/**" @@ -33,15 +39,16 @@ on: - "pyproject.toml" - "uv.lock" - ".github/workflows/docs.yml" - # Run by hand on main, a release/v* branch or a v* tag, e.g. to publish the first - # version or to rebuild an older one. + - ".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: write -# Every line pushes to the same gh-pages branch, so deploys from all branches and -# tags queue behind one another instead of racing. +# Deploys push to the gh-pages branch, so they queue behind one another instead +# of racing. concurrency: group: gh-pages cancel-in-progress: false @@ -49,9 +56,9 @@ concurrency: jobs: deploy: name: Deploy versioned docs - # Forks (including a maintainer fork carrying release/v* branches) must not - # publish their own gh-pages. - if: github.repository == 'swgoh-utils/comlink-python' + # 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 @@ -59,9 +66,16 @@ jobs: # 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 @@ -74,67 +88,39 @@ jobs: - name: Pick docs version id: version env: - REF: ${{ github.ref }} REF_NAME: ${{ github.ref_name }} run: | - case "$REF" in - refs/tags/v*) - release="${REF_NAME#v}" ;; - refs/heads/main | refs/heads/release/v*) - release="$(grep -m1 -oE '^## \[v?[0-9]+\.[0-9]+\.[0-9]+[^]]*\]' CHANGELOG.md \ - | sed -E 's/^## \[v?([0-9][^]]*)\]$/\1/')" ;; - *) - echo "::error::Docs deploy from $REF is not supported; run it on main, release/v* or a v* tag." - exit 1 ;; - esac - - if [[ ! "$release" =~ ^([0-9]+)\.([0-9]+) ]]; then - echo "::error::Cannot read a MAJOR.MINOR version from '$release'." - exit 1 - fi - version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" - - # PEP 440 pre-release markers: 3.0.0a2, 3.0.0b1, 3.0.0rc1, 3.0.0.dev1. - if [[ "$release" =~ (a|b|rc|\.dev)[0-9]+$ ]]; then - alias="dev" - title="$version (pre-release)" - elif [[ "$REF" == refs/heads/release/v* ]]; then - echo "::notice::$REF_NAME stages a stable release; its docs deploy from main once it merges." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - else - alias="latest" - title="$version" - fi - - echo "Deploying $REF as version=$version alias=$alias title='$title'" - { - echo "version=$version" - echo "alias=$alias" - echo "title=$title" - } >> "$GITHUB_OUTPUT" + # `[]` 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) - if: steps.version.outputs.skip != 'true' + env: + DOCS_PRERELEASE: ${{ steps.version.outputs.prerelease }} run: uv run mkdocs build --strict - name: Configure git identity - if: steps.version.outputs.skip != 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Deploy with mike - if: steps.version.outputs.skip != 'true' 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: | - uv run mike deploy --push --update-aliases --title "$TITLE" "$VERSION" "$ALIAS" + 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 - # The first stable deploy also points the root URL at `latest`. Later - # deploys leave the default alone (moving `latest` is enough). - if [[ "$ALIAS" == "latest" ]] && ! git cat-file -e gh-pages:index.html 2>/dev/null; then + # 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 diff --git a/.gitignore b/.gitignore index 9a36a4f..a9e2eba 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d5a019..8ae997e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 750402b..b72b1b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -563,57 +563,65 @@ This is why conventional commit messages matter — they become the changelog an ### Publishing the docs -The docs site at [swgoh-utils.github.io/comlink-python](https://swgoh-utils.github.io/comlink-python/) is versioned with [mike](https://github.com/jimporter/mike). Each release line builds its own copy of the site into a folder on the `gh-pages` branch, and the version picker in the header (Material for MkDocs, `extra.version.provider: mike`) switches between them. [`docs.yml`](.github/workflows/docs.yml) runs the deploy after every push that touches the docs: +The docs site at [swgoh-utils.github.io/comlink-python](https://swgoh-utils.github.io/comlink-python/) is versioned with [mike](https://github.com/jimporter/mike). Each version has its own copy of the site in a folder on the `gh-pages` branch, and the version picker in the header (Material for MkDocs, `extra.version.provider: mike`) switches between them. -| Pushed ref | Version | Alias | Picker title | -|---|---|---|---| -| `main` | `X.Y` of the first release in `CHANGELOG.md` (today `2.5`) | `latest` | `2.5` | -| `release/v*` whose `CHANGELOG.md` names a pre-release (`3.0.0a2`, `3.0.0rc1`, …) | `3.0` | `dev` | `3.0 (pre-release)` | -| `release/vX.Y.Z` for a stable release (from `prepare-release.yml`) | skipped; its docs deploy from `main` once the release PR merges | | | -| a `v*` tag, when the workflow is run by hand on it | from the tag | `latest` or `dev`, as above | | +Deploys run **only from `release/*` branches of `swgoh-utils/comlink-python`**. [`docs.yml`](.github/workflows/docs.yml) runs after every push to one that touches the docs. The docs version is the [PEP 440](https://peps.python.org/pep-0440/) version in the branch name (`release/` and an optional `v` are stripped), and [`.github/scripts/docs_version.py`](.github/scripts/docs_version.py) picks the alias from what is already deployed: -- The root URL redirects to `latest`, so it always opens the newest stable release. Patch releases redeploy their minor version (`2.5.1` replaces the `2.5` folder). -- Pre-release builds set `extra.prerelease: true` in `mkdocs.yml`, which shows a "pre-release" banner on every page ([`overrides/main.html`](overrides/main.html)). Release builds leave it unset and instead get Material's "older version" banner once a newer line becomes `latest`. -- Every deploy queues in the `gh-pages` concurrency group, so runs from different lines never race. -- `mkdocs.yml`, `overrides/` and `docs.yml` must stay the same on every line, except for `extra.prerelease` and the nav. +| Branch | Version | Alias | Picker title | +|---|---|---|---| +| `release/v2.5.0` (newest stable so far) | `2.5.0` | `latest`, the site default | `2.5.0` | +| `release/v2.4.2` after `2.5.0` is deployed | `2.4.2` | none | `2.4.2` | +| `release/v2.6.0` after `2.5.0` | `2.6.0` | `latest` (moves off `2.5.0`) | `2.6.0` | +| `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`, `release/v3.0.0a2.dev1` | as named | `dev` | `… (dev)` | +| `release/v3.0.0a2` after `3.0.0a3` is deployed | `3.0.0a2` | none | `3.0.0a2 (alpha)` | +| `release/next`, `release/v2.5.0+local` | the job fails: not a public PEP 440 version | | | + +- **Stable versions** take `latest` only when they are at least as new as every stable version already deployed, so a patch for an older line never takes it. The root URL redirects to `latest`. +- **Pre-releases** take the alias for their kind (`alpha`, `beta`, `rc`, or `dev`, which wins for `aN.devM`) only when they are at least as new as every deployed version of that kind. They never touch `latest` or the default, and every page shows a "pre-release" banner ([`overrides/main.html`](overrides/main.html); the deploy sets `DOCS_PRERELEASE`, which feeds `extra.prerelease` in `mkdocs.yml`). Stable versions instead get Material's "older version" banner whenever they are not `latest`. +- **Every release is its own folder.** `2.5.0` and `2.5.1` both stay in the picker; remove ones you no longer want with `mike delete` (below). +- **Nothing else deploys.** Pushes to `main`, `develop` or tags, runs in forks, and manual runs on a non-`release/*` ref skip the job. Deleting a release branch after its PR merges leaves its docs on `gh-pages`. +- **Release timing.** `prepare-release.yml` pushes `release/vX.Y.Z` before the release PR merges and before the PyPI publish, so a stable release's docs can go live (and take `latest`) before the package is on PyPI. That push is made with `GITHUB_TOKEN`, though, and GitHub does not start workflows from `GITHUB_TOKEN` pushes: run **Actions → Deploy docs → Run workflow** on the new release branch (later pushes to the branch, such as review fixes, deploy on their own). +- Deploys queue in the `gh-pages` concurrency group, so they never race. **Preview the versioned site locally** without touching the remote (the deploy commits to a local `gh-pages` branch only): ```bash uv sync --group docs -uv run mike deploy --title "2.5" 2.5 latest # or: --title "3.0 (pre-release)" 3.0 dev +uv run mike deploy --title "2.5.0" 2.5.0 latest # or: --title "3.0.0a2 (alpha)" 3.0.0a2 alpha uv run mike set-default latest -uv run mike serve # http://localhost:8000/ -git branch -D gh-pages # throw the local preview away +uv run mike serve # http://localhost:8000/ +git branch -D gh-pages # throw the local preview away ``` For a quick single-version preview, `uv run mkdocs serve` is still enough; the version picker only appears under `mike serve`. #### One-time cutover (maintainer) -The site used to be deployed by `actions/deploy-pages` from `main` only. To switch to the versioned layout: +The site used to be deployed by `actions/deploy-pages` from `main`, as a single version. To switch to the versioned layout: -1. **Merge the versioned docs setup into `main`** (through `develop` and the normal release PR, or a docs-only PR). The deploy that runs on that push creates the `gh-pages` branch with `2.5/`, the `latest` alias and the root redirect (`mike set-default latest` runs automatically on the first stable deploy). The old site stays live while the Pages source is still "GitHub Actions". -2. **Check the first version.** If the merge did not touch any docs path, run **Actions → Deploy docs → Run workflow** on `main`. Deploy `2.5` before any pre-release: until a stable version exists there is no root redirect. To do it by hand instead: +1. **Merge the versioned docs setup into `develop`** (the normal PR), so every future `release/vX.Y.Z` branch carries it. +2. **Publish 2.5.0.** `main` is the 2.5.0 release, so cut `release/v2.5.0` from `main`, bring the docs setup onto it (cherry-pick the merged commits from `develop`), and push it to `swgoh-utils/comlink-python`. The deploy creates `gh-pages` with `2.5.0/`, the `latest` alias and the root redirect. The old site stays live while the Pages source is still "GitHub Actions". By hand instead: ```bash - git switch main && git pull + git switch release/v2.5.0 uv sync --group docs - uv run mike deploy --push --update-aliases --title "2.5" 2.5 latest + uv run mike deploy --push --update-aliases --title "2.5.0" 2.5.0 latest uv run mike set-default --push latest ``` -3. **Switch the Pages source.** In **Settings → Pages → Build and deployment**, change **Source** from "GitHub Actions" to **"Deploy from a branch"**, branch **`gh-pages`**, folder **`/ (root)`**. Check that the root URL opens the 2.5 docs. -4. **Publish the pre-release line.** Once the active `release/v3.*` branch lives in this repository, push to it (or run the workflow on it) to add `3.0` with the `dev` alias. The job runs only in `swgoh-utils/comlink-python`, so forks never publish their own `gh-pages`. -5. **Protect `gh-pages`** (optional, recommended). Add a ruleset or branch protection rule for `gh-pages` that blocks force pushes and deletion. Leave direct pushes allowed for GitHub Actions: the deploy pushes with the workflow's `GITHUB_TOKEN`, so a rule that requires pull requests or status checks would block it (or add "GitHub Actions" to the bypass list). -6. **Clean up.** The old `github-pages` environment created by `deploy-pages` can be deleted, or kept for history. +3. **Switch the Pages source.** In **Settings → Pages → Build and deployment**, change **Source** from "GitHub Actions" to **"Deploy from a branch"**, branch **`gh-pages`**, folder **`/ (root)`**. Check that the root URL opens the 2.5.0 docs. +4. **Protect `gh-pages`** (optional, recommended). Add a ruleset or branch protection rule for `gh-pages` that blocks force pushes and deletion. Leave direct pushes allowed for GitHub Actions: the deploy pushes with the workflow's `GITHUB_TOKEN`, so a rule that requires pull requests or status checks would block it (or add "GitHub Actions" to the bypass list). +5. **Clean up.** The old `github-pages` environment created by `deploy-pages` can be deleted, or kept for history. #### Maintaining versions -- **Remove a version** (for example an abandoned pre-release): `uv run mike delete --push 3.0`. -- **Retitle** a version: `uv run mike retitle --push 3.0 "3.0 (beta)"`. -- **When 3.0.0 is released**, `main` deploys `3.0` as `latest` and `--update-aliases` moves `latest` off `2.5`, which then shows the "older version" banner. Drop the leftover `dev` alias with `uv run mike alias --push --update-aliases dev`, or delete it, and remove `extra.prerelease` from `mkdocs.yml` on `main`. -- **Rebuild an older line** by running the workflow by hand on its tag (for example `v2.5.0`). A stable tag also moves `latest`, so only do this for the newest stable line. +- **Remove a version** (for example a superseded pre-release): `uv run mike delete --push 3.0.0a1`. +- **Retitle** a version: `uv run mike retitle --push 3.0.0b1 "3.0.0b1 (beta, final)"`. +- **Move an alias by hand**: `uv run mike alias --push --update-aliases 2.5.1 latest`. +- **Rebuild a version** by pushing to (or re-running the workflow on) its release branch; recreate the branch from its tag if it was deleted. --- diff --git a/mkdocs.yml b/mkdocs.yml index d794d84..0194780 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,14 +36,16 @@ nav: - "Exceptions": api/exceptions.md extra: - # Versioned with mike: each release line publishes its own folder on the + # Versioned with mike: each release/* branch publishes its own folder on the # gh-pages branch (see "Publishing the docs" in CONTRIBUTING.md). version: provider: mike default: latest alias: true - # Pre-release lines set `prerelease: true` here to show a banner on every - # page (overrides/main.html). Release lines leave it unset. + # Pre-release banner on every page (overrides/main.html). The deploy sets + # DOCS_PRERELEASE from the release branch's version; local builds use the + # default, which matches this line (2.x stable releases). + prerelease: !ENV [DOCS_PRERELEASE, false] extra_css: - stylesheets/extra.css diff --git a/overrides/main.html b/overrides/main.html index 3650d23..43bc38e 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -2,11 +2,12 @@ {# Version banners for the mike-versioned site (see "Publishing the docs" in - CONTRIBUTING.md). Each published version is built from its own branch, so a - branch decides its own banner through `extra.prerelease` in mkdocs.yml: + CONTRIBUTING.md). Every version is built from its release/* branch, and + `extra.prerelease` (set by the deploy from the branch's version) decides its + banner: - - pre-release lines (extra.prerelease: true) always show the announce banner; - - release lines show Material's "outdated" banner, which its JavaScript + - pre-release builds (extra.prerelease: true) always show the announce banner; + - release builds show Material's "outdated" banner, which its JavaScript reveals only when the version being viewed is not the default (`latest`). The `-` whitespace control matters: Material renders a banner whenever the diff --git a/tests/unit/test_docs_version.py b/tests/unit/test_docs_version.py new file mode 100644 index 0000000..48ff417 --- /dev/null +++ b/tests/unit/test_docs_version.py @@ -0,0 +1,140 @@ +"""Tests for .github/scripts/docs_version.py (docs version/alias decisions).""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "docs_version.py" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("docs_version", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Registered first: @dataclass resolves string annotations via sys.modules. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +docs_version = _load() + + +def _mike(*versions: str) -> str: + """Return ``mike list --json`` output listing *versions*.""" + return json.dumps([{"version": v, "title": v, "aliases": [], "properties": None} for v in versions]) + + +def _row(ref: str, *deployed: str) -> tuple[str, str, str, bool]: + d = docs_version.decide(ref, _mike(*deployed)) + return d.version, d.alias, d.title, d.set_default + + +# ── stable ────────────────────────────────────────────────────────────── + + +def test_first_stable_takes_latest_with_empty_gh_pages() -> None: + assert _row("release/v2.5.0") == ("2.5.0", "latest", "2.5.0", True) + + +def test_missing_gh_pages_output_counts_as_nothing_deployed() -> None: + d = docs_version.decide("release/v2.5.0", "") + assert (d.alias, d.set_default) == ("latest", True) + + +def test_older_patch_after_newer_stable_gets_no_alias() -> None: + assert _row("release/v2.4.2", "2.5.0") == ("2.4.2", "", "2.4.2", False) + + +def test_newer_stable_takes_latest() -> None: + assert _row("release/v2.6.0", "2.5.0", "2.4.2") == ("2.6.0", "latest", "2.6.0", True) + + +def test_redeploying_the_newest_stable_keeps_latest() -> None: + assert _row("release/v2.5.0", "2.5.0", "2.4.2")[1] == "latest" + + +def test_prereleases_do_not_hold_back_a_stable_latest() -> None: + assert _row("release/v2.6.0", "2.5.0", "3.0.0a2", "3.0.0.dev1")[1] == "latest" + + +def test_branch_name_without_v() -> None: + assert _row("release/2.5.0") == ("2.5.0", "latest", "2.5.0", True) + + +# ── pre-releases ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("ref", "expected"), + [ + ("release/v3.0.0a2", ("3.0.0a2", "alpha", "3.0.0a2 (alpha)", False)), + ("release/v3.0.0a3", ("3.0.0a3", "alpha", "3.0.0a3 (alpha)", False)), + ("release/v3.0.0b1", ("3.0.0b1", "beta", "3.0.0b1 (beta)", False)), + ("release/v3.0.0rc1", ("3.0.0rc1", "rc", "3.0.0rc1 (rc)", False)), + ("release/v3.0.0.dev1", ("3.0.0.dev1", "dev", "3.0.0.dev1 (dev)", False)), + ("release/v3.0.0a2.dev1", ("3.0.0a2.dev1", "dev", "3.0.0a2.dev1 (dev)", False)), + ], +) +def test_prerelease_takes_the_alias_for_its_kind(ref: str, expected: tuple[str, str, str, bool]) -> None: + assert _row(ref, "2.5.0") == expected + + +def test_prerelease_is_flagged_and_never_sets_default() -> None: + d = docs_version.decide("release/v3.0.0a2", _mike()) + assert (d.prerelease, d.set_default) == (True, False) + assert docs_version.decide("release/v2.5.0", _mike()).prerelease is False + + +def test_newer_alpha_moves_the_alpha_alias() -> None: + assert _row("release/v3.0.0a3", "2.5.0", "3.0.0a2")[1] == "alpha" + + +def test_older_alpha_after_newer_alpha_gets_no_alias() -> None: + assert _row("release/v3.0.0a2", "2.5.0", "3.0.0a3") == ("3.0.0a2", "", "3.0.0a2 (alpha)", False) + + +def test_only_same_kind_versions_compete_for_an_alias() -> None: + # A newer beta or rc does not stop an alpha from taking `alpha`. + assert _row("release/v3.0.0a3", "3.0.0b1", "3.0.0rc1")[1] == "alpha" + + +def test_prerelease_name_is_normalized() -> None: + assert _row("release/v3.0.0-alpha2")[:2] == ("3.0.0a2", "alpha") + + +# ── errors and output ─────────────────────────────────────────────────── + + +@pytest.mark.parametrize("ref", ["release/next", "release/v3.0.0-foo", "release/", "main", "release/v2.5.0+local"]) +def test_invalid_branch_names_are_rejected(ref: str) -> None: + with pytest.raises(ValueError, match="release/|PEP 440|local version"): + docs_version.decide(ref, _mike()) + + +def test_unparseable_deployed_entries_are_ignored() -> None: + assert _row("release/v2.5.0", "latest-preview")[1] == "latest" + + +def test_main_writes_github_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + out = tmp_path / "out" + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + assert docs_version.main(["docs_version.py", "release/v2.4.2", _mike("2.5.0")]) == 0 + assert out.read_text().splitlines() == [ + "version=2.4.2", + "alias=", + "title=2.4.2", + "set_default=false", + "prerelease=false", + ] + + +def test_main_reports_invalid_name_as_github_error(capsys: pytest.CaptureFixture[str]) -> None: + assert docs_version.main(["docs_version.py", "release/vNext", ""]) == 1 + assert capsys.readouterr().out.startswith("::error::branch 'release/vNext' does not name a PEP 440 version")