From ce6ce468f0555d9d71d5397763cac712a817e5e0 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Thu, 27 Aug 2026 15:29:56 +0200 Subject: [PATCH 1/5] Versioned docs --- .github/workflows/documentation.yml | 159 ++++++++++++++++++++++++++-- AGENTS.md | 15 ++- docs/_site/404.html | 29 +++++ docs/_site/index.html | 12 +++ docs/_site/switcher.js | 67 ++++++++++++ docs/conf.py | 14 ++- 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 docs/_site/404.html create mode 100644 docs/_site/index.html create mode 100644 docs/_site/switcher.js diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 11026c400..91258987f 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,5 +1,13 @@ name: documentation +# The published site (gh-pages) is versioned: every release lives under +# /vX.Y.Z/, the latest release is mirrored at /stable/ (the default landing +# spot), and pushes to main deploy to /dev/. Site-wide files (root redirect, +# 404 handler, version switcher) come from docs/_site/ on main. +# +# Publishing an old release retroactively: run this workflow manually with the +# tag name; tick "set_stable" only if that tag is the newest release. + on: pull_request: types: [opened, synchronize, reopened, edited] @@ -11,15 +19,81 @@ 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 +# Deploys push to gh-pages, so they must not run concurrently. PR builds only +# check that the docs build and can run freely (superseded runs are cancelled). +concurrency: + group: ${{ github.event_name == 'pull_request' && format('docs-pr-{0}', github.ref) || 'docs-deploy' }} + 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: | + TAG_RE='^v[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?$' + case "$EVENT" in + release) + if ! echo "$RELEASE_TAG" | grep -Eq "$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 ! echo "$INPUT_TAG" | grep -Eq "$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@v6 + with: + ref: ${{ steps.target.outputs.ref }} - uses: actions/setup-python@v6 with: python-version: "3.13" @@ -31,13 +105,82 @@ 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@v6 + with: + ref: main + path: chrome + - name: Check out gh-pages + if: ${{ steps.target.outputs.dest != '' }} + uses: actions/checkout@v6 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: | + # Everything at the site root except the version dirs is chrome and + # is regenerated below. This also clears the pre-versioning layout, + # where the docs pages themselves lived at the root. + 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 + 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, + ) + entries = [] + if (site / "stable").is_dir(): + entries.append({"name": "stable", "path": "stable"}) + if (site / "dev").is_dir(): + entries.append({"name": "dev (unreleased)", "path": "dev"}) + entries += [{"name": v, "path": v} for v in versions] + (site / "versions.json").write_text(json.dumps(entries, indent=2) + "\n") + EOF + cd site + git config user.name "github-actions[bot]" + git config user.email "41898270+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet || git commit -m "Deploy docs: $DEST" + git push diff --git a/AGENTS.md b/AGENTS.md index bd22319f7..f419f8886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,17 @@ 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. 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. 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..2c990744b --- /dev/null +++ b/docs/_site/404.html @@ -0,0 +1,29 @@ + + + + + BirdNET-Analyzer documentation + + +

Page not found.

+

Go to the latest stable documentation

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

Redirecting to the latest stable documentation…

+ + diff --git a/docs/_site/switcher.js b/docs/_site/switcher.js new file mode 100644 index 000000000..e135e80b2 --- /dev/null +++ b/docs/_site/switcher.js @@ -0,0 +1,67 @@ +/* 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. + */ +(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] || ""; + + fetch(base + "versions.json") + .then(function (r) { + return r.ok ? r.json() : null; + }) + .then(function (versions) { + if (!versions || versions.length < 2) return; + + 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: "; + + var select = document.createElement("select"); + select.style.cssText = + "background:#1a1a1a;color:#fcfcfc;border:none;font-size:13px"; + versions.forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v.path; + opt.textContent = v.name; + opt.selected = v.path === current; + select.appendChild(opt); + }); + select.addEventListener("change", function () { + // Keep the page path; 404.html catches pages a version doesn't have. + location.href = base + select.value + "/" + rest; + }); + + label.appendChild(select); + box.appendChild(label); + document.body.appendChild(box); + }) + .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 From 44ec6ce766226789d0f1800774e37d8007883672 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Thu, 27 Aug 2026 16:17:24 +0200 Subject: [PATCH 2/5] Updated switcher --- .github/workflows/documentation.yml | 23 ++++++- docs/_site/switcher.js | 102 ++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 91258987f..edc631353 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -148,6 +148,9 @@ jobs: 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 @@ -170,12 +173,26 @@ jobs: key=sort_key, reverse=True, ) + marker = site / "stable" / ".version" + stable = marker.read_text().strip() if marker.is_file() else None + + # dev on top, then releases newest-first. The release that /stable/ + # points at is shown as "vX.Y.Z (stable)" in place of its own entry, + # so no version is listed twice. entries = [] - if (site / "stable").is_dir(): - entries.append({"name": "stable", "path": "stable"}) if (site / "dev").is_dir(): entries.append({"name": "dev (unreleased)", "path": "dev"}) - entries += [{"name": v, "path": v} for v in versions] + if stable and stable not in versions and (site / "stable").is_dir(): + entries.append({"name": f"{stable} (stable)", "path": "stable"}) + for v in versions: + if v == stable: + entries.append( + {"name": f"{v} (stable)", "path": "stable", "aliases": [v]} + ) + else: + entries.append({"name": v, "path": v}) + if not stable and (site / "stable").is_dir(): + entries.insert(len(entries) and 1, {"name": "stable", "path": "stable"}) (site / "versions.json").write_text(json.dumps(entries, indent=2) + "\n") EOF cd site diff --git a/docs/_site/switcher.js b/docs/_site/switcher.js index e135e80b2..ece9ef82b 100644 --- a/docs/_site/switcher.js +++ b/docs/_site/switcher.js @@ -4,6 +4,10 @@ * 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"; @@ -25,41 +29,81 @@ 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"); + versions.forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v.path; + opt.textContent = v.name; + opt.selected = matches(v); + select.appendChild(opt); + }); + 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:rgba(0,0,0,.2);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:#1a1a1a;color:#fcfcfc;border:none;font-size:13px"; + label.appendChild(select); + box.appendChild(label); + document.body.appendChild(box); + } + fetch(base + "versions.json") .then(function (r) { return r.ok ? r.json() : null; }) .then(function (versions) { if (!versions || versions.length < 2) return; - - 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: "; - - var select = document.createElement("select"); - select.style.cssText = - "background:#1a1a1a;color:#fcfcfc;border:none;font-size:13px"; - versions.forEach(function (v) { - var opt = document.createElement("option"); - opt.value = v.path; - opt.textContent = v.name; - opt.selected = v.path === current; - select.appendChild(opt); - }); - select.addEventListener("change", function () { - // Keep the page path; 404.html catches pages a version doesn't have. - location.href = base + select.value + "/" + rest; - }); - - label.appendChild(select); - box.appendChild(label); - document.body.appendChild(box); + var select = build(versions); + if (!mount(select)) mountFallback(select); }) .catch(function () { /* no versions.json (e.g. local build): no switcher */ From 469a5b90a21f6cf003604b23dc0a493b820eb32e Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Thu, 27 Aug 2026 16:46:32 +0200 Subject: [PATCH 3/5] . --- .github/workflows/documentation.yml | 106 +++++++++++++++++++--------- AGENTS.md | 18 ++++- docs/_site/404.html | 56 ++++++++++++--- docs/_site/index.html | 33 ++++++++- docs/_site/switcher.js | 38 ++++++++-- 5 files changed, 201 insertions(+), 50 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index edc631353..0fb263327 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -36,10 +36,17 @@ on: permissions: contents: write -# Deploys push to gh-pages, so they must not run concurrently. PR builds only -# check that the docs build and can run freely (superseded runs are cancelled). +# Release deploys are queued separately from dev deploys: GitHub keeps only one +# pending run per group, so a busy main branch must not be able to evict a +# queued release and leave that release without docs. The two groups can +# therefore overlap, which the deploy step handles by re-applying onto the +# latest gh-pages if its push is rejected. PR builds never deploy and can run +# freely (superseded runs are cancelled). concurrency: - group: ${{ github.event_name == 'pull_request' && format('docs-pr-{0}', github.ref) || 'docs-deploy' }} + group: >- + ${{ github.event_name == 'pull_request' + && format('docs-pr-{0}', github.ref) + || (github.event_name == 'push' && 'docs-deploy-dev' || 'docs-deploy-release') }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: @@ -55,10 +62,12 @@ jobs: 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 ! echo "$RELEASE_TAG" | grep -Eq "$TAG_RE"; then + 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 @@ -72,7 +81,7 @@ jobs: fi ;; workflow_dispatch) - if ! echo "$INPUT_TAG" | grep -Eq "$TAG_RE"; then + if ! [[ "$INPUT_TAG" =~ $TAG_RE ]]; then echo "::error::tag must look like vX.Y.Z, got '$INPUT_TAG'" exit 1 fi @@ -137,24 +146,31 @@ jobs: DEST: ${{ steps.target.outputs.dest }} STABLE: ${{ steps.target.outputs.stable }} run: | - # Everything at the site root except the version dirs is chrome and - # is regenerated below. This also clears the pre-versioning layout, - # where the docs pages themselves lived at the root. - 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' + # Guard: $DEST is interpolated into rm -rf below. + [ -n "$DEST" ] || { echo "::error::empty deploy target"; exit 1; } + + # Everything this deploy writes, as a function: if the push is + # rejected because a concurrent deploy landed first, the whole thing + # is re-applied on top of that deploy rather than overwriting it. + apply() { + # Everything at the site root except the version dirs is chrome and + # is regenerated below. This also clears the pre-versioning layout, + # where the docs pages themselves lived at the root. + 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 @@ -182,8 +198,6 @@ jobs: entries = [] if (site / "dev").is_dir(): entries.append({"name": "dev (unreleased)", "path": "dev"}) - if stable and stable not in versions and (site / "stable").is_dir(): - entries.append({"name": f"{stable} (stable)", "path": "stable"}) for v in versions: if v == stable: entries.append( @@ -191,13 +205,39 @@ jobs: ) else: entries.append({"name": v, "path": v}) - if not stable and (site / "stable").is_dir(): - entries.insert(len(entries) and 1, {"name": "stable", "path": "stable"}) + + # /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 - cd site - git config user.name "github-actions[bot]" - git config user.email "41898270+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet || git commit -m "Deploy docs: $DEST" - git push + } + + 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 f419f8886..bcbbcc43c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,9 +63,21 @@ 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. 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. +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.1`–`v2.4.0`.** Older tags either lack the `docs` + extra (`v2.0.0`, so no Sphinx gets installed) or don't match the `vX.Y.Z` tag + pattern the workflow requires (`1.4.0`). Run `ruff check` and `python -m pytest` before handing work back. diff --git a/docs/_site/404.html b/docs/_site/404.html index 2c990744b..0ca7357c6 100644 --- a/docs/_site/404.html +++ b/docs/_site/404.html @@ -6,23 +6,61 @@

Page not found.

-

Go to the latest stable documentation

+

Go to the latest documentation

diff --git a/docs/_site/index.html b/docs/_site/index.html index 509ed7aaa..aff845fb0 100644 --- a/docs/_site/index.html +++ b/docs/_site/index.html @@ -2,11 +2,42 @@ - BirdNET-Analyzer documentation +

Redirecting to the latest stable documentation…

+ diff --git a/docs/_site/switcher.js b/docs/_site/switcher.js index ece9ef82b..965ee38a2 100644 --- a/docs/_site/switcher.js +++ b/docs/_site/switcher.js @@ -39,13 +39,31 @@ 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; @@ -74,7 +92,7 @@ slot.style.opacity = "1"; select.style.cssText = "max-width:100%;padding:2px 4px;border-radius:3px;" + - "background:rgba(0,0,0,.2);color:#fcfcfc;font-size:90%;" + + "background-color:#2c3e50;color:#fcfcfc;font-size:90%;" + "border:1px solid rgba(255,255,255,.3);cursor:pointer"; slot.appendChild(select); return true; @@ -90,20 +108,32 @@ var label = document.createElement("label"); label.textContent = "Version: "; select.style.cssText = - "background:#1a1a1a;color:#fcfcfc;border:none;font-size:13px"; + "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; - var select = build(versions); - if (!mount(select)) mountFallback(select); + whenReady(function () { + var select = build(versions); + if (!mount(select)) mountFallback(select); + }); }) .catch(function () { /* no versions.json (e.g. local build): no switcher */ From 076894a49d25c9d6f4d2ef4ca0f635b8cabef1b3 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Thu, 27 Aug 2026 17:15:34 +0200 Subject: [PATCH 4/5] . --- .github/workflows/documentation.yml | 34 +++++++++-------------------- AGENTS.md | 7 +++--- docs/_site/404.html | 3 +++ docs/_site/index.html | 4 ++++ 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 0fb263327..beff50249 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,12 +1,5 @@ name: documentation -# The published site (gh-pages) is versioned: every release lives under -# /vX.Y.Z/, the latest release is mirrored at /stable/ (the default landing -# spot), and pushes to main deploy to /dev/. Site-wide files (root redirect, -# 404 handler, version switcher) come from docs/_site/ on main. -# -# Publishing an old release retroactively: run this workflow manually with the -# tag name; tick "set_stable" only if that tag is the newest release. on: pull_request: @@ -36,17 +29,14 @@ on: permissions: contents: write -# Release deploys are queued separately from dev deploys: GitHub keeps only one -# pending run per group, so a busy main branch must not be able to evict a -# queued release and leave that release without docs. The two groups can -# therefore overlap, which the deploy step handles by re-applying onto the -# latest gh-pages if its push is rejected. PR builds never deploy and can run -# freely (superseded runs are cancelled). +# 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' || 'docs-deploy-release') }} + && 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: @@ -149,13 +139,10 @@ jobs: # Guard: $DEST is interpolated into rm -rf below. [ -n "$DEST" ] || { echo "::error::empty deploy target"; exit 1; } - # Everything this deploy writes, as a function: if the push is - # rejected because a concurrent deploy landed first, the whole thing - # is re-applied on top of that deploy rather than overwriting it. + # Re-runnable: a rejected push re-applies this onto the newer tip. apply() { - # Everything at the site root except the version dirs is chrome and - # is regenerated below. This also clears the pre-versioning layout, - # where the docs pages themselves lived at the root. + # 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 {} + @@ -192,9 +179,8 @@ jobs: marker = site / "stable" / ".version" stable = marker.read_text().strip() if marker.is_file() else None - # dev on top, then releases newest-first. The release that /stable/ - # points at is shown as "vX.Y.Z (stable)" in place of its own entry, - # so no version is listed twice. + # 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"}) diff --git a/AGENTS.md b/AGENTS.md index bcbbcc43c..a20fb38b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,9 +75,10 @@ Two things worth knowing before touching this: 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.1`–`v2.4.0`.** Older tags either lack the `docs` - extra (`v2.0.0`, so no Sphinx gets installed) or don't match the `vX.Y.Z` tag - pattern the workflow requires (`1.4.0`). +- **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 index 0ca7357c6..345b3875a 100644 --- a/docs/_site/404.html +++ b/docs/_site/404.html @@ -7,6 +7,9 @@

Page not found.

Go to the latest documentation

+ +

Development documentation