From 62329583abdce1f8cb118325862aea8b4b3035c4 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Fri, 21 Aug 2026 09:48:13 +0000 Subject: [PATCH] test: probe whether a workflow_run Pages deploy re-serves at a repeat SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing currently nests in ci.yml, so its ~650s (on blueapi) sits inside every PR's CI run. Moving it to `workflow_run` would take it off that path and delete a lot of machinery — the version-name injection, the dispatch shim, the warn job, and the tag asset race. Whether that is possible turns on one unknown. `workflow_run` sets GITHUB_SHA to the default branch's HEAD rather than the commit that was built, and deploy-pages stamps pages_build_version = GITHUB_SHA, which actions/deploy-pages#383 reports as silently no-op'd when it repeats. That *suggests* at most one serving deploy per merge. But we hold a counterexample: a repeat workflow_dispatch at an already-deployed SHA does re-serve — publish.yml's re-dispatch trampoline depends on it and works in production. So event type matters beyond pages_build_version and the rule cannot be assumed to generalise. The Pages deployment record is created whether or not the origin moves, so the API cannot answer this. Only a real deploy against the real origin can. - _probe-workflow-run.yml: log the workflow_run context (is github.sha really the default-branch HEAD?), then deploy through the real engine so the assembled site stays correct and publish.yml's Verify step reports the answer in-run. Guarded on a successful, non-fork triggering run — workflow_run hands a write token to a job triggered by fork-PR code, which is the footgun the warn job exists to avoid. - publish.yml: accept workflow_run in the deploy job's guard. No other change; version-name "" is an existing mode, and the triggering run has completed, so its docs artifact is discoverable by the normal gather with no injection. - ci.yml: disable the inline publish so workflow_run is the only deploy path. Tags included, deliberately — a re-dispatch deploy is a workflow_dispatch, which we already know re-serves, and that is the confound the experiment must avoid. Temporary. All three changes revert together once the question is answered. Co-Authored-By: Claude Opus 5 --- .github/workflows/_probe-workflow-run.yml | 100 ++++++++++++++++++++++ .github/workflows/ci.yml | 9 +- .github/workflows/publish.yml | 4 + 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/_probe-workflow-run.yml diff --git a/.github/workflows/_probe-workflow-run.yml b/.github/workflows/_probe-workflow-run.yml new file mode 100644 index 0000000..a707323 --- /dev/null +++ b/.github/workflows/_probe-workflow-run.yml @@ -0,0 +1,100 @@ +name: Probe (workflow_run) + +# TEMPORARY EXPERIMENT — delete once the question below is answered. +# +# QUESTION: does a Pages deploy triggered by `workflow_run` update the served ORIGIN +# when that SHA has already been deployed? +# +# Why we can't reason it out. `workflow_run` sets GITHUB_SHA to the DEFAULT BRANCH's HEAD, +# not the triggering commit (GitHub's events table; confirmed on scikit-learn, whose +# consecutive workflow_run runs from different PRs all carry the same head_sha). And +# actions/deploy-pages stamps pages_build_version = GITHUB_SHA, which per +# actions/deploy-pages#383 is silently no-op'd when it repeats. Together that *suggests* +# at most one serving deploy per merge to the default branch. +# +# But that inference is unsafe: a repeat `workflow_dispatch` at an already-deployed SHA +# demonstrably DOES re-serve — the whole re-dispatch trampoline in publish.yml depends on +# it and it works in production. So event type matters beyond pages_build_version, in a +# way #383 doesn't explain, and workflow_run might behave like workflow_dispatch. The +# Pages deployment RECORD is created either way (so the API can't tell us); only the +# origin can. Hence a real deploy against the real site. +# +# THE OBSERVATIONS (see the plan; read the answer from switcher.json at the origin): +# 1. push to main → first deploy of that SHA; should serve. Baseline, and it +# proves the gather works with no `version-name` injection. +# 2. tag v0.0.0a1 on that same main commit → SECOND deploy of the SAME SHA, but now +# assembling a site that CONTAINS v0.0.0a1. Different bytes, +# same SHA — the real-world tag case. If the origin gains +# v0.0.0a1, workflow_run re-serves and the migration is on. +# 3. a docs PR, pushed twice → both deploys land at the same unchanged main HEAD. Does +# /pr-/ appear, and does it UPDATE on the second push? +# Highest-volume case; first to break if the collision is real. +# +# Safety: this deploys the normal, correctly assembled site — the only question is whether +# the origin moves. Worst case it goes stale (not broken), and publish.yml's +# "Verify the deployed origin" step turns that into a red check rather than a silent lie. +# Recovery is a manual workflow_dispatch of publish-dispatch.yml, which we know re-serves. +on: + workflow_run: + workflows: [CI] # matched by workflow NAME (ci.yml's `name:`), not filename + types: [completed] + +jobs: + # Always runs, even for a failed/fork CI run: the context dump is half the experiment. + # `github.sha` here is the value deploy-pages will use as pages_build_version. + context: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Log the workflow_run context + env: + WR: ${{ toJSON(github.event.workflow_run) }} + run: | + set -euo pipefail + printf '%-34s %s\n' \ + 'github.sha (→ pages_build_version)' '${{ github.sha }}' \ + 'github.ref' '${{ github.ref }}' \ + 'github.ref_name' '${{ github.ref_name }}' \ + 'github.event_name' '${{ github.event_name }}' \ + '— triggering run —' '' \ + 'workflow_run.id' '${{ github.event.workflow_run.id }}' \ + 'workflow_run.event' '${{ github.event.workflow_run.event }}' \ + 'workflow_run.conclusion' '${{ github.event.workflow_run.conclusion }}' \ + 'workflow_run.head_branch' '${{ github.event.workflow_run.head_branch }}' \ + 'workflow_run.head_sha' '${{ github.event.workflow_run.head_sha }}' \ + 'head_repository' '${{ github.event.workflow_run.head_repository.full_name }}' + # The comparison that matters: if github.sha != workflow_run.head_sha, the deploy + # is stamped with something other than the commit that was actually built. + if [ '${{ github.sha }}' = '${{ github.event.workflow_run.head_sha }}' ]; then + echo "::notice::github.sha MATCHES the triggering commit — no collision risk" + else + echo "::notice::github.sha (${{ github.sha }}) differs from the built commit \ + (${{ github.event.workflow_run.head_sha }}) — every deploy between merges reuses this SHA" + fi + echo "--- full workflow_run payload ---" + printf '%s\n' "$WR" + + # The actual experiment: deploy through the REAL engine, so the site content is correct + # and publish.yml's Verify step reports the answer in-run. + # + # version-name: "" → pure durable gather. Unlike the inline path, the triggering run has + # COMPLETED, so its `docs` artifact is discoverable by the normal gather (by default-branch + # or by PR head SHA) with no injection. Whether that actually holds is observation 1. + # + # The fork guard is not optional: workflow_run hands a write token to a job triggered by + # fork-PR code (the pwn-request footgun that publish.yml's read-only `warn` job exists to + # avoid). Fork CI runs are excluded here and stay excluded. + deploy: + needs: [context] + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository + uses: ./.github/workflows/publish.yml + with: + version-name: "" + permissions: + contents: read + actions: write + pages: write + id-token: write + statuses: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e6d71d..d0d9ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,16 @@ jobs: # fork PR's token is read-only, so untrusted code never reaches a write token. # `actions: write` lets the re-dispatch job re-fire the shim; `version-name` is the # in-run build for the inline path (ignored by the re-dispatch/warn branches). + # TEMPORARILY DISABLED — _probe-workflow-run.yml's experiment needs the workflow_run path + # to be the ONLY deploy path, so the experiment has one variable. Tags are disabled here + # too, deliberately: the re-dispatch job would deploy via workflow_dispatch, which we + # already know re-serves, and that is precisely the confound observation 2 must avoid. + # RESTORE to: + # if: github.repository == 'DiamondLightSource/myst-version-switcher-plugin' + # when the probe is deleted. publish: needs: [lint, test, docs] - if: github.repository == 'DiamondLightSource/myst-version-switcher-plugin' + if: false uses: ./.github/workflows/publish-dispatch.yml with: version-name: ${{ needs.docs.outputs.version-name }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b395c8b..a5e0448 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,8 +89,12 @@ jobs: # Internal PR / default-branch push (inline), or any workflow_dispatch → deploy. deploy: + # `workflow_run` is TEMPORARY — _probe-workflow-run.yml's experiment (delete both once + # it has answered). Its caller already gates on conclusion + non-fork; the deploy + # itself needs no other change, because version-name "" is an existing supported mode. if: >- github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_run' || (github.event_name == 'push' && github.ref_type != 'tag') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)