Skip to content

[Add] Choose what a site contributes to: WordPress Core or Gutenberg (#251) - #476

Merged
juanmaguitar merged 9 commits into
juanmaguitar/251-playground-servefrom
juanmaguitar/251-contribute-to
Sep 17, 2026
Merged

juanmaguitar merged 9 commits into
juanmaguitar/251-playground-servefrom
juanmaguitar/251-contribute-to

Conversation

@juanmaguitar

@juanmaguitar juanmaguitar commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Why

A contributor who wants to work on the block editor has no way in: every site the app creates is a wordpress-develop checkout. The three PRs under this one taught the app to clone, build, watch and serve a Gutenberg checkout; none of them let anyone ask for one. This is the PR that does, now that the whole path works. #251 has the case.

What changes

The create-site dialog gains a Contribute to choice, WordPress Core or Gutenberg, each with a line saying what the app does with such a site today, read off the registry so the copy lives in one place. Core is selected by default. The choice goes to wordpress:setup as the projectType the stack already accepts, and the button and dialog are renamed Create a site, since "Create WordPress Core site" is now false. The rename reaches the two specs and the screenshot script that click it, and the two screenshots that show it are regenerated.

A Gutenberg site is told apart on screen and shown only what applies. The sidebar row and the site header carry a Gutenberg tag; the checklist's words are the target's (the repository it clones, the packages it compiles, the WordPress it launches with the checkout as its plugin), from a setup block in the registry, and the type reaches the row from the moment the dialog closes because the placeholder carries the choice. The Trac-shaped cards show only where the work item is a Trac ticket: on a Gutenberg site the ticket card gives way to a line saying that working on a GitHub issue from here, with its branch and its pull request, comes in a later version; the apply card is Check out a pull request with no patch-file path; Attach to Trac is not offered while Hand it to a mentor still is; the pull-request destination says the flow is not available yet before it invites a sign-in; a wpct://ticket link that arrives while a Gutenberg site is in front is turned away by name, kept for a Core site, and the note hides on that site only. Every one of those strings lives in the registry's cards block, and a test pins that no card of a non-Trac target speaks of Trac.

Docs: creating a site (the new field, what each choice clones, the tag and what a Gutenberg page leaves out), getting started, the setup wizard, the terminal (the Gutenberg script list, and why test is not on it), running the site (a Gutenberg site is a plugin inside a stock WordPress; what the file-modification lock means), Trac tickets and applying patches (which cards are Core-only).

The opt-in real-setup lane now runs one job per target and platform (a Core setup alone takes half an hour on the Windows runner, and two in one job hit its limit): the Gutenberg variant also logs in and reads the Plugins screen (the checkout is the active Gutenberg, and cannot be deleted), and checks that stopping the watch leaves no process of its tree alive (POSIX; Windows is probed by the workflow only for the flow). Its name field is filled after the choice and read back until right: in the first seconds of a freshly started app the value Playwright filled came back with letters added or dropped (real-setupes, real-setu), which sent the site to a folder the test was not watching. Not reproduced by hand, nor in a warmed-up app across a dozen tries; noted here rather than hidden.

Deliberately not in this PR: a GitHub issue as the work item (the ticket card still asks for a Trac ticket on a Gutenberg site, and its linked-PR list searches wordpress-develop), opening a pull request against WordPress/gutenberg, and wpct://issue/N. They are the next three PRs; until they land a Gutenberg site is for building, running and trying pull requests by checkout (which already fetches from the site's own origin), not for filing work. So that a Gutenberg site cannot open a pull request against wordpress-develop in the meantime, github:open-pr refuses any site that is not Core, with a sentence that points at the patch file; the choice's copy and the docs say only what is wired.

How to test this

Platforms: macOS and Windows; the Gutenberg path spawns npm and Playground through the platform shims. Build: the Buildkite artifact for the current head.

The Real WordPress setup workflow is the pass: it now creates one site per target and, for Gutenberg, logs in and reads the Plugins screen (Gutenberg active, no Delete link anywhere) and checks that Stop leaves no watcher process behind (POSIX). Run locally by the author on macOS, 15 Sep, current head: the Gutenberg variant passed in 4.0 minutes end to end. Runs on this branch: https://github.com/WordPress/contributor-toolkit/actions/runs/34955889102 on ecab2bd with one job per target and platform: Core green on macOS (2.7 min) and Windows; Gutenberg green on Windows end to end; Gutenberg on macOS red at the last step only, the survivor check, which is #479's fix (see Risks). The two earlier runs (34948704875, 34949992162) ran both targets in one job and were cancelled on Windows at the 60-minute limit, which is why the jobs are split now.

By hand, either platform, current head, in a folder you do not mind deleting:

  1. Click Create a site. Expected: the dialog has three fields, and Contribute to is on WordPress Core, with a line under each choice.
  2. Choose Gutenberg, name the site gb, pick a folder, click Create site. Expected: the folder created is gb, and the terminal shows the clone of WordPress/gutenberg.
  3. Wait for the checklist to run install and build on its own. Expected: about a minute for the install and half a minute for the build on a fast machine; This site is ready to work on.
  4. Click Start dev server and finish the wizard. Expected: the Build watcher tab shows npm run dev building the packages once and then Watching for changes…; the server URL appears; the site opens.
  5. Log in (admin / password), open Plugins. Expected: Gutenberg is active with a Deactivate link; no plugin has Delete; there is no Add New Plugin.
  6. Open Posts > Add New. Expected: the editor loads.
  7. Click Stop build watch, then Stop dev server. Expected on macOS: pgrep -f gutenberg-trunk in a terminal (anywhere) prints nothing within a few seconds.
  8. In the terminal panel, type npm run grunt. Expected: refused, with the Gutenberg list (build, dev, test:unit, lint, lint:js).
    8a. Look at the sidebar and the header. Expected: a Gutenberg tag on the row and next to the status (a Core site wears Core in the same places, since 00181bf (was df4f989 before the rebase onto trunk at 576d218): JuanMa's ask on 2026-09-16, a mixed list read as one tagged and the rest bare); the checklist steps read "Download Gutenberg", "Compile the Gutenberg packages…", "Launch a WordPress with this checkout as its Gutenberg plugin…"; no Trac ticket card, a GitHub issue card saying it comes later; the apply card reads Check out a pull request with no .diff link.
    8b. With the Gutenberg site in front, open wpct://ticket/62281 from a browser (or open -a "<build>/dist/mac-arm64/WordPress Contributor Toolkit.app" "wpct://ticket/62281" with every other copy quit). Expected: "Ticket #62281 cannot be linked to ", no Link ticket button, git branch in the checkout shows no ticket/62281; click Hide, switch to a Core site: the question is asked there.
  9. On the Gutenberg site, open Review & submit changes and try Open a pull request (sign in if asked). Expected: refused with "Opening a pull request from this site is not supported yet — save the patch file instead."; nothing reaches GitHub.
  10. Create a second site with the default choice. Expected: exactly what a site was before this stack: grunt -- _watch in the watcher tab, grunt accepted in the terminal, a pull request opens as before.

What must not have happened: a Core site changing in any way (step 10); a Gutenberg site reaching GitHub's fork or pull-request endpoints (step 9); the served Gutenberg site offering Delete on its plugin (step 5); a watcher process outliving Stop (step 7); the checkout gaining files from the served site (git status in it is clean apart from build*, which Gutenberg ignores).

Risks and limitations

  • Review outcome across six passes: 15 [fix here] fixed, 12 [follow-up] (8 taken, 4 deferred: pgrep -f misses argv-less helpers, the no-Delete assertion's strength, and two pre-existing Core sentences in the Review & submit modal's staleness banner and dirty-tree detail).

  • On a Gutenberg site that has never been built, the dev-server start runs npm run build and then npm run dev, which builds everything again before watching: about twenty seconds twice on a fast machine. And the watcher tab reads "watching" while dev is still in its first build. Both are the [Add] Run the watcher and accept the terminal scripts of the site's own target (#251) #470 review's follow-ups; deferred here because collapsing the two needs the watcher's own "Watching for changes" line as the ready signal, which is its own change.

  • sites:add (adopting a folder) still records every adopted checkout as Core. Adopting a Gutenberg checkout gives a site that builds with grunt; detection (package.json name) is a small follow-up.

  • Stop does not end Gutenberg's watcher tree on a slow machine. On the macOS runner, 3 of 3 runs left tsc --build (the native TypeScript binary Gutenberg's npm run dev runs) alive after Stop: it ignores the SIGTERM the app sends the process group while it is mid-build, and the app's escalation to SIGKILL after 3 s reaches only the direct child (npm:kill in src/main.js). Locally, where the first build had finished, it died 3 of 3 times. The fix belongs to kill-tree.js and npm:kill on trunk, so it is its own pull request ([Fix] Force the whole process tree when Stop's SIGTERM is ignored, not only the runner #479); this PR's real-setup lane is what found it, and stays red on macOS until that lands.

  • The process-tree check runs on POSIX only, and by argv (pgrep -f <site path>), so a helper whose arguments carry no path, such as esbuild's service, is not seen by it; on Windows the workflow proves the flow, not the kill (its taskkill /T path has injected unit coverage in kill-tree.test.cjs).

  • The Plugins-screen assertion that no Delete link exists only bites if the stock install ships an inactive plugin; a stronger probe (plugin-install.php refusing under DISALLOW_FILE_MODS) is a follow-up.

  • A first Gutenberg serve needs the network for the WordPress release Playground then caches.

  • The sidebar heading still reads "WordPress Core"; it is the app's title, so a follow-up rather than this PR.

  • The name-field mutation seen by the real-setup lane in a fresh app (above) is unexplained. If a contributor ever reports a site named with letters they did not type, this is where to look.

  • The sidebar row's accessible name is now <label> Core / <label> Gutenberg; the engine journeys' helper matches the label plus that word. Any other test that finds a row by exact name has to do the same.

Related

Part of #251. Stacked on #471.


Design decisions and alternatives considered
  • A radio, not a dropdown: two options, both worth reading, and the help line under it is where a first-time contributor learns the choice is permanent.
  • "Create a site" rather than "Create a contributor site": the sidebar button is short and the dialog says the rest.
  • The choice is exposed here, after serve, rather than in [Add] A registry of contribution targets, driving the clone and the built check (#251) #469 with the field: a Gutenberg site the app could create but not run is a state the app must not produce.
Review outcome (required — see AGENTS.md)

Six passes, each by a fresh Explore subagent context (local /self-review), each on the head that moved since the previous one. Totals: 15 [fix here] fixed, 12 [follow-up] (8 taken, 4 deferred and recorded under Risks).

  • Pass 1, 3c0c4ba: 4 fix / 4 follow-up. The choice's copy promised "where its pull requests go" while github:open-pr targeted wordpress-develop for every site (fixed: the handler refuses non-Core sites, copy narrowed); a docs claim about empty names (fixed); no PR-time coverage of the radio (fixed: legacy-site.spec.js); stale comment and unused description field (fixed: per-option descriptions). Follow-ups taken: mutation comment reworded, three docs pages and the clone string; deferred: pgrep -f misses argv-less helpers, the no-Delete assertion's strength.

  • Pass 2, 28978e7: reconciled; (this pass was the CodeRabbit-style re-check of the first's fixes, no new findings beyond the ones carried).

  • Pass 3, ecab2bd (screen adaptations): 3 fix / 3 follow-up. Four card strings decided in index.jsx (fixed: registry cards block); the wpct://ticket prompt not gated by provider, which could put a Gutenberg site on a Trac ticket branch with no card naming it (fixed: deepLinkNotice takes the provider, unsupported state); PR preview URL hardcoded to wordpress-develop and the modal pointing at a card the page lacks (fixed). Follow-ups taken: mentor destination back outside the gate, a journey with a seeded Gutenberg site, two docs pages.

  • Pass 4, 94a12cb: 2 fix / 3 follow-up. "Not now" on the refusal note cleared the ticket app-wide while the text promised to keep it (fixed: hidden per site); the submit sidebar still said "fork wordpress-develop" and counted "the other two" (fixed: upstream-aware, neutral counts). Follow-ups taken: journey asserts no ticket branch, unused workItemNoun removed, the last inline string moved to cards.

  • Pass 5, e4057cd: 2 fix / 3 follow-up. The hide reset keyed on the ticket number, so the same ticket twice did nothing (fixed: keyed on the arrival); the signed-out sign-in pitch survived the refusal (fixed: refusal first). Follow-ups taken: prAfter and the sign-in sentence into the registry; deferred: the reset re-firing on the null-and-back prop (taken in pass 6).

  • Pass 6, 2f2ded7: 3 fix / 2 follow-up. No data-side guard that a non-Trac target's cards say nothing of Trac (fixed: registry test); a dead Gutenberg signInCannot (fixed: removed); the destination's cost line still promised the fork (fixed: prCost per type). Follow-ups: the reset keyed on the arrival stamp (taken); pre-existing Core wording in the staleness banner and the dirty-modal detail (deferred, not introduced here).

  • Review: completed — heads 3c0c4ba, 28978e7, ecab2bd, 94a12cb, e4057cd, 2f2ded7 / base fee96e5; deterministic layer (lint, npm test, journeys) and the opt-in real setup run by the author on every head; outcome above

  • Since review: 2f2ded7 → 3023bd3 reviewed by a seventh fresh-context pass: all four of pass 6's findings resolved, no new findings; lint, bundle, npm test (1484) and 30/30 journeys green on this head

Screenshots or recording

docs/public/screenshots/empty-state.png and create-site-modal.png regenerated from the fixture tier (npm run shots -- --only=…). Before: "Create WordPress Core site" and two fields. After: "Create a site" and the Contribute to radio between the name and the location.

🤖 Generated with Claude Code

Stack correction review — 2026-09-17

0 remaining [fix here] · 0 new [follow-up] in this correction pass. No PR-local behavior changed; git range-diff reports every original commit unchanged (=) against its rebased base.

  • Current PR head: 08315156268f34ab94f6f0c81192f02642d48ce0; configured base head: 244ca76c360e183a8da9f2343d7cf05fc05f5924. Previous PR head: ab2cfa708e325d58f8895113e1737bf20b76b3be; only the inherited stack corrections and commit identities changed.
  • Reviewer: fresh Codex context /root/review_fixes. Completed five-dimension review of the correction delta from previously reviewed integrated head bc8c4fda3fb78d51cd6f3abd5fc1b0f1e89c0095 to c8c86677491012f9c9d4e99fe43b4ceff1c9647b, then re-reviewed through final stack head 34550eec7a9031d2d5f03116801a60e313fa0559. The additional runtime-deletion finding was fixed and verified; the original three findings remain resolved. Earlier review records above are historical.
  • Validation on the combined stack: lint; Node unit/integration/IPC suite (1,547 passed, two runtime skips); Electron suite (1,549 passed); 33 macOS journeys; documentation build. The real Playground restart/deletion regression passed on macOS. Windows CI results must be read on these new heads; this pass does not claim a fresh Windows real-setup run.
  • CodeRabbit's earlier check is not review coverage for these new heads. This local correction review is the review of record for the delta; no new bot review was requested.

@juanmaguitar juanmaguitar added this to the v1.2.0 milestone Sep 15, 2026
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: f8b0c4e3-25db-4c4b-bcf8-9df4db4a3c66

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@juanmaguitar
juanmaguitar added this pull request to stack #477 September 15, 2026 09:12
juanmaguitar added a commit that referenced this pull request Sep 16, 2026
The path rewrite that steers a Trac patch from before the src/ move under src/ is wordpress-develop's, and it ran on every patch. A Gutenberg diff already names the file where it lives, and the Core rules would send a root index.php or a wp-* path somewhere the repository does not have. The registry's patch.layout now picks the mapper: src-layout is the rewrite as it was, repo-relative leaves every path as named, and an unknown value throws rather than guessing, since the registry test pins the two and a typo there must not quietly rewrite a whole patch.

The layout rides from the site through parsePatchFiles, rewritePatchPaths and applyPatchToDir, so the preview and the apply read the same one and what is shown is what is written; the undo after a failed persist carries it too. Callers that name none get Core's, so nothing outside a Gutenberg site changes. On a Gutenberg site nothing reaches this yet, the file picker and the Trac attachments are hidden there since #476; it is here so the layer is right before a caller arrives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/251-contribute-to branch from df4f989 to 00181bf Compare September 16, 2026 10:42
juanmaguitar added a commit that referenced this pull request Sep 16, 2026
The path rewrite that steers a Trac patch from before the src/ move under src/ is wordpress-develop's, and it ran on every patch. A Gutenberg diff already names the file where it lives, and the Core rules would send a root index.php or a wp-* path somewhere the repository does not have. The registry's patch.layout now picks the mapper: src-layout is the rewrite as it was, repo-relative leaves every path as named, and an unknown value throws rather than guessing, since the registry test pins the two and a typo there must not quietly rewrite a whole patch.

The layout rides from the site through parsePatchFiles, rewritePatchPaths and applyPatchToDir, so the preview and the apply read the same one and what is shown is what is written; the undo after a failed persist carries it too. Callers that name none get Core's, so nothing outside a Gutenberg site changes. On a Gutenberg site nothing reaches this yet, the file picker and the Trac attachments are hidden there since #476; it is here so the layer is right before a caller arrives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
juanmaguitar added a commit that referenced this pull request Sep 17, 2026
…t only the runner (#479)

## Why

**Stop build watch** sends SIGTERM to the script's process group and,
three seconds later, SIGKILL to the direct child only. A descendant that
sits through the first signal outlives the second. Gutenberg's `npm run
dev` runs the native TypeScript binary, which ignores SIGTERM while it
is mid-build: on the macOS GitHub runner, the real-setup lane of #476
found `node_modules/@typescript/typescript-darwin-arm64/lib/tsc --build`
alive after Stop in three runs out of three. On a fast machine, where
the first build had finished by the time Stop arrived, it died three
times out of three. Core's grunt tree has never shown this, but nothing
in the handler was specific to Gutenberg: any descendant that ignores
SIGTERM was being left behind.

## What changes

The escalation is the same group signal, forced, by pid:
`killTreeByPid(pid, 'SIGKILL')` in `kill-tree.js`, which applies
`killTreePlan` without consulting the `ChildProcess`. That detail is the
fix's second half: by the time the three seconds are up the runner has
usually died of the first signal, so anything that checks
`exitCode`/`signalCode` first (as `killChildTree` rightly does) reads
"nothing to do" about a tree that is still there. `close`, the event
that says the tree let go of its pipes, stands the escalation down,
which also closes the window in which the OS could have reused the pid.
Every other caller keeps SIGTERM through the unchanged `killChildTree`.
Windows needed nothing, `taskkill /T /F` forces from the start, and the
plan returns the same command whatever the signal.

Root cause, in two parts: the last resort reached only the runner, and
the runner dying takes the pipes with it but not a grandchild that chose
to stay; and any escalation routed through the exited `ChildProcess`
would have been skipped anyway.

## How to test this

Platforms: **macOS or Linux** by hand (the forced signal is a POSIX
group signal; Windows is unchanged and its `taskkill /F` plan is covered
by the injected unit tests). Build: the current head.

The tests carry it:

```
node --test tests/unit/kill-tree.integration.test.cjs
node --test tests/unit/ipc-wiring.test.cjs
```

The integration test forks a real grandchild under a detached shell (`sh
-c '"$0" -e "$1" & wait'`, so the shell does not `exec` into it),
asserts it is a descendant, watches the shell die of the polite group
signal while the grandchild sits through it, shows that a kill routed
through the exited `ChildProcess` now does nothing, and then forces the
group by pid and sees the grandchild end. The wiring tests tick the
handler's three-second timer and assert the forced signal goes by pid,
and that a `close` before the timer stands it down; the first fails on
the old handler, where the second step was `child.kill('SIGKILL')`.

By hand, macOS, from any Gutenberg checkout that has run `npm ci` (the
#476 stack creates one; `git clone
https://github.com/WordPress/gutenberg.git` and `npm ci` on Node 24 do
too, but from the app's own terminal you need #476):

1. Start the build watch on a Gutenberg site and click **Stop build
watch** while the watcher tab still shows packages being built (within
its first twenty seconds).
2. In a terminal, anywhere: `pgrep -fl 'tsc --build'`. Expected within
about four seconds: nothing.

**What must not have happened:** a Core site's Stop changing in any way
(same SIGTERM first, same three seconds, the forced signal only reaching
the tree that ignored it); anything outside the script's own process
group being signalled (the plan refuses a pid that is not a positive
integer, and the target is the detached runner's group).

## Risks and limitations

- Review outcome: two passes, 6 [fix here] fixed, 3 [follow-up] deferred
(below).
- The forced signal gives a descendant no chance to clean up. It comes
only after the polite one and three seconds, the same grace the runner
itself had.
- The quit sweep and the stop before a site delete still send SIGTERM
alone, so a stubborn descendant outlives app quit, and a delete can be
refused with `ETIMEDOUT` while `tsc` holds the runner's pipes. Both are
the review's follow-ups: the quit needs `preventDefault`, a sweep, a
grace, a forced pass by pid and a guarded `app.quit()`, its own
sequencing change; the delete path can retry `killChildTreeAndWait` with
a forced pass by pid before refusing.
- Pressing Stop repeatedly arms a timer and a `close` listener per
press; harmless while the tree lives, a listener warning past ten
presses. Follow-up.
- The integration test is POSIX-only: process groups are. Windows's plan
is a `taskkill /F`, covered by injection in `kill-tree.test.cjs`.

## Related

Found by the real-setup lane of #476 (part of #251). Not part of that
stack: the fix is on trunk's own code.

---

<details>
<summary>Design decisions and alternatives considered</summary>

- Escalating in `npm:kill` alone rather than inside
`killChildTreeAndWait`: the handler already owned the three-second last
resort; moving escalation into the shared helper would change the quit
sweep and the delete path in the same commit, which deserve their own
look (follow-ups).
- Not detecting "ignored SIGTERM" before forcing: the timer is the
detection, and it was already there.

</details>

<details>
<summary>Review outcome (required — see AGENTS.md)</summary>

Two passes, both by a fresh Explore subagent context (local
`/self-review`).

**Pass 1, head `a965232`:** 4 [fix here] · 2 [follow-up].
- 🔴 Architecture: the escalation never fired. `killChildTree` skips a
`ChildProcess` whose runner already exited of SIGTERM, and the runner
does exactly that, so the forced signal was never sent (verified by the
reviewer on a real detached tree). Fixed in `b7f5c54`: the escalation
goes by pid (`killTreeByPid`), stood down on `close`.
- 🟡 Tests: the integration test's `sh -c "<cmd>"` `exec`'d into node, so
there was no grandchild. Fixed: `sh -c '"$0" -e "$1" & wait'`,
positional arguments, the grandchild asserted to be a descendant.
- 🟡 Tests: the pid promise had no timeout or rejection. Fixed: 60 s test
timeout, rejects on early exit.
- 🔵 Architecture: two comments overstated the behaviour. Fixed.
- Follow-ups (open): the quit sweep, the Playground stops and the stop
before a site delete still send SIGTERM alone; the delete can be refused
with `ETIMEDOUT` while a stubborn descendant holds the pipes. Recorded
under Risks.

**Pass 2, head `b7f5c54`:** all four earlier findings resolved (the
reviewer confirmed on macOS that the orphaned group persists after its
leader dies and that the group path, not the fallback, ends the
grandchild); 2 [fix here] · 1 [follow-up].
- 🟡 Cross-platform: the timer was armed even when the child had already
closed before Stop, so no SIGTERM was sent, `close` never cleared it,
and a forced signal went to a pid the OS may have reissued (on Windows,
`taskkill /T /F` of an unrelated tree). Fixed in `6f0e055`: armed only
when the polite signal was attempted; a wiring test pins the
closed-child case.
- 🔵 Cross-platform: the comments claimed Windows takes no second step
while the code ran a second `taskkill`. Fixed: no escalation on Windows,
comments match.
- 🔵 Architecture [follow-up]: repeated Stop presses add a `close`
listener and a timer each; harmless while the tree lives, noisy past ten
presses. Deferred.

- **Review:** completed — reviewed heads `a965232` and `b7f5c54` / base
`8b1341e`; deterministic layer run by the author (lint clean, 1455 pass
/ 2 pre-existing skips) and re-run by the second pass for the touched
suites; outcome above
- **Since review:** `b7f5c54 → 7bb9d40` checked: the two fixes above and
a wiring-stub correction (the spies answer true like the real
killChildTree, so the guard arms); lint and `npm test` re-run green

</details>

<details>
<summary>Screenshots or recording</summary>

Nothing on screen changes.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/251-contribute-to branch from 00181bf to ab2cfa7 Compare September 17, 2026 06:57
juanmaguitar added a commit that referenced this pull request Sep 17, 2026
The path rewrite that steers a Trac patch from before the src/ move under src/ is wordpress-develop's, and it ran on every patch. A Gutenberg diff already names the file where it lives, and the Core rules would send a root index.php or a wp-* path somewhere the repository does not have. The registry's patch.layout now picks the mapper: src-layout is the rewrite as it was, repo-relative leaves every path as named, and an unknown value throws rather than guessing, since the registry test pins the two and a typo there must not quietly rewrite a whole patch.

The layout rides from the site through parsePatchFiles, rewritePatchPaths and applyPatchToDir, so the preview and the apply read the same one and what is shown is what is written; the undo after a failed persist carries it too. Callers that name none get Core's, so nothing outside a Gutenberg site changes. On a Gutenberg site nothing reaches this yet, the file picker and the Trac attachments are hidden there since #476; it is here so the layer is right before a caller arrives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
juanmaguitar and others added 9 commits September 17, 2026 17:22
…251)

The create-site dialog gains a "Contribute to" choice, read off the registry, Core by default; the choice goes to wordpress:setup as the type the stack already accepts. The button and the dialog are "Create a site" now, since the old name is false, and the two specs and the screenshot script that click it follow, as do the two screenshots that show it.

Docs: creating a site, getting started, the setup wizard, the terminal (Gutenberg's script list and why bare `test` is not on it), running the site (a Gutenberg site is a plugin inside a stock WordPress, and what the file-modification lock means).

The opt-in real-setup lane runs once per target. The Gutenberg variant logs in and reads the Plugins screen (Gutenberg active, no Delete links), and checks that stopping the watch leaves no process of its tree alive, which a signal to npm alone does not manage. The name is filled after the choice and read back until right: in the first seconds of a fresh app the filled value came back with letters added or dropped, not reproduced by hand or in a warmed-up app.

Deferred, from #470's review: on a never-built Gutenberg site the start builds twice (npm run build, then npm run dev's own first build), and the watcher tab reads "watching" during that first build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…est it cannot open yet (#251)

From the pre-PR review. The choice's help line and the docs promised "where its pull requests go", but the pull-request flow forks and targets wordpress-develop for every site; until it reads the type, `github:open-pr` refuses a site that is not Core with a sentence that points at the patch file, and the copy says only what is wired. Each choice carries its own line now (the registry's `description`, read nowhere before), attached to the radio rather than to the group. The docs no longer claim an empty name yields a default folder, which the dialog refuses; the pages that still described every site as wordpress-develop say the project's repository; the checklist's clone line does too. The legacy-site journey pins the two radios and the Core default at pull-request cost. The real-setup spec's comment on the mutated name field no longer implies the app rewrote it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…inds one (#251)

The first workflow run on this branch found one process still naming the Gutenberg site 20 s after Stop, on the macOS runner, and the assertion said only how many. It lists their command lines now, and waits 30 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ly what applies (#251)

From JuanMa's pass on the first build with a Gutenberg site. The sidebar row and the site header carry a "Gutenberg" tag; Core rows are untouched. The checklist's words are the target's, from a `setup` block in the registry: the clone step names the repository it clones, the build step the packages it compiles, the last step the WordPress it launches with the checkout as its plugin. The type reaches the row from the moment the dialog closes: the placeholder carries the choice, since the store learns it only when the clone finishes.

The Trac-shaped cards show only where the work item is a Trac ticket: on a Gutenberg site the ticket card gives way to a line saying that working on a GitHub issue from here, with its branch and its pull request, comes in a later version; the apply card is "Check out a pull request" with no patch-file path; "Attach to Trac" is not offered. The pull-request refusal from the previous commit stays underneath.

The real-setup workflow runs one job per target and platform: a Core setup alone takes half an hour on the Windows runner, and two in one job hit its limit (both earlier runs on this branch did).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… away on a Gutenberg site (#251)

From the third review pass. The four strings the previous commit decided inside the component (the placeholder card's heading and body, the apply card's heading and description) come from a `cards` block per type now, beside `setup`, and the heading is the registry's own work-item label. A `wpct://ticket` link that arrives while a Gutenberg site is in front is turned away by name instead of linking a Trac ticket to it: deep-link-notice.cjs takes the site's provider and answers a dismissable `unsupported` state that keeps the ticket for a Core site. The pull-request preview names the site's own upstream in the URL it keeps, and the Review & submit modal no longer points at a Trac card the page does not have. "Hand it to a mentor" is back outside the gate: a patch file for a mentor works on any checkout, and that block holds the only editor of the WordPress.org handle.

A journey seeds a site as Gutenberg and asserts the tag, the absence of the Trac-shaped cards, and the refused ticket link. Two docs pages say which cards are Core-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…name the site's upstream (#251)

From the fourth review pass. The note that turns a Trac ticket away on a Gutenberg site offered "Not now", which cleared the ticket app-wide while the note, the module and the guide said it was kept for a Core site; the note hides on that site only now, and a Core site opened next still asks. The Review & submit sidebar named wordpress-develop and counted "the other two" destinations on every site; it reads the site's upstream, counts nothing, and mentions Trac only where Trac applies. The sentence the pull-request destination shows when there is nothing to open one for comes from the registry, the last such string that was decided in the component. The unused work-item noun is gone from the registry. The Gutenberg journey checks no ticket branch appeared and that hiding the note is a per-site act.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st before inviting a sign-in (#251)

From the fifth review pass. The note's reset keyed on the ticket number, so the same ticket linked twice after Hide did nothing, and the prop going null while another site was in front un-hid it; it keys on the link event now, and only a non-null one resets. The pull-request destination said "not supported yet" only to a signed-in contributor while inviting the signed-out one to authorise GitHub for a flow the app then refuses; the refusal comes first now. Its remaining Trac-shaped sentences (what happens after, what signing in cannot do) come from the registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the note's reset on the arrival (#251)

From the sixth review pass. The registry test asserts the property the renderer cannot be tested for: a target whose work item is not a Trac ticket has no card string that speaks of Trac or tickets. The pull-request destination's cost line comes from the registry too, and on a Gutenberg site says the patch file is the way. The Gutenberg sign-in sentence was unreachable behind the refusal and is gone. The hidden note resets on the link's stamp rather than the prop object, which goes null and back when another site is looked at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…included (#251)

The pill was only worn by a site that was not the default, so a list of
mixed sites showed one tagged and the rest bare. The registry now carries
a short tag per type and every row and header shows it. The sidebar row's
accessible name grows by that word, which the engine journeys' helper
matches on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/251-contribute-to branch from ab2cfa7 to 0831515 Compare September 17, 2026 15:25
@juanmaguitar
juanmaguitar merged commit 2ab1d78 into trunk Sep 17, 2026
10 of 19 checks passed
@juanmaguitar
juanmaguitar deleted the juanmaguitar/251-contribute-to branch September 17, 2026 16:34
juanmaguitar added a commit that referenced this pull request Sep 17, 2026
…quests that fix it (#251) (#480)

## Why

A Gutenberg site could be built, run and used to try pull requests, but
it had no work item: the card that on a Core site holds the Trac ticket
said that working on a GitHub issue comes in a later version, the
linked-PR search only knew `wordpress-develop`, and a pasted
pull-request URL was checked against the wrong repository. This is the
PR that gives a Gutenberg site its issue. #251 has the case.

## What changes

Two stack-review corrections: Gutenberg's conflict/no-base guidance now
preserves the issue branch and offers a backup plus mentor help, instead
of instructing deletion followed by an unavailable patch import. Its
mentor handoff header now uses the site's provider and links the actual
GitHub issue (`# Issue: …`); Core retains its Trac header.

**Reading an issue.** `src/renderer/github-issue.cjs` is the counterpart
of `trac-ticket.cjs`: a bare number, `#71234` or a pasted issue URL
(anchor, query, trailing slash included) resolve to an id; a
pull-request URL is refused by name, because pasting the PR instead of
the issue it fixes is the obvious mistake; an issue on another
repository is refused, so a site cannot cite a number from a project it
is not a checkout of; the URL branch gets the same bounds check as the
typed one. `src/work-item.cjs` chooses between that parser and Trac's
from the site's type, defaulting to Trac so a site made before project
types existed keeps its behaviour with no migration. The registry holds
what is true of one target (label, browse link, branch namespace); the
provider holds what is true of one kind of work item.

**Linking one.** `sites:set-ticket` parses through the site's provider.
The stored key stays `tracTicket` for both kinds: renaming it touches
every read on the card, the switcher and the patch handlers for no
behaviour, and the decision is recorded above the handler. The branch is
`issue/71234`, not `ticket/71234`: the noun the upstream uses, and a
contributor reading `git branch` in their own client sees it. Core keeps
`ticket/` untouched, so no existing site moves; every read in
`ticket-branches.js` accepts both namespaces (a branch is read by its
name alone), only the writers take the prefix, and a test walks the list
against the registry and against the changes note's own grammar so a
third namespace cannot be added to one side only.

**The pull requests that fix it.** The linked-PR search goes to the
site's own repository, and a row survives on the citation its work-item
kind uses: on Gutenberg the forms GitHub itself reads as "for this
issue" (`Fixes #71234`, `Closes WordPress/gutenberg#71234`, or the issue
URL), so the list agrees with the issue page's own linked pull requests;
a bare number in prose does not count, for the reason it never did on
Trac. The cache is keyed per work item, and a work item is a number in a
repository: Trac ticket 62281 and Gutenberg issue 62281 are different
things. Core keeps the bare key it always had, so nothing cached is
thrown away. The pasted-URL guard refuses a pull request from another
repository by that repository's name.

**Patch layout.** The path rewrite that steers a pre-`src/` Trac patch
under `src/` was wordpress-develop's and ran on every patch. The
registry's `patch.layout` now picks the mapper: `src-layout` is the
rewrite as it was, `repo-relative` leaves every path as named, an
unknown value throws rather than guessing. The layout rides from the
site through preview, apply and the undo after a failed persist, so what
is shown is what is written. On a Gutenberg site nothing reaches it yet
(the picker and the attachments are hidden there); it is here so the
layer is right before a caller arrives.

**The card.** It shows on every site and takes its words from the
provider and the registry: **GitHub issue**, *Working on issue #71234*,
**Link issue**, **Open on GitHub**, the good-first-issues link. What is
Trac's alone stays on Core sites: **Read details from Trac**, the
attachments block, **Attach to Trac**, the patch-file picker. The pure
copy modules take the site's noun rather than restating it (changes
note, tickets card heading, trunk-moved notice and its refusals,
next-action hint), the switch sentences name an `issue/` branch by its
number, and the applied-PR box says the issue's work returns on revert
rather than "returns to trunk". The placeholder card and the picker
sentence that said issues come later are gone.

**Docs:** `docs/guide/gutenberg-issues.md`, the counterpart of the Trac
page, in the nav; the four pages that said issues come in a later
version now point at it.

**Left for PR 5 on purpose:** the **Open a pull request** card on a
Gutenberg site still says it is not available and offers the patch file;
`github:open-pr` still refuses a non-Core site; the "Link a Trac ticket
to this site first" hint in that card is Core-only until then.
`wpct://issue/N` is PR 6.

## How to test this

Correction regressions, **either platform**, current head; from the
repository root: `node --test tests/unit/ticket-trunk-notice.test.cjs
tests/unit/ipc-wiring.test.cjs tests/unit/patch-provenance.test.cjs`.
Both corrections were checked red before the fix and green afterwards.
The recovery test covers conflict with/without named paths and unknown
base; it forbids the unlink/delete/reapply sequence for Gutenberg. The
real handoff export test reads the saved file, checks the correct issue
URL and absence of Trac, and verifies the patch still parses; its Core
counterpart preserves the old header.

Platforms: **either**; nothing here spawns anything platform-specific
beyond what #471 already covered. Build: the Buildkite artifact for the
current head.

The Gutenberg journey (`tests/e2e/journeys/gutenberg-site.spec.js`)
walks the whole path on a Git fixture: the card asks for an issue, a
pull-request URL is refused by name before anything is written, an issue
URL links and lands on `issue/71234`, a `wordpress-develop` pull-request
URL is refused, a `WordPress/gutenberg` one is checked out, the applied
box says the issue's work returns, revert lands back on the issue's
branch with its tree restored, and a `wpct://ticket` link is still
turned away. `npm run test:e2e`: 30 passed on macOS at the current head.

By hand, either platform, on a Gutenberg site (needs the network for
GitHub):

1. In the **GitHub issue** card, paste
`https://github.com/WordPress/gutenberg/pull/4496` and click **Link
issue**. Expected: "That is a pull request. Link the issue it fixes
instead."; `git branch` in the checkout is unchanged.
2. Paste `https://github.com/WordPress/wordpress-develop/issues/1` and
link. Expected: "Only WordPress/gutenberg issues can be linked here."
3. Type a real open issue's number and link. Expected: *Working on issue
#N*, **Open on GitHub** opens it, `git branch` shows `issue/N` and no
`ticket/N`; no **Read details from Trac**, no **Trac attachments**.
4. Under **Linked pull requests**, expected: the pull requests the issue
page lists under *linked pull requests*, and nothing that merely
mentions the number. Pick one with **Apply…**: the preview lists its
files, **Apply and rebuild** lands on `pr/NNNN`, the green box reads *PR
#NNNN is applied* with *Your issue changes are saved separately and
return when you revert this PR.*; **Revert this PR** returns to
`issue/N`.
5. In **Check out a pull request**, paste a `wordpress-develop`
pull-request URL. Expected: "Only WordPress/gutenberg pull requests can
be applied here."
6. **Unlink**, then link the same issue again. Expected: *Your issues on
this site* lists it in between; linking again switches back with no new
branch.
7. On a Core site, link a Trac ticket, apply a Trac attachment, check
out a `wordpress-develop` pull request. Expected: exactly what it was;
the linked-PR cache key for a Core ticket is unchanged
(`ticketPatches:<id>` in the store).

**What must not have happened:** a Core site changing in any way (step
7); a `ticket/` branch appearing on a Gutenberg site (step 3); a
`wordpress-develop` pull request cited by a Trac ticket showing under a
Gutenberg issue of the same number (step 4, if such a number exists).

## Risks and limitations

Gutenberg still has no patch importer or automatic conflict resolver.
Recovery deliberately retains the branch and directs the contributor to
save a backup and ask a mentor; simply adding import would label the
restored work as an applied external patch and block submission as their
own work.

- Review outcome (self-review against
`.github/instructions/code-review.instructions.md`, fresh agent context,
head `98650dd` on base `juanmaguitar/251-contribute-to`, completed over
the full diff; lint and unit suite clean): 5 [fix here], all fixed in
`f96db22` (the noun threaded into the trunk-moved refusals, the action
gate, the dirty-trunk question and the patch-review heading; the field's
accessible name and the attach URL read from the provider; callerless
`defaultPrTitle` and the diff fetch's repo option removed;
`bodyCitesIssue` narrowed to GitHub's own rule). 1 [follow-up]:
`parseIssueRef`'s default repository duplicates the registry's Gutenberg
entry; every real caller threads the repository, so it only matters for
a second GitHub-issue target. Two style notes: `showTracCards` now means
three things at four call sites (the "no PR flow yet" one gets its own
flag in PR 5). CodeRabbit: one review on `bdaf6a5`, 1 low finding (the
undo test never sent the undo), fixed in `b354059`.
- Found by hand on macOS after opening (2026-09-16), fixed in `6ee65ec`:
linking an issue by hand armed #292's auto-read, which opened the Trac
window on the Core ticket with the same number and painted its summary,
status, milestone and attachments note under the issue. The auto-read
and the scraped facts are Trac's alone in the renderer now, and
`trac:list-attachments` refuses a non-Trac site before the window can
open. Reproduced first by a journey assertion (a stubbed Trac records
zero scrapes and none of its facts show) and an ipc-wiring test.
- `git:fetch-pr-diff` stays Core-only and says so: nothing has invoked
it since pull requests became checkouts (#458). Removing the handler and
its preload entry is a follow-up (the packaged smoke test pins the API
surface).
- The renderer's changes note holds its own copy of the two branch
namespaces as a regex: `ticket-branches.js` owns the list but reaches
for Git and cannot be bundled. A test walks the list against the regex
so they cannot drift apart unnoticed.
- `bodyCitesIssue` matches GitHub's own linking rule: a closing keyword,
followed by `#N`, `owner/repo#N` or the issue URL. A pull request that
says "Related to #N" or drops the URL in prose is not listed, on GitHub
or here; a contributor looking for it can paste its URL.
- The "Link a Trac ticket to this site first" message in the
pull-request card, and the card itself on a Gutenberg site, are PR 5's.
- `sites:add` (adopting a folder) still records every adopted checkout
as Core; carried over from #476.

## Related

Part of #251. Stacked on #476 (rebased with it onto trunk `576d218` on
2026-09-16; the tickets card #472 moved keeps its place and shows on
every site).


🤖 Generated with [Claude Code](https://claude.com/claude-code)

<details>
<summary>Stack correction review — 2026-09-17</summary>

0 remaining [fix here] · 0 new [follow-up] in this correction pass. Both
recovery and mentor-attribution findings are resolved.

- Current PR head: `7b7db146c92022fd5960d6e677f05406bc97831b`;
configured base head: `08315156268f34ab94f6f0c81192f02642d48ce0`.
Previous PR head: `4915881a062f65fe8e3e6fe5fac8d6e34e806066`.
- Reviewer: fresh Codex context `/root/review_fixes`. Completed
five-dimension review of the correction delta from previously reviewed
integrated head `bc8c4fda3fb78d51cd6f3abd5fc1b0f1e89c0095` to
`c8c86677491012f9c9d4e99fe43b4ceff1c9647b`, then re-reviewed through
final stack head `34550eec7a9031d2d5f03116801a60e313fa0559`. The
additional runtime-deletion finding was fixed and verified; the original
three findings remain resolved. Earlier review records above are
historical.
- Validation on the combined stack: lint; Node unit/integration/IPC
suite (1,547 passed, two runtime skips); Electron suite (1,549 passed);
33 macOS journeys; documentation build. The real Playground
restart/deletion regression passed on macOS. Windows CI results must be
read on these new heads; this pass does not claim a fresh Windows
real-setup run.
- CodeRabbit's earlier check is not review coverage for these new heads.
This local correction review is the review of record for the delta; no
new bot review was requested.

</details>


<details>
<summary>Windows journey synchronization fix — 2026-09-17</summary>

The PR checkout journey now waits for the pull-request input to reappear
and become enabled after restoration, before inspecting Git, files and
persisted settings. All previous assertions remain; no production code
changed.

Current head: `6b9598c44f171b4899abd945853f816556377519`; configured
base: `08315156268f34ab94f6f0c81192f02642d48ce0`. Earlier review records
are historical. Fresh reviewer `/root/review_ci_sync` completed the
five-dimension review of the four-line correction against
`7b7db146c92022fd5960d6e677f05406bc97831b`, published as
`6b9598c44f171b4899abd945853f816556377519`: 0 [fix here], 0 [follow-up].
The rebased integrated tree differs from the previously reviewed stack
only by those same four test lines.

Reproduction: temporarily delay the real leave-PR handler after checkout
and before metadata persistence; the original journey fails with
expected ticket/60001, received pr/7. With the new wait, the same
delayed run passes. The diagnostic delay was removed before committing.
On the corrected #480 head, lint and build pass, Node tests report 1,539
passed and 2 runtime skips, and all 31 macOS journeys pass. From the
repository root on macOS or Windows at the current head, run `npm run
build:once && npm run test:e2e`. New-head macOS and Windows journeys and
packaged smoke pass, as do lint, docs and macOS unit tests. The Windows
Electron unit suite is still running. No new CodeRabbit review was
requested; its check says automatic reviews are disabled. The integrated
#486 stack also passes all 33 journeys locally on macOS.

</details>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanmaguitar added a commit that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant