Publish #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish | |
| # Reconstruct the WHOLE versioned docs site from durable sources (the consumer's | |
| # default-branch build, release docs.zip assets, open-PR build artifacts) and | |
| # deploy it directly to GitHub Pages — no gh-pages branch. This is the PRIVILEGED | |
| # half (pages/id-token/statuses write), kept in its own reusable workflow so it can | |
| # only run two trusted ways: | |
| # | |
| # workflow_call — invoked by the consumer's ci.yml AFTER a successful build, for | |
| # INTERNAL events only (internal PRs, pushes to the default | |
| # branch/tags). The caller never calls it for a fork PR, so | |
| # untrusted fork code never reaches a write token. The current | |
| # build isn't a *completed* CI run yet, so the caller passes its | |
| # `version-name`; this run downloads the same-run `docs` artifact | |
| # and stages it directly (otherwise a default-branch/tag push | |
| # would publish the PREVIOUS build). A consumer's manual fork-PR | |
| # preview also comes through here — a workflow_dispatch wrapper in | |
| # their repo calls this with `pr: <n>` (and version-name: ""). | |
| # workflow_dispatch — this repo's own maintainer opt-in to preview an EXTERNAL fork | |
| # PR (the local, non-cross-repo path). | |
| # | |
| # 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@<ref>` 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. | |
| on: | |
| workflow_call: | |
| inputs: | |
| version-name: | |
| description: Version name (pr-<n> | <default-branch> | <tag>) of the in-run build to inject. | |
| required: true | |
| 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). | |
| 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 a consumer's workflow_dispatch wrapper, since a | |
| reusable workflow can't be workflow_dispatch'd cross-repo directly. | |
| required: false | |
| default: "" | |
| type: string | |
| workflow_dispatch: | |
| inputs: | |
| pr: | |
| description: External fork PR number to approve (pins its head SHA) and preview. | |
| required: false | |
| permissions: | |
| contents: read # checkout + read release assets | |
| actions: read # gh run download (this run's + cross-run docs artifacts) | |
| pages: write # deploy to Pages | |
| id-token: write # deploy-pages OIDC | |
| statuses: write # set the preview-approved status on a fork PR head SHA | |
| concurrency: | |
| group: pages | |
| cancel-in-progress: false | |
| jobs: | |
| publish: | |
| # The canonical-repo guard lives in the caller (its publish job's `if`), not | |
| # here, so this reusable workflow stays generic. Both entry points are trusted: | |
| # the caller only calls it for internal events, and workflow_dispatch needs write | |
| # access (a fork dispatching it deploys to the fork's own Pages). | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| 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). | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| # THIS reusable workflow's repo + commit (the `job` context resolves to the | |
| # reusable file, not the caller), so the assemble scripts match the ref the | |
| # consumer pinned publish.yml@<ref> to — automatically, no version bump. | |
| # Sparse: only assemble/ is needed. | |
| - name: Fetch assemble scripts | |
| uses: actions/checkout@v7 | |
| with: | |
| repository: ${{ job.workflow_repository }} | |
| ref: ${{ job.workflow_sha }} | |
| path: .mvs | |
| sparse-checkout: assemble | |
| # Fork opt-in: pin the PR's head commit as approved. assemble gathers an external | |
| # PR only when its head SHA carries this status, so a later push (new SHA) silently | |
| # drops the preview until re-approved. Gated on `pr` alone (not event_name) so it | |
| # works both from this repo's own workflow_dispatch and from a consumer's dispatch | |
| # wrapper that passes `pr` through workflow_call. | |
| - name: Approve fork PR head SHA | |
| if: inputs.pr != '' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR: ${{ inputs.pr }} | |
| run: | | |
| set -euo pipefail | |
| sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid) | |
| gh api --method POST "repos/$REPO/statuses/$sha" \ | |
| -f state=success -f context=preview-approved \ | |
| -f description="Fork docs preview approved" | |
| # When publishing within the build's own run (workflow_call), download this | |
| # run's `docs` artifact so assemble can stage it directly as version-name — | |
| # the run isn't a completed success yet, so the gather can't find it (or would | |
| # find a stale prior build). Skipped on workflow_dispatch (version-name empty). | |
| - name: Download this run's docs artifact | |
| if: inputs.version-name != '' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: docs | |
| path: ${{ runner.temp }}/assemble-current | |
| # Run the assemble script directly (cwd = the consumer's checkout, so its | |
| # `git tag` sees the consumer's tags). The script self-locates assemble.mjs and | |
| # writes `dir=<site>` to $GITHUB_OUTPUT. | |
| - name: Assemble versioned site | |
| id: site | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| GUARD_DEFAULT_BRANCH: ${{ inputs.guard-default-branch }} | |
| ARTIFACT_VERSION_NAME: ${{ inputs.version-name }} | |
| ARTIFACT_ZIP: ${{ inputs.version-name != '' && format('{0}/assemble-current/docs.zip', runner.temp) || '' }} | |
| run: bash "$GITHUB_WORKSPACE/.mvs/assemble/assemble.sh" | |
| - name: Upload Pages artifact | |
| uses: actions/upload-pages-artifact@v5 | |
| with: | |
| path: ${{ steps.site.outputs.dir }} | |
| # Deploy the uploaded artifact. deploy-pages stamps the deployment with | |
| # pages_build_version = GITHUB_SHA and has no input to change it; the value is | |
| # server-validated against the OIDC commit claim, so it CANNOT be overridden | |
| # (a custom value 404s — actions/deploy-pages#383). The Pages backend silently | |
| # 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. | |
| - name: Deploy to GitHub Pages | |
| id: deployment | |
| uses: actions/deploy-pages@v5 | |
| # deploy-pages reports success once the deployment RECORD flips active — even | |
| # 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). | |
| - name: Verify the deployed origin matches what we assembled | |
| env: | |
| PAGE_URL: ${{ steps.deployment.outputs.page_url }} | |
| SITE_DIR: ${{ steps.site.outputs.dir }} | |
| run: | | |
| set -euo pipefail | |
| [ -n "$PAGE_URL" ] || { echo "::error::deploy-pages did not report a page_url"; exit 1; } | |
| local_json="$SITE_DIR/switcher.json" | |
| [ -f "$local_json" ] || { echo "::error::no switcher.json at $local_json"; exit 1; } | |
| # Command substitution strips trailing newlines from both sides, so the | |
| # comparison is robust to a curl/file trailing-newline difference. | |
| want=$(cat "$local_json") | |
| url="${PAGE_URL%/}/switcher.json" | |
| timeout_s=360 | |
| deadline=$(( $(date +%s) + timeout_s )) | |
| attempt=0 | |
| while :; do | |
| attempt=$((attempt + 1)) | |
| got=$(curl -fsS "$url?cb=$(date +%s%N)" || true) # cache-bust → hit the origin | |
| if [ -n "$got" ] && [ "$got" = "$want" ]; then | |
| echo "origin matches the assembled switcher.json (attempt $attempt)" | |
| exit 0 | |
| fi | |
| if [ "$(date +%s)" -ge "$deadline" ]; then | |
| echo "::error::live switcher.json still does not match the assembled one after ${timeout_s}s — Pages origin likely stale/wedged" | |
| echo "--- origin headers (frozen etag/last-modified ⇒ wedged) ---" | |
| curl -sI "$url?cb=$(date +%s%N)" | grep -iE '^(HTTP/|etag|last-modified|age)' | tr -d '\r' | |
| echo "--- assembled ($local_json) ---"; printf '%s\n' "$want" | |
| echo "--- live ($url) ---"; printf '%s\n' "$got" | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done |