From d349e9665cca16f013d314bb7fdb842ab57d9090 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:58:39 +0100 Subject: [PATCH 1/4] fix: give merge_group builds a valid version name in docs.yml merge_group's ref_name is GitHub's internal gh-readonly-queue//pr--, which contains '/' and fails the URL-path-segment check, hard-failing docs.yml for repos using a merge queue. These builds are never gathered by publish.yml's engine, so a fixed "merge-queue" name is enough. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0183zj2nfvaMK3Yo14Wwkych --- .github/workflows/docs.yml | 9 +++++++++ test/workflow-harness/test_gather.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c6fb0d6..ebf7bcd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -103,6 +103,15 @@ jobs: set -euo pipefail if [ "$EVENT" = pull_request ]; then name="pr-$PR_NUMBER" + elif [ "$EVENT" = merge_group ]; then + # merge_group's ref_name is GitHub's internal + # gh-readonly-queue//pr-- — it contains '/' (and, via + # the base branch, whatever else a branch name allows) so it fails the check + # below outright. This build is never gathered: the site-side gather only + # reads the default branch's HEAD artifact, open PRs' artifacts and release + # zips, never a merge queue's temporary branch, so the name just needs to be + # a valid path segment, not a parse of GitHub's ref. + name="merge-queue" else name="$REF_NAME" fi diff --git a/test/workflow-harness/test_gather.py b/test/workflow-harness/test_gather.py index e98c9db..9173226 100644 --- a/test/workflow-harness/test_gather.py +++ b/test/workflow-harness/test_gather.py @@ -362,6 +362,17 @@ def docs_yml_version_name(tmp, event, **kw): check(f"an unusable version name is rejected ({bad!r})", r.returncode != 0 and "not usable as a URL path segment" in log(r), log(r)) +with tempfile.TemporaryDirectory() as tmp: + # merge_group's ref_name is GitHub's own gh-readonly-queue//pr-- — + # it contains '/' and would otherwise fail the same check as "feature/x" above. + # This build is never gathered (the gather never reads a merge queue's temporary + # branch), so a fixed, valid name is enough — no need to parse GitHub's ref. + built = docs_yml_version_name( + tmp, "merge_group", + ref_name="gh-readonly-queue/main/pr-1645-579cb18403ab661103b5e1093396e45b2e91939f") + check(f"a merge_group build gets a fixed, valid name ({built!r})", + built == "merge-queue", built) + print("\n-- linking the published docs back to the triggering commit --") def link(tmp, *, dirs=(), built_branch="main", **exprs): env, rt, data = setup(tmp, [], [], []) From e0300a746aa9687f7480d841a842f47a4a64bd48 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:14:05 +0100 Subject: [PATCH 2/4] fix: skip merge_group builds and exclude them from publish's guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs.yml now skips uploading a merge_group build's docs artifact — it's never gathered (the version name is the fixed "merge-queue" literal), so uploading it was pure storage churn. publish.yml's guard gains a third term, event != 'merge_group', since the other two guards both still pass for a merge-queue run and would otherwise fire a full, no-op site reassemble-and-redeploy on every queue entry. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BzmGP9Z1oWtHKVLbS8YD8C --- .github/workflows/docs.yml | 7 +++++++ .github/workflows/publish.yml | 15 +++++++++++---- CLAUDE.md | 22 ++++++++++++---------- docs/explanations/architecture.md | 10 ++++++++++ docs/how-to/choose-publish-events.md | 7 ++++++- docs/reference/workflows.md | 3 ++- docs/tutorials/adding-to-a-fresh-repo.md | 15 ++++++++++----- test/workflow-harness/test_shape.py | 2 ++ 8 files changed, 60 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ebf7bcd..85bd1e4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -173,7 +173,14 @@ jobs: ( cd "$staging" && zip -rq "$RUNNER_TEMP/docs.zip" html ) # compression-level 0: docs.zip is already compressed. + # + # Skipped on merge_group: that build's version name is the fixed literal + # "merge-queue" (see the step above), and the site-side gather never looks for + # it — only the default branch's HEAD artifact, open PRs' artifacts and release + # zips are gathered. Uploading it anyway would be pure storage churn against the + # repo's Actions quota, once per queue entry. - name: Upload docs artifact + if: github.event_name != 'merge_group' uses: actions/upload-artifact@v7 with: name: docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ead0747..00a8f0e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,7 +37,7 @@ on: jobs: publish: - # Two guards, both load-bearing: + # Three guards, all load-bearing: # # conclusion == 'success' — a failed CI run has no usable docs artifact, and # workflow_run fires on failures too. @@ -49,12 +49,19 @@ jobs: # dispatches this workflow with `pr`, which records an approval against that exact # head SHA — so a later push to the PR drops the preview until re-approved. # - # A manual dispatch has no `workflow_run` payload, so both terms are empty and the - # first clause carries it. Removing either guard fails OPEN. + # event != 'merge_group' — a merge-queue CI run's docs.yml build is never uploaded + # (its version name is the fixed "merge-queue" literal, and the gather never looks + # for it), so gathering here would only reassemble and redeploy a site that changes + # nothing — once per queue entry, on top of the deploy the subsequent push to the + # base branch already triggers. + # + # A manual dispatch has no `workflow_run` payload, so all three terms are empty and the + # first clause carries it. Removing any of the three guards fails OPEN. if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.event != 'merge_group') uses: ./.github/workflows/publish-gh-pages.yml with: # Set (dispatch only) → approve and preview that fork PR's head SHA. diff --git a/CLAUDE.md b/CLAUDE.md index 26f6947..a1491e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,9 +205,9 @@ Both cache-save steps ask this question — gated on `github.ref` they would fir deploy, including one dispatched from a non-default ref, whose entry only that ref can read. -`test/workflow-harness/test_shape.py` asserts this, plus the caller's two guards -(`conclusion == 'success'`, `head_repository == this repo`). All three **fail OPEN**, and -they live in `if:` expressions the gather harness can't reach. +`test/workflow-harness/test_shape.py` asserts this, plus the caller's three guards +(`conclusion == 'success'`, `head_repository == this repo`, `event != 'merge_group'`). +All four **fail OPEN**, and they live in `if:` expressions the gather harness can't reach. **The fork guard is not optional:** `workflow_run` runs with a WRITE token even when a fork's PR triggered it (pwn-request). Fork builds reach the site only via a maintainer @@ -281,9 +281,10 @@ Two top-level workflows: `ci.yml` builds, `publish.yml` listens for it finishing publishing. - `publish.yml` — **the file each consumer carries**, and the one place `` is pinned. `workflow_run` (CI completing) + `workflow_dispatch` (fork-PR preview via `pr`, - manual re-deploy) → `publish-gh-pages.yml`. Its job `if:` carries the two guards that - fail open (`conclusion == 'success'`, `head_repository == this repo`) and the - `max-releases`/`max-prs` literals. This repo's copy is identical to a consumer's but + manual re-deploy) → `publish-gh-pages.yml`. Its job `if:` carries the three guards that + fail open (`conclusion == 'success'`, `head_repository == this repo`, + `event != 'merge_group'`) and the `max-releases`/`max-prs` literals. This repo's copy + is identical to a consumer's but for the `uses:` path (local vs pinned) — same `30`/`20` caps, so the file people copy is the file that is actually exercised. - `publish-gh-pages.yml` — **assemble + deploy ENGINE, privileged.** `workflow_call` @@ -419,16 +420,17 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.event != 'merge_group') uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/publish-gh-pages.yml@ with: { pr: "${{ inputs.pr }}", max-releases: "30", max-prs: "20" } permissions: { pages: write, id-token: write, contents: read, actions: read, statuses: write } ``` `ci.yml` has **no** publish job. Copy `publish.yml` verbatim from the -[tutorial](docs/tutorials/adding-to-a-fresh-repo.md) rather than hand-rolling it — both -`if:` guards fail OPEN, and `workflows: [CI]` matches the entry workflow by NAME, so -renaming it silently stops publishing. +[tutorial](docs/tutorials/adding-to-a-fresh-repo.md) rather than hand-rolling it — all +three `if:` guards fail OPEN, and `workflows: [CI]` matches the entry workflow by NAME, +so renaming it silently stops publishing. ## Upstreaming diff --git a/docs/explanations/architecture.md b/docs/explanations/architecture.md index f06ea04..2a14c4b 100644 --- a/docs/explanations/architecture.md +++ b/docs/explanations/architecture.md @@ -212,6 +212,16 @@ never reaches the engine automatically. A maintainer publishes a preview by disp `publish.yml` with the PR number, which records approval against that exact head SHA; a later push to the PR drops the preview until re-approved. +### merge_group runs are excluded too + +A merge-queue CI run gets a valid but fixed version name (`merge-queue`, see `docs.yml`) +so the build itself does not hard-fail — but that build's artifact is deliberately never +uploaded, and the gather never looks for it. Without a third guard term, +`github.event.workflow_run.event != 'merge_group'`, the caller's other two guards both +still pass for such a run, so `publish.yml` would fire the engine anyway: a full +reassemble-and-deploy that changes nothing, once per queue entry, on top of the deploy the +subsequent push to the base branch already triggers. + ## The inline-bash / JS split inside `assemble` The `assemble` logic is split between `publish-gh-pages.yml` inline steps and diff --git a/docs/how-to/choose-publish-events.md b/docs/how-to/choose-publish-events.md index c8d16c5..bdbe252 100644 --- a/docs/how-to/choose-publish-events.md +++ b/docs/how-to/choose-publish-events.md @@ -38,6 +38,7 @@ jobs: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.event != 'merge_group' && github.event.workflow_run.event != 'pull_request') ``` @@ -54,7 +55,7 @@ release's deploy was the only chance that release ever had to reach the site. ## What you must not remove -The two guards in the shipped `if:` are not style: +The three guards in the shipped `if:` are not style: - **`conclusion == 'success'`** — `workflow_run` fires on failed runs too, and a failed run has no usable `docs` artifact. @@ -63,6 +64,10 @@ The two guards in the shipped `if:` are not style: untrusted code a privileged path to your live site. Fork builds reach the site only when a maintainer dispatches `publish.yml` with a `pr` number, which records approval against that exact head SHA. +- **`event != 'merge_group'`** — a merge-queue build's `docs` artifact is never uploaded + and never gathered (see `docs.yml`'s version-name step), so without this term every + queue entry triggers a full site reassemble-and-redeploy that changes nothing, on top + of the deploy the subsequent push to the base branch already causes. Both fail *open* if removed — the site keeps deploying and nothing looks wrong — so `test/workflow-harness/test_shape.py` asserts them in this repo. Consider the same if you diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 9bda6ab..ef2949f 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -22,7 +22,8 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.event != 'merge_group') uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/publish-gh-pages.yml@ with: pr: ${{ inputs.pr }} diff --git a/docs/tutorials/adding-to-a-fresh-repo.md b/docs/tutorials/adding-to-a-fresh-repo.md index 023ddfd..d7e8f73 100644 --- a/docs/tutorials/adding-to-a-fresh-repo.md +++ b/docs/tutorials/adding-to-a-fresh-repo.md @@ -124,14 +124,19 @@ on: jobs: publish: - # Both guards matter. `conclusion` because workflow_run fires on failed runs too, and - # a failed run has no usable docs artifact. `head_repository` because workflow_run - # gets a WRITE token even when a fork's PR triggered it — without this, untrusted - # code reaches your live site. Both fail OPEN if removed. + # All three guards matter. `conclusion` because workflow_run fires on failed runs + # too, and a failed run has no usable docs artifact. `head_repository` because + # workflow_run gets a WRITE token even when a fork's PR triggered it — without this, + # untrusted code reaches your live site. `event != 'merge_group'` because a + # merge-queue build is never gathered (it has no artifact to gather), so without + # this term every queue entry triggers a full, no-op site redeploy on top of the one + # the subsequent push to the base branch already causes. All three fail OPEN if + # removed. if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository) + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.event != 'merge_group') uses: DiamondLightSource/myst-version-switcher-plugin/.github/workflows/publish-gh-pages.yml@__LATEST_TAG__ with: pr: ${{ inputs.pr }} diff --git a/test/workflow-harness/test_shape.py b/test/workflow-harness/test_shape.py index dad2a99..27f6460 100644 --- a/test/workflow-harness/test_shape.py +++ b/test/workflow-harness/test_shape.py @@ -97,6 +97,8 @@ def load(name): check("requires the triggering run to have succeeded", "conclusion == 'success'" in guard, guard) check("excludes forks", "head_repository.full_name == github.repository" in guard, guard) +check("excludes merge_group runs (never gathered, so a no-op redeploy)", + "workflow_run.event != 'merge_group'" in guard, guard) check("still allows a manual dispatch", "workflow_dispatch" in guard, guard) # Site-size policy has to hold on EVERY path into the engine, and this workflow is its From 10a56c86d79d71bd224898acf4c050289ee9e6d8 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:17:56 +0100 Subject: [PATCH 3/4] docs: fix stale two-guard references missed in the merge_group guard update code-review caught two leftover mentions of "two guards" after the previous commit added a third (event != 'merge_group'). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BzmGP9Z1oWtHKVLbS8YD8C --- docs/explanations/architecture.md | 2 +- docs/how-to/choose-publish-events.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/explanations/architecture.md b/docs/explanations/architecture.md index 2a14c4b..eb58fc3 100644 --- a/docs/explanations/architecture.md +++ b/docs/explanations/architecture.md @@ -201,7 +201,7 @@ one dispatched from another ref, whose entry only that ref could ever read. They Because that lives in an `if:` expression rather than a shell script, the gather harness cannot reach it; `test/workflow-harness/test_shape.py` asserts it structurally, along with -the caller's two guards — all of which fail *open*, and so would never announce themselves. +the caller's three guards — all of which fail *open*, and so would never announce themselves. ### Fork PRs still cannot deploy themselves diff --git a/docs/how-to/choose-publish-events.md b/docs/how-to/choose-publish-events.md index bdbe252..b89f3ab 100644 --- a/docs/how-to/choose-publish-events.md +++ b/docs/how-to/choose-publish-events.md @@ -69,7 +69,7 @@ The three guards in the shipped `if:` are not style: queue entry triggers a full site reassemble-and-redeploy that changes nothing, on top of the deploy the subsequent push to the base branch already causes. -Both fail *open* if removed — the site keeps deploying and nothing looks wrong — so +All three fail *open* if removed — the site keeps deploying and nothing looks wrong — so `test/workflow-harness/test_shape.py` asserts them in this repo. Consider the same if you edit your copy. From bbc8895fc9b052b94b7599e9605bac052b921bad Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:59:50 +0100 Subject: [PATCH 4/4] test: assert docs.yml skips uploading the docs artifact on merge_group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review flagged that this step-level if: had no coverage anywhere in the harness — test_shape.py only asserted publish.yml's job-level guard, so a future refactor could silently drop or invert it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011i7JVVjbNJ9ptXJaetbFH5 --- test/workflow-harness/test_shape.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/workflow-harness/test_shape.py b/test/workflow-harness/test_shape.py index 27f6460..b6ce8ae 100644 --- a/test/workflow-harness/test_shape.py +++ b/test/workflow-harness/test_shape.py @@ -154,5 +154,18 @@ def load(name): check("builds without publishing", "publish" not in ci["jobs"], list(ci["jobs"])) check("uploads the docs artifact the engine gathers", "docs" in ci["jobs"], list(ci["jobs"])) +print("\n-- reusable: docs.yml --") +docs_wf = load("docs.yml") +docs_steps = docs_wf["jobs"]["build"]["steps"] +upload = next((s for s in docs_steps if s.get("name") == "Upload docs artifact"), None) +check("has an 'Upload docs artifact' step", upload is not None, [s.get("name") for s in docs_steps]) +if upload: + # merge_group's version name is a fixed literal shared by every PR in the batch, so + # publishing it would overwrite an unrelated version's site — see the code comment + # above this step for why the build still runs but the upload is skipped. + check("skips uploading on merge_group", + "github.event_name != 'merge_group'" in str(upload.get("if", "")), + upload.get("if")) + print("\n" + ("FAILURES: " + ", ".join(fails) if fails else "all shape checks passed")) sys.exit(1 if fails else 0)