diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2289057..4419fcf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -84,11 +84,15 @@ jobs: # 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 (site/, site/, site/pr-), so the two - # cannot drift. build-command comes through an env var (not interpolated into - # the script) and is run with `eval` so compound commands work. + # cannot drift. VERSION_NAME is the bare version token, exported for builds + # that need it directly — e.g. a Sphinx conf.py setting pydata-sphinx-theme's + # switcher `version_match` — without parsing it back out of BASE_URL. + # build-command comes through an env var (not interpolated into the script) + # and is run with `eval` so compound commands work. - name: Build docs env: BASE_URL: /${{ github.event.repository.name }}/${{ steps.ver.outputs.version-name }} + VERSION_NAME: ${{ steps.ver.outputs.version-name }} BUILD_COMMAND: ${{ inputs.build-command }} run: | set -euo pipefail @@ -116,6 +120,6 @@ jobs: path: ${{ runner.temp }}/docs.zip compression-level: 0 - # NOTE: the fork-PR "preview not published" hint now lives in publish-dispatch.yml - # (its `warn` job), which runs for fork PRs too — so this build job stays purely - # about building + uploading the docs artifact. + # NOTE: the fork-PR "preview not published" hint lives in publish.yml's `warn` + # job (reached via the publish-dispatch.yml shim), which runs for fork PRs too — + # so this build job stays purely about building + uploading the docs artifact. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3523b37..18b9bc6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -131,6 +131,22 @@ jobs: - name: Create staging dirs run: mkdir -p "$RUNNER_TEMP/site" "$RUNNER_TEMP/gather" + # The live Pages base URL, from the Pages API — respects a custom domain + # (CNAME); falls back to the conventional github.io URL if the API call + # fails. Used by the _sources restore below and baked into switcher.json. + - name: Resolve the Pages URL + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + url=$(gh api "repos/$REPO/pages" -q .html_url 2>/dev/null || true) + if [ -z "$url" ]; then + owner=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]') + url="https://$owner.github.io/${REPO#*/}" + fi + echo "PAGES_URL=${url%/}" >> "$GITHUB_ENV" + echo "Pages URL: ${url%/}" + # Releases are permanent sources; the seed release (if present) stands in for the # default branch until _sources/ is established — store it as $DEFAULT.zip so the # priority-overwrite chain below can supersede it with a fresher source. @@ -150,27 +166,39 @@ jobs: || echo "::warning::release $tag download failed — skipping" done - # Branch CI artifacts overwrite the seed (higher priority). Default branch: latest - # successful push run. PRs: internal always; external forks only when the head SHA - # carries a preview-approved status. No $ARTIFACT_VERSION_NAME skip — the current - # build (staged next) overwrites anything gathered here with the same name. + # Branch CI artifacts overwrite the seed (higher priority). Found via the + # artifacts API by NAME (`docs` — the contract), never by workflow filename, + # so the consumer's entry workflow can be called anything. An artifact's + # workflow_run carries head_branch/head_sha/head_repository_id but no run + # conclusion — existence is the signal (docs.yml only uploads after a + # successful build). Default branch: newest non-expired docs artifact built + # from THIS repo's code on that branch (head_repository_id keeps a same-named + # fork branch out). PRs: keyed by head SHA — internal always; external forks + # only when the SHA carries a preview-approved status. No + # $ARTIFACT_VERSION_NAME skip — the current build (staged next) overwrites + # anything gathered here with the same name. - name: Gather branch CI artifacts env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gather_run() { - local query="$1" dest="$2" label="$3" - local run; run=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?$query&status=success&per_page=1" \ - -q '.workflow_runs[0].id // empty') - [ -n "$run" ] || return 0 + repo_id=$(gh api "repos/$REPO" -q .id) + gather_artifact() { + local filter="$1" dest="$2" label="$3" + local id; id=$(gh api --paginate \ + "repos/$REPO/actions/artifacts?name=docs&per_page=100" \ + | jq -rs "[.[].artifacts[] | select((.expired | not) and ($filter))] | sort_by(.created_at) | last | .id // empty") + [ -n "$id" ] || return 0 + # The API serves the artifact as a zip-of-the-artifact (docs.zip inside). local tmp; tmp=$(mktemp -d) - gh run download "$run" --repo "$REPO" -n docs -D "$tmp" \ - && mv "$tmp/docs.zip" "$RUNNER_TEMP/gather/$dest.zip" \ + { gh api "repos/$REPO/actions/artifacts/$id/zip" > "$tmp/artifact.zip" \ + && unzip -q "$tmp/artifact.zip" docs.zip -d "$tmp" \ + && mv "$tmp/docs.zip" "$RUNNER_TEMP/gather/$dest.zip"; } \ || echo "::warning::$label artifact unavailable — skipping" rm -rf "$tmp" } - gather_run "branch=$DEFAULT&event=push" "$DEFAULT" "default-branch CI" + gather_artifact ".workflow_run.head_branch == \"$DEFAULT\" and .workflow_run.head_repository_id == $repo_id" \ + "$DEFAULT" "default-branch CI" prs=$(gh pr list --repo "$REPO" --state open --limit 200 \ --json number,headRefOid,isCrossRepository -q '.[] | [.number, .headRefOid, .isCrossRepository] | @tsv') while IFS=$'\t' read -r num sha cross; do @@ -180,7 +208,7 @@ jobs: -q 'any(.[]; .context=="preview-approved" and .state=="success")') [ "$approved" = "true" ] || continue fi - gather_run "head_sha=$sha" "pr-$num" "PR #$num" + gather_artifact ".workflow_run.head_sha == \"$sha\"" "pr-$num" "PR #$num" done <<< "$prs" # Highest priority: the current build overwrites anything gathered above. @@ -209,11 +237,9 @@ jobs: run: | set -euo pipefail [ -f "$RUNNER_TEMP/gather/$DEFAULT.zip" ] && exit 0 - owner=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]') - pages_url="https://$owner.github.io/${REPO#*/}" - if curl -fsSL "$pages_url/$SOURCES_DIR/$DEFAULT.zip" \ + if curl -fsSL "$PAGES_URL/$SOURCES_DIR/$DEFAULT.zip" \ -o "$RUNNER_TEMP/gather/$DEFAULT.zip" 2>/dev/null; then - echo "::notice::$DEFAULT restored from durable in-site copy ($pages_url/$SOURCES_DIR/$DEFAULT.zip)" + echo "::notice::$DEFAULT restored from durable in-site copy ($PAGES_URL/$SOURCES_DIR/$DEFAULT.zip)" else echo "::error::$DEFAULT: not provided by current build, releases, branch CI, or durable in-site copy — deploy aborted" exit 1 @@ -245,7 +271,7 @@ jobs: run: | set -euo pipefail stable_src=$(node "$GITHUB_WORKSPACE/.mvs/assemble/assemble.mjs" generate \ - --site-dir "$RUNNER_TEMP/site" --repo "$REPO" --required "$DEFAULT") + --site-dir "$RUNNER_TEMP/site" --base-url "$PAGES_URL" --required "$DEFAULT") [ -n "$stable_src" ] && ln -s "$stable_src" "$RUNNER_TEMP/site/stable" - name: Upload Pages artifact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adf3048..260cef3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,11 @@ jobs: TAG: ${{ github.ref_name }} run: | set -euo pipefail - # Flag a/b/rc tags as prereleases (mirrors the version-discovery marker). + # Flag PEP 440-style prerelease tags — a/b/rc following a digit, e.g. + # 1.0a1 / 2.0rc1 (mirrors assemble.mjs's isPrerelease). Anchoring on the + # digit keeps tags that merely contain those letters out. prerelease="" - case "$TAG" in *a*|*b*|*rc*) prerelease="--prerelease" ;; esac + case "$TAG" in *[0-9]a*|*[0-9]b*|*[0-9]rc*) prerelease="--prerelease" ;; esac if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then # The Release already exists (e.g. published from the UI on a mutable repo). diff --git a/CLAUDE.md b/CLAUDE.md index 4088d79..3e7d820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ public action. ## Key design decisions -See [`docs/explanation/architecture.md`](docs/explanation/architecture.md) for the +See [`docs/explanations/architecture.md`](docs/explanations/architecture.md) for the full rationale. In short: ### The default branch is self-durable in the site (`_sources/`) @@ -47,7 +47,9 @@ persists the default branch's `docs.zip` (the one it arrived as — current buil gathered run, or the fallback — copied verbatim) into the published site at `_sources/.zip` (excluded from version discovery), and a deploy whose fresh artifact is gone restores the branch from that durable in-site copy (fetched from -`PAGES_URL`, default `https://.github.io/`). Before the branch ever builds +`$PAGES_URL` — the live Pages URL resolved from the Pages API, so custom domains +work, falling back to `https://.github.io/`; the same URL roots the +`switcher.json` entries). Before the branch ever builds docs (mid gh-pages migration) a final rung reads a **published seed release** (`pages-default-seed`, created by `scripts/migrate.sh` from the old gh-pages `/` tree) and persists it to `_sources` on that deploy — so a repo can cut over to the @@ -125,7 +127,8 @@ With no releases and no other branches, `assemble` produces a single-entry rather than failing. The "preferred" version (the redirect target, flagged `preferred: true` in switcher.json, rendered with a ★) is the newest deployed non-prerelease tag, falling back to `main`/`master`. Prerelease detection mirrors -`release.yml` (an `a`/`b`/`rc` marker). +`release.yml` (an `a`/`b`/`rc` marker following a digit, PEP 440 style — so +`1.0a1`/`2.0rc1` are prereleases but `release-1.0` is not). ### `stable/` alias When a non-prerelease release is deployed, the site serves a `stable/` symlink @@ -172,13 +175,16 @@ Sub-workflows of `ci.yml`: - `_test.yml` — `npm test` - `docs.yml` — **reusable build, parameterised for cross-repo reuse.** Compute the version name (`pr-` / default-branch / tag) → run `build-command` (required - input) with `BASE_URL` set → pack `docs.zip` (bare `html/`, staged so - any `html-dir` works) → upload the `docs` artifact → warn on fork PRs. No deploy; - `contents: read` only. Installs uv unconditionally and relies on the runner's + input) with `BASE_URL` + `VERSION_NAME` set (the latter for builds that need the + bare token, e.g. a Sphinx conf.py setting pydata's switcher `version_match`) → + pack `docs.zip` (bare `html/`, staged so any `html-dir` works) → upload the `docs` + artifact. No deploy; `contents: read` only (the fork-PR hint lives in publish.yml's + `warn` job). Installs uv unconditionally and relies on the runner's preinstalled Node, so `build-command` can be `make docs` / `npx … myst build` / `tox -e docs` regardless of project. This repo passes `npm ci && npm run docs`. It OWNS the build↔publish contract (version name, BASE_URL, docs.zip `html/` root, - `docs` artifact name) so consumers only choose a command. + `docs` artifact name — publish.yml gathers cross-run artifacts by that NAME via the + artifacts API, never by workflow filename) so consumers only choose a command. - `release.yml` — **PUBLIC reusable, tag-only.** Downloads every artifact in the run and attaches them to the tag's GitHub Release via `gh` — `gh release create` if no Release exists yet (draft→upload→publish atomically, so immutable-safe), else diff --git a/assemble/assemble.mjs b/assemble/assemble.mjs index cdea77b..c204080 100644 --- a/assemble/assemble.mjs +++ b/assemble/assemble.mjs @@ -7,7 +7,7 @@ * switcher.json + index.html, and prints the stable-alias source dir on stdout. * Exposed as one subcommand: * - * node assemble.mjs generate --site-dir --repo [--required ] + * node assemble.mjs generate --site-dir --base-url [--required ] * → write switcher.json + index.html into ; print the stable-alias * source dir (the newest deployed release) on stdout, or nothing. Also * exit-1s if a --required branch is absent from the site. @@ -92,11 +92,13 @@ export function orderVersions(builds, tags) { } /** - * Is `tag` a prerelease? Mirrors `release.yml`'s test (an `a`, `b`, or `rc` - * marker in the name, PEP 440 style) so "stable" means the same thing repo-wide. + * Is `tag` a prerelease? Mirrors `release.yml`'s test (a PEP 440-style `a`, + * `b`, or `rc` marker *following a digit*, e.g. `1.0a1`/`2.0rc1`) so "stable" + * means the same thing repo-wide. Anchoring on the digit keeps tags that merely + * contain those letters (`release-1.0`, `beta-program`) out of the prerelease set. */ export function isPrerelease(tag) { - return /a|b|rc/i.test(tag); + return /\d(a|b|rc)/i.test(tag); } /** @@ -153,13 +155,17 @@ export function missingRequired(required = [], versions = []) { return required.filter((branch) => !have.has(branch)); } -/** Build the pydata switcher array for `org/repo`, flagging the stable entry. */ -export function switcherStruct(repository, versions, preferred) { - const [org, repoName] = repository.split("/"); +/** + * Build the pydata switcher array rooted at `baseUrl` (the site's live Pages + * URL — publish.yml resolves it from the Pages API, so custom domains work), + * flagging the stable entry. + */ +export function switcherStruct(baseUrl, versions, preferred) { + const base = String(baseUrl).replace(/\/+$/, ""); return versions.map((version) => { const entry = { version, - url: `https://${org}.github.io/${repoName}/${version}/`, + url: `${base}/${version}/`, }; if (version === preferred) entry.preferred = true; return entry; @@ -167,12 +173,8 @@ export function switcherStruct(repository, versions, preferred) { } /** Serialise the switcher exactly as the Python tool did (2-space JSON). */ -export function renderSwitcher(repository, versions, preferred) { - return JSON.stringify( - switcherStruct(repository, versions, preferred), - null, - 2, - ); +export function renderSwitcher(baseUrl, versions, preferred) { + return JSON.stringify(switcherStruct(baseUrl, versions, preferred), null, 2); } /** Root redirect to `target` (relative, so it is host- and repo-agnostic). */ @@ -202,7 +204,7 @@ function csv(value) { } /** - * `generate --site-dir --repo [--required ]` — write switcher.json + + * `generate --site-dir --base-url [--required ]` — write switcher.json + * index.html and emit the stable-alias source. Runs after all gathering, so it * also hard-fails (exit 1) if a `--required` branch is absent from the site. */ @@ -211,15 +213,15 @@ function cmdGenerate(rest) { args: rest, options: { "site-dir": { type: "string" }, - repo: { type: "string" }, + "base-url": { type: "string" }, required: { type: "string" }, }, }); const siteDir = values["site-dir"]; - const repo = values.repo; - if (!siteDir || !repo) { + const baseUrl = values["base-url"]; + if (!siteDir || !baseUrl) { throw new Error( - "usage: assemble.mjs generate --site-dir --repo [--required ]", + "usage: assemble.mjs generate --site-dir --base-url [--required ]", ); } @@ -251,7 +253,7 @@ function cmdGenerate(rest) { writeFileSync( join(siteDir, "switcher.json"), - renderSwitcher(repo, versions, preferred), + renderSwitcher(baseUrl, versions, preferred), "utf8", ); if (redirectTarget) { diff --git a/docs/explanations/architecture.md b/docs/explanations/architecture.md index 011c3ab..ff83542 100644 --- a/docs/explanations/architecture.md +++ b/docs/explanations/architecture.md @@ -12,7 +12,7 @@ publishes it **directly to GitHub Pages** via `actions/upload-pages-artifact` + `actions/deploy-pages`. There is **no `gh-pages` branch** — `deploy-pages` publishes one artifact as the *entire* site, which is a whole-site-replace. The four source kinds and their durability are in the -[reference](../reference/workflows.md#what-publishyml-gathers). +[reference](../reference/workflows.md#what-publish-yml-gathers). Releases are permanent, so old versions never vanish. PR previews come from CI artifacts and silently drop if the artifact expires and nothing rebuilds — fine for @@ -68,7 +68,7 @@ ordering: ## The `docs.zip` / version-name contracts -Two contracts (described in the [reference](../reference/workflows.md#the-docszip--version-name-contracts)) +Two contracts (described in the [reference](../reference/workflows.md#the-docs-zip-version-name-contracts)) keep build and reconstruction in sync. The design rationale in both is to eliminate sanitisation: @@ -242,8 +242,10 @@ The `stable` segment name is a fixed convention, hardcoded in the widget. - **PR build not yet green / SHA moved:** an open PR whose current head SHA has no successful CI run is skipped; its preview appears once the build passes. - **Merged/closed PR:** drops from the gather (open-PRs only) on the next deploy. -- **Prereleases:** excluded from `preferred`/redirect (an `a`/`b`/`rc` marker, parity - with the release workflow), but still listed in the switcher if gathered. +- **Prereleases:** excluded from `preferred`/redirect (an `a`/`b`/`rc` marker + following a digit, PEP 440 style — parity with the release workflow; a tag that + merely contains those letters, like `release-1.0`, is not a prerelease), but still + listed in the switcher if gathered. - **Concurrency:** `concurrency: { group: pages, cancel-in-progress: false }` makes deploys last-writer-wins; reconstructing from durable sources keeps that mostly self-healing. diff --git a/docs/how-to/migrate-from-gh-pages.md b/docs/how-to/migrate-from-gh-pages.md index 23989dc..8048b03 100644 --- a/docs/how-to/migrate-from-gh-pages.md +++ b/docs/how-to/migrate-from-gh-pages.md @@ -30,7 +30,10 @@ Two consequences drive the procedure: 1. **Your old releases are not durable yet.** Their docs exist only as directories on `gh-pages`; the matching Releases have no `docs.zip`. So run 1 includes a one-time - **backfill** of `docs.zip` onto those Releases — not just a config flip. + **backfill** of `docs.zip` onto those Releases — not just a config flip. (A tag with + a `gh-pages` directory but **no Release at all** gets one *created* with the + `docs.zip` — covering repos that tag without releasing, and fork rehearsals, since + forking copies tags but not Releases.) 2. **The default branch has no permanent source until a publish captures it.** Each deploy persists the branch's `docs.zip` into the site (`_sources/.zip`) and restores from there when the CI artifact expires — but before the *first* publish @@ -52,8 +55,10 @@ and it removes the seed release too. ## Optional: rehearse on a fork first To de-risk the real migration, run the whole thing on a fork before touching the -upstream site. A fork copies the repo's `gh-pages` branch and release tags, so the -migration has the same inputs — and it deploys to *your* `github.io`, never upstream's. +upstream site. A fork copies the repo's `gh-pages` branch and tags — but **not the +Releases** — so the backfill *creates* a Release (with the `docs.zip`) for every tag +that has a `gh-pages` directory. The migration therefore has the same inputs and keeps +every version, and it deploys to *your* `github.io`, never upstream's. 1. **Fork the repo and enable Actions on the fork** (the **Actions** tab → enable workflows; forks start with Actions disabled). You have admin on your own fork, @@ -101,12 +106,13 @@ tag to the fork if you also want to rehearse the tag re-dispatch. scripts/migrate.sh ORG/REPO --dry-run ``` -It prints the backfill + seed plan and probes the current site, uploading nothing and -flipping nothing. Compare the **backfill plan** against the **probe list**: any version -the live site serves that is *not* in the backfill plan is a `gh-pages` directory with -**no matching GitHub Release** — it will be **dropped** from the reconstructed site (it -stays on `gh-pages`, so it is recoverable, but won't be served). Cut real Releases for -any such versions you want to keep before proceeding. +It prints the backfill + seed plan, then a **drop report**: every version the live +site's `switcher.json` currently lists that the new model will *not* serve (not the +default branch, not an open-PR preview, and no tag + `gh-pages` directory to backfill +from or existing `docs.zip` Release). Dropped versions stay on `gh-pages` until +finalize, so they are recoverable — cut a real Release (or restore the tag) for any you +want to keep before proceeding. It ends by probing the current site; nothing is +uploaded and nothing is flipped. ## Step 2 — prepare (uploads + flips; no deploy) @@ -116,11 +122,14 @@ scripts/migrate.sh ORG/REPO It does the following, then **stops with `gh-pages` intact and still serving**: -1. **Backfill (non-destructive, idempotent).** For each release tag that is a `gh-pages` - directory and whose Release lacks a `docs.zip`, zip that directory (bare `html/` - root) and attach it as `docs.zip`. Tags containing `/` are skipped. -2. **Seed the default branch.** Capture the gh-pages `/` tree as the published - `pages-default-seed` release, so the default branch is durable before any publish. +1. **Backfill (non-destructive, idempotent).** For each tag that is a `gh-pages` + directory: zip that directory (bare `html/` root) and attach it as `docs.zip` to its + Release — or, if no Release exists for the tag, *create* one with the `docs.zip` + (flagged prerelease for `a`/`b`/`rc` tags). Tags containing `/` are skipped. +2. **Seed the default branch.** Capture the gh-pages `/` tree (or the + `--seed-from ` directory, if the old site published it under another name) as + the published `pages-default-seed` release, so the default branch is durable before + any publish. 3. **Flip the Pages source → GitHub Actions** and **open the `github-pages` environment's `deployment_branch_policy`** to "no restriction" (so deploys from PR/tag refs — which run under the nested-publish model — aren't rejected by the environment). The flip is @@ -152,6 +161,9 @@ Once the publish has deployed and `_sources/.zip` is live: scripts/migrate.sh ORG/REPO --delete-gh-pages ``` +(Or run it right after merging with `--wait`: the guard then polls until the durable +copy goes live — up to 30 minutes — instead of failing immediately.) + This **guards** the deletion: it refuses unless `https://ORG.github.io/REPO/_sources/.zip` returns `200` — i.e. the deployed site holds a durable copy of the default branch, so the new model can reconstruct @@ -182,7 +194,9 @@ lost. That is exactly why the deletion is a separate, gated run. | flag | effect | |---|---| -| `--dry-run` | Print the backfill + seed plan + probe the current site only; upload nothing; skip the flip. | +| `--dry-run` | Print the backfill + seed plan and the drop report + probe the current site only; upload nothing; skip the flip. | | `--delete-gh-pages` | **The only mode that deletes.** Guard that `_sources/.zip` is live (`200`), verify the live site, then delete `gh-pages` **and the seed release**. | | `--pages-ref ` | `gh-pages` ref to read (default `origin/gh-pages`). | +| `--seed-from ` | `gh-pages` directory to seed the default branch from, when the old site published it under a different name (e.g. `latest/`). | | `--yes` | Skip the typed confirmation before deleting `gh-pages` (with `--delete-gh-pages`; use with care). | +| `--wait` | With `--delete-gh-pages`: poll (up to 30 min) until `_sources/.zip` goes live instead of failing the guard immediately — lets you finalize right after merging the pipeline PR. | diff --git a/docs/how-to/use-with-sphinx.md b/docs/how-to/use-with-sphinx.md new file mode 100644 index 0000000..44442c2 --- /dev/null +++ b/docs/how-to/use-with-sphinx.md @@ -0,0 +1,103 @@ +# How-to: use the workflows with a Sphinx project + +The two reusable workflows are build-tool-agnostic: `docs.yml` only needs a command +that turns your sources into HTML, and `publish.yml` only needs the `docs.zip` +contract. A Sphinx project using +[pydata-sphinx-theme](https://pydata-sphinx-theme.readthedocs.io) doesn't even need +this project's plugin — the theme has a **built-in version switcher that reads the +same pydata-format `switcher.json`** that `publish.yml` generates. + +:::{note} python-copier-template +This setup will be rolled into a copier update in a later release of +[python-copier-template](https://github.com/DiamondLightSource/python-copier-template), +so repos generated from that template will pick it up via `copier update` rather than +by following this page by hand. +::: + +## 1. Configure the theme's switcher in `conf.py` + +`docs.yml` exports two env vars to your build: `BASE_URL` (the sub-path this build is +served at, `/REPO/`) and `VERSION_NAME` (the bare version token — +`pr-`, the default branch, or the tag). Wire them into the theme: + +```python +# docs/conf.py +import os + +version_name = os.environ.get("VERSION_NAME", "local") + +html_theme = "pydata_sphinx_theme" +html_theme_options = { + "switcher": { + "json_url": "https://ORG.github.io/REPO/switcher.json", + "version_match": version_name, + }, + "navbar_end": ["version-switcher", "theme-switcher", "navbar-icon-links"], + # switcher.json doesn't exist until the first deploy; don't fail the build on it. + "check_switcher": False, +} +``` + +Unlike MyST's book-theme, Sphinx emits *relative* asset URLs, so the build doesn't +strictly need `BASE_URL` to render correctly — but `version_match` is what makes the +dropdown highlight the version being viewed. + +## 2. Call the workflows from your CI + +Exactly as in the [tutorial](../tutorials/adding-to-a-fresh-repo.md), with a Sphinx +build command and its output directory: + +```yaml +# .github/workflows/ci.yml (docs + publish jobs) +jobs: + docs: + uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/docs.yml@__LATEST_TAG__ + with: + build-command: tox -e docs # or: sphinx-build -T docs build/html + html-dir: build/html # wherever your build writes the HTML + + release: + needs: [docs] + if: github.ref_type == 'tag' + uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/release.yml@__LATEST_TAG__ + permissions: + contents: write + + publish: + needs: [docs] + if: github.repository == 'ORG/REPO' + uses: ./.github/workflows/publish-dispatch.yml + with: + version-name: ${{ needs.docs.outputs.version-name }} + permissions: + contents: read + actions: write + pages: write + id-token: write + statuses: write +``` + +`uv` is preinstalled by `docs.yml` (honouring a committed `.python-version`), so +`build-command` can equally be `uv run sphinx-build …`. Add the +`publish-dispatch.yml` shim and set the Pages source / environment policy exactly as +the [tutorial](../tutorials/adding-to-a-fresh-repo.md) describes — none of that is +MyST-specific. + +## 3. Intersphinx via the `stable/` alias + +Sphinx writes relative URIs into `objects.inv`, so other projects can point +intersphinx at the constant alias and their references always resolve against your +latest release: + +```python +intersphinx_mapping = { + "REPO": ("https://ORG.github.io/REPO/stable/", None), +} +``` + +## Migrating an existing Sphinx `gh-pages` site + +The [gh-pages migration](./migrate-from-gh-pages.md) applies unchanged — it operates +on the `gh-pages` tree and Releases, not on the build tool. If the old site published +the default branch's docs under a directory not named after the branch (some setups +use `latest/`), pass `--seed-from ` to the prepare run. diff --git a/docs/myst.yml b/docs/myst.yml index 0efe995..c36febc 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -8,6 +8,17 @@ project: - ../plugins/version-switcher.mjs # Build-only: swaps __LATEST_TAG__ in the tutorial for this repo's latest tag. - ./_ext/inject-latest-tag.mjs + # Escalate link/cross-reference quality rules from warning to error so that, + # combined with `myst build --strict` (the `npm run docs` command CI uses), a + # broken link, an unresolved cross reference, or an empty auto-filled label + # fails the build instead of publishing a dead link. + error_rules: + - rule: link-resolves + severity: error + - rule: reference-target-resolves + severity: error + - rule: link-text-exists + severity: error toc: - file: index.md - file: tutorials.md @@ -16,6 +27,7 @@ project: - file: how-to.md children: - file: how-to/migrate-from-gh-pages.md + - file: how-to/use-with-sphinx.md - file: how-to/contribute.md - file: explanations.md children: diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 85c8aa2..cabcf63 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -30,7 +30,7 @@ directly; it is not a separately consumed action. ## `docs.yml` — build (unprivileged) Builds the docs at the versioned `BASE_URL`, packs `docs.zip` (bare `html/` root), -uploads the `docs` artifact, and warns on fork PRs. Declares `contents: read` only — +and uploads the `docs` artifact. Declares `contents: read` only — it never holds a write token. Installs `uv` unconditionally and relies on the runner's preinstalled Node, so `build-command` can be `make` / `npx` / `tox` / `npm` driven. @@ -43,6 +43,12 @@ preinstalled Node, so `build-command` can be `make` / `npx` / `tox` / `npm` driv |---|---| | `version-name` | The version this build was served at (`pr-` \| default-branch \| ``) — pass to `publish.yml`. | +`build-command` runs with two env vars set: **`BASE_URL`** +(`/REPO/` — the sub-path the build is served at) and +**`VERSION_NAME`** (the bare version token — for builds that need it directly, +e.g. a Sphinx `conf.py` setting the pydata theme's switcher `version_match`; see +[how-to: use with Sphinx](../how-to/use-with-sphinx.md)). + ## `publish.yml` — the engine, owns the branching (privileged) Reconstructs the whole site and deploys it to Pages, routing each event to one of three @@ -92,9 +98,15 @@ Every deploy rebuilds the complete tree from authoritative inputs: | version kind | source | durability | |---|---|---| | current build | this run's `docs` artifact, staged via `version-name` (highest priority — overwrites any same-name gathered source) | n/a (just built) | -| default branch (e.g. `main`) | latest CI **push** artifact → durable `_sources/.zip` in the live site (hard-fail if neither exists) | durable — re-persisted into the site each deploy | +| default branch (e.g. `main`) | newest `docs` artifact built from that branch → durable `_sources/.zip` in the live site (hard-fail if neither exists) | durable — re-persisted into the site each deploy | | released tags | the `docs.zip` asset attached to each **GitHub Release** (the migration seed release, if present, seeds the default-branch zip) | permanent | -| open PRs (`pr-`) | each PR's build artifact, keyed by current head SHA — internal always, fork PRs only when the SHA carries a `preview-approved` status | ephemeral — drops when the PR merges/closes | +| open PRs (`pr-`) | each PR's `docs` artifact, keyed by current head SHA — internal always, fork PRs only when the SHA carries a `preview-approved` status | ephemeral — drops when the PR merges/closes | + +Branch and PR artifacts are found via the **artifacts API by name** (`docs` — the +artifact name is the contract), not by workflow filename, so the consumer's entry +workflow can be called anything. The URLs baked into `switcher.json` (and the +`_sources` restore) use the site's live Pages URL from the **Pages API**, so a +custom domain (CNAME) works. A version no longer gathered (a merged/closed PR, a deleted release) is correctly dropped, because `deploy-pages` replaces the *entire* site. Sources are gathered in diff --git a/package.json b/package.json index 9835b32..e255ec4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myst-version-switcher-plugin", "private": true, - "description": "A pydata-style version switcher for MyST, as a single anywidget plugin, plus a CI action to generate switcher.json.", + "description": "A pydata-style version switcher for MyST, as a single anywidget plugin, plus reusable CI workflows that reconstruct and deploy the versioned site.", "type": "module", "license": "Apache-2.0", "repository": { @@ -10,12 +10,11 @@ }, "scripts": { "test": "node test/test-url-logic.mjs && node test/test-assemble.mjs", - "docs": "cd docs && myst build --html", + "docs": "cd docs && myst build --html --strict", "docs-dev": "cd docs && myst start" }, "files": [ "plugins/version-switcher.mjs", - "assemble/assemble.sh", "assemble/assemble.mjs" ], "devDependencies": { diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 04bb36c..480e08a 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -32,16 +32,22 @@ # the Pages source back to "Deploy from a branch" to restore serving instantly. # # Usage: -# scripts/migrate.sh [--dry-run] [--pages-ref ] -# scripts/migrate.sh --delete-gh-pages [--pages-ref ] [--yes] +# scripts/migrate.sh [--dry-run] [--pages-ref ] [--seed-from ] +# scripts/migrate.sh --delete-gh-pages [--pages-ref ] [--yes] [--wait] # -# --dry-run print the backfill + seed plan + probe the current site only; -# upload nothing, skip the flip. +# --dry-run print the backfill + seed plan, the versions the new model +# will DROP, + probe the current site; upload nothing, skip +# the flip. # --delete-gh-pages finalize: guard _sources/.zip is live, verify the live # site, then delete gh-pages + the seed release. The only mode # that deletes. # --pages-ref gh-pages ref to read (default: origin/gh-pages) +# --seed-from gh-pages dir to seed the default branch from, when it isn't +# named after the branch (e.g. an old site publishing latest/). # --yes skip the typed confirmation before deleting gh-pages. +# --wait with --delete-gh-pages: poll until _sources/.zip goes +# live (up to 30 min) instead of failing the guard immediately — +# lets you run finalize right after merging the pipeline PR. set -euo pipefail REPO="" @@ -49,11 +55,13 @@ PAGES_REF="origin/gh-pages" DRY_RUN=false DELETE_GH_PAGES=false ASSUME_YES=false +WAIT=false +SEED_FROM="" SEED_TAG="pages-default-seed" # published seed release holding the default branch's docs usage() { - echo "usage: scripts/migrate.sh [--dry-run] [--pages-ref ]" >&2 - echo " scripts/migrate.sh --delete-gh-pages [--pages-ref ] [--yes]" >&2 + echo "usage: scripts/migrate.sh [--dry-run] [--pages-ref ] [--seed-from ]" >&2 + echo " scripts/migrate.sh --delete-gh-pages [--pages-ref ] [--yes] [--wait]" >&2 } while [ $# -gt 0 ]; do @@ -61,7 +69,9 @@ while [ $# -gt 0 ]; do --dry-run) DRY_RUN=true; shift ;; --delete-gh-pages) DELETE_GH_PAGES=true; shift ;; --yes) ASSUME_YES=true; shift ;; + --wait) WAIT=true; shift ;; --pages-ref) PAGES_REF="$2"; shift 2 ;; + --seed-from) SEED_FROM="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) if [ -z "$REPO" ]; then REPO="$1"; else echo "unexpected arg: $1" >&2; exit 2; fi; shift ;; @@ -72,6 +82,9 @@ if [ -z "$REPO" ]; then usage; exit 2; fi if $DELETE_GH_PAGES && $DRY_RUN; then echo "--delete-gh-pages and --dry-run are mutually exclusive" >&2; exit 2 fi +if $WAIT && ! $DELETE_GH_PAGES; then + echo "--wait only makes sense with --delete-gh-pages" >&2; exit 2 +fi OWNER="${REPO%%/*}" NAME="${REPO##*/}" @@ -84,7 +97,10 @@ _current_origin=$(git remote get-url origin 2>/dev/null || echo "") _normalized=$(printf '%s' "$_current_origin" | sed 's|.*github\.com[:/]||; s|\.git$||') if [ "$_normalized" != "$REPO" ]; then _tmp_clone=$(mktemp -d) - git clone "https://github.com/$REPO.git" "$_tmp_clone" + # Blobless partial clone: all refs + tags but no file contents up front — the + # gh-pages blobs (the bulk of a docs repo) are fetched lazily by `git archive` + # only for the dirs actually backfilled/seeded. + git clone --filter=blob:none "https://github.com/$REPO.git" "$_tmp_clone" cd "$_tmp_clone" fi unset _current_origin _normalized @@ -100,35 +116,93 @@ fetch_pages_ref() { } # --- backfill docs.zip from the gh-pages tree (non-destructive) -------------- -# For each release tag that is a gh-pages dir and whose release lacks a docs.zip, -# zip that dir as a bare html/ and attach it. Tags containing `/` are skipped: -# they are never built/published under the new model. Branch dirs (main/) are NOT -# backfilled here — the default branch self-heals from its own CI artifact once it -# runs the new pipeline (and is the reason gh-pages is kept until then). +# For each tag that is a gh-pages dir: if its release lacks a docs.zip, zip that +# dir as a bare html/ and attach it; if there is NO release at all, CREATE one +# with the docs.zip (created-with-asset, so immutable-release-safe). The create +# path is what makes a fork rehearsal faithful — forking copies branches and tags +# but not releases, so without it every released version would drop — and also +# covers repos that tag without cutting releases. Tags containing `/` are +# skipped: they are never built/published under the new model. Branch dirs +# (main/) are NOT backfilled here — the default branch self-heals from its own CI +# artifact once it runs the new pipeline (and is the reason gh-pages is kept +# until then). backfill() { echo echo "-- 1. Backfilling docs.zip from $PAGES_REF --" fetch_pages_ref - local pages_dirs tag dir has tmp + local pages_dirs tag dir has tmp action prerelease pages_dirs=$(git ls-tree -d --name-only "$PAGES_REF") for tag in $(git tag -l); do case "$tag" in */*) continue ;; esac # not published dir="$tag" if ! grep -qxF "$dir" <<<"$pages_dirs"; then continue; fi # no gh-pages dir - has=$(gh release view "$tag" --repo "$REPO" --json assets \ - -q 'any(.assets[]; .name=="docs.zip")' 2>/dev/null || echo false) - if [ "$has" = "true" ]; then continue; fi # already has it - echo " backfill $tag (from $dir/)" + if gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then + has=$(gh release view "$tag" --repo "$REPO" --json assets \ + -q 'any(.assets[]; .name=="docs.zip")') + if [ "$has" = "true" ]; then continue; fi # already has it + action="attach to existing release" + else + action="create release" + fi + echo " backfill $tag (from $dir/ — $action)" if $DRY_RUN; then continue; fi tmp=$(mktemp -d) git archive "$PAGES_REF" "$dir" | tar -x -C "$tmp" # → $tmp/$dir/… mv "$tmp/$dir" "$tmp/html" ( cd "$tmp" && zip -rq docs.zip html ) # bare html/ root - gh release upload "$tag" "$tmp/docs.zip" --repo "$REPO" --clobber + if [ "$action" = "create release" ]; then + # Prerelease marker: a/b/rc following a digit (parity with release.yml). + prerelease="" + case "$tag" in *[0-9]a*|*[0-9]b*|*[0-9]rc*) prerelease="--prerelease" ;; esac + gh release create "$tag" "$tmp/docs.zip" --repo "$REPO" --verify-tag \ + --latest=false $prerelease --title "$tag" \ + --notes "Docs backfilled from the gh-pages branch by migrate.sh." + else + gh release upload "$tag" "$tmp/docs.zip" --repo "$REPO" --clobber + fi rm -rf "$tmp" done } +# --- report versions the reconstructed site will DROP ------------------------- +# The new model serves: the default branch (seeded), tags whose release has (or +# will get, via backfill) a docs.zip, and open PRs. Anything else the LIVE site +# currently lists in switcher.json — extra branch dirs, dirs whose tag was +# deleted — is never gathered, so it drops from the reconstructed site. It stays +# on gh-pages until finalize (recoverable), but say so up front instead of +# leaving the operator to diff the plan against the probe by eye. +report_drops() { + echo + echo "-- Versions the reconstructed site will DROP --" + fetch_pages_ref + local versions v default drops=0 + default=$(gh repo view "$REPO" --json defaultBranchRef -q .defaultBranchRef.name) + if ! versions=$(curl -fsSL "$BASE/switcher.json?cb=$(date +%s%N)" 2>/dev/null | node -e \ + 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const e of JSON.parse(s))console.log(e.version)})' 2>/dev/null); then + echo " (no live switcher.json at $BASE — skipping the drop report)" + return 0 + fi + for v in $versions; do + [ "$v" = "$default" ] && continue # seeded + case "$v" in pr-*) continue ;; esac # own artifact + if git tag -l | grep -qxF "$v"; then + # a tag: kept if backfill covers it (gh-pages dir) or a docs.zip exists + if git ls-tree -d --name-only "$PAGES_REF" | grep -qxF "$v"; then continue; fi + has=$(gh release view "$v" --repo "$REPO" --json assets \ + -q 'any(.assets[]; .name=="docs.zip")' 2>/dev/null || echo false) + [ "$has" = "true" ] && continue + fi + echo " DROP $v (no tag+gh-pages dir to backfill and no release docs.zip)" + drops=$((drops + 1)) + done + if [ "$drops" = "0" ]; then + echo " none — every currently served version survives the migration" + else + echo " ($drops version(s) will no longer be served. They remain on gh-pages" + echo " until finalize; cut a real Release for any you want to keep.)" + fi +} + # --- seed the default branch durably (so we can cut over before it builds docs) --- # The default branch has no durable source until it builds docs.zip under the new # pipeline, and a contents:read deploy can't read a draft — so capture the gh-pages @@ -140,17 +214,22 @@ seed_default_branch() { echo echo "-- 1b. Seeding the default branch durably (published release '$SEED_TAG') --" fetch_pages_ref - local default tmp + local default srcdir tmp default=$(gh repo view "$REPO" --json defaultBranchRef -q .defaultBranchRef.name) - if ! git ls-tree -d --name-only "$PAGES_REF" | grep -qxF "$default"; then - echo " no '$default/' dir on $PAGES_REF — nothing to seed (skipping)" + # The gh-pages dir usually shares the branch name; --seed-from overrides for + # old sites that published it under another name (e.g. latest/). + srcdir="${SEED_FROM:-$default}" + if ! git ls-tree -d --name-only "$PAGES_REF" | grep -qxF "$srcdir"; then + echo " no '$srcdir/' dir on $PAGES_REF — nothing to seed (skipping)." + echo " (If the default branch's docs live under another dir, rerun with --seed-from ;" + echo " without a seed, the pipeline PR's publish stays red until it merges.)" return 0 fi - echo " seed $default (from $default/ on $PAGES_REF → release '$SEED_TAG')" + echo " seed $default (from $srcdir/ on $PAGES_REF → release '$SEED_TAG')" if $DRY_RUN; then return 0; fi tmp=$(mktemp -d) - git archive "$PAGES_REF" "$default" | tar -x -C "$tmp" - mv "$tmp/$default" "$tmp/html" + git archive "$PAGES_REF" "$srcdir" | tar -x -C "$tmp" + mv "$tmp/$srcdir" "$tmp/html" ( cd "$tmp" && zip -rq docs.zip html ) # bare html/ root if gh release view "$SEED_TAG" --repo "$REPO" >/dev/null 2>&1; then gh release upload "$SEED_TAG" "$tmp/docs.zip" --repo "$REPO" --clobber @@ -200,18 +279,29 @@ verify() { # live site directly: if that copy is served, the default branch survives a gh-pages # delete (and a later artifact expiry), so it is safe to remove. guard_default_durable() { - local default url code + local default url code deadline default=$(gh repo view "$REPO" --json defaultBranchRef -q .defaultBranchRef.name) url="$BASE/_sources/$default.zip" echo "-- guard: is the default branch ($default) durable in the site (_sources)? --" - code=$(curl -s -o /dev/null -w '%{http_code}' "$url?cb=$(date +%s%N)") - if [ "$code" != "200" ]; then - echo "::error::REFUSING to delete gh-pages: $url is not live ($code)." >&2 - echo " The deployed site has no durable copy of '$default' yet, so gh-pages is still" >&2 - echo " the only recoverable copy. Open/merge the pipeline PR so its publish persists" >&2 - echo " _sources/$default.zip into the site, then retry." >&2 - exit 1 - fi + deadline=$(( $(date +%s) + 1800 )) + while :; do + code=$(curl -s -o /dev/null -w '%{http_code}' "$url?cb=$(date +%s%N)") + [ "$code" = "200" ] && break + if ! $WAIT; then + echo "::error::REFUSING to delete gh-pages: $url is not live ($code)." >&2 + echo " The deployed site has no durable copy of '$default' yet, so gh-pages is still" >&2 + echo " the only recoverable copy. Open/merge the pipeline PR so its publish persists" >&2 + echo " _sources/$default.zip into the site, then retry (or rerun with --wait to poll)." >&2 + exit 1 + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "::error::gave up after 30 min: $url still not live ($code). Has the pipeline" >&2 + echo " PR's publish deployed? Check the repo's Actions runs, then retry." >&2 + exit 1 + fi + echo " not live yet ($code) — waiting 15s (Ctrl-C to abort; gh-pages is untouched)" + sleep 15 + done echo " ok: $url is live — '$default' is durable independently of gh-pages" } @@ -241,7 +331,10 @@ if $DELETE_GH_PAGES; then fi echo echo "-- Deleting gh-pages (rollback gone after this) --" - git push origin --delete "${PAGES_REF#origin/}" + # Via the API, not `git push --delete`: gh is the auth we required, whereas a + # git-over-https push from the temp clone would need a separately configured + # credential helper (`gh auth setup-git`). + gh api --method DELETE "repos/$REPO/git/refs/heads/${PAGES_REF#origin/}" delete_seed_release # the in-site _sources copy now carries the default branch echo "Done. Site is served from GitHub Actions; gh-pages removed." exit 0 @@ -253,13 +346,14 @@ fi # ============================================================================ backfill seed_default_branch +report_drops if $DRY_RUN; then echo echo "-- (dry-run) probing current site --" verify || echo "(dry-run probe failed — expected if the site isn't deployed yet)" echo - echo "Dry run complete: backfill + seed plan shown (nothing uploaded); flip skipped." + echo "Dry run complete: backfill + seed + drop plan shown (nothing uploaded); flip skipped." exit 0 fi @@ -292,3 +386,4 @@ echo " the seed and persists _sources/.zip into the deployed site." echo " 2. Once that publish has deployed, finalize (verify + delete gh-pages and the" echo " seed). The guard checks _sources/.zip is live first:" echo " scripts/migrate.sh $REPO --delete-gh-pages" +echo " (add --wait to run it right after merging — it polls until the copy is live)" diff --git a/test/test-assemble.mjs b/test/test-assemble.mjs index c2bc93f..c9acbc4 100644 --- a/test/test-assemble.mjs +++ b/test/test-assemble.mjs @@ -85,7 +85,7 @@ ok( assert.deepEqual(discoverVersions(join(site, "does-not-exist")), []); ok("discoverVersions returns [] for a missing dir"); -// --- isPrerelease: rc/a/b markers (parity with release.yml) --- +// --- isPrerelease: digit-anchored rc/a/b markers (parity with release.yml) --- assert.equal(isPrerelease("2.1"), false); assert.equal(isPrerelease("2.1.0"), false); assert.ok(isPrerelease("2.1rc1")); @@ -93,6 +93,12 @@ assert.ok(isPrerelease("3.0a2")); assert.ok(isPrerelease("3.0b1")); ok("isPrerelease flags rc/a/b tags only"); +// tags that merely contain a/b/rc letters (no digit before) are NOT prereleases. +assert.equal(isPrerelease("release-1.0"), false); +assert.equal(isPrerelease("beta-program"), false); +assert.equal(isPrerelease("stable-2.0"), false); +ok("isPrerelease ignores a/b/rc letters not following a digit"); + // --- preferredVersion: newest deployed stable tag, else main --- assert.equal(preferredVersion(["main", "2.1", "2.0"], tags), "2.1"); ok("preferredVersion picks the newest deployed stable tag"); @@ -153,26 +159,36 @@ ok("missingRequired is a no-op with no required branches"); // --- switcherStruct shape, with the stable entry flagged --- assert.deepEqual( switcherStruct( - "DiamondLightSource/myst-version-switcher-plugin", + "https://diamondlightsource.github.io/myst-version-switcher-plugin/", ["main", "2.1"], "2.1", ), [ { version: "main", - url: "https://DiamondLightSource.github.io/myst-version-switcher-plugin/main/", + url: "https://diamondlightsource.github.io/myst-version-switcher-plugin/main/", }, { version: "2.1", - url: "https://DiamondLightSource.github.io/myst-version-switcher-plugin/2.1/", + url: "https://diamondlightsource.github.io/myst-version-switcher-plugin/2.1/", preferred: true, }, ], ); ok("switcherStruct builds the pydata array and flags the preferred entry"); +// a custom-domain base URL (no trailing slash) roots the entries verbatim. +assert.deepEqual(switcherStruct("https://docs.example.com", ["main"], null), [ + { version: "main", url: "https://docs.example.com/main/" }, +]); +ok("switcherStruct roots entries at a custom-domain base URL"); + // --- exact serialisation (2-space, no trailing newline), parity with json.dumps(indent=2) --- -const text = renderSwitcher("acme/widget", ["main", "2.0"], "2.0"); +const text = renderSwitcher( + "https://acme.github.io/widget", + ["main", "2.0"], + "2.0", +); assert.equal( text, `[