Skip to content

Commit 4dbfaff

Browse files
juanmaguitarclaude
andauthored
[Change] Publish the docs site per release, not on every trunk push (#487)
## Why The published guide at https://wordpress.github.io/contributor-toolkit/guide/getting-started.html is built from trunk on every push that touches `docs/`. Merging the Gutenberg stack therefore put five unreleased features in front of users: the latest release is v1.1.0, and nothing anyone can download has "Working on a GitHub issue", the Gutenberg site type, or the Gutenberg PR panel. This is not a docs mistake. Docs shipping in the same PR as the change they describe is the rule here, and the consequence of that rule is that trunk's guide is always ahead of the newest build. What was wrong is publishing trunk. ## What changes The site now tracks the latest **release**. Deploy runs only on `workflow_dispatch`, with a required `ref` input naming what to publish, so cutting a release advances the guide with one command: ```bash gh workflow run docs.yml --ref trunk -f ref=v1.2.0 ``` Two decisions a reviewer would otherwise have to reverse-engineer: **Why the ref is an input rather than the dispatch target.** Dispatching on tag `v1.1.0` would run the copy of `docs.yml` that exists *at that tag*, whose deploy job is gated on `github.ref == 'refs/heads/trunk'`. It would build and silently skip the deploy. So the dispatch happens on trunk and the ref to publish travels as an input. **Why the deploy guard still tests `github.ref`.** It means something narrower now, and the comment says so: it decides which copy of this workflow may hold the Pages OIDC token (the reviewed, merged one), not what gets published. `inputs.ref` decides that, and publishing an arbitrary ref is the point. The `github-pages` environment already has a deployment-branch policy allowing only `trunk`, so this is belt and braces. Pushes to trunk and pull requests still build, they just never deploy: a dead link fails when it is proposed and again when it lands, rather than at release time. Not in this PR: any change to `docs/` content. Trunk's guide stays as it is and goes live at v1.2.0. ## How to test this Platform: any. This is CI-only, nothing in the app changes. **Starting state:** this branch, and the repo's Actions tab. 1. Open this PR's **Docs** check. Expected: the `build site` job runs and passes; **no** `deploy to Pages` job appears. The old behaviour was the same for PRs, so this confirms nothing regressed. 2. In the run's summary page, look at the **Record what was built** step's output. Expected: a "Built `<sha>`" line naming the commit, and no "Not published" warning (that warning is for dispatches only). 3. After merge, go to Actions → Docs → **Run workflow**. Expected: a **"Git ref to publish"** box, required, with no prefilled value. 4. Run it with branch `trunk` and ref `v1.1.0`. Expected: build passes, `deploy to Pages` runs, the run summary names `v1.1.0` and commit `576d218`, and the live guide loses the Gutenberg pages: no "Working on a GitHub issue" in the sidebar, and `/guide/gutenberg-issues.html` 404s. 5. The sharp-edge path: run it with branch `v1.1.0` (the tag) instead of trunk. Expected: it builds, the deploy is skipped, and the run summary carries a "⚠️ Not published" line telling you to re-run with `--ref trunk -f ref=<tag>`. Without that line this would be an all-green run that published nothing. **What must not have happened:** - A deploy triggered by a plain push to trunk. The whole point is that merging docs no longer publishes them. - The site published from trunk while the input said `v1.1.0` — check the deployed page, not just the green check. - A release publish cancelled mid-flight by an unrelated docs merge. Workflow-level concurrency cancels jobs too, so dispatches get their own concurrency group; the job-level `docs-deploy` group does not cover this. ## Risks and limitations 3 [fix here] · 2 [follow-up] from the review pass, all 5 addressed. Detail in the collapsed block. - The guide is now stale by design between releases. Anyone reading the live site while trunk is ahead sees the last release, which is the intent, but it does mean a docs fix for a *released* bug also waits for the next release unless someone dispatches deliberately (which they can, at any time, with any tag). - Whoever dispatches decides what goes live. `inputs.ref` can name an unmerged branch or a PR merge ref, so "the site is reviewed code" is now a habit rather than a branch rule. The build job holds nothing worth stealing (`contents: read`, `persist-credentials: false`, and the deploy job checks nothing out), so this is not a token-escalation path. Called out in the file's own comment. - Steps 3-5 above cannot be exercised before merge: `workflow_dispatch` only offers the inputs declared in the copy of the file on the default branch. What *was* verified locally is that the v1.1.0 docs still build clean under today's toolchain (`npm ci --prefix docs` + `vitepress build` at tag `v1.1.0`, no dead links, 19 guide pages, no `gutenberg-issues.html`), so the restore dispatch is not going to fail on content. ## Related Follow-up to the Gutenberg docs stack: #486, #481, #480, #476, #471. --- <details> <summary>Design decisions and alternatives considered</summary> **Reverting `docs/` on trunk to its v1.1.0 state.** Rejected: it fights the repo rule that docs ship with the change that makes them true, and every reverted page would have to be un-reverted at v1.2.0, with the Gutenberg screenshots going in and out of the tree twice. **A long-lived `docs-live` branch that auto-deploys, fast-forwarded at each release.** Rejected as one more branch to maintain for the same outcome a dispatch already gives. **A default value for the `ref` input.** Rejected: a hardcoded default names one release tag and is wrong from the next release onwards. Resolving "latest release tag" in a step was the alternative, and it adds a `gh` call and a failure mode to a job that runs twice a year. Typing the tag also makes the run record say what went live. </details> <details> <summary>Review outcome (required — see AGENTS.md)</summary> 3 [fix here] · 2 [follow-up] — all 5 addressed in `628820c`. - 🟡 security, `docs.yml` summary step: the ref was interpolated into `run:` through `${{ }}`, which substitutes into the script's source before bash parses it, so a ref name carrying `$( )` or a quote would have been code. Now passed through `env:` and read as `"$REF"`, the way `download-stats.yml` passes its values, with `set -euo pipefail` added. Verified by running the step locally with `REF='v1.1.0$(echo PWNED)"'`: printed literally. - 🟡 architecture, concurrency: `docs-build-${{ github.ref }}` put a release dispatch and a routine trunk push in the same group, and workflow-level concurrency cancels the whole run, jobs included, so a docs merge could cancel a publish mid-flight. Dispatches are now keyed on `github.run_id`. - 🔵 architecture, checkout: `ref: ${{ inputs.ref || github.ref }}` was not the no-op it claimed for push events. Checkout's own default is `github.sha`; `github.ref` would build whatever the branch tip is when the step runs. Changed to `github.sha`. - 🔵 [follow-up] security: the header paragraph this diff removed was the one saying the deploy never runs on PR code. That property genuinely changes, so it is now stated on the deploy guard rather than dropped. - 🔵 [follow-up] architecture: a dispatch on a non-trunk ref was an all-green run that published nothing. The summary step now writes a "Not published" warning naming the correct invocation. The pass also confirmed, case by case, that the new guard holds: `pull_request` (fork included) fails the event test; a fork cannot dispatch at all; a branch or tag dispatch fails the ref test. - **Review:** completed — `/self-review` per AGENTS.md, judgement pass in a fresh subagent context given only the diff and `code-review.instructions.md`; reviewed head = working tree over base `c91908c` (uncommitted at review time; the reviewed content is now committed as `628820c` with the five findings applied on top). Deterministic layer: `npm run lint` clean, `npm test` 1547 pass / 0 fail / 2 skipped. `actionlint` is not installed here and Docker was not running, so the workflow was checked by YAML parse and by executing the new shell step locally against both event shapes. - **Since review:** `c91908c` → `628820c` — the five findings, plus one CONTRIBUTING.md wording fix. No re-review; the changes are the review's own remedies. </details> <details> <summary>Implementation notes</summary> `inputs` is a recognised context here because the workflow declares `on.workflow_dispatch.inputs`; on `push` and `pull_request` it evaluates to empty rather than failing expression evaluation, so `inputs.ref || github.sha` falls through cleanly. `CONTRIBUTING.md` changes in the same PR because its "The documentation site" section stated the old behaviour in prose, and this change makes that sentence false. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c91908c commit 4dbfaff

2 files changed

Lines changed: 83 additions & 12 deletions

File tree

‎.github/workflows/docs.yml‎

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
# The docs are their own npm package under docs/ so this job never runs the root package's
44
# postinstall (electron-builder + esbuild) — building a static site needs none of that.
55
#
6+
# The site tracks the latest *release*, not trunk. Trunk documents features that nobody has a
7+
# build of yet. Merging a feature and its docs together is the rule here (a change and the docs
8+
# it makes true ship in one PR), so between releases trunk's guide describes UI the downloaded
9+
# app does not have. Publishing on every trunk push told users about it; this is what #486 and
10+
# the Gutenberg stack did to the live guide. So the deploy runs only on `workflow_dispatch`,
11+
# takes the ref to publish as an input, and a release advances the site by hand:
12+
#
13+
# gh workflow run docs.yml --ref trunk -f ref=v1.1.0
14+
#
15+
# Pushes to trunk and pull requests still build, they just never deploy: a dead link or bad
16+
# markdown fails the moment it is proposed and again once it lands, rather than at release time.
17+
#
618
# Screenshots are committed to the repo, not captured here. Capturing them needs the Electron
719
# app running against a seeded site, which is minutes of flaky work on a runner; a maintainer
820
# runs `npm run shots` locally when the UI changes instead (see scripts/screenshots/). Four of
@@ -15,9 +27,6 @@
1527
# renamed once (experimental-wp-dev-env → contributor-toolkit). Deriving it means another rename
1628
# cannot break every asset URL.
1729
#
18-
# Pull requests get a build-only job: a broken link or bad markdown fails before merge, and
19-
# the deploy job (which holds the OIDC token) never runs on PR code.
20-
#
2130
# One-time setup: Settings → Pages → Source must be set to "GitHub Actions".
2231

2332
name: Docs
@@ -33,16 +42,27 @@ on:
3342
- 'docs/**'
3443
- '.github/workflows/docs.yml'
3544
workflow_dispatch:
45+
inputs:
46+
ref:
47+
# Required, with no default: a default would name one release tag and be wrong from the
48+
# next release onwards, and this input is typed a couple of times a year. Naming the tag
49+
# every time also makes the run record say what went live.
50+
description: 'Git ref to publish. The release tag, e.g. v1.1.0'
51+
required: true
52+
type: string
3653

3754
permissions:
3855
contents: read
3956

4057
# Two pushes racing would build in an arbitrary order, so the older build is cancelled and the
41-
# newest commit wins. The deploy job has its own group, and that one does not cancel: killing an
42-
# in-flight `deploy-pages` mid-publish is how a site ends up half-updated. Same reasoning as the
43-
# `cancel-in-progress: false` in download-stats.yml.
58+
# newest commit wins. A dispatch gets a group of its own, keyed on the run: workflow-level
59+
# concurrency cancels the whole run, jobs included, so without this a docs merge landing during
60+
# a release publish would cancel that publish mid-flight — and the job-level `docs-deploy` group
61+
# below does not help, since it only stops a second deploy job from cancelling this one. Killing
62+
# an in-flight `deploy-pages` mid-publish is how a site ends up half-updated. Same reasoning as
63+
# the `cancel-in-progress: false` in download-stats.yml.
4464
concurrency:
45-
group: docs-build-${{ github.ref }}
65+
group: docs-build-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
4666
cancel-in-progress: true
4767

4868
jobs:
@@ -54,6 +74,11 @@ jobs:
5474
# an OIDC token that can publish to the site, and a SHA cannot be repointed.
5575
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
5676
with:
77+
# The ref to publish on a dispatch; the triggering commit otherwise. `inputs` is
78+
# empty outside `workflow_dispatch`, so push and pull_request builds are unchanged:
79+
# `github.sha` is what checkout defaults to on its own, and pinning the commit rather
80+
# than the branch means a merge landing mid-run cannot swap out what is being built.
81+
ref: ${{ inputs.ref || github.sha }}
5782
# This job runs the pull request's own code — `npm ci` with lifecycle scripts
5883
# enabled (esbuild needs its postinstall) and a VitePress build that evaluates
5984
# docs/.vitepress/config.mjs. Leaving the job's GITHUB_TOKEN in .git/config while
@@ -77,16 +102,56 @@ jobs:
77102
DOCS_BASE: /${{ github.event.repository.name }}/
78103
run: npm run build --prefix docs
79104

105+
# What is about to go live, on the run's own page. A dispatch is driven by a ref typed
106+
# into a box, and "which commit did we publish?" should not have to be reconstructed from
107+
# that box weeks later.
108+
#
109+
# The ref reaches the shell through `env`, not through `${{ }}` inside `run:`, the way
110+
# download-stats.yml passes its values. An expression is substituted into the script's
111+
# source before bash parses it, so a ref name carrying `$( )` or a quote would be code
112+
# rather than text. Only someone who can already dispatch this workflow could type it,
113+
# but that is the same argument as `shell: false` elsewhere in this repo.
114+
- name: Record what was built
115+
env:
116+
REF: ${{ inputs.ref || github.sha }}
117+
# A dispatch that is not on trunk builds and then skips the deploy job, which shows up
118+
# as an all-green run that published nothing. Naming the release tag in `--ref` instead
119+
# of in `-f ref=` is the easy way to get there, so the summary says it outright.
120+
WILL_DEPLOY: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/trunk' }}
121+
run: |
122+
set -euo pipefail
123+
# The backticks below are Markdown for the job summary, not command substitution,
124+
# which is why every literal is single-quoted and every value arrives through printf.
125+
# shellcheck disable=SC2016
126+
{
127+
printf '### Built `%s`\n\n' "$REF"
128+
printf 'Commit: `%s`\n\n' "$( git rev-parse HEAD )"
129+
if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ] && [ "$WILL_DEPLOY" != 'true' ]; then
130+
printf '%s\n' '⚠️ **Not published.** A dispatch only deploys when it is run on `trunk`.' \
131+
'Re-run it as `gh workflow run docs.yml --ref trunk -f ref=<tag>`: `--ref` says which' \
132+
'copy of this workflow runs, `-f ref=` says which tag goes live.'
133+
fi
134+
} >> "$GITHUB_STEP_SUMMARY"
135+
80136
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
81137
with:
82138
path: docs/.vitepress/dist
83139

84140
deploy:
85141
name: deploy to Pages
86-
# Deploy only what has landed on trunk. Testing the event name is not enough: a
87-
# workflow_dispatch can name any ref, so an unmerged branch would publish to the live
88-
# site. Testing the ref itself is what actually enforces "trunk only".
89-
if: github.ref == 'refs/heads/trunk'
142+
# Deploy only on an explicit dispatch, and only when this workflow file is the one on trunk.
143+
# The ref test does not constrain *what* gets published any more; `inputs.ref` decides that,
144+
# and publishing an arbitrary ref is the point. It constrains which copy of this file may
145+
# hold the OIDC token: the reviewed, merged one. Dispatching from a branch would otherwise
146+
# let an unmerged edit of this workflow publish to the live site.
147+
#
148+
# What it deliberately stops covering is the provenance of the published bytes. The old rule
149+
# meant the live site could only be code that had landed on trunk; `inputs.ref` can now name
150+
# an unmerged branch, or a pull request's merge ref, and that build goes live. That is the
151+
# point of the input, and the build job it runs in holds no token worth stealing
152+
# (`contents: read`, `persist-credentials: false`, and deploy checks nothing out), but
153+
# "what is on the site was reviewed" is now the dispatcher's habit rather than a branch rule.
154+
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/trunk'
90155
needs: build
91156
runs-on: ubuntu-latest
92157
permissions:

‎CONTRIBUTING.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@ Nothing enforces steps 1–4. Skipping them means a human reviewer is the first
7070

7171
## The documentation site
7272

73-
The user guide under `docs/` is a VitePress site, deployed to GitHub Pages by `.github/workflows/docs.yml` on every push to trunk that touches it.
73+
The user guide under `docs/` is a VitePress site, built by [`docs.yml`](.github/workflows/docs.yml) whenever it changes, on the pull request and again on the push to trunk, and deployed to GitHub Pages only when someone runs that workflow by hand. The live site therefore tracks the latest **release**, not trunk: your docs land with your change, and reach users when the version that contains the feature ships. Advancing it is one command, run from trunk as part of cutting a release:
74+
75+
```bash
76+
gh workflow run docs.yml --ref trunk -f ref=v1.2.0
77+
```
78+
79+
The `ref` input is what gets published, so it is the release tag, not a branch. The dispatch itself has to be on trunk: a run started on any other ref builds but will not deploy.
7480

7581
It is a **separate npm package** with its own lockfile, so a root `npm ci` does not install it and the commands below fail with `vitepress: not found` until you run this once:
7682

0 commit comments

Comments
 (0)