From ff7b26db9795593f032592588576420e8dfa1f5f Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Fri, 19 Jun 2026 19:52:25 +0000 Subject: [PATCH] Nest publish in CI, simplify assemble injection, add diataxis docs Three changes from todo.md, refined over several review passes: 1. Publish visible on internal PRs. publish.yml -> reusable _publish.yml (workflow_call + workflow_dispatch); ci.yml nests it as a `publish` job for internal events only (canonical-repo + non-fork guard lives in ci.yml), so the deploy shows as a PR/commit check. Fork PRs get a warning step in _docs.yml's build job linking the manual opt-in. Because publish runs inside the build's own run, the action downloads this run's `docs` artifact and assemble.sh unzips + stages it via `artifact-version-name`, skipping the re-gather of that version. Operator: the github-pages environment must allow the deploying refs (recommend "Deployment branches and tags" -> No restriction). 2. Rename the version concept "token" -> "version-name" throughout (avoids confusion with GitHub auth tokens). 3. Docs reworked into a Diataxis tree mirroring python-copier-template-example: README selling-points are {include}d into index.md (+ a 4-card grid); category landing pages; tutorial / how-to (migrate, contribute) / explanation (architecture, folding in the deleted DESIGN.md) / reference (directive, action). Dev commands move to .github/CONTRIBUTING.md; the tutorial {literalinclude}s the generic _docs.yml build so it can't drift. Deferred release-layer cache tracked as issue #6. Co-Authored-By: Claude Opus 4.8 --- .github/CONTRIBUTING.md | 45 ++ .github/workflows/_docs.yml | 47 +- .github/workflows/_publish.yml | 96 ++++ .github/workflows/ci.yml | 34 +- .github/workflows/publish.yml | 86 --- CLAUDE.md | 116 +++-- DESIGN.md | 634 ----------------------- README.md | 66 ++- assemble/action.yml | 22 + assemble/assemble.mjs | 2 +- assemble/assemble.sh | 29 +- docs/explanations.md | 7 + docs/explanations/architecture.md | 218 ++++++++ docs/how-to.md | 7 + docs/how-to/contribute.md | 2 + docs/how-to/migrate-from-gh-pages.md | 74 +++ docs/index.md | 171 +----- docs/myst.yml | 33 +- docs/reference.md | 7 + docs/reference/action.md | 99 ++++ docs/reference/directive.md | 58 +++ docs/tutorials.md | 7 + docs/tutorials/adding-to-a-fresh-repo.md | 188 +++++++ plugins/version-switcher.mjs | 2 +- scripts/migrate.sh | 2 +- 25 files changed, 1078 insertions(+), 974 deletions(-) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/workflows/_publish.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 DESIGN.md create mode 100644 docs/explanations.md create mode 100644 docs/explanations/architecture.md create mode 100644 docs/how-to.md create mode 100644 docs/how-to/contribute.md create mode 100644 docs/how-to/migrate-from-gh-pages.md create mode 100644 docs/reference.md create mode 100644 docs/reference/action.md create mode 100644 docs/reference/directive.md create mode 100644 docs/tutorials.md create mode 100644 docs/tutorials/adding-to-a-fresh-repo.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..277db73 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +This repo ships two halves under one `vX.Y.Z` tag: the `version-switcher` plugin +(`plugins/version-switcher.mjs`) and the `assemble` site action (`assemble/`). It is +a JS-only repo — no build step, no framework. + +## Developing + +```bash +npm test # run the test suite (node, no framework) +npm run docs # build docs (the same command CI uses) +npm run docs-dev # live-preview docs with the plugin loaded from local plugins/ +``` + +`docs/myst.yml` loads the plugin from the local `plugins/` path (not a release URL), +so edits are reflected on rebuild. + +**Browser caveat:** `` popups don't open in the -VS Code Simple Browser — open the forwarded port in a real browser and -hard-reload (MyST caches the localised esm). - -## Releasing - -```bash -git tag vX.Y.Z && git push origin vX.Y.Z -``` - -CI runs tests and the docs build, `_release.yml` publishes a GitHub Release with -`version-switcher.mjs` (and the tag's `docs.zip`) as assets, and `publish.yml` -reconstructs + deploys the site. The `assemble` action is consumed from the repo -tree at the same tag, so one tag versions both halves. +delivered as a single `anywidget` plugin **plus** an `assemble` CI action that +reconstructs the whole versioned docs site from durable sources every deploy and +publishes it directly to GitHub Pages. + +The two halves are versioned together under one `vX.Y.Z` tag but consumed +differently — the plugin as a release asset, the action from the repo tree: + +What | Where +:---: | :---: +Plugin (widget) | `plugins/version-switcher.mjs` → a release-asset URL in `myst.yml` `plugins` +Site action | `assemble/` → `uses: …/assemble@` in your CI workflow +Source | +Documentation | +Releases | + +The single `.mjs` is both the build-time MyST plugin and the browser runtime — MyST +localises it into your site, so there is no second asset to host. The `assemble` +action rebuilds the *complete* site every deploy (main's build, each release's +`docs.zip`, every open PR's artifact), so deletions self-heal and there is no +`gh-pages` branch to drift. + + + +See for the full +documentation: a [tutorial](https://diamondlightsource.github.io/myst-version-switcher-plugin/tutorials/adding-to-a-fresh-repo) +for adding it to a fresh repo, how-to guides, the architecture explanation, and the +directive + action reference. + +Contributing: see [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md). ## License diff --git a/assemble/action.yml b/assemble/action.yml index 7775101..11efa04 100644 --- a/assemble/action.yml +++ b/assemble/action.yml @@ -26,6 +26,15 @@ inputs: description: Token for gh (release assets + cross-run artifacts + statuses). required: false default: ${{ github.token }} + artifact-version-name: + description: >- + Version name (pr- | main | ) of THIS workflow run's `docs` artifact to + download and stage directly, instead of gathering that version from durable + sources. Used when the caller publishes WITHIN the build's own CI run (the run + isn't a completed success yet, so the gather can't discover it — or would find + a stale previous build). Empty (default) → pure durable gather. + required: false + default: "" outputs: dir: @@ -35,6 +44,17 @@ outputs: runs: using: composite steps: + # When asked to inject this run's build (artifact-version-name set), download the + # `docs` artifact here via the official action — it isn't a completed-success run + # the gather can find. assemble.sh unzips + stages it and skips re-gathering that + # version (the unzip stays in the script, beside the other gather extracts). + - name: Download this run's docs artifact + if: inputs.artifact-version-name != '' + uses: actions/download-artifact@v4 + with: + name: docs + path: ${{ runner.temp }}/assemble-current + # Gather (main + releases + approved open-PR artifacts) → generate switcher + # redirect → stable alias → output the site dir. The logic lives in # assemble.sh (runnable standalone for local testing) + assemble.mjs beside it. @@ -45,4 +65,6 @@ runs: GH_TOKEN: ${{ inputs.token }} REPO: ${{ inputs.repo }} GUARD_DEFAULT_BRANCH: ${{ inputs.guard-default-branch }} + ARTIFACT_VERSION_NAME: ${{ inputs.artifact-version-name }} + ARTIFACT_ZIP: ${{ inputs.artifact-version-name != '' && format('{0}/assemble-current/docs.zip', runner.temp) || '' }} run: bash "$GITHUB_ACTION_PATH/assemble.sh" diff --git a/assemble/assemble.mjs b/assemble/assemble.mjs index b0df944..332f26b 100644 --- a/assemble/assemble.mjs +++ b/assemble/assemble.mjs @@ -22,7 +22,7 @@ import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parseArgs } from "node:util"; -/** The `stable/` alias directory name (a fixed convention; see DESIGN). */ +/** The `stable/` alias directory name (a fixed convention; see docs/ explanation). */ export const STABLE_ALIAS = "stable"; /** Run a git command and return its non-empty stdout lines. */ diff --git a/assemble/assemble.sh b/assemble/assemble.sh index 307d72b..94e0139 100755 --- a/assemble/assemble.sh +++ b/assemble/assemble.sh @@ -14,6 +14,12 @@ # absent from the site; any other value disables the guard # GH_TOKEN token for gh (release assets, runs, statuses) # SITE output dir (default: $RUNNER_TEMP/site, else ./_site) +# ARTIFACT_VERSION_NAME version name (pr- | main | ) to stage directly from +# ARTIFACT_ZIP instead of gathering — used when publishing +# inside the build's own run (the run isn't a completed +# success yet). The matching gather is skipped. The action +# downloads the artifact; this script unzips + stages it. +# ARTIFACT_ZIP docs.zip (bare html/ root) to stage at ARTIFACT_VERSION_NAME # # Requires node + gh on PATH. assemble.mjs sits next to this script. set -euo pipefail @@ -22,6 +28,8 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" : "${REPO:?REPO is required (org/repo)}" GUARD_DEFAULT_BRANCH="${GUARD_DEFAULT_BRANCH:-true}" +ARTIFACT_VERSION_NAME="${ARTIFACT_VERSION_NAME:-}" +ARTIFACT_ZIP="${ARTIFACT_ZIP:-}" if [ -n "${SITE:-}" ]; then : elif [ -n "${RUNNER_TEMP:-}" ]; then SITE="$RUNNER_TEMP/site" else SITE="$PWD/_site" @@ -55,14 +63,29 @@ download_run() { # $1=runId $2=out dir $3=label fi } +# --- current build: unzip + stage the docs.zip the action downloaded (if any) --- +# Published inside the build's own run, so it isn't a completed success the gather +# can discover (and a main/tag push would otherwise re-gather the PREVIOUS build). +# Each gather below skips ARTIFACT_VERSION_NAME so nothing clobbers this fresh build. +if [ -n "$ARTIFACT_VERSION_NAME" ]; then + if [ -f "$ARTIFACT_ZIP" ]; then + extract "$ARTIFACT_ZIP" "$ARTIFACT_VERSION_NAME" "current build '$ARTIFACT_VERSION_NAME'" + else + echo "::error::ARTIFACT_VERSION_NAME=$ARTIFACT_VERSION_NAME but ARTIFACT_ZIP=$ARTIFACT_ZIP is not a file" + exit 1 + fi +fi + # --- main: latest successful push build on the default branch --- -main_run=$(gh run list --repo "$REPO" --workflow ci.yml --branch "$default" \ +# Skip if it was staged as the current build (else we'd fetch a STALE earlier run). +main_run="" +[ "$default" = "$ARTIFACT_VERSION_NAME" ] || main_run=$(gh run list --repo "$REPO" --workflow ci.yml --branch "$default" \ --event push --status success --limit 1 --json databaseId -q '.[0].databaseId // empty') if [ -n "$main_run" ]; then if download_run "$main_run" "$TMP/dl-$default" "branch $default"; then extract "$TMP/dl-$default/docs.zip" "$default" "branch $default" fi -else +elif [ "$default" != "$ARTIFACT_VERSION_NAME" ]; then echo "::warning::no successful CI build found for default branch $default" fi @@ -72,6 +95,7 @@ tags=$(gh api --paginate "repos/$REPO/releases" \ for tag in $tags; do case "$tag" in */*) continue ;; esac # never built/published; skip [ "$tag" = "$default" ] && continue + [ "$tag" = "$ARTIFACT_VERSION_NAME" ] && continue # staged fresh from this run's build if gh release download "$tag" --repo "$REPO" -p docs.zip -O "$TMP/rel-$tag.zip"; then extract "$TMP/rel-$tag.zip" "$tag" "release $tag" else @@ -85,6 +109,7 @@ prs=$(gh pr list --repo "$REPO" --state open --limit 200 \ -q '.[] | [.number, .headRefOid, .isCrossRepository] | @tsv') while IFS=$'\t' read -r num sha cross; do [ -z "$num" ] && continue + [ "pr-$num" = "$ARTIFACT_VERSION_NAME" ] && continue # staged fresh from this run's build if [ "$cross" = "true" ]; then approved=$(gh api "repos/$REPO/commits/$sha/statuses" \ -q 'any(.[]; .context=="preview-approved" and .state=="success")') diff --git a/docs/explanations.md b/docs/explanations.md new file mode 100644 index 0000000..09eb6ed --- /dev/null +++ b/docs/explanations.md @@ -0,0 +1,7 @@ +# Explanation + +How it works and why it works that way. + +```{toc} +:context: children +``` diff --git a/docs/explanations/architecture.md b/docs/explanations/architecture.md new file mode 100644 index 0000000..5508129 --- /dev/null +++ b/docs/explanations/architecture.md @@ -0,0 +1,218 @@ +# Explanation: architecture + +This explains *why* the versioned site is built the way it is — the design +reasoning behind the `assemble` action and the build/publish workflow split. For +the *what* (inputs, options, copy-paste snippets), see the +[reference](../reference/action.md) and [tutorial](../tutorials/adding-to-a-fresh-repo.md). + +## The core idea: reconstruct the whole site every deploy + +Every deploy rebuilds the **complete** site tree from authoritative sources and +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. + +| version kind | source | durability | +|---|---|---| +| current build | the build injected into the action this run | n/a (just built) | +| released tags | the `docs.zip` asset attached to each **GitHub Release** | permanent | +| branch previews (e.g. `main`) | the latest successful CI run's `docs` **artifact** | ephemeral (fine — branches move) | +| open PRs (`pr-`) | each PR's build artifact, keyed by head SHA | ephemeral (drops on merge/close) | + +Releases are permanent, so old versions never vanish. Branch and PR previews come +from CI artifacts and silently drop if the artifact expires and nothing rebuilds — +acceptable for *optional* dev/preview docs. A required branch (the default branch) +is guarded: `assemble` hard-fails rather than publish a site missing it. + +### Why this replaced the `gh-pages` + `keep_files` model + +The previous model (mirrored from `python-copier-template-example`) had three +problems for a MyST/book-theme site: + +1. **The CI `docs.zip` artifact is not locally previewable.** book-theme emits + *root-absolute* asset URLs (`/build/_assets/app.css`) regardless of `BASE_URL`, + so opening `index.html` over `file://` resolves assets against the filesystem root + → 404 → unstyled, broken. There is no relative-path mode. Local preview means + `myst start`, or serving a `BASE_URL`-free build over HTTP. +2. **`BASE_URL` is mandatory and per-version.** Each version lives at + `///` and must be built with `BASE_URL=//`. One + build cannot serve two paths. +3. **`keep_files: true` accumulation drifts.** The published site becomes whatever + has piled up on `gh-pages` over time; there is no single source of truth, and the + branch history grows without bound. + +Reconstructing the live set every deploy and letting `deploy-pages` replace the +whole site makes deletion self-healing: a merged PR or a deleted release simply +isn't gathered next time, so it disappears — no `keep_files` drift, no branch to +prune. + +## The `docs.zip` and version-token contracts + +Two small contracts let the build (in CI) and the reconstruction (in `assemble`) +agree without coordinating: + +- **`docs.zip` is one zip with a bare `html/` root.** The CI build packs it once and + delivers the *same file* two ways — uploaded verbatim as the `docs` artifact + (every run, `compression-level: 0` since it is already compressed) and attached + verbatim as the `docs.zip` Release asset on tags. So there is a single contract and + no repack; both release and branch/PR gather unzip the same `html/` shape. +- **The version name is both the site sub-dir and the `BASE_URL`.** A mismatch + produces a version whose root-absolute assets 404, so the two must be identical. + They are, by construction: the name is `pr-` (an integer), `main` (the default + branch), or a tag without `/` (the `tags: ['*']` trigger never matches `/`). The + build sets `BASE_URL=//` and `assemble` files the artifact at + `site/` — the same literal name on both sides. There is **no + sanitisation**: with nothing to transform, there is nothing to drift, and no parity + test to maintain. + +## Split build (unprivileged) from publish (privileged) + +A `pull_request` run from a **fork** gets a read-only `GITHUB_TOKEN` and no secrets — +a deliberate security boundary, so a PR can't deface the site or exfiltrate secrets. +The architecture makes that boundary structural by splitting build from publish: + +- **CI (unprivileged)** runs `myst build` and uploads the `docs` artifact for + *every* event, forks included. It never holds a write token. +- **`_publish` (privileged)** runs `assemble` + the Pages deploy. It runs only in the + trusted upstream context. + +So a fork's build can never reach a write token; only trusted code deploys. + +### Why publish is *nested* in CI (for internal events) + +The deploy is surfaced as a **job inside the CI run** (`ci.yml`'s `publish` job → +`_publish.yml` via `workflow_call`) so its status and URL are visible on the PR / +commit — rather than running invisibly after the fact. But this is gated to +**internal events only**: the `publish` job's `if` excludes fork PRs +(`head.repo.full_name != github.repository`). A fork PR's build instead emits a +warning (a step in the build job) that the preview was not published, linking the +manual opt-in. The privileged `_publish` is therefore reachable two ways, both +trusted: `workflow_call` (nested, internal) and `workflow_dispatch` (the maintainer +fork opt-in). + +The cost of nesting is an environment-policy change: because internal PRs and tags +now deploy from **their own ref**, the `github-pages` environment's deployment-branch +policy must allow those refs. The alternative — triggering publish via +`workflow_run` after CI completes — keeps deploys on the default branch only, but at +the price of the deploy being invisible on the PR. This project chose visibility. + +### Why the current build is *injected* + +Because publish now runs *inside* the build's own CI run, that run isn't a +*completed* successful run yet — so `assemble`'s normal gather can't discover it. For +a `main` or tag push it would be worse than missing: the gather would find the +**previous** successful run and publish a build behind by one commit. So `ci.yml` +passes the build's version name to `_publish`, which hands it to `assemble` as +`artifact-version-name`; the action downloads this run's `docs` artifact and +`assemble` unzips + stages it directly, **skipping the re-gather of that version**. +Everything else still comes from durable sources. The fork opt-in path passes no current build — there the fork is +gathered from durable sources via its approved head SHA's successful run. + +## The bash / JS split inside `assemble` + +`assemble` is a composite action (`action.yml`) over two implementation files: + +- **`assemble.sh`** does the IO plumbing — `gh` downloads, `unzip`, `mv`, the + `stable/` symlink — where shelling out is concise. It is also runnable standalone, + so the `gh` plumbing can be exercised locally. +- **`assemble.mjs`** is the pure-ish kernel: ordering, prerelease detection, + `switcher.json`/redirect rendering, and the folded-in required-branch guard. Its + functions take plain data and return strings/verdicts, so they unit-test without + git, the network, or the filesystem. + +Pure bash is ruled out — semver ordering, prerelease detection and JSON rendering +are not unit-testable in bash. Bash never parses JSON itself: every extraction uses +`gh`'s built-in `-q`/`--jq` (it embeds real jq), never a piped standalone `jq` — a +`gh … | jq` pipe would mask an API failure as empty output. Gather order is +irrelevant; all ordering and prerelease logic lives in `generate`. + +## Fork-PR previews: per-commit maintainer opt-in + +The risk with a fork PR is not the build (it never holds a write token) but +**serving fork-authored HTML/JS under the canonical `*.github.io` domain** — +phishing/defacement under a trusted URL, and free arbitrary-content hosting. So a +fork preview is **never automatic** and is **pinned to a specific commit**: + +- A maintainer who has reviewed the PR runs `_publish.yml` via `workflow_dispatch` + with the PR number. That privileged run (only write-access users can dispatch it) + sets a `preview-approved` **commit status** on the PR's *current head SHA*, then + assembles. +- `assemble` includes a fork PR **only when its head SHA carries that status**. + Approval is therefore **per-commit**: a new push changes the head SHA, the status + no longer matches, and the preview **silently drops on the next deploy** until a + maintainer re-approves — closing the bait-and-switch hole (approve benign docs, + then push malicious content). +- The approval is durable GitHub state (a commit status), re-read by *every* + assemble, so it survives unrelated deploys. Closing/merging the PR drops it (gather + is open-PRs only); a maintainer can `POST` a `failure` status to revoke early. + +Rejected alternatives: **`pull_request_target`** (privileged but checks out base code +— building PR-head content under it is the classic RCE footgun, since a MyST build +runs PR-authored plugins); **auto-publishing every fork PR** (unattended untrusted +content on the canonical domain); **the fork's own Pages** (required all-branch push +triggers and gave contributors no canonical preview). + +## Stable alias + +Other projects fetch this site's `objects.inv` for cross-references, so they need a +**stable URL that always points at the latest release** — not a version number that +changes every release. The site therefore publishes a `stable/` alias. + +- **`stable/` is the newest deployed non-prerelease tag — never `main`.** Before the + first release there is no `stable/`; the root redirect falls back to `main`. +- **It is a symlink in the assembled tree** (`ln -s "$preferred" stable`). + `upload-pages-artifact` tars with `--dereference`, so it is inflated to a real copy + at deploy. +- **The root `index.html` redirects to `stable/`** (a constant target) whenever it + exists, so the canonical entry URL never changes. + +MyST writes **base-relative** URIs into `objects.inv`, so a consumer pointing +intersphinx at `…/repo/stable/` resolves every target under `/stable/` — the links +stay stable rather than pinning to a concrete version. + +The widget keeps `switcher.json` listing **real versions only** (no `stable` entry), +with `preferred: true` on the latest release. Visiting `/stable/` selects the +concrete release it aliases (so the dropdown shows e.g. `v2.0`, not a separate +"stable" item), and switching to a pinned version preserves the page path onto it. +The `stable` segment name is a fixed convention, hardcoded in the widget. + +## Edge cases + +- **First deploy:** no releases, only `main` built → single-entry `switcher.json`, + redirect → `main/`. Graceful; no release required. +- **Release without `docs.zip`** (cut before this scheme): not selected by the + releases query (it filters on a `docs.zip` asset) → skipped, no hard failure. +- **Default branch missing** (`guard-default-branch: true`): if `main` has no recent + successful build to gather and none was injected, `generate` **hard-fails** rather + than publish a site missing it. +- **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. +- **Concurrency:** `concurrency: { group: pages, cancel-in-progress: false }` makes + deploys last-writer-wins; reconstructing from durable sources keeps that mostly + self-healing. + +## Deferred: a release-layer cache + +Re-downloading and unzipping every release's `docs.zip` on every deploy is the one +recurring cost that scales with the number of releases. A GitHub Actions cache of the +immutable released-tags layer could skip those re-downloads, but it is **deliberately +not built yet** — the dominant cost (N sequential `gh` round-trips) is already +addressed by one paginated releases call, and the benefit is zero at adoption. The +full analysis and an implementation sketch are tracked as future work in +[issue #6](https://github.com/DiamondLightSource/myst-version-switcher-plugin/issues/6). + +## Key resolved decisions + +- **One thin action wrapper, `assemble/`, over the `assemble.mjs` kernel.** The build + half needs no action — it computes the clean token inline and uploads the `docs` + artifact. +- **Direct Pages publish, no `gh-pages` branch** (`upload-pages-artifact` + + `deploy-pages`), requiring the repo's Pages source set to "GitHub Actions". +- **JS core + bash glue.** Pure functions (and their node tests) live in + `assemble.mjs`; bash does the `gh`/`unzip`/`mv` IO. Python was a contender (the + team is Python-heavy) but loses on a second toolchain in a JS-only repo. +- **`_release.yml` attaches `docs.zip`** (it downloads the run's `docs` artifact and + uploads it verbatim), so the action only ever *reads* release assets. diff --git a/docs/how-to.md b/docs/how-to.md new file mode 100644 index 0000000..ba801fd --- /dev/null +++ b/docs/how-to.md @@ -0,0 +1,7 @@ +# How-to Guides + +Practical step-by-step guides for the more experienced user. + +```{toc} +:context: children +``` diff --git a/docs/how-to/contribute.md b/docs/how-to/contribute.md new file mode 100644 index 0000000..6e41979 --- /dev/null +++ b/docs/how-to/contribute.md @@ -0,0 +1,2 @@ +```{include} ../../.github/CONTRIBUTING.md +``` diff --git a/docs/how-to/migrate-from-gh-pages.md b/docs/how-to/migrate-from-gh-pages.md new file mode 100644 index 0000000..2bf7125 --- /dev/null +++ b/docs/how-to/migrate-from-gh-pages.md @@ -0,0 +1,74 @@ +# How-to: migrate from an existing `gh-pages` site + +If your docs already publish to a `gh-pages` branch (the `keep_files` model), this +moves you onto the reconstruct-from-durable-sources model **without losing any +served version** and with an instant rollback until the very last step. + +Your old version history lives as directories on `gh-pages` (e.g. `v0.1.0/`, +`v0.2.0/`, `main/`). The new model reconstructs released versions from `docs.zip` +**Release assets**, which those tags don't have yet — so migration is a one-time, +guarded backfill plus a Pages-source flip, not just a config change. + +## Before you start + +- You need `gh` authenticated with **repo-admin** on the target repo (flipping the + Pages source needs admin; a CI token can't do it — which is why this is a local + script, not a workflow). +- Add the two workflows from the + [tutorial](../tutorials/adding-to-a-fresh-repo.md) first (CI + `_publish`), so a + deploy exists to verify against. + +## Always dry-run first + +`scripts/migrate.sh` performs the whole sequence. Start with `--dry-run`, which +prints the backfill plan and probes the current site but uploads nothing and skips +every destructive step: + +```bash +scripts/migrate.sh ORG/REPO --dry-run +``` + +Check that the backfill plan lists exactly the release tags you expect to recover. + +## The migration sequence + +Run it for real (drop `--dry-run`): + +```bash +scripts/migrate.sh ORG/REPO +``` + +It executes, in order: + +1. **Backfill (non-destructive, idempotent).** For each release tag that is a + `gh-pages` directory and whose Release lacks a `docs.zip`, it zips that directory + as a bare `html/` root and attaches it as `docs.zip`. Tags containing `/` are + skipped (they are never published under the new model). Branch dirs like `main/` + need nothing — they self-heal on the next branch CI. **This must be first:** the + reconstructed site is built from these assets. +2. **Flip the Pages source → GitHub Actions** (`gh api PUT …/pages`). It must happen + here: `deploy-pages` refuses to publish unless the source is already "GitHub + Actions", so you can't verify a new deploy before flipping. +3. **Trigger a deploy.** Either pass `--deploy-workflow ` to dispatch one + automatically, or push to `main` / re-run CI when prompted. This reconstructs the + full tree (backfilled `docs.zip` + `main`'s build) and publishes it. +4. **Pause + verify.** The script fetches `switcher.json`, checks every listed + version URL returns `200`, and waits for your explicit confirmation. +5. **Delete `gh-pages`** — only after step 4 is green. + +## Rollback + +Between steps 2 and 5 the `gh-pages` branch is no longer *served* but still +*exists* — it is your rollback. If verification fails, flip the Pages source back to +**Deploy from a branch / `gh-pages`** and serving is restored instantly, with no +data lost. That is exactly why the delete is last and gated behind an explicit +confirmation; the script says so when it pauses. + +## Useful flags + +| flag | effect | +|---|---| +| `--dry-run` | Print the backfill plan + probe the current site only; upload nothing; skip flip/deploy/delete. | +| `--pages-ref ` | `gh-pages` ref to read (default `origin/gh-pages`). | +| `--deploy-workflow ` | `gh workflow run ` to trigger step 3 automatically; omit to be prompted to deploy by hand. | +| `--yes` | Skip the interactive confirmation before deleting `gh-pages` (use with care). | diff --git a/docs/index.md b/docs/index.md index 520c148..c73dd3e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,11 @@ --- site: - hide_outline: false + hide_outline: true --- -# myst-version-switcher-plugin - -A pydata-style documentation **version switcher** for [MyST](https://mystmd.org), -packaged as a single `anywidget` plugin — plus a CI action that generates the -`switcher.json` it reads. +```{include} ../README.md +:end-before: -:::{version-switcher} -:json-url: https://ORG.github.io/REPO/switcher.json -::: -``` - -The single `.mjs` is both the build-time MyST plugin and the browser runtime — -MyST localises it into your site and there is no second asset to host. - -## Directive options +## How the documentation is structured -| option | required | default | meaning | -| --- | --- | --- | --- | -| `json-url` | yes | — | URL (absolute or root-relative) to `switcher.json` | -| `version-match` | no | auto-detect from URL | force the "current" version | -| `preserve-path` | no | `true` | carry the page path across versions vs go to the version root | -| `probe-target` | no | `true` | HEAD the target page and fall back to the version root if it 404s; set `false` for cross-origin switchers where the probe is CORS-blocked | -| `class` | no | — | extra container classes | +Documentation is split into [four categories](https://diataxis.fr), also accessible +from the links in the top bar. -## Behaviour +::::{grid} 2 -**Path preservation + existence fallback.** On `/v1/x/y`, switching to v2 goes to -`/v2/x/y` when a HEAD probe finds it, else `/v2`. The probe is reliable -same-origin (the production gh-pages case); cross-origin probes can be -CORS-blocked and are treated as indeterminate — the path is kept rather than -stranding users at the root. +:::{card} Tutorial +:link: tutorials.md -**Local dev.** On `localhost`, where no version URL prefixes the page path, the -widget synthesises a `local (dev)` entry rooted at `/` so the switcher is usable -during `myst start`. - -**Stable alias.** The site serves a `stable/` copy of the newest release, so the -canonical entry URL never changes (handy for inter-project `objects.inv` -cross-references). Visiting a `…/stable/` page selects the concrete release it -aliases in the dropdown, and switching to a pinned version preserves the page -path onto it. - -## Assembling + publishing the versioned site - -Publishing is split into **two workflows**, so untrusted fork-PR builds can never -deploy: +Add versioned docs to a fresh repo, start to finish. New users start here. +::: -- An **unprivileged CI workflow** builds the docs at `BASE_URL=/REPO/` and - uploads the build as a `docs` artifact (`docs.zip`, bare `html/` root). It runs - for every PR (including forks), push to `main`, and tag — but never publishes. - The version `` is `pr-` for PRs, else the ref name (`main`, or a - tag without `/`). -- A **privileged publish workflow**, triggered by CI completing (`workflow_run`), - runs the **`assemble`** action: it reconstructs the *whole* site from durable - sources — `main`'s latest build, every release's `docs.zip` asset, and each - **open PR**'s build artifact — writes `switcher.json` + a root redirect, creates - the `stable/` alias, and outputs the site dir for `deploy-pages`. It publishes - **directly to GitHub Pages** (no `gh-pages` branch). +:::{card} How-to Guides +:link: how-to.md -Because the publish workflow always runs from the **default branch**, the -`github-pages` environment only needs to allow that one ref — tags and PRs deploy -through it, not from their own refs. +Practical step-by-step guides — e.g. migrating from an existing `gh-pages` site. +::: -`switcher.json` is the standard pydata format, with the newest non-prerelease tag -flagged `preferred` (rendered with a ★): +:::{card} Explanation +:link: explanations.md -```json -[ - { "version": "main", "url": "https://ORG.github.io/REPO/main/" }, - { "version": "2.1", "url": "https://ORG.github.io/REPO/2.1/", "preferred": true }, - { "version": "2.0", "url": "https://ORG.github.io/REPO/2.0/" } -] -``` - -Set your repo's **Pages source to "GitHub Actions"** (Settings → Pages), then add -the two workflows: +How the reconstruct-from-sources model works, and why it works that way. +::: -```yaml -# .github/workflows/ci.yml — build + verify (unprivileged; runs for forks) -name: CI -on: - pull_request: - push: { branches: [main], tags: ['*'] } # '*' never matches '/' -jobs: - docs: - runs-on: ubuntu-latest - permissions: { contents: read } - steps: - - uses: actions/checkout@v5 - - id: ver # pr- on PRs, else main / a no-slash tag - run: | - if [ "${{ github.event_name }}" = pull_request ]; then - echo "token=pr-${{ github.event.pull_request.number }}" - else echo "token=${{ github.ref_name }}"; fi >> "$GITHUB_OUTPUT" - - run: cd docs && myst build --html - env: { BASE_URL: /REPO/${{ steps.ver.outputs.token }} } - - run: ( cd docs/_build && zip -rq "$RUNNER_TEMP/docs.zip" html ) - - uses: actions/upload-artifact@v4 - with: { name: docs, path: ${{ runner.temp }}/docs.zip, compression-level: 0 } - # + a tag-only `release` job attaching that docs.zip + version-switcher.mjs. -``` +:::{card} Reference +:link: reference.md -```yaml -# .github/workflows/publish.yml — assemble + deploy (privileged; upstream only) -name: Publish -on: - workflow_run: { workflows: [CI], types: [completed] } - workflow_dispatch: { inputs: { pr: { required: false } } } # fork-PR opt-in -permissions: { contents: read, actions: read, pages: write, id-token: write, statuses: write } -concurrency: { group: pages, cancel-in-progress: false } -jobs: - publish: - if: >- - github.repository == 'ORG/REPO' && - ( github.event_name == 'workflow_dispatch' || - ( github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository ) ) - 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 ordering + prerelease - - id: site - uses: DiamondLightSource/myst-version-switcher-plugin/assemble@ - with: { repo: ${{ github.repository }} } - - uses: actions/upload-pages-artifact@v3 - with: { path: ${{ steps.site.outputs.dir }} } - - id: deployment - uses: actions/deploy-pages@v4 -``` +The `version-switcher` directive options and the `assemble` action contract. +::: -Released versions live as `docs.zip` assets — the CI build uploads the `docs` -artifact, your tag-only release step attaches that *same file* to the GitHub -Release verbatim, and `assemble` reconstructs the release from it. **Every PR -(internal or fork) builds the full site** to verify it; **internal PRs, `main`, and -tags publish** as soon as CI passes; an **external fork PR** publishes only after a -maintainer opts it in by running the publish workflow with its PR number -(`workflow_dispatch` → `pr`), which pins that commit as approved (a later push -drops it until re-approved). The first deploy (no releases) produces a single-entry -`switcher.json` and a redirect to the current version rather than failing. +:::: diff --git a/docs/myst.yml b/docs/myst.yml index fbf5366..412ce69 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -8,9 +8,38 @@ project: - ../plugins/version-switcher.mjs toc: - file: index.md + - file: tutorials.md + children: + - file: tutorials/adding-to-a-fresh-repo.md + - file: how-to.md + children: + - file: how-to/migrate-from-gh-pages.md + - file: how-to/contribute.md + - file: explanations.md + children: + - file: explanations/architecture.md + - file: reference.md + children: + - file: reference/directive.md + - file: reference/action.md + - url: https://github.com/DiamondLightSource/myst-version-switcher-plugin/releases + title: Release Notes site: template: book-theme - options: - logo: images/dls-logo.svg parts: navbar_end: navbar_end.md + nav: + - title: Tutorial + url: /tutorials + - title: How-to Guides + url: /how-to + - title: Explanation + url: /explanations + - title: Reference + url: /reference + options: + # Output page URLs by folder, e.g. /reference/directive rather than the + # default flattened /directive. + folders: true + logo: images/dls-logo.svg + github_url: https://github.com/DiamondLightSource/myst-version-switcher-plugin diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..fa00670 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,7 @@ +# Reference + +Technical reference for the directive and the action. + +```{toc} +:context: children +``` diff --git a/docs/reference/action.md b/docs/reference/action.md new file mode 100644 index 0000000..95a8865 --- /dev/null +++ b/docs/reference/action.md @@ -0,0 +1,99 @@ +# Reference: the `assemble` action + workflow contract + +`assemble` is a composite action that reconstructs the **whole** versioned docs +site from durable sources and outputs its directory for the caller to publish to +GitHub Pages. It does **not** deploy (that is `deploy-pages`, which is job-scoped and +owned by the caller). + +```yaml +- id: site + uses: DiamondLightSource/myst-version-switcher-plugin/assemble@ + with: + repo: ${{ github.repository }} +- uses: actions/upload-pages-artifact@v3 + with: { path: ${{ steps.site.outputs.dir }} } +- uses: actions/deploy-pages@v4 +``` + +It requires Node + `gh` on PATH (preinstalled on GitHub runners) and the repo +checked out with tags (`fetch-depth: 0`, for version ordering + prerelease +detection). + +## Inputs + +| input | required | default | meaning | +|---|---|---|---| +| `repo` | no | `${{ github.repository }}` | `org/repo`, for version URLs and `gh` lookups. | +| `guard-default-branch` | no | `true` | When `true`, hard-fail if the repo's **default branch** is not in the site — guards against publishing a hole when its latest build artifact has expired. Set `false` only for throwaway previews. | +| `token` | no | `${{ github.token }}` | Token for `gh`: release assets, cross-run artifacts, and the `preview-approved` status. | +| `artifact-version-name` | no | `""` | Version name (`pr-` \| `main` \| ``) of **this run's** `docs` artifact. When set, the action downloads that artifact and `assemble` unzips + stages it as this version directly, instead of gathering it from durable sources. Used when publishing **inside the build's own CI run** (the run isn't a completed success yet, so the gather can't discover it — or would find a stale previous build). Empty → pure durable gather. | + +## Outputs + +| output | meaning | +|---|---| +| `dir` | Path to the assembled publish root, for `upload-pages-artifact`. | + +## What it gathers + +Every deploy rebuilds the complete tree from authoritative inputs: + +| version kind | source | durability | +|---|---|---| +| current build | this run's `docs` artifact, downloaded + staged via `artifact-version-name` | n/a (just built) | +| default branch (e.g. `main`) | latest successful CI **push** run's `docs` artifact | ephemeral — self-heals on the next branch CI | +| released tags | the `docs.zip` asset attached to each **GitHub Release** | 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 | + +A version no longer gathered (a merged/closed PR, a deleted release) is correctly +dropped, because `deploy-pages` replaces the *entire* site. The version passed as +`artifact-version-name` is staged first and **skipped** by every gather, so a stale +previous build never clobbers the fresh one. + +## The `docs.zip` / version-name contracts + +Two contracts let the build (in CI) and the reconstruction (in `assemble`) agree +without coordination: + +- **`docs.zip` is one zip with a bare `html/` root.** The CI build packs it once and + delivers the *same file* two ways: uploaded verbatim as the `docs` artifact + (every run), and attached verbatim as the `docs.zip` Release asset on tags. Both + the release gather and the branch/PR gather unzip the same `html/` shape. +- **The version name is the site sub-dir *and* the `BASE_URL`.** It is `pr-` for + PRs, else the ref name (`main`, or a tag without `/`). The build sets + `BASE_URL=/REPO/` and `assemble` files the artifact at + `site/` — the same literal name on both sides, so assets never 404. + There is **no sanitisation**: version names are clean by construction (the + `tags: ['*']` trigger never builds `/`-tags). + +## Running it standalone + +`assemble.sh` is runnable outside the action so the `gh` plumbing can be exercised +locally: + +```bash +REPO=DiamondLightSource/myst-version-switcher-plugin GH_TOKEN=$(gh auth token) \ + assemble/assemble.sh +``` + +It is driven entirely by env (`REPO`, `GUARD_DEFAULT_BRANCH`, `GH_TOKEN`, `SITE`, +and — for injection — `ARTIFACT_VERSION_NAME` + `ARTIFACT_ZIP`, the `docs.zip` the +action downloads before calling the script). The pure logic +(ordering, prerelease detection, `switcher.json`/redirect rendering, the +required-branch guard) lives in `assemble.mjs` and is unit-tested without git, the +network, or the filesystem. + +## Caller workflow shape + +The caller owns two workflows split by privilege; see the +[architecture explanation](../explanations/architecture.md) for the why and the +[tutorial](../tutorials/adding-to-a-fresh-repo.md) for the full copy-paste snippets: + +- An **unprivileged CI** builds at `BASE_URL=/REPO/`, uploads the `docs` + artifact, and (on tags) attaches `docs.zip` + the plugin to the Release. It then + nests the publish reusable workflow as a job — **for internal events only**. +- A **privileged `_publish`** reusable workflow runs `assemble` → + `upload-pages-artifact` → `deploy-pages`, carrying the `github-pages` environment + + `pages`/`id-token`/`statuses` permissions + `concurrency`. It is reachable two + ways: `workflow_call` (the nested internal path) and `workflow_dispatch` (the + maintainer fork-PR opt-in). diff --git a/docs/reference/directive.md b/docs/reference/directive.md new file mode 100644 index 0000000..89ab093 --- /dev/null +++ b/docs/reference/directive.md @@ -0,0 +1,58 @@ +# Reference: the `version-switcher` directive + +The plugin registers one MyST directive, `version-switcher`, rendered as an +`anywidget`. Place it wherever you want the dropdown — typically the `navbar_end` +part, but it works in any page body. + +```markdown +:::{version-switcher} +:json-url: https://ORG.github.io/REPO/switcher.json +::: +``` + +## Options + +| option | required | default | meaning | +| --- | --- | --- | --- | +| `json-url` | yes | — | URL (absolute or root-relative) to a pydata-format `switcher.json`. | +| `version-match` | no | auto-detect from the URL | Force the "current" version instead of detecting it from the page path. | +| `preserve-path` | no | `true` | Carry the current page path across versions (vs. jumping to the version root). | +| `probe-target` | no | `true` | HEAD-probe the target page and fall back to the version root if it 404s. Set `false` for cross-origin switchers where the probe is CORS-blocked. | +| `class` | no | — | Extra class names for the widget container. | + +Booleans default to `true` unless explicitly set to `false`. + +## `switcher.json` format + +Standard pydata format — an array of `{ version, url }`, with the preferred +(newest non-prerelease) entry flagged `preferred` and rendered with a ★: + +```json +[ + { "version": "main", "url": "https://ORG.github.io/REPO/main/" }, + { "version": "2.1", "url": "https://ORG.github.io/REPO/2.1/", "preferred": true }, + { "version": "2.0", "url": "https://ORG.github.io/REPO/2.0/" } +] +``` + +The `assemble` action generates this file for you (see the +[action reference](./action.md)); you only point `json-url` at it. + +## Behaviour + +**Path preservation + existence fallback.** On `/v1/x/y`, switching to v2 goes to +`/v2/x/y` when a HEAD probe finds it, else `/v2`. The probe is reliable same-origin +(the production GitHub Pages case); cross-origin probes can be CORS-blocked and are +treated as indeterminate — the path is kept rather than stranding users at the root. +Set `probe-target: false` to skip probing entirely. + +**Local dev.** On `localhost`, where no version prefix precedes the page path, the +widget synthesises a `local (dev)` entry rooted at `/` so the switcher is usable +during `myst start`. + +**Stable alias.** The site serves a `stable/` copy of the newest release, so the +canonical entry URL never changes (handy for inter-project `objects.inv` +cross-references). Visiting a `…/stable/` page selects the concrete release it +aliases in the dropdown, and switching to a pinned version preserves the page path +onto it. The `stable` segment name is a fixed convention. See the +[architecture explanation](../explanations/architecture.md#stable-alias). diff --git a/docs/tutorials.md b/docs/tutorials.md new file mode 100644 index 0000000..30bf2dd --- /dev/null +++ b/docs/tutorials.md @@ -0,0 +1,7 @@ +# Tutorial + +A start-to-finish walkthrough for new users. Begin here. + +```{toc} +:context: children +``` diff --git a/docs/tutorials/adding-to-a-fresh-repo.md b/docs/tutorials/adding-to-a-fresh-repo.md new file mode 100644 index 0000000..34881a0 --- /dev/null +++ b/docs/tutorials/adding-to-a-fresh-repo.md @@ -0,0 +1,188 @@ +# Tutorial: add versioned docs to a fresh repo + +This walks you, start to finish, through giving a MyST docs site a pydata-style +version switcher and a versioned GitHub Pages deployment. By the end you will have: + +- the switcher dropdown in your navbar, +- every push to `main`, tag, and internal PR published at its own URL, and +- a `stable/` alias pointing at your latest release. + +It assumes a repo that already builds docs with `myst build --html` from a `docs/` +directory. Replace `ORG/REPO` throughout, and pin a real `` from this project's +[releases](https://github.com/DiamondLightSource/myst-version-switcher-plugin/releases). + +## 1. Add the plugin to your MyST project + +In `docs/myst.yml`, load the plugin from its release asset and route a navbar part: + +```yaml +# docs/myst.yml +project: + plugins: + - https://github.com/DiamondLightSource/myst-version-switcher-plugin/releases/download//version-switcher.mjs +site: + template: book-theme + parts: + navbar_end: navbar_end.md +``` + +Then place the directive (see the [directive reference](../reference/directive.md) +for all options): + +```markdown + +:::{version-switcher} +:json-url: https://ORG.github.io/REPO/switcher.json +::: +``` + +The `json-url` points at a `switcher.json` that does not exist yet — the `assemble` +action will generate it on your first deploy. + +## 2. Set the Pages source to "GitHub Actions" + +In **Settings → Pages**, set **Source** to **GitHub Actions** (not "Deploy from a +branch"). `deploy-pages` refuses to publish otherwise. + +## 3. Add the CI workflows (a generic build + a thin entry) + +The **build half is generic** — compute the version name, build at the versioned +`BASE_URL`, pack `docs.zip`, upload the `docs` artifact, and (on a fork PR) warn that +the preview won't auto-publish. This project's own reusable build workflow is below +verbatim; reuse it as-is, adapting the install/build steps (`npm ci` / `npm run docs`) +if you don't drive MyST through npm: + +:::{literalinclude} ../../.github/workflows/_docs.yml +:language: yaml +:caption: .github/workflows/_docs.yml (a reusable `workflow_call` build) +::: + +Then a thin **`ci.yml`** runs that build for every event and nests the publish +workflow — but only for internal events on your repo (the canonical-repo + non-fork +guard). Fork PRs build to verify, but never deploy: + +```yaml +# .github/workflows/ci.yml +name: CI +on: + pull_request: + push: { branches: [main], tags: ['*'] } # '*' never matches '/' + +jobs: + docs: + uses: ./.github/workflows/_docs.yml + + # (optional) a tag-only release job attaching docs.zip + version-switcher.mjs. + + publish: + needs: [docs] + if: >- + github.repository == 'ORG/REPO' && + ( 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 +``` + +## 4. Add the publish workflow (assemble + deploy) + +This is the privileged half. It is reusable (`workflow_call`, nested by `ci.yml` for +internal events) and also directly dispatchable (`workflow_dispatch`, the fork-PR +opt-in). On the nested path it injects the just-built artifact so a `main`/tag push +publishes *this* build, not the previous one. + +```yaml +# .github/workflows/_publish.yml +name: Publish +on: + workflow_call: + inputs: + version-name: { required: true, type: string } + workflow_dispatch: + inputs: + pr: { required: false } # external fork PR number to approve + preview +permissions: + contents: read + actions: read + pages: write + id-token: write + statuses: write +concurrency: { group: pages, cancel-in-progress: false } + +jobs: + publish: + # The canonical-repo guard lives in 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 ordering + prerelease detection + + # Dispatch path: pin the fork PR's current head SHA as approved. + - if: github.event_name == 'workflow_dispatch' && inputs.pr != '' + env: { GH_TOKEN: '${{ github.token }}', REPO: '${{ github.repository }}', PR: '${{ inputs.pr }}' } + run: | + 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, assemble downloads THIS run's `docs` artifact and stages + # it as `artifact-version-name` (it isn't a completed success yet). Empty on + # dispatch → a pure durable gather. + - id: site + uses: DiamondLightSource/myst-version-switcher-plugin/assemble@ + with: + repo: ${{ github.repository }} + artifact-version-name: ${{ inputs.version-name }} + - uses: actions/upload-pages-artifact@v3 + with: { path: ${{ steps.site.outputs.dir }} } + - id: deployment + uses: actions/deploy-pages@v4 +``` + +## 5. Allow the deploying refs in the `github-pages` environment + +Because internal PRs and tags now deploy from **their own ref** (the publish job +runs inside their CI run), the `github-pages` environment's deployment policy must +allow those refs. In **Settings → Environments → github-pages**, it is recommended +to set **Deployment branches and tags** to **No restriction**. + +## 6. First deploy + +Push to `main`. CI builds `main`, the `publish` job assembles a single-entry +`switcher.json` and an `index.html` redirecting to `main/`, and deploys. Visit +`https://ORG.github.io/REPO/` — the redirect lands you on `main/` with the switcher +showing one entry. (The single-entry first deploy is graceful by design; no release +is required.) + +## 7. Cut your first release + +Tag a release and attach the built docs as a `docs.zip` asset (bare `html/` root) — +the easiest way is a tag-only job in `ci.yml` that downloads the `docs` artifact and +uploads it verbatim, alongside `version-switcher.mjs`. + +```bash +git tag v1.0.0 && git push origin v1.0.0 +``` + +On the next deploy, `assemble` gathers that release, flags it `preferred` (★), +creates the `stable/` alias pointing at it, and the root redirect now targets the +constant `stable/` URL. Your switcher now lists `main` and `1.0.0`, and +`https://ORG.github.io/REPO/stable/` always resolves to the latest release — a +stable URL for cross-project `objects.inv` references. + +## Where next + +- The [architecture explanation](../explanations/architecture.md) — why it works + this way. +- The [`assemble` action reference](../reference/action.md) — inputs, outputs, the + `docs.zip` contract. +- Migrating an existing site? See + [how-to: migrate from `gh-pages`](../how-to/migrate-from-gh-pages.md). diff --git a/plugins/version-switcher.mjs b/plugins/version-switcher.mjs index 1660cdc..a45bb00 100644 --- a/plugins/version-switcher.mjs +++ b/plugins/version-switcher.mjs @@ -28,7 +28,7 @@ const PLUGIN_PATH = new URL(import.meta.url).pathname; /** * The `stable/` alias segment (a fixed convention shared with the `assemble` - * action; see DESIGN). The published site serves `stable/` as a copy of the + * action; see docs/ explanation). The published site serves `stable/` as a copy of the * newest release, so a page may be viewed under `/stable/…` even though no * switcher entry has that pathname — the widget maps it back to the concrete * release. diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 2e90b88..a4ced14 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -2,7 +2,7 @@ # # One-time gh-pages → durable-source migration, run LOCALLY by an operator. # -# Why local, not CI (see DESIGN.md "Migration"): +# Why local, not CI (see docs/how-to/migrate-from-gh-pages.md): # - flipping the Pages source needs repo-admin, which a CI GITHUB_TOKEN lacks; # - the destructive steps want a human watching with their own `gh auth`; # - it leaves no workflow_dispatch stub behind in each consumer repo.