Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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:** `<select>` 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).

## Running the assemble action locally

`assemble/assemble.sh` is runnable standalone so the `gh` plumbing can be exercised
outside CI:

```bash
REPO=DiamondLightSource/myst-version-switcher-plugin GH_TOKEN=$(gh auth token) \
assemble/assemble.sh
```

The pure logic (ordering, prerelease detection, `switcher.json`/redirect rendering,
the required-branch guard) lives in `assemble/assemble.mjs` and is unit-tested
(`npm test`) without git, the network, or the filesystem.

## Releasing

```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```

CI runs lint + tests + the docs build; `_release.yml` creates a GitHub Release with
`version-switcher.mjs` and the tag's `docs.zip` as assets; and the nested
`_publish.yml` reconstructs + deploys the site including the new tag. The plugin URL
and the `assemble` action both resolve to the same tag, so one tag versions both
halves.
47 changes: 34 additions & 13 deletions .github/workflows/_docs.yml
Original file line number Diff line number Diff line change
@@ -1,45 +1,52 @@
on:
workflow_call:
outputs:
version-name:
description: The version name this build was served at (pr-<n> | main | <tag>).
value: ${{ jobs.build.outputs.version-name }}

# Build the docs at the versioned BASE_URL and upload this build's `docs` artifact
# (docs.zip, bare html/ root). This is the UNPRIVILEGED half: it runs for every
# event — PRs (including forks), pushes to main, and tags — but never publishes.
# publish.yml (triggered by CI completing) reconstructs the whole site from these
# artifacts + release assets and deploys it to Pages. See DESIGN.md.
# _publish.yml (called by ci.yml after this build, on internal events) reconstructs
# the whole site from these artifacts + release assets and deploys it to Pages,
# injecting THIS build's artifact for the current version name. See docs explanation.
jobs:
build:
runs-on: ubuntu-latest
outputs:
version-name: ${{ steps.ver.outputs.version-name }}
steps:
- uses: actions/checkout@v5

- name: Install dependencies
run: npm ci

# Version token = the site sub-dir this build is served at, and the BASE_URL
# it must be built with. pr-<n> for PRs (clean by construction); otherwise the
# ref name — `main`, or a tag without `/` (the `tags: ['*']` trigger never
# matches `/`). No sanitisation: every token is filesystem/URL-safe already.
- name: Compute version token
# Version name = the site sub-dir this build is served at, and the BASE_URL it
# must be built with. pr-<n> for PRs (clean by construction); otherwise the ref
# name — `main`, or a tag without `/` (the `tags: ['*']` trigger never matches
# `/`). No sanitisation: every version name is filesystem/URL-safe already.
- name: Compute version name
id: ver
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = pull_request ]; then
token="pr-${{ github.event.pull_request.number }}"
name="pr-${{ github.event.pull_request.number }}"
else
token="${{ github.ref_name }}"
name="${{ github.ref_name }}"
fi
echo "token=$token" >> "$GITHUB_OUTPUT"
echo "version-name=$name" >> "$GITHUB_OUTPUT"

# BASE_URL must match the versioned sub-path the build is served at, or its
# root-absolute assets 404. assemble files this build's artifact at the same
# token (site/main, site/<tag>, site/pr-<n>), so the two cannot drift.
# version name (site/main, site/<tag>, site/pr-<n>), so the two cannot drift.
- name: Build docs
env:
BASE_URL: /${{ github.event.repository.name }}/${{ steps.ver.outputs.token }}
BASE_URL: /${{ github.event.repository.name }}/${{ steps.ver.outputs.version-name }}
run: npm run docs

# Pack the build as docs.zip with a bare html/ root — the durable contract
# both publish.yml's gather and _release.yml's asset rely on.
# both _publish.yml's gather and _release.yml's asset rely on.
- name: Pack docs.zip (bare html/ root)
run: |
set -euo pipefail
Expand All @@ -52,3 +59,17 @@ jobs:
name: docs
path: ${{ runner.temp }}/docs.zip
compression-level: 0

# Fork PRs build + verify here but do not auto-publish (ci.yml's publish job
# excludes them — the security boundary). Surface that on the PR with a link to
# the manual preview opt-in, rather than silently publishing nothing.
- name: Explain the fork-preview opt-in
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::warning title=Docs preview not published::This is a fork PR, so the \
versioned docs site is NOT auto-published (fork builds run with a read-only \
token). A maintainer can publish a preview by running the Publish workflow \
for PR #${{ github.event.pull_request.number }}: \
https://github.com/${{ github.repository }}/actions/workflows/_publish.yml"
96 changes: 96 additions & 0 deletions .github/workflows/_publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Publish

# Reconstruct the WHOLE versioned docs site from durable sources (main's build,
# release docs.zip assets, open-PR build artifacts) and deploy it directly to
# GitHub Pages — no gh-pages branch. This is the PRIVILEGED half, kept in its own
# file so it can only run two ways, both trusted:
#
# workflow_call — invoked by ci.yml AFTER a successful build, for INTERNAL
# events only (internal PRs, pushes to main/tags). ci.yml never
# calls it for a fork PR, so untrusted fork code never reaches a
# write token. The current build isn't a *completed* CI run yet,
# so ci.yml passes its `version-name`; assemble downloads this
# run's `docs` artifact and stages it directly (skipping the
# re-gather of that version) — otherwise a main/tag push would
# publish the PREVIOUS build.
# workflow_dispatch — a maintainer's opt-in to preview an EXTERNAL fork PR: pins
# its current head commit as approved, then assembles (no current
# build to inject — assemble gathers the fork from durable
# sources via the approved SHA's successful run).
#
# Both entry points run in the trusted upstream context. See the architecture
# explanation in docs/.
on:
workflow_call:
inputs:
version-name:
description: Version name (pr-<n> | main | <tag>) of the in-run build to inject.
required: true
type: string
workflow_dispatch:
inputs:
pr:
description: External fork PR number to approve (pins its head SHA) and preview.
required: false

permissions:
contents: read # checkout + read release assets
actions: read # gh run download (this run's + cross-run docs artifacts)
pages: write # deploy to Pages
id-token: write # deploy-pages OIDC
statuses: write # set the preview-approved status on a fork PR head SHA

concurrency:
group: pages
cancel-in-progress: false

jobs:
publish:
# The canonical-repo guard lives in the caller (ci.yml's publish job), not here,
# so this reusable workflow stays generic. Both entry points are trusted: ci.yml
# only calls it for internal events on the canonical repo, and workflow_dispatch
# needs write access (a fork dispatching it deploys to the fork's own Pages).
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # tags, for version ordering + prerelease detection

# workflow_dispatch (fork opt-in): pin THIS commit as approved. assemble gathers
# an external PR only when its head SHA carries this status, so a later push
# (new SHA) silently drops the preview until a maintainer re-approves.
- name: Approve fork PR head SHA
if: github.event_name == 'workflow_dispatch' && inputs.pr != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ inputs.pr }}
run: |
set -euo pipefail
sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)
gh api --method POST "repos/$REPO/statuses/$sha" \
-f state=success -f context=preview-approved \
-f description="Fork docs preview approved"

# On the nested call, `artifact-version-name` tells assemble to download this
# run's `docs` artifact and stage it as that version (it isn't a completed
# success yet, so the gather can't find it — or would find a stale prior build).
# Empty on workflow_dispatch → assemble does a pure durable gather.
- name: Assemble versioned site
id: site
uses: ./assemble
with:
repo: ${{ github.repository }}
artifact-version-name: ${{ inputs.version-name }}

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: ${{ steps.site.outputs.dir }}

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
34 changes: 29 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
name: CI

# Verify + build only. Runs on every PR (including forks), pushes to main, and
# tags — lint, test, build the docs, and on tags publish the release assets. It
# uploads each build's `docs` artifact but NEVER deploys to Pages; publish.yml
# (triggered by this workflow completing) owns the deploy. Keeping CI unprivileged
# is what lets fork PRs build safely. See DESIGN.md.
# Verify + build, then publish on INTERNAL events. Runs on every PR (including
# forks), pushes to main, and tags — lint, test, build the docs, and on tags
# publish the release assets. It uploads each build's `docs` artifact.
#
# Publishing is nested here (the `publish` job → _publish.yml) so its status is
# visible on the PR / commit, but ONLY for internal events: a fork PR's build runs
# with a read-only token and must never deploy, so the `publish` job is skipped for
# it (the `_docs` reusable workflow points a maintainer at the manual opt-in
# instead). See the architecture explanation in docs/.
on:
pull_request:
push:
Expand All @@ -27,3 +31,23 @@ jobs:
uses: ./.github/workflows/_release.yml
permissions:
contents: write # create the GitHub Release + attach assets

# Internal events only: an internal PR, or a push to main/tag, has a same-repo
# build we can trust + deploy. _publish.yml reconstructs the whole site and
# injects this build's `docs` artifact for `docs.version-name`. Fork PRs (head
# repo ≠ this repo) are excluded — _docs.yml's build job warns them instead.
publish:
needs: [lint, test, docs]
if: >-
github.repository == 'DiamondLightSource/myst-version-switcher-plugin' &&
( 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
86 changes: 0 additions & 86 deletions .github/workflows/publish.yml

This file was deleted.

Loading
Loading