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
62 changes: 16 additions & 46 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ name: CI
# 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 NON-TAG 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). TAGS deploy via the `publish-tag` trampoline (workflow_dispatch) instead
# of inline — a same-SHA tag deploy would otherwise be silently dropped by Pages. See
# the architecture explanation in docs/.
# Publishing is nested here (the `publish` job → publish-dispatch.yml → publish.yml) so
# its status is visible on the PR / commit. A single `publish` job covers every event:
# publish-dispatch.yml branches internally into deploy (internal PR / main push), tag
# trampoline (a same-SHA tag deploy would be silently dropped by Pages unless dispatched),
# or a fork-PR warning (forks build with a read-only token and never deploy). See the
# architecture explanation in docs/.
on:
pull_request:
push:
Expand Down Expand Up @@ -38,51 +37,22 @@ jobs:
permissions:
contents: write # create the GitHub Release + attach assets

# Internal NON-TAG events (an internal PR or a push to main): a same-repo build we
# can trust + deploy inline, with the publish status visible on the PR/commit. These
# are the FIRST deploy of their SHA, so the Pages backend serves them fine.
# 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. TAGS are excluded too: see publish-tag below.
# One publish job for every event — publish-dispatch.yml owns the branching (deploy
# inline / tag trampoline / fork-PR warn), so there's no separate tag or fork job and
# no ref/fork guards here. It runs for fork PRs too, but only to surface the opt-in
# hint: the wrapper's `deploy` job excludes forks and a fork PR's token is read-only,
# so untrusted code never reaches a write token. `actions: write` lets the wrapper's
# tag trampoline re-dispatch itself; `version-name` is the in-run build for the inline
# path (ignored by the trampoline/warn branches).
publish:
needs: [lint, test, docs]
if: >-
github.repository == 'DiamondLightSource/myst-version-switcher-plugin' &&
github.ref_type != 'tag' &&
( github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository )
uses: ./.github/workflows/publish.yml
if: github.repository == 'DiamondLightSource/myst-version-switcher-plugin'
uses: ./.github/workflows/publish-dispatch.yml
with:
version-name: ${{ needs.docs.outputs.version-name }}
permissions:
contents: read
actions: read
actions: write # the wrapper's tag trampoline dispatches via gh workflow run
pages: write
id-token: write
statuses: write

# Tags can't deploy inline. A release tag is cut on the merge commit, so it shares
# the default branch's just-deployed SHA — and the Pages backend silently DROPS a
# second same-SHA deploy on a non-`workflow_dispatch` event (reports success, keeps
# serving the old artifact; see publish.yml). The escape hatch is that a
# `workflow_dispatch` deploy of that SHA forces a re-serve. So instead of nesting
# publish.yml inline, re-trigger it as a workflow_dispatch (after `release`, so the
# dispatched run gathers the new release's docs.zip). The dispatched run does the
# deploy + verify; its status shows on its own run rather than this tag's CI, which
# is fine — a tag has no PR to annotate. (Consumers do the same by dispatching their
# fork-preview wrapper instead — see the how-to.)
publish-tag:
needs: [lint, test, docs, release]
if: >-
github.repository == 'DiamondLightSource/myst-version-switcher-plugin' &&
github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
actions: write # dispatch publish.yml
steps:
- name: Re-deploy via workflow_dispatch (tag trampoline)
env:
GH_TOKEN: ${{ github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
gh workflow run publish.yml --repo "$GITHUB_REPOSITORY" --ref "$DEFAULT_BRANCH"
23 changes: 3 additions & 20 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ on:
required: false
default: docs/_build/html
type: string
preview-workflow:
description: Consumer's dispatchable publish workflow file, for the fork-preview hint.
required: false
default: publish.yml
type: string
outputs:
version-name:
description: The version name this build was served at (pr-<n> | <default-branch> | <tag>).
Expand Down Expand Up @@ -109,18 +104,6 @@ jobs:
path: ${{ runner.temp }}/docs.zip
compression-level: 0

# Fork PRs build + verify here but do not auto-publish (the consumer'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
env:
PREVIEW_WORKFLOW: ${{ inputs.preview-workflow }}
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 $PREVIEW_WORKFLOW \
workflow for PR #${{ github.event.pull_request.number }}: \
https://github.com/${{ github.repository }}/actions/workflows/$PREVIEW_WORKFLOW"
# 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.
113 changes: 113 additions & 0 deletions .github/workflows/publish-dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Publish (dispatch)

# The ONE wrapper around publish.yml (the assemble + deploy engine) — it owns ALL the
# publish decisions so a consumer's ci.yml needs a single `publish` job (no separate
# tag or fork-warning job) and docs.yml needs no fork-preview hint. publish.yml is a
# pure `workflow_call` engine; this file branches on the originating event into one of
# three jobs:
#
# deploy — internal PR / default-branch push (via ci.yml's workflow_call), or any
# workflow_dispatch (the trampoline's re-dispatch, a fork-PR preview, a
# manual re-deploy). Nests publish.yml; for the inline path it injects the
# in-run build via `version-name`, otherwise pure durable gather.
# trampoline — a TAG push (via workflow_call). An inline tag deploy would be silently
# DROPPED by Pages (it reuses the default branch's just-deployed SHA on a
# non-dispatch event — deploy-pages#383), so re-dispatch THIS workflow as
# `workflow_dispatch`; that event forces a re-serve. Waits for ci.yml's
# parallel release job to attach the tag's docs.zip first (the dispatched
# run gathers the tag from that durable asset).
# warn — a FORK PR. Fork builds run with a read-only token and must never deploy,
# so just surface the manual-opt-in hint on the PR. (The token is
# read-only here regardless — `deploy` is `if`-excluded for forks too, and
# the workflow file itself comes from the trusted base, not the fork.)
#
# This repo DOGFOODS the exact wrapper a consumer adds — theirs differs only in the
# `deploy` job's `uses:` target (DiamondLightSource/myst-version-switcher-plugin/
# .github/workflows/publish.yml@<tag>), the single place they pin the engine's <tag>.
on:
workflow_call:
inputs:
version-name:
description: Version name of an in-run build to inject (pr-<n> | <default-branch> | <tag>); "" → pure durable gather.
required: false
default: ""
type: string
guard-default-branch:
description: "'false' to skip the default-branch-present guard (e.g. mid gh-pages migration)."
required: false
default: "true"
type: string
pr:
description: External fork PR number to approve + preview.
required: false
default: ""
type: string
workflow_dispatch:
inputs:
pr:
description: External fork PR number to approve (pins its head SHA) and preview. Leave empty to just re-deploy.
required: false
default: ""

jobs:
# Internal inline (PR / default-branch push) or any workflow_dispatch → deploy.
deploy:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref_type != 'tag') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
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
uses: ./.github/workflows/publish.yml
with:
version-name: ${{ inputs.version-name }}
guard-default-branch: ${{ inputs.guard-default-branch || 'true' }}
pr: ${{ inputs.pr }}

# A tag can't deploy inline — re-dispatch this workflow so the deploy runs on a
# workflow_dispatch event (which forces Pages to re-serve a same-SHA deploy).
trampoline:
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: read # gh release view
actions: write # gh workflow run (re-dispatch self)
steps:
- name: Wait for the release, then re-deploy via workflow_dispatch
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# ci.yml's release job attaches the tag's docs.zip in parallel; the dispatched
# run gathers the tag from that asset, so don't dispatch until it exists.
for _ in $(seq 1 36); do
gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1 && break
sleep 5
done
gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1 \
|| { echo "::error::release $TAG not found after 3 min — did the release job fail?"; exit 1; }
gh workflow run publish-dispatch.yml --repo "$REPO" --ref "$DEFAULT_BRANCH"

# Fork PR: never deploys (read-only token). Surface the manual-opt-in hint on the PR.
warn:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Explain the fork-preview opt-in
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-dispatch.yml \
workflow for PR #${{ github.event.pull_request.number }}: \
https://github.com/${{ github.repository }}/actions/workflows/publish-dispatch.yml"
53 changes: 22 additions & 31 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,19 @@ name: Publish
# Reconstruct the WHOLE versioned docs site from durable sources (the consumer's
# default-branch 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 (pages/id-token/statuses write), kept in its own reusable workflow so it can
# only run two trusted ways:
# half (pages/id-token/statuses write) and a pure ENGINE: its only entry point is
# `workflow_call`, and the ONLY thing that calls it is a `publish-dispatch.yml`
# wrapper (this repo's own, or a consumer's pinned at <tag>). That wrapper exposes the
# engine as both `workflow_dispatch` (the tag trampoline + fork-PR opt-in + manual
# re-deploy) and `workflow_call` (ci.yml's inline publish for internal non-tag
# events). Keeping the dispatch surface in the wrapper means this engine has a single
# trusted caller and no event-type branching of its own.
#
# workflow_call — invoked by the consumer's ci.yml AFTER a successful build, for
# INTERNAL events only (internal PRs, pushes to the default
# branch/tags). The caller 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 the caller passes its
# `version-name`; this run downloads the same-run `docs` artifact
# and stages it directly (otherwise a default-branch/tag push
# would publish the PREVIOUS build). A consumer's manual fork-PR
# preview also comes through here — a workflow_dispatch wrapper in
# their repo calls this with `pr: <n>` (and version-name: "").
# workflow_dispatch — this repo's own maintainer opt-in to preview an EXTERNAL fork
# PR (the local, non-cross-repo path).
# `version-name` (set by ci.yml's inline path, threaded through the wrapper) names an
# in-run build to stage directly — the run isn't a *completed* CI run yet, so the
# gather can't find it (or would find a stale prior build); this run downloads that
# run's `docs` artifact and stages it. The dispatch paths pass version-name "" → pure
# durable gather. `pr` (fork opt-in) pins + previews a fork PR's head SHA.
#
# The assemble logic is THIS repo's assemble/assemble.sh (+ assemble.mjs); we run it
# directly rather than wrap it in a composite action. To version-match the scripts
Expand All @@ -34,8 +32,9 @@ on:
workflow_call:
inputs:
version-name:
description: Version name (pr-<n> | <default-branch> | <tag>) of the in-run build to inject.
required: true
description: Version name (pr-<n> | <default-branch> | <tag>) of the in-run build to inject; "" → pure durable gather.
required: false
default: ""
type: string
guard-default-branch:
description: >-
Expand All @@ -48,16 +47,10 @@ on:
pr:
description: >-
External fork PR number to approve (pins its head SHA) and preview. Set this
(with version-name: "") from a consumer's workflow_dispatch wrapper, since a
reusable workflow can't be workflow_dispatch'd cross-repo directly.
(with version-name: "") from the publish-dispatch.yml wrapper's dispatch path.
required: false
default: ""
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
Expand All @@ -72,10 +65,9 @@ concurrency:

jobs:
publish:
# The canonical-repo guard lives in the caller (its publish job's `if`), not
# here, so this reusable workflow stays generic. Both entry points are trusted:
# the caller only calls it for internal events, and workflow_dispatch needs write
# access (a fork dispatching it deploys to the fork's own Pages).
# The canonical-repo guard lives upstream (ci.yml's publish job `if`, and the
# wrapper's dispatch needs write access), not here, so this engine stays generic.
# Its single caller is publish-dispatch.yml, which only reaches it on trusted paths.
runs-on: ubuntu-latest
environment:
name: github-pages
Expand All @@ -101,9 +93,8 @@ jobs:

# Fork opt-in: pin the PR's head 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 re-approved. Gated on `pr` alone (not event_name) so it
# works both from this repo's own workflow_dispatch and from a consumer's dispatch
# wrapper that passes `pr` through workflow_call.
# drops the preview until re-approved. Gated on `pr` alone, which the
# publish-dispatch.yml wrapper threads in from its dispatch input.
- name: Approve fork PR head SHA
if: inputs.pr != ''
env:
Expand All @@ -120,7 +111,7 @@ jobs:
# When publishing within the build's own run (workflow_call), download this
# run's `docs` artifact so assemble can stage it directly as version-name —
# the run isn't a completed success yet, so the gather can't find it (or would
# find a stale prior build). Skipped on workflow_dispatch (version-name empty).
# find a stale prior build). Skipped when version-name is "" (the dispatch paths).
- name: Download this run's docs artifact
if: inputs.version-name != ''
uses: actions/download-artifact@v8
Expand Down
Loading
Loading