diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 9141592a4..9dc625e3f 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,5 +1,6 @@ name: documentation + on: pull_request: types: [opened, synchronize, reopened] @@ -11,15 +12,87 @@ on: - main paths: [docs/**, .github/workflows/documentation.yml, birdnet_analyzer/cli.py] + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to build and publish (e.g. v2.4.0)" + required: true + type: string + set_stable: + description: "Also publish this version as stable" + required: false + type: boolean + default: false permissions: contents: write +# One group per deploy target: a shared group lets a newer run evict a queued +# deploy. Overlap is safe, the deploy step retries onto a newer gh-pages tip. +concurrency: + group: >- + ${{ github.event_name == 'pull_request' + && format('docs-pr-{0}', github.ref) + || github.event_name == 'push' && 'docs-deploy-dev' + || format('docs-deploy-{0}', github.event.release.tag_name || inputs.tag) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: docs: runs-on: ubuntu-latest steps: + - name: Determine build source and target directory + id: target + env: + EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_STABLE: ${{ inputs.set_stable }} + run: | + # [[ =~ ]] anchors on the whole string, so a value containing a + # newline cannot pass and inject extra lines into $GITHUB_OUTPUT. + TAG_RE='^v[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?$' + case "$EVENT" in + release) + if ! [[ "$RELEASE_TAG" =~ $TAG_RE ]]; then + echo "::error::release tag '$RELEASE_TAG' does not look like vX.Y.Z, not deploying docs" + exit 1 + fi + echo "ref=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "dest=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + # Prereleases get their /vX.Y.Z-rc/ dir but must not become stable. + if [ "$RELEASE_PRERELEASE" = "true" ]; then + echo "stable=false" >> "$GITHUB_OUTPUT" + else + echo "stable=true" >> "$GITHUB_OUTPUT" + fi + ;; + workflow_dispatch) + if ! [[ "$INPUT_TAG" =~ $TAG_RE ]]; then + echo "::error::tag must look like vX.Y.Z, got '$INPUT_TAG'" + exit 1 + fi + echo "ref=$INPUT_TAG" >> "$GITHUB_OUTPUT" + echo "dest=$INPUT_TAG" >> "$GITHUB_OUTPUT" + echo "stable=$INPUT_STABLE" >> "$GITHUB_OUTPUT" + ;; + push) + echo "ref=" >> "$GITHUB_OUTPUT" + echo "dest=dev" >> "$GITHUB_OUTPUT" + echo "stable=false" >> "$GITHUB_OUTPUT" + ;; + *) # pull_request: build as a check, no deploy + echo "ref=" >> "$GITHUB_OUTPUT" + echo "dest=" >> "$GITHUB_OUTPUT" + echo "stable=false" >> "$GITHUB_OUTPUT" + ;; + esac - uses: actions/checkout@v7 + with: + ref: ${{ steps.target.outputs.ref }} - uses: actions/setup-python@v7 with: python-version: "3.13" @@ -32,13 +105,126 @@ jobs: - name: Install dependencies run: uv pip install --system .[docs] - name: Sphinx build + env: + DEST: ${{ steps.target.outputs.dest }} run: | - sphinx-build -E docs _build - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + EXTRA="" + if [ -n "$DEST" ] && [ "$DEST" != "dev" ]; then + # Old tags hardcode a version and don't load the switcher, so both + # are forced from here; on current conf.py the -D flags are no-ops. + EXTRA="-D version=${DEST#v} -D release=${DEST#v}" + EXTRA="$EXTRA -D html_js_files=https://birdnet-team.github.io/BirdNET-Analyzer/switcher.js" + elif [ "$DEST" = "dev" ]; then + EXTRA="-D version=dev -D release=dev" + fi + # Doctrees in a temp dir so they don't end up in the deployed site. + sphinx-build -E -d "$RUNNER_TEMP/doctrees" $EXTRA docs _build + - name: Check out site chrome from main + if: ${{ steps.target.outputs.dest != '' }} + uses: actions/checkout@v7 + with: + ref: main + path: chrome + - name: Check out gh-pages + if: ${{ steps.target.outputs.dest != '' }} + uses: actions/checkout@v7 with: - publish_branch: gh-pages - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: _build/ - force_orphan: true + ref: gh-pages + path: site + - name: Deploy to GitHub Pages + if: ${{ steps.target.outputs.dest != '' }} + env: + DEST: ${{ steps.target.outputs.dest }} + STABLE: ${{ steps.target.outputs.stable }} + run: | + # Guard: $DEST is interpolated into rm -rf below. + [ -n "$DEST" ] || { echo "::error::empty deploy target"; exit 1; } + + # Re-runnable: a rejected push re-applies this onto the newer tip. + apply() { + # The root holds only chrome (regenerated below) and version dirs; + # this also clears the old unversioned layout. + find site -mindepth 1 -maxdepth 1 \ + ! -name .git ! -name 'v[0-9]*' ! -name stable ! -name dev \ + -exec rm -rf {} + + rm -rf "site/$DEST" + cp -a _build "site/$DEST" + if [ "$STABLE" = "true" ]; then + rm -rf site/stable + cp -a _build site/stable + # Records which release /stable/ currently holds, so later deploys + # (which regenerate versions.json from disk) can still label it. + echo "$DEST" > site/stable/.version + fi + cp -a chrome/docs/_site/. site/ + touch site/.nojekyll + python - <<'EOF' + import json + import re + from pathlib import Path + + site = Path("site") + + def sort_key(name): + # v3.0.0 sorts above v3.0.0-rc, which sorts above v2.4.0. + core, _, suffix = name[1:].partition("-") + return [int(x) for x in core.split(".")], suffix == "", suffix + + versions = sorted( + (d.name for d in site.iterdir() + if d.is_dir() + and re.fullmatch(r"v\d+(\.\d+)*(-[A-Za-z0-9.]+)?", d.name)), + key=sort_key, + reverse=True, + ) + marker = site / "stable" / ".version" + stable = marker.read_text().strip() if marker.is_file() else None + + # dev first, then releases newest-first; the stable release is listed + # once, as "vX.Y.Z (stable)". + entries = [] + if (site / "dev").is_dir(): + entries.append({"name": "dev (unreleased)", "path": "dev"}) + for v in versions: + if v == stable: + entries.append( + {"name": f"{v} (stable)", "path": "stable", "aliases": [v]} + ) + else: + entries.append({"name": v, "path": v}) + + # /stable/ exists but no version dir claims it (only reachable by + # hand-editing gh-pages): list it plainly instead of hiding it. + if (site / "stable").is_dir() and not any( + e["path"] == "stable" for e in entries + ): + entries.insert( + 1 if entries and entries[0]["path"] == "dev" else 0, + {"name": f"{stable} (stable)" if stable else "stable", + "path": "stable"}, + ) + (site / "versions.json").write_text(json.dumps(entries, indent=2) + "\n") + EOF + } + + git -C site config user.name "github-actions[bot]" + git -C site config user.email "41898270+github-actions[bot]@users.noreply.github.com" + + for attempt in 1 2 3; do + apply + git -C site add -A + if git -C site diff --cached --quiet; then + echo "Nothing to deploy for $DEST" + exit 0 + fi + git -C site commit -q -m "Deploy docs: $DEST" + if git -C site push; then + exit 0 + fi + # A concurrent deploy landed first: rebuild on top of it and retry. + echo "Push rejected, re-applying onto the latest gh-pages (attempt $attempt)" + git -C site fetch -q origin gh-pages + git -C site reset -q --hard FETCH_HEAD + done + echo "::error::could not push docs for $DEST after 3 attempts" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index bb04c5d6a..8c7243810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,30 @@ worth knowing before running the suite: ## CI Six workflows in `.github/workflows/`: `lint.yml` and `ci.yml` (the test matrix) run -on every PR to `main`, `documentation.yml` on docs changes, `docker-build.yml` on -Dockerfile/`pyproject.toml` changes and on release, and `publish.yml` / -`test-publish.yml` on release. All of them use `paths:` filters, so a PR touching only -docs will not run the test matrix. +on every PR to `main`, `documentation.yml` on docs changes and on release, +`docker-build.yml` on Dockerfile/`pyproject.toml` changes and on release, and +`publish.yml` / `test-publish.yml` on release. All of them use `paths:` filters, so a +PR touching only docs will not run the test matrix. + +The published docs are **versioned**: `documentation.yml` deploys each release to +`/vX.Y.Z/` on `gh-pages` (mirrored at `/stable/`, the default landing spot), pushes +to `main` deploy to `/dev/`, and old releases can be backfilled via the workflow's +manual trigger (`tag`, plus `set_stable` when that tag is the newest release). +Prereleases get their own directory but never become `/stable/`. The site-root +redirect, 404 handler and version switcher live in `docs/_site/`, which is deployed +to the `gh-pages` root and excluded from the Sphinx build. + +Two things worth knowing before touching this: + +- **`/stable/` only exists once a release has been deployed to the versioned site.** + Until then the root redirect and the 404 handler fall back to the newest version + directory and then to `/dev/`, so the site still works, but it is serving + unreleased docs. Backfill the current release (manual trigger, `set_stable` + ticked) right after the versioning workflow first lands on `main`. +- **Backfilling only reaches `v2.1.0`–`v2.4.0`.** `v2.0.0` and `v2.0.0-rc` have a + `docs/conf.py` but no `docs` extra, so no Sphinx gets installed; every `v1.x` tag + predates `pyproject.toml` entirely. (The bare `1.4.0` tag is also rejected by the + workflow's `vX.Y.Z` pattern.) Run `ruff check` and `python -m pytest` before handing work back. diff --git a/docs/_site/404.html b/docs/_site/404.html new file mode 100644 index 000000000..345b3875a --- /dev/null +++ b/docs/_site/404.html @@ -0,0 +1,70 @@ + + + + + BirdNET-Analyzer documentation + + +

Page not found.

+

Go to the latest documentation

+ +

Development documentation

+ + + diff --git a/docs/_site/index.html b/docs/_site/index.html new file mode 100644 index 000000000..808a5d825 --- /dev/null +++ b/docs/_site/index.html @@ -0,0 +1,47 @@ + + + + + + BirdNET-Analyzer documentation + + + +

Redirecting to the latest stable documentation…

+ +

Development documentation

+ + + diff --git a/docs/_site/switcher.js b/docs/_site/switcher.js new file mode 100644 index 000000000..965ee38a2 --- /dev/null +++ b/docs/_site/switcher.js @@ -0,0 +1,141 @@ +/* Version switcher for the published docs. + * + * Deployed to the gh-pages site root by .github/workflows/documentation.yml and + * loaded by every published docs version (via html_js_files, or -D for release + * tags whose conf.py predates it). Reads versions.json, generated at deploy + * time from the version directories that actually exist on gh-pages. + * + * The dropdown replaces the version line the sphinx_rtd_theme prints under the + * project name in the sidebar; if that element is missing (another theme, a + * stripped page) it falls back to a small floating box. + */ +(function () { + "use strict"; + + // The site root is wherever this script was served from. + var script = document.currentScript; + if (!script || !script.src) return; + var base = script.src.replace(/switcher\.js.*$/, ""); + + // Which version dir is the current page under, and what comes after it? + // Pages not under the site root (e.g. a local sphinx build opened via + // file://, which still loads this script from the live site) get no + // switcher. + if (location.href.indexOf(base) !== 0) return; + var m = location.href + .slice(base.length) + .match(/^(stable|dev|v[0-9][^/]*)(?:\/(.*))?$/); + if (!m) return; + var current = m[1]; + var rest = m[2] || ""; + + function matches(entry) { + return ( + entry.path === current || + (entry.aliases || []).indexOf(current) !== -1 + ); + } + + function build(versions) { + var select = document.createElement("select"); + select.setAttribute("aria-label", "Documentation version"); + var matched = false; + versions.forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v.path; + opt.textContent = v.name; + opt.selected = matches(v); + if (opt.selected) matched = true; + // Options inherit the select's colour but the popup background is the + // browser's, so set both here or the text can end up unreadable. + opt.style.backgroundColor = "#2c3e50"; + opt.style.color = "#fcfcfc"; + select.appendChild(opt); + }); + if (!matched) { + // versions.json is cached (max-age 600), so a freshly published version + // can be missing from it. Show what is actually being viewed rather than + // letting the browser display the first entry as if it were selected. + var here = document.createElement("option"); + here.textContent = current; + here.disabled = true; + here.selected = true; + here.style.backgroundColor = "#2c3e50"; + here.style.color = "#fcfcfc"; + select.insertBefore(here, select.firstChild); + } + select.addEventListener("change", function () { + // Keep the page path; 404.html catches pages a version doesn't have. + location.href = base + select.value + "/" + rest; + }); + return select; + } + + function mount(select) { + // sphinx_rtd_theme: the version line sits under the project name in the + // sidebar search area. Reuse that slot so the switcher looks native. + var search = document.querySelector(".wy-side-nav-search"); + if (!search) return false; + + var slot = search.querySelector(".version"); + if (!slot) { + slot = document.createElement("div"); + slot.className = "version"; + var home = search.querySelector("a"); + if (home && home.nextSibling) { + search.insertBefore(slot, home.nextSibling); + } else { + search.appendChild(slot); + } + } + slot.textContent = ""; + slot.style.opacity = "1"; + select.style.cssText = + "max-width:100%;padding:2px 4px;border-radius:3px;" + + "background-color:#2c3e50;color:#fcfcfc;font-size:90%;" + + "border:1px solid rgba(255,255,255,.3);cursor:pointer"; + slot.appendChild(select); + return true; + } + + function mountFallback(select) { + var box = document.createElement("div"); + box.style.cssText = + "position:fixed;bottom:12px;left:12px;z-index:1000;" + + "background:#1a1a1a;color:#fcfcfc;border-radius:4px;" + + "padding:6px 10px;font-size:13px;font-family:sans-serif;" + + "box-shadow:0 1px 4px rgba(0,0,0,.4)"; + var label = document.createElement("label"); + label.textContent = "Version: "; + select.style.cssText = + "background-color:#1a1a1a;color:#fcfcfc;border:none;font-size:13px"; + label.appendChild(select); + box.appendChild(label); + document.body.appendChild(box); + } + + // This script runs from , so the sidebar it mounts into may not exist + // yet when a cached versions.json resolves immediately. + function whenReady(fn) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", fn); + } else { + fn(); + } + } + + fetch(base + "versions.json") + .then(function (r) { + return r.ok ? r.json() : null; + }) + .then(function (versions) { + if (!versions || versions.length < 2) return; + whenReady(function () { + var select = build(versions); + if (!mount(select)) mountFallback(select); + }); + }) + .catch(function () { + /* no versions.json (e.g. local build): no switcher */ + }); +})(); diff --git a/docs/conf.py b/docs/conf.py index 6badc1499..221c90f0c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,6 +8,8 @@ import os import sys +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version sys.path.insert(0, os.path.abspath(".")) sys.path.insert(1, os.path.abspath("..")) @@ -15,7 +17,11 @@ project = "BirdNET-Analyzer" copyright = "%Y, BirdNET-Team" author = "Stefan Kahl" -version = "2.1.1" +# Overridden with -D version=... when the docs workflow builds a release tag. +try: + release = version = _dist_version("birdnet_analyzer") +except PackageNotFoundError: + release = version = "dev" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -32,7 +38,7 @@ } templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ["_build", "_site", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output @@ -45,6 +51,10 @@ html_logo = "_static/birdnet_logo.png" html_static_path = ["_static"] html_css_files = ["css/custom.css"] +# Version switcher served from the gh-pages site root (docs/_site/switcher.js). +# One shared copy serves every published version; the script shows nothing on +# pages that are not under the published site (e.g. local builds). +html_js_files = ["https://birdnet-team.github.io/BirdNET-Analyzer/switcher.js"] html_theme_options = {"style_external_links": True, "navigation_depth": 2} html_show_sourcelink = False html_show_sphinx = False