Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 194 additions & 8 deletions .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: documentation


on:
pull_request:
types: [opened, synchronize, reopened]
Expand All @@ -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"
Expand All @@ -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
28 changes: 24 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
70 changes: 70 additions & 0 deletions docs/_site/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BirdNET-Analyzer documentation</title>
</head>
<body>
<p id="message">Page not found.</p>
<p><a id="home" href="/BirdNET-Analyzer/stable/">Go to the latest documentation</a></p>
<!-- Escape hatch that works without JavaScript and before the first
release is deployed, when /stable/ does not exist yet. -->
<p><a href="/BirdNET-Analyzer/dev/">Development documentation</a></p>
<script>
// Pre-versioning the docs lived at the site root, so old deep links like
// /BirdNET-Analyzer/usage/cli.html land here and are sent to the same
// page in the default version. Links already inside a version directory
// stay here: that page just doesn't exist in that version, and
// redirecting again would loop. The exception is /stable/, which is
// legitimately absent until the first release is published.
(function () {
"use strict";
var base = "/BirdNET-Analyzer/";
var message = document.getElementById("message");
var home = document.getElementById("home");
if (location.pathname.indexOf(base) !== 0) return;
var rest = location.pathname.slice(base.length);
var inVersion = rest.match(/^(stable|dev|v[0-9][^/]*)(?:\/(.*))?$/);

function pick(versions) {
if (!versions || !versions.length) return null;
var paths = versions.map(function (e) {
return e.path;
});
if (paths.indexOf("stable") !== -1) return "stable";
for (var i = 0; i < paths.length; i++) {
if (paths[i].charAt(0) === "v") return paths[i];
}
return paths[0];
}

function go(target, path) {
location.replace(
base + target + "/" + path + location.search + location.hash
);
}

fetch(base + "versions.json")
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (versions) {
var target = pick(versions);
if (target) home.href = base + target + "/";
if (inVersion) {
if (inVersion[1] === "stable" && target && target !== "stable") {
return go(target, inVersion[2] || "");
}
message.textContent =
"This page does not exist in the documentation version you selected.";
return;
}
go(target || "stable", rest);
})
.catch(function () {
if (!inVersion) go("stable", rest);
});
})();
</script>
</body>
</html>
47 changes: 47 additions & 0 deletions docs/_site/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="canonical" href="stable/">
<title>BirdNET-Analyzer documentation</title>
<noscript><meta http-equiv="refresh" content="0; url=stable/"></noscript>
</head>
<body>
<p>Redirecting to the <a href="stable/">latest stable documentation</a>&hellip;</p>
<!-- Without JavaScript the noscript refresh above targets stable/, which
does not exist until the first release is deployed; this link always
does. -->
<p><a href="dev/">Development documentation</a></p>
<script>
// Normally /stable/. Before the first release is published to the
// versioned site there is no stable/ yet, so fall back to the newest
// version directory and finally to dev rather than redirecting into
// a 404.
(function () {
"use strict";
var base = location.pathname.replace(/[^/]*$/, "");
function go(target) {
location.replace(base + target + "/");
}
fetch(base + "versions.json")
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (versions) {
if (!versions || !versions.length) return go("stable");
var paths = versions.map(function (e) {
return e.path;
});
if (paths.indexOf("stable") !== -1) return go("stable");
for (var i = 0; i < paths.length; i++) {
if (paths[i].charAt(0) === "v") return go(paths[i]);
}
go(paths[0]);
})
.catch(function () {
go("stable");
});
})();
</script>
</body>
</html>
Loading