From 0d3307459844b0b615ecd01f812fcf6c470169a0 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Mon, 22 Jun 2026 09:14:02 +0000 Subject: [PATCH 1/2] Docs: migrate to myst-version-switcher assemble model (v0.5.0) Replace the legacy peaceiris/actions-gh-pages + keep_files publish (and the in-repo make_switcher.py) with the version-switcher assemble model, and adopt the shared `make docs` build used by the other PandABlocks repos: - ci.yml + _docs.yml + _publish.yml (the documented tutorial split). _docs.yml drives the build through `make docs` (npx mystmd, pinned by MYSTMD_VERSION in CONFIG) and packs docs.zip (bare html/ root); _publish.yml runs assemble@v0.5.0 -> upload-pages-artifact -> deploy-pages. - Add a minimal Makefile + CONFIG.example (the repo had none) exposing the same `make docs` target; gitignore the local CONFIG copy. - .devcontainer/Dockerfile (kas-based): add make + npm so `make docs` works in the container. - docs/myst.yml: add the version-switcher plugin v0.5.0; add navbar_end.md. - Publish the `docs` branch as the live version during migration (guard-default-branch: false; main has no MyST docs yet). - Remove the old docs.yml and .github/pages/{make_switcher.py,index.html}. The Pages-source flip + gh-pages backfill/delete are a separate cutover. Co-Authored-By: Claude Opus 4.8 --- .claude/settings.local.json | 8 +++ .devcontainer/Dockerfile | 10 ++-- .github/pages/index.html | 11 ---- .github/pages/make_switcher.py | 96 ---------------------------------- .github/workflows/_docs.yml | 87 ++++++++++++++++++++++++++++++ .github/workflows/_publish.yml | 86 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 55 +++++++++++++++++++ .github/workflows/docs.yml | 85 ------------------------------ .gitignore | 3 ++ CONFIG.example | 13 +++++ Makefile | 25 +++++++++ docs/myst.yml | 4 ++ docs/navbar_end.md | 3 ++ 13 files changed, 290 insertions(+), 196 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 .github/pages/index.html delete mode 100755 .github/pages/make_switcher.py create mode 100644 .github/workflows/_docs.yml create mode 100644 .github/workflows/_publish.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/docs.yml create mode 100644 CONFIG.example create mode 100644 Makefile create mode 100644 docs/navbar_end.md 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..7c69f499 --- /dev/null +++ b/.github/workflows/_publish.yml @@ -0,0 +1,86 @@ +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 + + # 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 +::: From e32019bec07c1806d8e453911b9328cec34620e1 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Mon, 22 Jun 2026 11:24:44 +0000 Subject: [PATCH 2/2] Docs: overlay legacy main/ from gh-pages during migration main has no MyST docs build yet, so the assemble model drops /main/. Until docs merges to main, stage the existing gh-pages main/ build into the assemble site dir so generate lists main in switcher.json and /main/ keeps serving. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/_publish.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 7c69f499..9594d139 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -51,6 +51,26 @@ jobs: 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 != ''