diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..667e97f3
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Read(//workspaces/**)",
+ "Bash(find / -maxdepth 5 -type d \\\\\\( -name \"PandABlocks-FPGA\" -o -name \"PandABlocks-server\" -o -name \"PandABlocks-rootfs\" -o -name \"PandABlocks.github.io\" \\\\\\))"
+ ]
+ }
+}
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 361cad19..e4b66da5 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -5,7 +5,9 @@ FROM ghcr.io/siemens/kas/kas:4.8 AS developer
USER root
-# Add any system dependencies for the developer environment here
-# RUN apt-get update -y && apt-get install -y --no-install-recommends \
-# some-tool \
-# && apt-get clean
+# Add any system dependencies for the developer environment here.
+# npm provides npx, used by `make docs` to run mystmd on demand for the docs build.
+RUN apt-get update -y && apt-get install -y --no-install-recommends \
+ make \
+ npm \
+ && apt-get clean
diff --git a/.github/pages/index.html b/.github/pages/index.html
deleted file mode 100644
index c495f39f..00000000
--- a/.github/pages/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
- Redirecting to main branch
-
-
-
-
-
-
diff --git a/.github/pages/make_switcher.py b/.github/pages/make_switcher.py
deleted file mode 100755
index c06813af..00000000
--- a/.github/pages/make_switcher.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Make switcher.json to allow docs to switch between different versions."""
-
-import json
-import logging
-from argparse import ArgumentParser
-from pathlib import Path
-from subprocess import CalledProcessError, check_output
-
-
-def report_output(stdout: bytes, label: str) -> list[str]:
- """Print and return something received frm stdout."""
- ret = stdout.decode().strip().split("\n")
- print(f"{label}: {ret}")
- return ret
-
-
-def get_branch_contents(ref: str) -> list[str]:
- """Get the list of directories in a branch."""
- stdout = check_output(["git", "ls-tree", "-d", "--name-only", ref])
- return report_output(stdout, "Branch contents")
-
-
-def get_sorted_tags_list() -> list[str]:
- """Get a list of sorted tags in descending order from the repository."""
- stdout = check_output(["git", "tag", "-l", "--sort=-v:refname"])
- return report_output(stdout, "Tags list")
-
-
-def get_versions(ref: str, add: str | None) -> list[str]:
- """Generate the file containing the list of all GitHub Pages builds."""
- # Get the directories (i.e. builds) from the GitHub Pages branch
- try:
- builds = set(get_branch_contents(ref))
- except CalledProcessError:
- builds = set()
- logging.warning(f"Cannot get {ref} contents")
-
- # Add and remove from the list of builds
- if add:
- builds.add(add)
-
- # Get a sorted list of tags
- tags = get_sorted_tags_list()
-
- # Make the sorted versions list from main branches and tags
- versions: list[str] = []
- for version in ["master", "main"] + tags:
- if version in builds:
- versions.append(version)
- builds.remove(version)
-
- # Add in anything that is left to the bottom
- versions += sorted(builds)
- print(f"Sorted versions: {versions}")
- return versions
-
-
-def write_json(path: Path, repository: str, versions: list[str]):
- """Write the JSON switcher to path."""
- org, repo_name = repository.split("/")
- struct = [
- {"version": version, "url": f"https://{org}.github.io/{repo_name}/{version}/"}
- for version in versions
- ]
- text = json.dumps(struct, indent=2)
- print(f"JSON switcher:\n{text}")
- path.write_text(text, encoding="utf-8")
-
-
-def main(args=None):
- """Parse args and write switcher."""
- parser = ArgumentParser(
- description="Make a versions.json file from gh-pages directories"
- )
- parser.add_argument(
- "--add",
- help="Add this directory to the list of existing directories",
- )
- parser.add_argument(
- "repository",
- help="The GitHub org and repository name: ORG/REPO",
- )
- parser.add_argument(
- "output",
- type=Path,
- help="Path of write switcher.json to",
- )
- args = parser.parse_args(args)
-
- # Write the versions file
- versions = get_versions("origin/gh-pages", args.add)
- write_json(args.output, args.repository, versions)
-
-
-if __name__ == "__main__":
- main()
diff --git a/.github/workflows/_docs.yml b/.github/workflows/_docs.yml
new file mode 100644
index 00000000..c63fddf3
--- /dev/null
+++ b/.github/workflows/_docs.yml
@@ -0,0 +1,87 @@
+on:
+ workflow_call:
+ outputs:
+ version-name:
+ description: The version name this build was served at (pr- | main | docs | ).
+ value: ${{ jobs.build.outputs.version-name }}
+
+# Build the docs at the versioned BASE_URL and upload this build's `docs` artifact
+# (docs.zip, bare html/ root). This is the UNPRIVILEGED half: it runs for every
+# event — PRs (including forks), pushes to main/docs, and tags — but never
+# publishes. _publish.yml (nested by ci.yml on internal events) reconstructs the
+# whole site from these artifacts + release assets and deploys it to Pages.
+#
+# Like the other PandABlocks repos, the build is driven through `make docs`, which
+# runs mystmd on demand via npx (pinned by MYSTMD_VERSION in CONFIG) — so we only
+# need Node on the PATH. meta-panda's docs are pure MyST (no python directives).
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ outputs:
+ version-name: ${{ steps.ver.outputs.version-name }}
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ # `make docs` reads MYSTMD_VERSION from CONFIG.
+ - name: Create CONFIG
+ run: cp CONFIG.example CONFIG
+
+ # Version name = the site sub-dir this build is served at, and the BASE_URL it
+ # must be built with. pr- for PRs; otherwise the ref name (main, docs, or a
+ # tag without `/`). No sanitisation: every name is filesystem/URL-safe already.
+ - name: Compute version name
+ id: ver
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = pull_request ]; then
+ name="pr-${{ github.event.pull_request.number }}"
+ else
+ name="${{ github.ref_name }}"
+ fi
+ echo "version-name=$name" >> "$GITHUB_OUTPUT"
+
+ # BASE_URL must match the versioned sub-path the build is served at, or its
+ # root-absolute assets 404. assemble files this build's artifact at the same
+ # version name, so the two cannot drift.
+ - name: Build docs
+ env:
+ BASE_URL: /meta-panda/${{ steps.ver.outputs.version-name }}
+ run: make docs
+
+ # Drop the ~135 MB templates/ dir (downloaded book-theme node sources, a
+ # build-time cache) before packing.
+ - name: Remove build cache
+ run: rm -rf docs/_build/templates
+
+ # Pack the build as docs.zip with a bare html/ root — the durable contract
+ # both _publish.yml's gather and the docs-release asset rely on.
+ - name: Pack docs.zip (bare html/ root)
+ run: |
+ set -euo pipefail
+ ( cd docs/_build && zip -rq "$RUNNER_TEMP/docs.zip" html )
+
+ # compression-level 0: docs.zip is already compressed.
+ - name: Upload docs artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: docs
+ path: ${{ runner.temp }}/docs.zip
+ compression-level: 0
+
+ # Fork PRs build + verify here but do not auto-publish (ci.yml's publish job
+ # excludes them — the security boundary). Surface that on the PR.
+ - name: Explain the fork-preview opt-in
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.pull_request.head.repo.full_name != github.repository
+ run: |
+ echo "::warning title=Docs preview not published::This is a fork PR, so the \
+ versioned docs site is NOT auto-published (fork builds run with a read-only \
+ token). A maintainer can publish a preview by running the Publish workflow \
+ for PR #${{ github.event.pull_request.number }}: \
+ https://github.com/${{ github.repository }}/actions/workflows/_publish.yml"
diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml
new file mode 100644
index 00000000..9594d139
--- /dev/null
+++ b/.github/workflows/_publish.yml
@@ -0,0 +1,106 @@
+name: Publish
+
+# Reconstruct the WHOLE versioned docs site from durable sources (the live branch's
+# build, release docs.zip assets, open-PR build artifacts) and deploy it directly to
+# GitHub Pages via the published myst-version-switcher `assemble` action — no
+# gh-pages branch. This is the PRIVILEGED half, kept in its own file so it can only
+# run two trusted ways:
+#
+# workflow_call — nested by ci.yml AFTER a successful build, for INTERNAL events
+# only (internal PRs, pushes to main/docs/tags). ci.yml passes
+# this build's `version-name`; assemble downloads this run's
+# `docs` artifact and stages it directly (the run isn't a
+# completed success yet, so the gather can't discover it).
+# workflow_dispatch — a maintainer's opt-in to preview an EXTERNAL fork PR.
+#
+# guard-default-branch is false during the docs migration: the new MyST docs live on
+# the `docs` branch, so the default branch (main) has no build to guard against yet.
+on:
+ workflow_call:
+ inputs:
+ version-name:
+ description: Version name (pr- | main | docs | ) of the in-run build to inject.
+ required: true
+ type: string
+ workflow_dispatch:
+ inputs:
+ pr:
+ description: External fork PR number to approve (pins its head SHA) and preview.
+ required: false
+
+permissions:
+ contents: read # checkout + read release assets
+ actions: read # gh run download (this run's + cross-run docs artifacts)
+ pages: write # deploy to Pages
+ id-token: write # deploy-pages OIDC
+ statuses: write # set the preview-approved status on a fork PR head SHA
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ # The canonical-repo guard lives in the caller (ci.yml's publish job), not here.
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0 # tags, for version ordering + prerelease detection
+
+ # MIGRATION SHIM: the new docs live on the `docs` branch; `main` has no MyST
+ # build yet, so the assemble model would drop /main/. Until `docs` merges to
+ # `main`, overlay the existing gh-pages main/ build into the assemble site dir
+ # ($RUNNER_TEMP/site, which assemble.sh mkdir -p's but never wipes) so generate
+ # lists `main` in switcher.json and /main/ keeps serving. Remove this step (and
+ # delete gh-pages) once main itself is migrated.
+ - name: Stage legacy main/ from gh-pages (migration shim)
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ if git fetch --quiet origin gh-pages 2>/dev/null \
+ && git cat-file -e origin/gh-pages:main/index.html 2>/dev/null; then
+ mkdir -p "$RUNNER_TEMP/site/main"
+ git archive origin/gh-pages main | tar -x --strip-components=1 -C "$RUNNER_TEMP/site/main"
+ echo "Staged legacy main/ from gh-pages"
+ else
+ echo "::warning::no gh-pages main/ to stage — skipping"
+ fi
+
+ # workflow_dispatch (fork opt-in): pin THIS commit as approved.
+ - name: Approve fork PR head SHA
+ if: github.event_name == 'workflow_dispatch' && inputs.pr != ''
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ inputs.pr }}
+ run: |
+ set -euo pipefail
+ sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)
+ gh api --method POST "repos/$REPO/statuses/$sha" \
+ -f state=success -f context=preview-approved \
+ -f description="Fork docs preview approved"
+
+ # On the nested call, `artifact-version-name` tells assemble to download this
+ # run's `docs` artifact and stage it as that version (it isn't a completed
+ # success yet). Empty on workflow_dispatch -> a pure durable gather.
+ - name: Assemble versioned site
+ id: site
+ uses: DiamondLightSource/myst-version-switcher-plugin/assemble@v0.5.0
+ with:
+ repo: ${{ github.repository }}
+ guard-default-branch: false
+ artifact-version-name: ${{ inputs.version-name }}
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ${{ steps.site.outputs.dir }}
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..b5fbdd5a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,55 @@
+name: Docs CI
+
+# Build + verify the docs on every event, then publish on INTERNAL events. The
+# build (`_docs.yml`) runs for PRs (including forks), pushes to main/docs, and tags,
+# and uploads each build's `docs` artifact. Publishing is nested here (the `publish`
+# job -> `_publish.yml`) so its status is visible on the PR/commit, but ONLY for
+# internal events on this repo: a fork PR's build runs with a read-only token and
+# must never deploy.
+#
+# `docs` is a publish trigger during the docs migration: the new MyST docs live on
+# the `docs` branch (main has none yet), so `docs` is the live version.
+on:
+ pull_request:
+ push:
+ branches: [main, docs]
+ tags: ['*'] # '*' never matches '/'
+
+jobs:
+ docs:
+ uses: ./.github/workflows/_docs.yml
+
+ # Tag-only: attach this build's docs.zip (bare html/ root) to the GitHub Release
+ # so `assemble` can reconstruct that released version on future deploys.
+ docs-release:
+ needs: [docs]
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: docs
+ - env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release upload "${{ github.ref_name }}" docs.zip --clobber --repo "${{ github.repository }}"
+
+ # Internal events only: an internal PR, or a push to main/docs/tag, has a
+ # same-repo build we can trust + deploy. Fork PRs (head repo != this repo) are
+ # excluded — _docs.yml's build job warns them instead.
+ publish:
+ needs: [docs]
+ if: >-
+ github.repository == 'PandABlocks/meta-panda' &&
+ ( github.event_name != 'pull_request' ||
+ github.event.pull_request.head.repo.full_name == github.repository )
+ uses: ./.github/workflows/_publish.yml
+ with:
+ version-name: ${{ needs.docs.outputs.version-name }}
+ permissions:
+ contents: read
+ actions: read
+ pages: write
+ id-token: write
+ statuses: write
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
deleted file mode 100644
index 8802f111..00000000
--- a/.github/workflows/docs.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: Docs CI
-
-on:
- push:
- branches:
- - main
- - docs
- tags:
- - '*'
- pull_request:
-
-permissions:
- contents: write
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Avoid git conflicts when tag and branch pushed at same time
- if: github.ref_type == 'tag'
- run: sleep 60
-
- - name: Checkout
- uses: actions/checkout@v5
- with:
- # Need this to get version number from last tag
- fetch-depth: 0
-
- # meta-panda is a Yocto layer, not a Python/uv project, so we install
- # mystmd directly with npm rather than via `uv run tox -e docs`. The build
- # command itself (`cd docs && myst build --html --strict`) matches the
- # template. --strict makes the build exit non-zero on any error-severity
- # message (e.g. an unresolved cross-repo xref), failing CI rather than
- # publishing broken links.
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 20
-
- - name: Install MyST
- run: npm install -g mystmd@1.10.1
-
- - name: Sanitize ref name for docs version
- run: echo "DOCS_VERSION=${GITHUB_REF_NAME//[^A-Za-z0-9._-]/_}" >> $GITHUB_ENV
-
- # BASE_URL is required so assets/links resolve under the versioned Pages
- # sub-path (https://pandablocks.github.io/meta-panda/$DOCS_VERSION/). This
- # is the one deviation from the template, whose mystmd migration does not
- # yet set it.
- - name: Build docs
- env:
- BASE_URL: /meta-panda/${{ env.DOCS_VERSION }}
- run: |
- cd docs
- myst build --html --strict
-
- # Drop the ~135 MB templates/ dir (downloaded book-theme node sources, a
- # build-time cache) before uploading. We keep docs/_build as the artifact
- # root so the archive retains the intermediate html/ directory; this trims
- # the zip ~7x (51 MB -> ~7 MB) without changing its layout.
- - name: Remove build cache from artifact
- run: rm -rf docs/_build/templates
-
- - name: Upload built docs artifact
- uses: actions/upload-artifact@v4
- with:
- name: docs
- path: docs/_build
-
- - name: Move to versioned directory
- run: mv docs/_build/html .github/pages/$DOCS_VERSION
-
- - name: Write switcher.json
- run: python3 .github/pages/make_switcher.py --add $DOCS_VERSION ${{ github.repository }} .github/pages/switcher.json
-
- - name: Publish Docs to gh-pages
- if: github.ref_type == 'tag' || github.ref_name == 'main' || github.ref_name == 'docs'
- # We pin to the SHA, not the tag, for security reasons.
- # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
- uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: .github/pages
- keep_files: true
diff --git a/.gitignore b/.gitignore
index 1e894b86..e6a69fd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
build/
sources/
+# Local build settings (copy of CONFIG.example)
+CONFIG
+
# MyST build output
docs/_build/
diff --git a/CONFIG.example b/CONFIG.example
new file mode 100644
index 00000000..cc6e6135
--- /dev/null
+++ b/CONFIG.example
@@ -0,0 +1,13 @@
+# Example configuration file for building the meta-panda documentation.
+#
+# Copy this file to a file named CONFIG and edit as appropriate.
+#
+# Note that this file is used as part of github Continuous Integration (see
+# .github/workflows/_docs.yml), so the entries in this file must refer to valid
+# paths/values in the CI container.
+
+# Version of mystmd (https://mystmd.org) used to build the documentation with
+# `make docs` / `make docs-dev`. Run on demand via npx, so no global install is
+# needed.
+#
+MYSTMD_VERSION = 1.10.1
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..c4c27434
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+# meta-panda is a Yocto/kas layer with no compiled build of its own; this Makefile
+# exists only to drive the documentation build the same way as the other
+# PandABlocks repos (`make docs`).
+#
+# Docs are built with MyST (mystmd), run on demand through npx so no global install
+# is needed; pin the version with MYSTMD_VERSION (see CONFIG.example). MyST writes
+# its output into docs/_build/html. --strict exits non-zero on any error-severity
+# message (e.g. an unresolved cross-repo xref) so CI fails rather than publishing
+# broken links.
+
+# The CONFIG file is required. If not present, create by copying CONFIG.example.
+include CONFIG
+
+MYST = npx --yes --package mystmd@$(MYSTMD_VERSION) myst
+
+docs:
+ cd docs && $(MYST) build --html --strict
+
+docs-dev:
+ cd docs && $(MYST) start
+
+clean-docs:
+ rm -rf docs/_build
+
+.PHONY: docs docs-dev clean-docs
diff --git a/docs/myst.yml b/docs/myst.yml
index 62c1c259..cbaf44a8 100644
--- a/docs/myst.yml
+++ b/docs/myst.yml
@@ -2,6 +2,8 @@ version: 1
project:
title: meta-panda
github: https://github.com/PandABlocks/meta-panda
+ plugins:
+ - https://github.com/DiamondLightSource/myst-version-switcher-plugin/releases/download/v0.5.0/version-switcher.mjs
# Cross-repository references (mystmd xref + Sphinx intersphinx).
# PROTOTYPE for upstreaming into the DLS python-copier-template (Stage A spec §6).
# The three core repos are mystmd projects (resolved via their published
@@ -84,6 +86,8 @@ project:
title: Release Notes
site:
template: book-theme
+ parts:
+ navbar_end: navbar_end.md
nav:
- title: Tutorials
url: /tutorials
diff --git a/docs/navbar_end.md b/docs/navbar_end.md
new file mode 100644
index 00000000..048e2cee
--- /dev/null
+++ b/docs/navbar_end.md
@@ -0,0 +1,3 @@
+:::{version-switcher}
+:json-url: https://pandablocks.github.io/meta-panda/switcher.json
+:::