Skip to content

Skip the unit and E2E suites on pull requests that change only Markdown - #660

Merged
ryanwelcher merged 2 commits into
trunkfrom
update/skip-tests-on-markdown-only-prs
Oct 8, 2026
Merged

ryanwelcher merged 2 commits into
trunkfrom
update/skip-tests-on-markdown-only-prs

Conversation

@ryanwelcher

Copy link
Copy Markdown
Collaborator

Why

Every pull request runs the unit suite and the E2E journeys and packaged smoke on macOS and Windows, and all six checks are required to merge. A pull request that only changes Markdown (README, RELEASING.md, AGENTS.md and the like) waits on all of them even though nothing they test has changed.

What changes

A pull request whose changed files all end in .md skips the unit suite, the journeys and the packaged smoke, and still merges.

paths-ignore cannot do this. A workflow it skips never reports its checks, so the required ones would sit at "pending" and block the merge. Instead, each workflow gets a small changes job on Linux that reads the PR's file list from the API. When only Markdown changed, *-skipped stub jobs report the same required check names as passed, and the real jobs are skipped. Pushes to trunk always run everything.

Deliberately not in it: anything under docs/ that is not Markdown (tests/unit/download-button-platform.test.cjs reads docs/.vitepress/theme), screenshots, and Lint, which is quick and also required.

How to test this

Platforms: none, this is CI only. The checks on this PR and the next Markdown-only PR are the test.

Starting state: this PR, which changes two workflow files.

  1. Look at the checks on this PR. Expected: changes reports code=true, and the real unit, Journeys and Packaged smoke jobs run on macOS and Windows.
  2. On a Markdown-only PR opened after this merges (or a throwaway one against this branch), look at the checks. Expected: the six required checks show as passed within about a minute, each from a job that prints "Only Markdown changed", and no macOS or Windows runner starts.
  3. Edit that PR's title. Expected: a new run appears with every job skipped, and the six required checks keep their earlier passes.

What must not have happened:

Cannot be tested here: the base-move case below needs a stacked pair of PRs to retarget, which I did not stage.

Risks and limitations

  • If the changes job fails (for example the API call errors), both the real and the stub jobs skip, so the required checks stay pending. That blocks the merge rather than letting it through, and re-running the workflow clears it.
  • Moving a PR's base re-runs changes. Without that, a Markdown-only PR stacked on a code PR and moved onto trunk by hand would keep its stub passes while its diff now contains the lower PR's code. The workflows now listen for edited and act only when the base changed. One unverified detail: whether the PR's file list already reflects the new base when the edited run starts.
  • The changes script is copied into both workflows rather than shared. Sharing it would mean a composite action for twenty lines.
  • Pre-PR review: 1 finding, fixed (see below).

Related

None.


Design decisions and alternatives considered
  • paths-ignore / paths: rejected, the required checks would never report.
  • A job-level if: on the existing matrix jobs alone: rejected. A skipped matrix job is not expanded, so it reports one check named literally Journeys (${{ matrix.os }}), and the required Journeys (macos-latest) never appears. This is already what happens to drafts today (see [Fix] Stop a Gutenberg build from spawning processes without bound (#275) #283's checks).
  • A step-level if: on every step of the real jobs, with runs-on falling back to Linux: works, but every new step would need the guard, and a missed one would run npm ci on the wrong runner. The stub jobs keep the real jobs unchanged apart from needs and if.
  • Changing the ruleset to require one gate job per workflow: needs an admin change to the ruleset and moves the required names; the stubs need neither.
  • dorny/paths-filter: a third-party action holding a token, for something gh api does in one call.
Review outcome

1 [fix here] after promotion · 0 left open. Lint clean; unit suite 2081 pass, 0 fail, 2 skipped (local, macOS).

  • Review: completed, separate agent context, head ee41b6d / base 6d5f334. One 🟡 finding, raised as follow-up and fixed here at the author's request: a Markdown-only stacked PR moved onto trunk by hand kept its stub passes, since neither workflow listened for edited. Also noted, left as is: the changes script is duplicated across the two workflows.
  • Since review: ee41b6d → 251885f re-reviewed by a separate agent context: finding resolved, no new findings. Checked title-only edits (earlier passes stand), base-move cancellation, the concurrency group expression for every event, and drafts.
  • CodeRabbit: not requested yet.
Implementation notes
  • The file list comes from gh api --paginate repos/…/pulls/N/files (up to 3000 files), taking previous_filename too, so a .js renamed to .md counts as code.
  • The test is grep -v '\.md$' > /dev/null, not grep -qv. Not every grep handles -q with -v the same way.
  • An empty file list counts as code.
  • The changes job swaps contents: read for pull-requests: read; it never checks out. Fork PRs get that permission too.
  • The concurrency group gets an -edit suffix only for a title or body edit, so those runs cancel only each other. A base move joins the normal group and cancels the stale run.

Screenshots: nothing on screen changed.

🤖 Generated with Claude Code

The six suite checks are required, so paths-ignore would leave a docs-only
PR pending forever. A changes job reads the PR's files instead, and stub jobs
report the same check names as passed on Linux when only .md files changed.
Moving a stacked Markdown-only PR onto trunk adds the code from the branch
beneath it, and nothing re-ran, so the stub passes stood. Listen for edited,
act only when the base changed, and keep title edits out of the cancelling
concurrency group.
@coderabbitai

coderabbitai Bot commented Oct 7, 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: Repository: WordPress/contributor-toolkit/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: cf2a965d-3266-4312-b778-636b4d08914a

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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@zaerl
zaerl self-requested a review October 8, 2026 08:39
@zaerl zaerl added this to the v2.0.0-beta.1 milestone Oct 8, 2026

@zaerl zaerl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea.

@ryanwelcher
ryanwelcher merged commit b17ca54 into trunk Oct 8, 2026
13 checks passed
@ryanwelcher
ryanwelcher deleted the update/skip-tests-on-markdown-only-prs branch October 8, 2026 12:50
zaerl added a commit that referenced this pull request Oct 8, 2026
## Why

`window.api.startServer` subscribes to `playground:log`,
`playground:url` and `playground:stopped` before it invokes
`playground:start`, and removes them only when the server announces it
has stopped. A start that **throws** (rather than answering `{ ok: false
}`) has no server, so nothing ever announces a stop and the three
listeners stay for the life of the window. The site's next server would
then open the site in the browser once more for each of them, and print
its log twice (#604).

Today a contributor cannot reach that next server: after a thrown start
the button refuses every further click until the app restarts. #603
fixes that dead button, and once it lands this leak becomes visible.
This PR is the half that makes the retry clean, and does not depend on
#603.

## What changes

- `startServer` in `src/preload.js` takes the shape `runPullRequest` in
the same file already has: one `cleanup`, called by the stop handler as
before, and now also when the invoke throws, before the error is
re-thrown.
- `startPhpServer`'s `catch` in `use-dev-server.jsx` also drops the mail
subscription, as `stopDevServer` does on every other way out. That
subscription is guarded against stacking (`use-site-mail.jsx`), so this
is tidying rather than a second leak: before, one subscription just
stayed open while no server ran.

Root cause: the only removal path ran on an event that a start which
throws before spawning can never produce. The `{ ok: false }` failures
(a server that exits before reporting an address, the 120 s timeout)
were never affected: each has a child whose `close` sends
`playground:stopped`.

Deliberately not here: the dead button after a thrown start. That is
#603, by another author.

## How to test this

**Platforms:** any. Nothing here touches paths, spawning or line
endings.

**Covered by the unit suite**, which runs on both platforms on every PR:

```bash
node --test tests/unit/preload-listeners.test.cjs
```

Run from the repository root. `startServer leaves no listener behind
when the start throws, and the next server is heard once` fails on trunk
(all three listener counts stay at 1, and the failed start's callbacks
receive the next server's log line and address) and passes here. A
second test pins the existing removal on `playground:stopped`, including
that another site's stop does not remove this site's listeners.

**By hand**, only once #603 is also applied, since without it there is
no second start to watch. Build from a branch with both, or from source:

**Starting state:** a built site, server stopped. In `src/main.js`, make
the `playground:start` handler throw once (the issue's step 1: `let once
= true;` above it, and `if (once) { once = false; throw new
Error('forced failure'); }` as its first line), then `npm start` from
the repository root.

1. Click **Start development server**. The Server log says `Failed to
start PHP server: … forced failure`, and the button goes back to **Start
development server**.
2. Click **Start development server** again. The site opens in the
browser **once**.

**What must not have happened:** the site opening in two browser tabs at
step 2, or each line of the Server log at step 2 appearing twice. Revert
the `main.js` edit afterwards.

## Risks and limitations

- Low. The change only runs on a path that ends in a thrown
`playground:start`, which needs `readSiteMeta`, the store,
`readSettings` or the spawn itself to throw. The normal start and stop
paths are unchanged, and the existing removal on `playground:stopped` is
now pinned by a test.
- The hook change has no test. It is one unconditional call inside a
`.jsx` hook, which the suite cannot load (TESTING.md, layer 1).
- Review: 0 [fix here] · 1 [follow-up], the follow-up being #603's fix.

## Related

Fixes #604. Independent of #603, and either can merge first. They touch
the same `catch` in `use-dev-server.jsx` on neighbouring lines, so the
second to merge may need a trivial rebase.

---

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

- **Copy `runPullRequest` rather than invent a new shape.** It is the
bridge in the same file that already solved this, and the issue
suggested it.
- **Not a journey.** The leak lives entirely in the preload closure,
which `tests/unit/preload-listeners.test.cjs` already loads against a
fake `ipcRenderer` for exactly this class of bug (#86, #149, #167). That
is the highest layer that sees it. A journey would also need #603 to
reach a second start.
- **Not `stopDevServer()` in the hook's `catch`.** That would also reset
`devServerActiveRef` and fix the dead button, which is #603's change, by
its author.

</details>

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

**0 [fix here] · 1 [follow-up].**

- 🟡 [follow-up], architecture (failure paths), `use-dev-server.jsx`
`startPhpServer` `catch`: `devServerActiveRef` stays `true` after a
thrown start, so the Start button is a silent no-op until restart. It
predates this change and is exactly what #603 fixes, so it is deferred
to #603 rather than taken over here. The new test's header comment was
reworded after the review to say that the leak's effect is only
reachable once a retry is possible.

Lint and tests: `npm test` passes 2095, 0 fail. ESLint on the changed
files is clean. Repo-wide `npm run lint` fails locally only inside
gitignored `.claude/worktrees` copies, not in tracked files.

- **Review:** completed. Separate agent context (Claude Code Explore
subagent) against `.github/instructions/code-review.instructions.md`,
step 3. Covered the uncommitted change on base `f5b3b81`; outcome as
above.
- **Since review:** committed as `77246ea`. The only difference from the
reviewed diff is the test header comment reworded as described above; no
code changed. Trunk has since moved to `b17ca54` (#660, CI workflow
paths only), which does not touch these files.

</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 Opus 5.5 <noreply@anthropic.com>
zaerl added a commit that referenced this pull request Oct 9, 2026
…dited (#676)

## Why

Since #660, editing a pull request's title or description turns its six
required checks (`unit`, `Journeys` and `Packaged smoke` on macOS and
Windows) into "Expected — Waiting for status to be reported", even
though they already passed on that commit. The merge box shows them as
pending until someone pushes or re-runs. Seen on #675: the runs at 07:59
passed, and the edits at 08:18 and 08:45 started runs
37904200109/37904200065 and 37906918098/37906918097 with every job
skipped.

## What changes

**Root cause:** #660 made `unit-tests.yml` and `e2e.yml` listen for
`edited`, so that a base move would re-run their `changes` job. For any
other edit, `changes` is skipped and every job after it is skipped too.
GitHub treats the newest run of a workflow on a commit as that commit's
checks. A skipped matrix job is reported with its name unexpanded (`unit
(${{ matrix.os }})`), so the required `unit (macos-latest)` and the rest
disappear from the newest run.

**Fix:** both workflows stop listening for `edited`, so a title or
description edit starts nothing in them and the earlier results stand. A
new `.github/workflows/base-change.yml` handles a base move instead. It
finds the latest Unit tests and E2E runs for the PR's head and re-runs
them with `gh run rerun`; a run that is still in progress is cancelled
first and re-run once it stops. A re-run is a new attempt of the same
run rather than a newer run, so it replaces those checks directly. The
`changes` job reads the PR's file list from the API at run time, so the
re-run sees the new base: a Markdown-only stacked PR moved onto trunk is
evaluated as code again, which is the guarantee #660 added.

Deliberately not in it: re-running anything for a fork PR. A fork's
token is read-only, so the workflow posts a warning instead (see Risks).

## How to test this

Platforms: none. This is CI only, and it runs on GitHub's Linux runners.
The workflows only take effect once this is on trunk: `edited` runs use
the workflow files from the PR's merge commit, so a PR opened after this
merges, or one that is pushed to after it merges, picks them up.

**Starting state:** this branch merged to trunk. You need a pull request
against trunk whose head was pushed after the merge, with all six
required checks passed.

1. Edit that PR's description and save. Then edit its title. Expected:
no new run of **Unit tests** or **E2E** appears in the Actions tab.
**Re-run on base change** shows a run in which the `rerun` and `fork`
jobs are both skipped. In the merge box, all six required checks are
still ✓, not "Expected — Waiting for status to be reported".
2. Make a stacked pair: PR A (any code change) against trunk, and PR B
with one Markdown-only commit on top of A's branch, opened against A's
branch. Wait for B's checks. Expected: B passes the six checks through
the stub jobs ("Only Markdown changed").
3. On PR B, choose **Edit** next to the title and change the base from
A's branch to `trunk`. Expected: a **Re-run on base change** run whose
`rerun` job prints `re-running run <id>` for `unit-tests.yml` and
`e2e.yml`. Unit tests and E2E each show a second attempt of their
earlier run. In it, `changes` reports `code=true` (B now carries A's
code), and the real macOS and Windows jobs run in place of the stubs.
4. Optional: repeat step 3 while B's Unit tests run is still in progress
(push a Markdown commit to B, then retarget it straight away). Expected:
the in-progress run is cancelled and then re-run, and the job log shows
both.

**What must not have happened:**

- The six required checks going to "Expected" after step 1. This is the
bug: on trunk today, step 1 reproduces it.
- The stub passes surviving step 3. That would mean B could merge A's
code without the suites having run on it.
- A push made right after a retarget having its new run cancelled. The
job compares the PR's live head with the event's head immediately before
each re-run, and stops if they differ.

**Cannot be tested here:** none of this runs before merge (see
Platforms), and there is no unit test for workflow files. In particular,
it is unverified that `GITHUB_TOKEN` with `actions: write` may cancel
and re-run a `pull_request` run in this repository. Steps 3 and 4 are
the check for that, and the first stacked retarget after merge should be
watched. The workflows pass `actionlint` (with shellcheck), and the `gh
run list` lookup was run against #675's real runs.

## Risks and limitations

- **A re-run tests the old merge commit.** A re-run keeps the original
event, so the suites check out the head merged into the *old* base. Only
the Markdown-only decision uses the new base. That is what #660's
guarantee needs, and it matches every PR run once its base branch has
moved on. #660's `edited` runs did check out a fresh merge with the new
base.
- **Fork PRs:** a fork PR whose base moves is not re-run. A
`::warning::` asks for a manual re-run or a push. `pull_request_target`
would have a write token, but re-running a fork's run with it might
bypass the approval that a first-time contributor's run waits for, so I
did not use it. It is rare: a fork PR based on an upstream branch other
than trunk.
- **Runs older than 30 days:** GitHub refuses to re-run them. The job
fails with an error that says to push, or to close and reopen the PR.
That check is not required, so it does not block the merge on its own.
- **PRs already open when this merges:** where the newest run on the
head is one of the old skipped `edited` runs (as on #675 now),
re-running it replays that skip. A push, or re-running the earlier
passing run from the Actions tab, clears it once.
- Review: 6 [fix here] · 1 [follow-up]. Five fixed; the remaining [fix
here] (no live run yet) is covered by How to test steps 3–4. The
follow-up is the open-PR transition above, noted rather than coded.

## Related

Follow-up to #660. Seen on #675.

---

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

- **Keep `edited`, but give the matrix jobs expanded names when
skipped:** not possible. GitHub does not expand a matrix for a job that
is skipped at the job level, which is why #660 added stub jobs in the
first place.
- **Keep `edited` and run the stubs/real jobs again on every edit:**
every title edit would cost two Linux jobs at best and the full
macOS/Windows suites at worst, and it would cancel or duplicate runs.
- **`workflow_dispatch` from the edit workflow:** this would start a
fresh run on the head with the new base. But it is a different event, so
the `pull_request`-only logic in `changes` would need a second branch,
and the checks would come from a run nobody pushed. A re-run reuses
everything that already exists.
- **Concurrency:** base moves share the group `base-change-<PR>`, so a
second move cancels the first and re-runs whatever the first left
behind. Any other edit gets a group per run (`-<run_id>`), so it cannot
cancel a base move. The concurrency group is evaluated before the job's
`if:`, so without this a description edit made during a base move would
have cancelled it.

</details>

<details>
<summary>Review outcome</summary>

6 [fix here] · 1 [follow-up]: 5 fixed, 1 covered by How to test, 1
follow-up recorded in Risks. `npm run lint` clean; unit suite 2096 pass,
0 fail, 2 skipped (local, macOS). `actionlint` clean on all workflows.

- **Review:** completed. Reviewer: a separate agent context, read-only.
Head `da2dbfa`, base `7ee3ead`. Findings:
- 🟡 A description edit could cancel a base-move run, because the
concurrency group is evaluated before `if:`. Fixed.
- 🟡 Re-running the old head's run after a push would cancel the new
head's run through the shared concurrency group. Fixed.
- 🟡 The 30-day re-run limit failed silently and aborted before the
second workflow. Fixed.
  - 🔵 The fork regression was unannounced. Fixed with a warning job.
  - 🔵 [follow-up] PRs open across the merge. Recorded in Risks.
- 🔵 The GitHub semantics are unverified in practice. Covered by How to
test steps 3–4.
- **Since review:** `da2dbfa → 667bd95` re-reviewed by the same separate
context. #1–#4 resolved. One new 🔵: a push during the cancel-and-wait
loop could still be cancelled. Fixed in `667bd95 → 8b5ea25`, which moves
the head check to just before each re-run. I checked that last change
myself; it was not reviewed again.
- **CodeRabbit:** not requested yet.

</details>

<details>
<summary>Implementation notes</summary>

- The run lookup is `gh run list --workflow <file> --event pull_request
--commit <head sha> --branch <head ref> --limit 1`. `--commit` matches
the run's head SHA, not the merge commit's, and `--event` leaves out
trunk push runs.
- The job needs `actions: write` (cancel, re-run) and `pull-requests:
read` (the head check). The workflow's default permissions are `{}`.
Every value from the event reaches the script through `env:`, never
interpolated into it.
- `if ! gh run rerun` keeps a failure for one workflow from skipping the
other under `bash -e`. The job then exits non-zero.
- The `changes` jobs lose their `if:`, and the concurrency groups lose
their `-edit` suffix, since neither workflow sees `edited` any more.

</details>

Screenshots: nothing on screen changed.

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

---------

Co-authored-by: Claude Opus 5.5 <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.

2 participants