From d8f5169639cebe7dec195ade69479a388e861887 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Tue, 28 Jul 2026 17:13:06 +0200 Subject: [PATCH 1/2] ci: deploy docs from a main ref via workflow_run on Release `deploy-docs.yml` triggered on `release: published`, which runs against the release *tag*. The `github-pages` environment allows deployments only from `main` (custom branch policies, no tag policies), so the Deploy job was rejected before running a step -- this path never worked (beta.8 and beta.9 both failed). `origin/main` is at 5.0.0-beta.9 while the live /beta/ site still advertises beta.8, and at GA the root site would keep serving v4.3.0. Trigger on `workflow_run` of the Release workflow instead: a `workflow_run` event runs against the default branch, so the ref is `main` and the existing environment policy passes -- no new secret, no repository-settings change. The jobs are gated on a successful Release; `push` on `docs/**` and `workflow_dispatch` are unchanged. Release is itself `workflow_run`-triggered (on CI), so this is a chained `workflow_run`, which GitHub does not guarantee will fire. The comment block documents that risk, the manual-dispatch fallback, and the tag-policy fallback. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-docs.yml | 40 +++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d40871f..cc4a7b4 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,8 +1,27 @@ name: Deploy Documentation -# Publish docs when a version is released (changesets creates a GitHub Release on publish), so the -# site documents the latest *released* library — and also when the docs themselves change on main -# (theme bumps, content edits), since those need no library release to be worth shipping. +# Publish docs after the Release workflow publishes a version, so the site documents the latest +# *released* library — and also when the docs themselves change on main (theme bumps, content +# edits), since those need no library release to be worth shipping. +# +# Why `workflow_run` on Release and NOT the obvious `release` / `types: [published]` trigger: the +# Deploy job targets the `github-pages` environment, whose protection rules use custom branch +# policies with a single `main` policy and NO tag policies. A `release` event runs against the +# release *tag*, so the deploy is rejected before a single step runs — `Tag "unthrown@5.0.0-beta.9" +# is not allowed to deploy to github-pages due to environment protection rules`. A `workflow_run` +# event runs against the default branch, so the ref is `main`, the policy passes, and no new secret +# or repository setting is needed. (Having Release dispatch this workflow instead is not an option: +# GitHub does not create new workflow runs from `GITHUB_TOKEN`-triggered events, and RELEASE_PAT +# carries only Contents + Pull requests write, not Actions write.) +# +# CAVEAT — Release is itself `workflow_run`-triggered (on CI), so this is a chain: +# CI -> Release -> Deploy Documentation. GitHub restricts some workflow-triggered events to prevent +# recursion, and a chained `workflow_run` is not guaranteed to fire. Verify on the next real +# release that this workflow starts once Release completes. If it does not, the fallback is to add +# tag policies to the `github-pages` environment — TWO patterns are required, `unthrown@*` and +# `@unthrown/*@*`, since the publishing run's tag varies per release and `*` does not match the `/` +# in a scoped tag — and to restore the `release: [published]` trigger. Meanwhile (and always), +# `workflow_dispatch` deploys the site manually from main. # # Versioned deploys: while a prerelease is in progress (`.changeset/pre.json` on main), the site is # built TWICE — the latest stable tag's docs at the root (the default a visitor lands on) and @@ -10,8 +29,10 @@ name: Deploy Documentation # progress, main IS the stable line and deploys alone to the root, exactly as before. Pages deploys # replace the whole site, so every run assembles all versions into one artifact. on: - release: - types: [published] + workflow_run: + workflows: ["Release"] + types: [completed] + branches: [main] push: branches: [main] paths: @@ -27,8 +48,9 @@ permissions: id-token: write concurrency: - # A version bump can publish several Releases at once (monorepo); collapse that burst of events - # for the same commit into a single Pages deploy — the latest run cancels earlier ones. + # A Pages deploy replaces the whole site, so only one may be in flight: serialize them. A Release + # now yields exactly one `workflow_run` event (no monorepo burst to collapse), but a `docs/**` + # push or a manual dispatch can still overlap it — the latest run cancels earlier ones. group: pages cancel-in-progress: true @@ -37,6 +59,10 @@ env: jobs: build: + # A `workflow_run` fires on ANY Release conclusion (failure, cancelled); deploy only after a + # successful one. `push` and `workflow_dispatch` are unconditional. `deploy` needs `build`, so + # a skipped build skips the deploy with it — this one gate covers both jobs. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout From 1c3c4cb6d7b908932be52e57695cd1281b89681f Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Tue, 28 Jul 2026 17:22:27 +0200 Subject: [PATCH 2/2] ci: isolate non-deploying docs runs into their own concurrency group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `workflow_run` has no conclusion filter in `on:`, so a failed or cancelled Release still starts a Deploy Documentation run. Workflow-level concurrency is evaluated when the run is created — before any job-level `if:` — so that run joined the `pages` group and could cancel a real in-flight Pages deploy before skipping its own `build`, silently leaving the site stale. Make the group conditional: a run that cannot deploy gets a throwaway `pages-noop-` group; every deploy-capable path (successful `workflow_run`, `push` on docs/**, `workflow_dispatch`) still resolves to `pages` and serialises exactly as before. The `build` job's `if:` is kept — it is what prevents the deploy; the group only governs contention. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-docs.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cc4a7b4..289cb10 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -51,7 +51,22 @@ concurrency: # A Pages deploy replaces the whole site, so only one may be in flight: serialize them. A Release # now yields exactly one `workflow_run` event (no monorepo burst to collapse), but a `docs/**` # push or a manual dispatch can still overlap it — the latest run cancels earlier ones. - group: pages + # + # Why the group is CONDITIONAL: `workflow_run` has no conclusion filter in `on:`, so a failed or + # cancelled Release still STARTS a run here. Workflow-level concurrency is evaluated when the run + # is created — before any job-level `if:` — so such a run would join `pages` and cancel a real + # in-flight deploy, and only then skip its own `build`. A failed release would silently leave the + # site stale. So a run that cannot deploy gets its own throwaway group keyed by `github.run_id`, + # where it contends with nothing (not even other no-op runs — they do nothing anyway). This only + # governs CONTENTION; the `build` job's `if:` below is what actually stops the deploy. The two are + # complementary — keep both. + # + # The `A && B || C` idiom yields `B` when `A && B` is truthy, else `C`; `format(...)` is always a + # non-empty string, so the group is the throwaway one for an unsuccessful `workflow_run` and + # plain `pages` otherwise (`github.event.workflow_run` is null for `push`/`workflow_dispatch`). + group: >- + ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'success' + && format('pages-noop-{0}', github.run_id) || 'pages' }} cancel-in-progress: true env: @@ -62,6 +77,10 @@ jobs: # A `workflow_run` fires on ANY Release conclusion (failure, cancelled); deploy only after a # successful one. `push` and `workflow_dispatch` are unconditional. `deploy` needs `build`, so # a skipped build skips the deploy with it — this one gate covers both jobs. + # + # This is the gate that PREVENTS the deploy; the conditional `concurrency` group above only + # keeps the run this gate is about to skip from cancelling a real one. Neither replaces the + # other: drop this `if:` and a failed Release would build and publish the site. if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: