Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ can be judged from the diff text alone.

- **Localization is all-or-nothing.** A diff that adds a key to
`birdnet_analyzer/lang/en.json`, or a `loc.localize("...")` call with a new key,
must add that key to **all** files in `birdnet_analyzer/lang/` (currently 10) with a
must add that key to **all** files in `birdnet_analyzer/lang/` with a
real translation — English text copied into `de.json` is a defect, not a
placeholder. The files stay sorted with 4-space indent (a json load/dump round-trip
with `ensure_ascii=False, indent=4, sort_keys=True`), and every translation keeps
Expand Down
210 changes: 201 additions & 9 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,18 +12,96 @@ 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
python_version:
description: "Python to build with (3.11 for tags pinning tensorflow 2.15)"
required: false
type: string
default: "3.13"

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"
# Old tags pin dependencies that have no wheels for current Python.
python-version: ${{ inputs.python_version || '3.13' }}
- name: Set up uv
uses: astral-sh/setup-uv@v10.0.1
with:
Expand All @@ -32,13 +111,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
Loading