diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b33324b..e890d2e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,8 +38,8 @@ the required-branch guard) lives in `assemble/assemble.mjs` and is unit-tested 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 +CI runs lint + tests + the docs build; `release.yml` creates the GitHub Release with +`version-switcher.mjs` + the tag's `docs.zip` attached (via `gh`); and the nested +publish (tag trampoline) reconstructs + deploys the site including the new tag. The plugin URL and the `uses:` refs for the reusable workflows all resolve to the same tag, so one tag versions both halves. diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml deleted file mode 100644 index 117f07b..0000000 --- a/.github/workflows/_release.yml +++ /dev/null @@ -1,41 +0,0 @@ -on: - workflow_call: - -jobs: - release: - runs-on: ubuntu-latest - - steps: - # The version-switcher plugin is the one asset that comes from the repo (not - # a build artifact). Check it out into a dot-dir — the `files: "*"` glob below - # only sees the cwd root and skips leading-dot paths — then copy just that file - # to the root, so the upload is exactly {version-switcher.mjs, docs.zip}. - - uses: actions/checkout@v7 - with: - path: .src - sparse-checkout: plugins/version-switcher.mjs - - run: cp .src/plugins/version-switcher.mjs . - - # The two steps below are the generic release job a consumer needs (download the - # built docs.zip, attach it to the Release). They are literalinclude'd into - # docs/tutorials/adding-to-a-fresh-repo.md between these markers — keep the markers - # when editing. The checkout + cp above is plugin-specific (it stages - # version-switcher.mjs) and is deliberately left outside the markers. - # docs-literalinclude:release-steps:start - # Download every artifact produced earlier in the run (here: docs.zip). - - name: Download artifacts - uses: actions/download-artifact@v8 - with: - merge-multiple: true - - - name: Create GitHub Release - # We pin to the SHA, not the tag, for security reasons. - # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }} - files: "*" # everything staged in the cwd — here, docs.zip - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # docs-literalinclude:release-steps:end diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index bffc936..4bd6dfb 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -8,3 +8,12 @@ jobs: - uses: actions/checkout@v7 - name: Unit tests (widget logic + make-switcher parity) run: npm test + # release.yml downloads every artifact in the run and attaches them, so name this + # explicitly (not the default "artifact") and fail loudly if the plugin is missing + # — it's a release asset. + - name: Upload version switcher artifact + uses: actions/upload-artifact@v7 + with: + name: version-switcher + path: plugins/version-switcher.mjs + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ac7d19..cf3953b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ 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-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 +# Publishing is nested here (the `publish` job → publish-dispatch.yml shim → publish.yml) +# so its status is visible on the PR / commit. A single `publish` job covers every event; +# publish.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: @@ -33,17 +33,17 @@ jobs: release: needs: [lint, test, docs] if: github.ref_type == 'tag' - uses: ./.github/workflows/_release.yml + uses: ./.github/workflows/release.yml permissions: contents: write # create the GitHub Release + attach assets - # 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). + # One publish job for every event — publish.yml (reached via the publish-dispatch.yml + # shim) 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: publish.yml'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 trampoline re-dispatch the shim; `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' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 35f0f78..3046b1d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -83,7 +83,7 @@ jobs: eval "$BUILD_COMMAND" # Pack the site as docs.zip with a bare html/ root — the durable contract both - # publish.yml's gather and _release.yml's asset rely on. Stage into html/ + # publish.yml's gather and release.yml's asset rely on. Stage into html/ # first so any html-dir name yields the same root. - name: Pack docs.zip (bare html/ root) env: diff --git a/.github/workflows/publish-dispatch.yml b/.github/workflows/publish-dispatch.yml index 981451f..557599a 100644 --- a/.github/workflows/publish-dispatch.yml +++ b/.github/workflows/publish-dispatch.yml @@ -1,44 +1,20 @@ 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@), the single place they pin the engine's . +# Thin shim — the one dispatchable file in this repo (and the one consumers copy). It +# forwards to publish.yml's engine, exposing it two ways: +# workflow_call — ci.yml's `publish` job, for every event (publish.yml branches into +# deploy / tag trampoline / fork warn). +# workflow_dispatch — the tag trampoline's re-dispatch, the fork-PR preview (`pr`), and +# manual re-deploys. +# A reusable workflow can't be `workflow_dispatch`'d cross-repo, so this shim must live +# in the consumer's repo; publish.yml's trampoline re-dispatches it by name +# (`dispatch-workflow`). This repo dogfoods the exact shim a consumer adds — theirs only +# differs in the `uses:` target (…/publish.yml@), the single place they pin . on: workflow_call: inputs: version-name: - description: Version name of an in-run build to inject (pr- | | ); "" → 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. + description: Version name of the in-run build to inject (passed by ci.yml's inline publish). required: false default: "" type: string @@ -50,64 +26,15 @@ on: 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 + publish: 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 + version-name: ${{ inputs.version-name }} # "" on dispatch → pure durable gather + pr: ${{ inputs.pr }} # set (dispatch only) → pin that fork head SHA + dispatch-workflow: publish-dispatch.yml # the file the tag trampoline re-dispatches 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" + contents: read + actions: write # publish.yml's trampoline re-dispatches this shim via gh + pages: write + id-token: write + statuses: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 96f9e6d..4dc20fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,36 @@ 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) 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 ). 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. +# default-branch build, release docs.zip assets, open-PR build artifacts) and deploy it +# directly to GitHub Pages — no gh-pages branch. The PRIVILEGED half. Its only caller is +# a thin `publish-dispatch.yml` shim (this repo's own, or a consumer's pinned at ) +# which exposes it as both `workflow_call` (ci.yml's `publish` job, for every event) and +# `workflow_dispatch` (the trampoline's re-dispatch + fork-PR opt-in + manual re-deploy). +# This workflow OWNS the event branching, into one of three jobs: # -# `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. +# deploy — internal PR / default-branch push / any workflow_dispatch → assemble + +# deploy + verify. The inline path injects the in-run build via +# `version-name`; the dispatch paths pure-gather (version-name ""). +# trampoline — a TAG push. An inline tag deploy is silently DROPPED by Pages (it reuses +# the default branch's just-deployed SHA on a non-dispatch event — +# deploy-pages#383), so wait for the release, then re-dispatch the shim +# (`inputs.dispatch-workflow`, a file in the CONSUMER's repo — a +# workflow_call run executes with the caller's repo + token, so we can +# dispatch it) → the deploy lands on a `workflow_dispatch` event and +# re-serves. A reusable workflow can't be dispatched cross-repo, which is +# why the shim (not this engine) is the dispatch target. +# warn — a FORK PR. Read-only token, never deploys; just surfaces the opt-in hint. # # 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 -# to this workflow, we check out the reusable workflow's OWN repo at its OWN commit -# via the `job` context: unlike github.workflow_* (which resolve to the CALLER's -# entry workflow), `job.workflow_repository` + `job.workflow_sha` resolve to the file -# that defines THIS job — i.e. the reusable workflow at the ref a consumer pinned -# `publish.yml@` to. So the scripts always match the workflow version, with no -# hardcoded repo and no manual release-time bump, in both the dogfood and cross-repo -# cases. assemble.mjs runs `git tag` in the cwd, so the CONSUMER's repo is checked -# out at the root (with tags) to supply their version list; the scripts come from -# .mvs. +# directly rather than wrap it in a composite action. To version-match the scripts to +# this workflow, we check out the reusable workflow's OWN repo at its OWN commit via the +# `job` context: unlike github.workflow_* (which resolve to the CALLER's entry workflow), +# `job.workflow_repository` + `job.workflow_sha` resolve to the file that defines THIS +# job — i.e. the reusable workflow at the ref a consumer pinned `publish.yml@` to. +# So the scripts always match the workflow version, with no hardcoded repo and no manual +# release-time bump. assemble.mjs runs `git tag` in the cwd, so the CONSUMER's repo is +# checked out at the root (with tags) to supply their version list; the scripts come +# from .mvs. on: workflow_call: inputs: @@ -38,40 +41,62 @@ on: type: string guard-default-branch: description: >- - 'true' (default) → hard-fail if the consumer's default branch is absent - from the assembled site (its build artifact expired). Set 'false' while a - repo's default branch is not yet publishing docs.zip (e.g. mid-migration). + 'true' (default) → hard-fail if the consumer's default branch is absent from + the assembled site (its build artifact expired). Set 'false' while a repo's + default branch is not yet publishing docs.zip (e.g. mid-migration). required: false default: "true" type: string pr: - description: >- - External fork PR number to approve (pins its head SHA) and preview. Set this - (with version-name: "") from the publish-dispatch.yml wrapper's dispatch path. + description: External fork PR number to approve (pins its head SHA) and preview. required: false default: "" type: string - -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 + dispatch-workflow: + description: >- + Filename of the publish-dispatch shim in the consumer's repo, re-dispatched by + the tag trampoline. Defaults to the conventional name; override if you renamed it. + required: false + default: publish-dispatch.yml + type: string jobs: - publish: - # 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. + # Fork PR: read-only token, never deploys — 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 \ + ${{ inputs.dispatch-workflow }} workflow for PR #${{ github.event.pull_request.number }}: \ + https://github.com/${{ github.repository }}/actions/workflows/${{ inputs.dispatch-workflow }}" + + # Internal PR / default-branch push (inline), 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) runs-on: ubuntu-latest + 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 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false steps: # The CONSUMER's repo, at the root cwd: assemble.mjs runs `git tag` here to # order that repo's deployed versions, so it needs their tags (fetch-depth: 0). @@ -93,8 +118,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, which the - # publish-dispatch.yml wrapper threads in from its dispatch input. + # drops the preview until re-approved. Gated on `pr` alone, which the shim threads + # in from its dispatch input. - name: Approve fork PR head SHA if: inputs.pr != '' env: @@ -144,12 +169,11 @@ jobs: # DROPS a *second* deploy of an already-deployed SHA on a non-`workflow_dispatch` # event (it reports success but the origin keeps serving the first artifact), # EXCEPT a `workflow_dispatch` deploy of that SHA, which forces a re-serve. A - # release tag is cut on the merge commit, so it shares the default branch's - # just-deployed SHA → an inline tag deploy would be dropped. The fix lives in the - # CALLER: tags don't deploy inline — they re-trigger this workflow via - # `workflow_dispatch` (see ci.yml's tag trampoline / the consumer how-to), which - # re-serves. main/internal-PR deploys are the FIRST of their SHA, so they serve - # fine inline. The Verify step below backstops any residual stale-origin case. + # release tag shares the default branch's just-deployed SHA, so an inline tag + # deploy would be dropped — which is why tags go through the `trampoline` job + # below (re-dispatch as workflow_dispatch) instead of here. main/internal-PR + # deploys are the FIRST of their SHA, so they serve fine inline. The Verify step + # below backstops any residual stale-origin case. - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 @@ -158,16 +182,16 @@ jobs: # if the Pages backend silently fails to update the SERVED origin (a wedged # site can serve a stale artifact indefinitely; see the 2026-06 incident where # a gh-pages→Actions cutover froze the origin while every deploy reported OK). - # The caller's tag trampoline (deploy via workflow_dispatch) prevents the - # tag-after-merge drop; this is the backstop that turns any *other* stale-origin - # failure into a red check instead of silently serving old docs. Confirm the live - # switcher.json byte-matches the one we just assembled, polling up to ~6 min — the - # window is generous because a release deploys TWICE in quick succession on the - # same SHA (the merge-to-default-branch push, then the tag trampoline), and the - # second (dispatch) re-serve can take a few minutes to propagate to the origin - # behind the first (observed ~4 min on 2026-06-25). The loop exits the instant the - # origin matches, so a fast/first-of-SHA deploy isn't slowed. On failure, dump the - # origin headers (a frozen etag/last-modified is the wedge signature). + # The trampoline (deploy via workflow_dispatch) prevents the tag-after-merge + # drop; this is the backstop that turns any *other* stale-origin failure into a + # red check instead of silently serving old docs. Confirm the live switcher.json + # byte-matches the one we just assembled, polling up to ~6 min — the window is + # generous because a release deploys TWICE in quick succession on the same SHA (the + # merge-to-default-branch push, then the tag trampoline), and the second (dispatch) + # re-serve can take a few minutes to propagate to the origin behind the first + # (observed ~4 min on 2026-06-25). The loop exits the instant the origin matches, + # so a fast/first-of-SHA deploy isn't slowed. On failure, dump the origin headers + # (a frozen etag/last-modified is the wedge signature). - name: Verify the deployed origin matches what we assembled env: PAGE_URL: ${{ steps.deployment.outputs.page_url }} @@ -201,3 +225,32 @@ jobs: fi sleep 5 done + + # A tag can't deploy inline (same SHA as the default-branch push → Pages drops it + # unless the event is workflow_dispatch). Wait for the release, then re-dispatch the + # shim so the deploy runs on a workflow_dispatch event and re-serves. + 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 the shim) + steps: + - name: Wait for the release, then re-deploy via workflow_dispatch + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + WF: ${{ inputs.dispatch-workflow }} + 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 "$WF" --repo "$REPO" --ref "$DEFAULT_BRANCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..adf3048 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +# Reusable: attach this run's build artifacts (the docs.zip; for this repo also +# version-switcher.mjs, fed in as a _test.yml artifact) to the tag's GitHub Release. +# Robust to how the release came to be, by branching on whether a Release already +# exists for the tag: +# +# - `git tag && git push` (no Release object yet) → CREATE it with the assets. +# `gh release create` does draft → upload → publish atomically, so the assets are +# in place before publish — this is the only path that is safe on a repo with +# IMMUTABLE RELEASES enabled. +# - a Release published from the UI on a NON-immutable repo → UPLOAD the assets to the +# existing (mutable) Release, preserving its notes / prerelease flag. +# +# Caller must grant `contents: write` and trigger it on the tag push. The whole step +# uses `gh`, so there is no third-party action to pin. +# +# LIMITATION — immutable repos + UI releases: GitHub does NOT run workflows for *draft* +# releases (community#7118), and a *published* immutable release rejects new assets +# ("Cannot upload assets to an immutable release"). So on an immutable repo you cannot +# have CI attach assets to a UI-made release — use the tag-push path above instead. +on: + workflow_call: + +jobs: + release: + runs-on: ubuntu-latest + steps: + # Every artifact produced earlier in the run, merged into dist/ (docs.zip, and + # for this repo version-switcher.mjs). + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Create or update the Release with its assets + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Flag a/b/rc tags as prereleases (mirrors the version-discovery marker). + prerelease="" + case "$TAG" in *a*|*b*|*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). + # Add the assets, leaving its notes + draft/published state untouched. + echo "Release $TAG exists — uploading assets" + gh release upload "$TAG" dist/* --clobber --repo "$REPO" + else + # No Release yet (tag push). Create it WITH the assets in one shot — + # gh release create is draft→upload→publish, so it is immutable-safe. + echo "Release $TAG does not exist — creating it with assets" + gh release create "$TAG" dist/* --generate-notes $prerelease --repo "$REPO" + fi diff --git a/CLAUDE.md b/CLAUDE.md index 834ab95..56307f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,10 @@ scripts/migrate.sh # operator gh-pages → durable-s test/ # npm test suite (node, no framework) docs/ # this repo's own docs (dogfoods the plugin) .github/workflows/docs.yml # PUBLIC reusable: build at the versioned BASE_URL → pack docs.zip → upload `docs` artifact (workflow_call; build-command input) -.github/workflows/publish.yml # PUBLIC reusable ENGINE: run assemble/ (checked out at job.workflow_sha) + deploy to Pages (PRIVILEGED). workflow_call ONLY; its sole caller is publish-dispatch.yml -.github/workflows/publish-dispatch.yml # PUBLIC wrapper around publish.yml: workflow_call (ci.yml's inline publish) + workflow_dispatch (tag trampoline + fork-PR opt-in + manual re-deploy). Dogfoods the wrapper consumers add -.github/workflows/ci.yml # this repo's own entry: _lint / _test / docs.yml / _release, then ONE publish job nesting publish-dispatch.yml for every event (the wrapper branches: deploy / tag-trampoline / fork-warn) +.github/workflows/publish.yml # PUBLIC reusable ENGINE (PRIVILEGED): branches on the event into 3 jobs — deploy (assemble@job.workflow_sha + Pages), tag-trampoline (re-dispatch the shim), fork-warn. workflow_call ONLY; reached via the publish-dispatch.yml shim +.github/workflows/publish-dispatch.yml # PUBLIC thin shim: workflow_call (ci.yml's publish, every event) + workflow_dispatch (trampoline re-dispatch + fork-PR opt-in + manual re-deploy) → forwards to publish.yml. The dispatchable file consumers copy (reusable workflows can't be dispatched cross-repo) +.github/workflows/release.yml # PUBLIC reusable: attach the run's build artifacts (docs.zip; + version-switcher.mjs via _test.yml) to the tag's GitHub Release via gh (create-or-upload, immutable-safe). Consumers `uses:` it directly +.github/workflows/ci.yml # this repo's own entry: _lint / _test / docs.yml / release, then ONE publish job nesting publish-dispatch.yml (shim) → publish.yml for every event (publish.yml branches: deploy / tag-trampoline / fork-warn) ``` ## Two halves, different lifecycles @@ -66,15 +67,14 @@ gathered (a merged/closed PR, a deleted release) is correctly dropped — no ### Split build (unprivileged) from publish (privileged), but nest publish for visibility `ci.yml` builds + uploads the `docs` artifact for every event including fork PRs. -It then nests a single privileged `publish` job (`uses: publish-dispatch.yml`) for -**every** event in the canonical repo. `publish-dispatch.yml` branches: an internal -PR / main push deploys inline (status **visible on the PR/commit**); a fork PR hits a -read-only **warn** job (untrusted fork-PR code never reaches a write token — the +It then nests a single privileged `publish` job (`uses: publish-dispatch.yml` shim → +`publish.yml`) for **every** event in the canonical repo. `publish.yml` branches: an +internal PR / main push deploys inline (status **visible on the PR/commit**); a fork PR +hits a read-only **warn** job (untrusted fork-PR code never reaches a write token — the `deploy` job is `if`-excluded for forks and the token is read-only anyway); a **tag** hits the **trampoline** (a tag shares the merge commit's SHA and Pages drops a second same-SHA deploy unless it's a `workflow_dispatch` — deploy-pages#383 — so it -re-dispatches `publish-dispatch.yml`). One `publish` job, no ref/fork guards in -`ci.yml`. +re-dispatches the shim). One `publish` job, no ref/fork guards in `ci.yml`. Because the inline publish runs **inside the build's own CI run**, the current build isn't a *completed* successful run the gather can discover (and for a main push the @@ -128,7 +128,7 @@ 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). ### `stable/` alias When a non-prerelease release is deployed, the site serves a `stable/` symlink @@ -143,33 +143,31 @@ One entry workflow (`ci.yml`) that nests the privileged publish: - `ci.yml` — **build + verify, then publish on internal events.** Triggers on `pull_request` + push to `main`/tags (no other-branch pushes; `*` excludes - `/`-tags). Orchestrates `_lint` / `_test` / `_docs` / `_release`, runs for forks, + `/`-tags). Orchestrates `_lint` / `_test` / `docs` / `release`, runs for forks, and uploads the `docs` artifact. It then nests a single `publish` job (`uses: publish-dispatch.yml`) for **every** event in the canonical repo (the `if` is just `repository == '…'`); grants `pages`/`id-token`/`statuses`/`actions:write` perms to the call and passes `version-name: needs.docs.outputs.version-name`. No - ref/fork guards and no separate tag job — `publish-dispatch.yml` does all the - branching. -- `publish-dispatch.yml` — **the one wrapper around the engine**, owning all publish - branching. `workflow_call` (ci.yml's `publish` job, every event) + `workflow_dispatch` - (the tag trampoline's re-dispatch + fork-PR opt-in + manual re-deploy). Three jobs by - originating event: **deploy** (internal PR / default-branch push inline, or any - dispatch → `uses: publish.yml`), **trampoline** (a tag — waits for `_release`, then - `gh workflow run publish-dispatch.yml` so the deploy is a `workflow_dispatch` that - re-serves; a same-SHA tag deploy is otherwise dropped by Pages — deploy-pages#383), - and **warn** (a fork PR — read-only, never deploys, posts the manual-opt-in hint; - moved here from `docs.yml`'s build job). This repo dogfoods the exact wrapper + ref/fork guards and no separate tag job — `publish.yml` does all the branching. +- `publish-dispatch.yml` — **the thin shim** (the dispatchable file each repo carries). + `workflow_call` (ci.yml's `publish` job, every event) + `workflow_dispatch` (the tag + trampoline's re-dispatch + fork-PR opt-in + manual re-deploy); both just `uses: + publish.yml`, threading `version-name`/`pr` + its own filename as `dispatch-workflow`. + It exists only because a reusable workflow can't be dispatched cross-repo, so the + trampoline needs a local file to re-dispatch. This repo dogfoods the exact shim consumers add (theirs pins `publish.yml@`; this one uses the local path). -- `publish.yml` — **assemble + deploy ENGINE, privileged.** `workflow_call` ONLY - (sole caller: `publish-dispatch.yml`). Sparse-checks-out this repo's `assemble/` at - `job.workflow_sha` (so the scripts match the ref the consumer pinned - `publish.yml@` to — see "Self-referencing the assemble scripts" below), runs - `assemble.sh`, then `upload-pages-artifact` + `deploy-pages`, carrying the - `github-pages` environment + `pages`/`id-token`/`statuses` perms + `concurrency`. - Its job has **no `if`** — the canonical-repo guard lives upstream in ci.yml's - `publish` job + the wrapper's dispatch (which needs write access). - On `workflow_call` with a non-empty `version-name` it injects the in-run build (see - the split design decision). See the architecture explanation in docs/. +- `publish.yml` — **assemble + deploy ENGINE, privileged; owns the event branching.** + `workflow_call` ONLY (reached via the shim). Three jobs by originating event: + **deploy** (internal PR / default-branch push inline, or any dispatch → sparse-checks + out this repo's `assemble/` at `job.workflow_sha` so the scripts match the pinned ref — + see "Self-referencing the assemble scripts" below — runs `assemble.sh`, then + `upload-pages-artifact` + `deploy-pages` + verify, carrying the `github-pages` + environment + perms + `concurrency`), **trampoline** (a tag — waits for the release, + then `gh workflow run ` in the consumer's repo with their token, so + the deploy lands as a `workflow_dispatch` that re-serves; a same-SHA tag deploy is + otherwise dropped — deploy-pages#383), and **warn** (a fork PR — read-only, posts the + opt-in hint). No canonical guard (it lives upstream in ci.yml). `version-name` injects + the in-run build on the inline path. See the architecture explanation in docs/. Sub-workflows of `ci.yml`: - `_lint.yml` — biome @@ -183,18 +181,23 @@ Sub-workflows of `ci.yml`: `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. -- `_release.yml` — attaches `version-switcher.mjs` + the tag's `docs.zip` (the - `docs` artifact, verbatim — no repack) as Release assets (tag-only). - -**Publish flow.** The single `publish` job (→ `publish-dispatch.yml`) runs for every -event and the wrapper branches. Internal PRs and `main` deploy inline as part of the +- `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 + `gh release upload --clobber` to an existing (UI-published, mutable) Release. No + third-party action. For this repo the artifacts are the tag's `docs.zip` (the `docs` + artifact, verbatim) + `version-switcher.mjs` (uploaded by `_test.yml` as an artifact, + so the generic workflow needs no plugin-specific step). Consumers `uses:` it directly. + +**Publish flow.** The single `publish` job (→ `publish-dispatch.yml` shim → `publish.yml`) +runs for every event and `publish.yml` branches. Internal PRs and `main` deploy inline as part of the same CI run once lint/test/docs pass (`publish.yml` injects this build + `assemble` gathers `main`, releases, and every other open PR → deploy), a visible check on the PR/commit. **Tags** hit the wrapper's `trampoline`: a release tag shares the merge commit's SHA, and GitHub Pages silently drops a second deploy of an already-deployed SHA *unless* the event is `workflow_dispatch` ([`actions/deploy-pages#383`](https://github.com/actions/deploy-pages/issues/383)) — -so it waits for `_release`, then `gh workflow run publish-dispatch.yml` +so it waits for the release, then `gh workflow run publish-dispatch.yml` (`workflow_dispatch`), which re-gathers from durable sources (incl. the new release) and deploys with the origin actually updating (the dispatched run carries the deploy status, not the tag's CI run). An **external fork PR** hits the wrapper's `warn` job @@ -226,9 +229,9 @@ forwarded port in a real browser and hard-reload (MyST caches the localized esm) git tag vX.Y.Z && git push origin vX.Y.Z ``` -CI runs lint + tests + docs build, `_release.yml` creates a GitHub Release with -`version-switcher.mjs` and the tag's `docs.zip` as assets, and `publish.yml` -(triggered by CI completing) reconstructs + deploys the site including the new tag. +CI runs lint + tests + docs build, then `release.yml` creates the GitHub Release with +`version-switcher.mjs` + the tag's `docs.zip` attached (via `gh`), and the tag +trampoline reconstructs + deploys the site including the new tag. The plugin URL and the `uses:` refs for `docs.yml`/`publish.yml` all resolve to the same tag. @@ -263,7 +266,7 @@ jobs: uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/docs.yml@ with: build-command: make docs # or: tox -e docs / npx … myst build / npm ci && npm run docs - publish: # every event — the wrapper branches + publish: # every event — publish.yml branches needs: [docs] if: github.repository == 'ORG/REPO' uses: ./.github/workflows/publish-dispatch.yml # your wrapper; it pins publish.yml@ @@ -272,10 +275,11 @@ jobs: permissions: { pages: write, id-token: write, contents: read, actions: write, statuses: write } ``` -Add a `_release.yml`-style job that attaches each tag's built `docs.zip` (bare -`html/` root) + the plugin as Release assets so `assemble` can reconstruct released -versions, and the `publish-dispatch.yml` wrapper around `publish.yml` — the single -caller of the engine and the one place you pin `publish.yml@`. The wrapper owns all +Add a `release` job that `uses:` this repo's `release.yml@` (with `permissions: +contents: write`) to attach each tag's built `docs.zip` (bare `html/` root) as a +Release asset so `assemble` can reconstruct released versions, and the +`publish-dispatch.yml` wrapper around `publish.yml` — the single caller of the engine +and the one place you pin `publish.yml@`. The wrapper owns all the branching: **deploy** (inline non-tag, or a dispatch), **trampoline** (a tag re-dispatches itself so the deploy is a `workflow_dispatch` — required for a release's same-SHA deploy to go live), and **warn** (a fork PR, read-only, posts the preview diff --git a/docs/explanations/architecture.md b/docs/explanations/architecture.md index e4af3ca..e947b05 100644 --- a/docs/explanations/architecture.md +++ b/docs/explanations/architecture.md @@ -107,21 +107,24 @@ 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-dispatch.yml` → `publish.yml` via `workflow_call`) so its status and URL are +`publish-dispatch.yml` shim → `publish.yml` via `workflow_call`) so its status and URL are visible on the PR / commit — rather than running invisibly after the fact. A single -`publish` job runs for **every** event; `publish-dispatch.yml` does the branching. - -`publish.yml` itself is a pure `workflow_call` **engine** with a single caller — -`publish-dispatch.yml`, the one wrapper that owns all the publish branching and is -reached by `workflow_call` (the inline `publish` job) and `workflow_dispatch` (the -trampoline's re-dispatch, the fork-PR opt-in, manual re-deploys). It routes each event -to one of three jobs: **deploy** (internal PR / default-branch push, or a dispatch), -**trampoline** (a tag — below), or **warn** (a fork PR — read-only, never deploys, just -posts the manual-opt-in hint; this replaced the old warning step in the build job). A -fork PR can't reach a write token (its `GITHUB_TOKEN` is read-only and `deploy` is -`if`-excluded for forks), so the security boundary holds while the hint now lives next -to the rest of the publish logic. This repo dogfoods the exact wrapper a consumer adds -(theirs just pins `publish.yml@`). +`publish` job runs for **every** event; `publish.yml` does the branching. + +`publish.yml` is the `workflow_call` **engine** and owns all the publish branching, +reached only through a thin `publish-dispatch.yml` shim that exposes it as both +`workflow_call` (the inline `publish` job) and `workflow_dispatch` (the trampoline's +re-dispatch, the fork-PR opt-in, manual re-deploys). It routes each event to one of three +jobs: **deploy** (internal PR / default-branch push, or a dispatch), **trampoline** (a tag +— below), or **warn** (a fork PR — read-only, never deploys, just posts the manual-opt-in +hint; this replaced the old warning step in the build job). A fork PR can't reach a write +token (its `GITHUB_TOKEN` is read-only and `deploy` is `if`-excluded for forks), so the +security boundary holds while the hint lives next to the rest of the publish logic. The +shim exists only because a reusable workflow can't be `workflow_dispatch`'d cross-repo, so +the trampoline needs a local file to re-dispatch — it `gh workflow run`s the shim by name +(`dispatch-workflow`), which works because a called workflow runs with the *caller's* repo +and token. This repo dogfoods the exact shim a consumer adds (theirs just pins +`publish.yml@`). The cost of nesting is an environment-policy change: because internal PRs now deploy from **their own ref**, the `github-pages` environment's deployment-branch policy must @@ -143,14 +146,15 @@ record active, but the origin keeps serving the *first* artifact. So an inline t deploy would "succeed" while the site stayed on the pre-tag build. The one documented escape hatch: a `workflow_dispatch` deploy of that same SHA -*forces* a re-serve. So tags don't deploy inline — the wrapper's `trampoline` job +*forces* a re-serve. So tags don't deploy inline — `publish.yml`'s `trampoline` job waits for `ci.yml`'s parallel `release` job to attach the new `docs.zip`, then -re-dispatches `publish-dispatch.yml` as a `workflow_dispatch`; that run re-gathers from -durable sources (now including the new -release) and deploys, and because its event is `workflow_dispatch` the origin updates. -`main` and internal-PR deploys are the *first* of their SHA, so they serve fine inline -and keep their PR/commit visibility. Consumers do the same by dispatching their own -`publish-dispatch.yml` wrapper (a reusable workflow can't be dispatched cross-repo). +`gh workflow run`s the shim as a `workflow_dispatch`; that run re-gathers from durable +sources (now including the new release) and deploys, and because its event is +`workflow_dispatch` the origin updates. `main` and internal-PR deploys are the *first* of +their SHA, so they serve fine inline and keep their PR/commit visibility. This all lives +in the shared engine — the consumer's only dispatch-related file is the thin shim (a +reusable workflow can't be dispatched cross-repo, so the trampoline needs a local file to +re-dispatch). The post-deploy origin-verify step in `publish.yml` backstops any residual stale origin by failing the run instead of serving stale docs silently. @@ -275,5 +279,6 @@ full analysis and an implementation sketch are tracked as future work in - **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 `assemble` only ever *reads* release assets. +- **`release.yml` attaches `docs.zip`** (it downloads the run's artifacts and + creates/uploads the Release via `gh`, verbatim), so `assemble` only ever *reads* + release assets. diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 0b82d78..887783f 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -36,47 +36,48 @@ 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`. | -## `publish.yml` — assemble + deploy engine (privileged) +## `publish.yml` — the engine, owns the branching (privileged) -Reconstructs the whole site and deploys it to Pages. Carries the `github-pages` -environment, `concurrency: pages`, and `pages`/`id-token`/`statuses` write permissions. -It's a pure **`workflow_call` engine** with a single caller: the `publish-dispatch.yml` -wrapper (below). The canonical-repo guard lives upstream (in `ci.yml` / the wrapper's -dispatch), so the engine stays generic. - -It self-checks-out this repo's `assemble/` scripts at `job.workflow_sha` / -`job.workflow_repository` (the `job` context resolves to the reusable file, not the -caller), so the scripts match the `` the consumer pinned — automatically, with no -version bump. The consumer's repo stays checked out at the root so `assemble.mjs`'s -`git tag` lists *their* versions. - -## `publish-dispatch.yml` — the wrapper (one per repo) - -The only thing that calls `publish.yml`, the single place you pin `publish.yml@`, -and the owner of **all** publish branching — so `ci.yml` needs just one `publish` job -(called for every event) and `docs.yml` needs no fork hint. Reached via `workflow_call` -(ci.yml's `publish`) and `workflow_dispatch` (the trampoline's re-dispatch, a fork-PR -preview, a manual re-deploy), it routes each event to one of three jobs: +Reconstructs the whole site and deploys it to Pages, routing each event to one of three +jobs. The `deploy` job carries the `github-pages` environment, `concurrency: pages`, and +`pages`/`id-token`/`statuses` write. Reached only via the `publish-dispatch.yml` shim +(below); the canonical-repo guard lives upstream (in `ci.yml`), so the engine stays +generic. - **`deploy`** — internal PR / default-branch push (inline), or any `workflow_dispatch`. - Nests `publish.yml`; the inline path injects the in-run build via `version-name`. -- **`trampoline`** — a tag push. Waits for the release, then re-dispatches this workflow - as `workflow_dispatch` so the deploy re-serves (see below). + Self-checks-out this repo's `assemble/` at `job.workflow_sha` / `job.workflow_repository` + (the `job` context resolves to the reusable file, not the caller), so the scripts match + the `` the consumer pinned — no version bump; the consumer's repo stays checked out + at the root so `assemble.mjs`'s `git tag` lists *their* versions. The inline path injects + the in-run build via `version-name`; the dispatch paths pure-gather. +- **`trampoline`** — a tag push. Waits for the release, then re-dispatches the shim + (`dispatch-workflow`, a file in the consumer's repo — a `workflow_call` run executes with + the caller's token, so it can dispatch it) so the deploy lands as a `workflow_dispatch` + and re-serves. - **`warn`** — a fork PR. Read-only, never deploys; just posts the manual-opt-in hint. -**Tags must deploy via `workflow_dispatch`**: GitHub Pages silently drops a second -deploy of an already-deployed SHA (a release tag shares the merge commit's SHA) unless -the event is `workflow_dispatch` -([`actions/deploy-pages#383`](https://github.com/actions/deploy-pages/issues/383); see -the [architecture explanation](../explanations/architecture.md)). A reusable workflow -can't be `workflow_dispatch`'d cross-repo, which is why the wrapper lives in each repo. +**Tags must deploy via `workflow_dispatch`**: GitHub Pages silently drops a second deploy +of an already-deployed SHA (a release tag shares the merge commit's SHA) unless the event +is `workflow_dispatch` +([`actions/deploy-pages#383`](https://github.com/actions/deploy-pages/issues/383); see the +[architecture explanation](../explanations/architecture.md)). + +## `publish-dispatch.yml` — the thin shim (one per repo) + +The only thing that calls `publish.yml`, the single place you pin `publish.yml@`, and +the **dispatchable file** the trampoline re-dispatches. It just forwards to the engine via +`workflow_call` (ci.yml's `publish`, every event) and `workflow_dispatch` (the trampoline's +re-dispatch, a fork-PR preview, a manual re-deploy). A reusable workflow can't be +`workflow_dispatch`'d cross-repo, which is why this file lives in each repo. -### `publish.yml` inputs (threaded through by the wrapper) +### `publish.yml` inputs (threaded through by the shim) | input | required | default | meaning | |---|---|---|---| | `version-name` | no | `""` | Version name of **this run's** `docs` artifact to stage directly, instead of gathering it from durable sources. Set by `ci.yml`'s inline publish (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 (the dispatch paths). | | `guard-default-branch` | no | `true` | When `true`, hard-fail if the consumer's default branch is absent from the site (its build artifact expired). Set `false` while a repo's default branch isn't yet publishing `docs.zip` (mid-migration). | +| `pr` | no | `""` | Fork PR number to approve (pins its head SHA as `preview-approved`) and preview. Set on the shim's `workflow_dispatch` path. | +| `dispatch-workflow` | no | `publish-dispatch.yml` | Filename of the shim in the consumer's repo, re-dispatched by the `trampoline` job. Override only if you renamed the shim. | ## What `publish.yml` gathers diff --git a/docs/tutorials/adding-to-a-fresh-repo.md b/docs/tutorials/adding-to-a-fresh-repo.md index 3228434..3e6503f 100644 --- a/docs/tutorials/adding-to-a-fresh-repo.md +++ b/docs/tutorials/adding-to-a-fresh-repo.md @@ -39,88 +39,79 @@ for all options): The `json-url` points at a `switcher.json` that does not exist yet — `publish.yml` will generate it on your first deploy. -## 2. Set the Pages source to "GitHub Actions" +## 2. Set the Pages source to "GitHub Actions" and allow deploys from any ref In **Settings → Pages**, set **Source** to **GitHub Actions** (not "Deploy from a branch"). `deploy-pages` refuses to publish otherwise. -## 3. Add the `ci.yml` (build · release · publish) +Because internal PRs deploy from their own ref, 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**. -This is the one workflow file you add. You don't copy the build/publish logic into your -repo — you **call** this project's two reusable workflows by full path, pinned to -``. Three jobs: +## 3. Add the workflow files + +Your project will use this project's two reusable workflows to build a single +version of the docs, then publish all available versions of the docs into a +single GitHub Pages site. + +It is recommended that you use the structure below. + +### `ci.yml` + +This is the entry point, it defines three jobs: - **`docs`** — `docs.yml` builds your site at the versioned `BASE_URL` and uploads the `docs` artifact, for every event (fork PRs included). - **`release`** — a small tag-only job that attaches the built `docs.zip` to the GitHub - Release. **Required:** that asset is a tag's *only* durable source (tags get no - `_sources/` copy, unlike the default branch), so without it a release drops on the - next deploy. -- **`publish`** — calls your `publish-dispatch.yml` wrapper (below) for **every** event; - the wrapper decides what to do (deploy a PR/`main` preview, trampoline a tag, or just - warn on a fork PR), so this is one small job with no ref/fork guards. Its status shows - on the PR/commit. Fork PRs build + verify but never reach a write token. + Release. +- **`publish`** — calls your `publish-dispatch.yml` wrapper which publishes the complete + built site (with all versions) to GitHub Pages. ```yaml # .github/workflows/ci.yml name: CI on: pull_request: - push: { branches: [main], tags: ['*'] } # '*' never matches '/' + push: + branches: [main] + tags: ['*'] jobs: - docs: + docs: # Call the docs building workflow directly uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/docs.yml@ with: # Whatever turns your sources into docs/_build/html at $BASE_URL. # uv and Node are preinstalled, so this can be make / tox / npx / npm. build-command: myst build --html # e.g. make docs · tox -e docs · npm ci && npm run docs - release: + release: needs: [docs] if: github.ref_type == 'tag' # tag pushes only - runs-on: ubuntu-latest + uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/release.yml@ permissions: contents: write # create the Release + attach assets - steps: - # ↓ paste the two steps from the literalinclude below ↓ - publish: # every event — the wrapper branches + publish: needs: [docs] - if: github.repository == 'ORG/REPO' - uses: ./.github/workflows/publish-dispatch.yml # your wrapper (below); it pins publish.yml@ + if: github.repository == 'ORG/REPO' # don't publish a pages site in the fork's org + uses: ./.github/workflows/publish-dispatch.yml # your workflow (below) with: version-name: ${{ needs.docs.outputs.version-name }} permissions: contents: read - actions: write # the wrapper's tag trampoline dispatches via gh workflow run + actions: write pages: write id-token: write statuses: write ``` -The `release` job's `steps:` are exactly what this repo's own `_release.yml` runs (minus -its plugin-specific `version-switcher.mjs` handling) — drop these in under `steps:` -above (`download-artifact` pulls the `docs` artifact; `action-gh-release`'s `files: "*"` -attaches everything in the working directory, here just that `docs.zip`): - -```{literalinclude} ../../.github/workflows/_release.yml -:language: yaml -:start-after: docs-literalinclude:release-steps:start -:end-before: docs-literalinclude:release-steps:end -``` - -That's the whole integration. `build-command` is the only thing most repos customise: -`docs.yml` owns the versioning contract (the `BASE_URL`, the `docs.zip` bare-`html/` -root, the `docs` artifact name), and `publish.yml` runs the site reconstruction -internally — it checks out this project's `assemble` scripts at the same `` via -`job.workflow_sha`, so there is no action or script for you to wire. +### `publish-dispatch.yml` -### The `publish-dispatch.yml` wrapper (required) - -`publish.yml` is a pure `workflow_call` engine, and this wrapper in your repo is the -**only** thing that calls it — also the single place you pin the engine's `@`. It -owns all the branching, so your `ci.yml` `publish` job stays a one-liner. Copy it +`publish.yml` is the engine and owns all the branching; this is a thin **shim** — the +only thing that calls it, and the one place you pin `@`. It has to exist as a file +in your repo (not just be `uses:`'d) because the tag trampoline re-dispatches it as a +`workflow_dispatch`, and a reusable workflow can't be dispatched cross-repo. Copy it verbatim, changing only ``: ```yaml @@ -129,73 +120,30 @@ name: Publish (dispatch) on: workflow_call: # ci.yml's `publish` job, for every event inputs: - version-name: { required: false, default: "", type: string } - guard-default-branch: { required: false, default: "true", type: string } - pr: { required: false, default: "", type: string } + version-name: { required: false, default: "", type: string } workflow_dispatch: # tag trampoline's re-dispatch + fork-PR preview + manual re-deploy inputs: - pr: { description: "Fork PR number to approve + preview (leave empty to just re-deploy)", required: false, default: "" } + pr: { description: "Fork PR to approve + preview (empty = re-deploy)", required: false, default: "" } jobs: - # Internal PR / default-branch push (inline), 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, actions: read, pages: write, id-token: write, statuses: write } + publish: uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/publish.yml@ with: - version-name: ${{ inputs.version-name }} # "" on dispatch → pure durable gather - guard-default-branch: ${{ inputs.guard-default-branch || 'true' }} - pr: ${{ inputs.pr }} # set → pin that fork head SHA - - # A tag can't deploy inline (same SHA as the default-branch push → Pages drops it - # unless the event is workflow_dispatch). Re-dispatch this workflow after the release. - trampoline: - if: github.event_name == 'push' && github.ref_type == 'tag' - runs-on: ubuntu-latest - permissions: { contents: read, actions: write } - steps: - - env: { GH_TOKEN: "${{ github.token }}", REPO: "${{ github.repository }}", TAG: "${{ github.ref_name }}", DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}" } - run: | - set -euo pipefail - 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"; exit 1; } - gh workflow run publish-dispatch.yml --repo "$REPO" --ref "$DEFAULT_BRANCH" - - # Fork PR: read-only token, never deploys — just surface the manual-opt-in hint. - warn: - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name != github.repository - runs-on: ubuntu-latest - permissions: {} - steps: - - run: | - echo "::warning title=Docs preview not published::Fork PR — not auto-published. A maintainer \ - can preview it by running publish-dispatch.yml with pr=${{ github.event.pull_request.number }}: \ - https://github.com/${{ github.repository }}/actions/workflows/publish-dispatch.yml" + version-name: ${{ inputs.version-name }} # "" on dispatch → pure durable gather + pr: ${{ inputs.pr }} # set (dispatch) → pin that fork head SHA + dispatch-workflow: publish-dispatch.yml # the file the tag trampoline re-dispatches + permissions: { contents: read, actions: write, pages: write, id-token: write, statuses: write } ``` -The three branches: **deploy** (internal PR / `main` push inline, or any dispatch), -**trampoline** (a tag re-dispatches itself so the deploy runs as a `workflow_dispatch` -— [why](../explanations/architecture.md)), and **warn** (a fork PR, read-only, never -deploys). A maintainer publishes a fork preview by running this workflow from the -Actions tab with the `pr` number. - -## 4. Allow the deploying refs in the `github-pages` environment - -Because internal PRs deploy from **their own ref** (the publish job runs inside their -CI run; tags deploy from the default-branch ref via the trampoline), 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**. +`publish.yml` then routes each event: **deploy** (internal PR / `main` push, or any +dispatch), **trampoline** (a tag → re-dispatches this shim so the deploy runs as a +`workflow_dispatch` — [why](../explanations/architecture.md)), or **warn** (a fork PR, +read-only, never deploys). A maintainer publishes a fork preview by running this workflow +from the Actions tab with the `pr` number. -## 5. Push your branch and open a PR +## 4. Push your branch and open a PR -Steps 2 and 4 are repo settings (done once in the UI); steps 1 and 3 are file changes — -commit them on a branch and open a PR. On the PR you'll see: +Make the changes from steps 1 and 3 in a branch and open a PR. On the PR you'll +see: - **`docs / build`** go green — it builds your docs at the versioned `BASE_URL` and uploads the `docs` artifact. This runs for every PR, forks included. @@ -210,7 +158,7 @@ commit them on a branch and open a PR. On the PR you'll see: > default-branch guard refuses to publish a site missing it. It clears the moment you > merge (step 6). Every PR after that previews normally. -## 6. Merge to main — your first deploy +## 5. Merge to main — your first deploy Merging pushes to `main`, which builds `main` and runs `publish`: it assembles a single-entry `switcher.json` and an `index.html` redirecting to `main/`, and deploys. @@ -219,15 +167,26 @@ switcher showing one entry. (The single-entry first deploy is graceful by design release required.) From here on, every push to `main` redeploys and every internal PR gets its own `/pr-/` preview. -## 7. Cut your first release +## 6. Cut your first release + +Push a tag: ```bash git tag v1.0.0 && git push origin v1.0.0 ``` -The tag build runs, the **`release`** job attaches its `docs.zip` to the GitHub Release, -and the next deploy's `assemble` gathers that release, flags it `preferred` (★), creates -the `stable/` alias pointing at it, and points the root redirect at the constant +The tag build runs and the **`release`** job creates the GitHub Release with that +build's `docs.zip` attached. This works on any repo, including ones with [immutable +releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) +enabled (it attaches the asset as the release is created, before it's sealed). + +> On a repo **without** immutable releases you can instead **publish a release from the +> GitHub UI** — that also creates the tag, and the `release` job attaches `docs.zip` to +> the release you published. (This doesn't work under immutable releases: a published +> immutable release can't receive assets after the fact, so use the tag push above.) + +Either way, the next deploy's `publish` gathers that release, flags it `preferred` (★), +creates the `stable/` alias pointing at it, and points the root redirect at 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.