Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 284 additions & 34 deletions .github/workflows/publish.yml

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ One entry workflow (`ci.yml`) that nests the privileged publish:
"Self-referencing assemble.mjs" above — runs inline gather + extract steps then
`assemble.mjs generate`, then `upload-pages-artifact` + `deploy-pages` + verify,
carrying the `github-pages` environment + perms + `concurrency`), **re-dispatch**
(a tag — waits for the release,
(a tag — waits for the tag's `docs.zip` release asset,
then `gh workflow run <dispatch-workflow>` 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
Expand Down Expand Up @@ -209,7 +209,10 @@ PR/commit. **Tags** hit the wrapper's `re-dispatch`: a release tag shares the me
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 the release, then `gh workflow run publish-dispatch.yml`
so it waits for the tag's `docs.zip` release **asset** (waiting only for the release
*record* races repos that publish the release from the UI before CI starts — the deploy
then lists releases before the asset lands and the tag drops out of its own site), 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
Expand Down
24 changes: 24 additions & 0 deletions docs/explanations/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,30 @@ to re-fire).
The post-deploy origin-verify step in `publish.yml` backstops any residual stale
origin by failing the run instead of serving stale docs silently.

#### The re-dispatch waits for the `docs.zip` **asset**, not the release

The gather selects a release by its `docs.zip` **asset**, so "release exists" is the
wrong thing to wait on. How much wrongness depends on how the consumer cuts releases:

- **Release created by `release.yml`** (this repo): the release is created
draft → upload → publish in one step, so its publish time already implies the asset.
Waiting on the record happens to be correct.
- **Release published from the GitHub UI** (the python-copier-template flow): publishing
the release *creates the tag*, which is what starts CI. The release record therefore
exists **before** the run that is supposed to wait for it, and `ci.yml`'s `release` job
won't attach `docs.zip` for another few minutes. Waiting on the record is a no-op, the
dispatched deploy lists releases before the upload lands, and **the tag drops out of
its own deploy** — silently, because a version that isn't gathered simply isn't there.
The site then stays one release behind until the *next* release's deploy carries the
previous one in.

So `re-dispatch` polls the tag's assets and dispatches only once `docs.zip` is attached
(30 min budget — the `release` job usually waits on a full test matrix). If no release in
the repo has ever carried a `docs.zip` the wait is skipped entirely; if the asset never
arrives, the job dispatches anyway (so the rest of the site refreshes) and then fails
loudly rather than shipping a site missing the tag. The retry budget for a wedged origin
is anchored on the asset's arrival for the same reason.

### Why the current build is *injected*

Because the inline publish runs *inside* the build's own CI run, that run isn't a
Expand Down
62 changes: 62 additions & 0 deletions docs/how-to/choose-publish-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# How-to: choose which events publish the site

The [tutorial](../tutorials/adding-to-a-fresh-repo.md)'s `publish` job runs on **every**
event and lets `publish.yml` do the branching:

```yaml
publish:
needs: [docs]
if: github.repository == 'ORG/REPO'
uses: ./.github/workflows/publish-dispatch.yml
```

Every deploy rebuilds the *whole* site from durable sources, so this is also what makes
the site **self-healing**: any deploy that came out wrong is corrected by the next one,
whatever triggered it. Narrowing the gate trades that away, so narrow it deliberately.

## If per-PR deploys are too noisy

Each deploy takes a few minutes and they serialise on the `pages` concurrency group, so
a busy repo may not want one per PR push. Publish on **tags and the default branch**:

```yaml
publish:
needs: [docs]
if: >-
github.repository == 'ORG/REPO' &&
(github.ref_type == 'tag' || github.ref == 'refs/heads/main')
```

You keep PR previews — **any** deploy gathers every open PR's build artifact, so
`/pr-<n>/` still appears; it just refreshes on the next merge to the default branch
rather than on every push to the PR. And you keep self-healing, at merge cadence.

:::{warning}
**Do not narrow it to tags alone.** With

```yaml
if: github.repository == 'ORG/REPO' && github.ref_type == 'tag' # ← don't
```

the *only* deploys are release deploys, so nothing corrects a bad one until the next
release. A single missed source then persists for weeks: a real repo that did this
shipped a site that was permanently one release behind, because each release's deploy
was the only chance that release had to reach the site.
:::

## What you cannot narrow away

The `publish` job must still be reached on a **tag** push, even if you only want release
deploys. That is the event that triggers `publish.yml`'s `re-dispatch` job — the
`workflow_dispatch` bounce that is the only way a release's same-SHA deploy reaches the
Pages origin at all (see [the architecture explanation](../explanations/architecture.md)).

Fork PRs are already handled without a gate: they reach the read-only `warn` job, never a
deploy.

## Checking what your gate actually does

On any run, `publish` showing as `skipped` in the job list means no deploy happened for
that event. Compare against the deploy's own job summary — every deploy publishes a
table of each deployed version and the source it came from, so you can see at a glance
whether a version is present and how it got there.
1 change: 1 addition & 0 deletions docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ project:
- file: how-to.md
children:
- file: how-to/migrate-from-gh-pages.md
- file: how-to/choose-publish-events.md
- file: how-to/use-with-sphinx.md
- file: how-to/contribute.md
- file: explanations.md
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ generic.
matches the `<tag>` 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.
- **`re-dispatch`** — a tag push. Waits for the release, then re-fires the shim
- **`re-dispatch`** — a tag push. Waits for the tag's **`docs.zip` release asset** —
not merely for the release record, which a UI-published release creates *before* CI
even starts, so the deploy would list releases before the asset landed and drop the
tag from its own site — then re-fires 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.
Expand All @@ -90,7 +93,7 @@ re-dispatch, a fork-PR preview, a manual re-deploy). A reusable workflow can't b
| `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). |
| `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-fired by the `re-dispatch` job. Override only if you renamed the shim. |
| `retry-until` | no | `""` | Internal — epoch-seconds deadline for auto-retrying a wedged Pages origin. Set only by the `re-dispatch` job (from the release's publish time) when it re-fires the shim; carried unchanged through any retries. **The shim must declare + forward this input** (`workflow_dispatch` + the `with:` block below) even though consumers never set it themselves — `re-dispatch` always passes it, and a shim missing the input rejects the dispatch. Don't set manually. |
| `retry-until` | no | `""` | Internal — epoch-seconds deadline for auto-retrying a wedged Pages origin. Set only by the `re-dispatch` job (15 min from the moment the tag's `docs.zip` asset lands — i.e. from when a deploy first becomes possible, *not* from the release's publish time, which a UI-published release stamps before CI even starts) when it re-fires the shim; carried unchanged through any retries. **The shim must declare + forward this input** (`workflow_dispatch` + the `with:` block below) even though consumers never set it themselves — `re-dispatch` always passes it, and a shim missing the input rejects the dispatch. Don't set manually. |

## What `publish.yml` gathers

Expand Down