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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/required-changes-from-trunk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Required changes from trunk

on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]
branches: [trunk]
push:
branches: [trunk]
workflow_dispatch:
inputs:
move-baseline:
description: 'Move the baseline tag to current trunk HEAD and flip stale statuses'
type: boolean
default: false

# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
manzoorwanijk marked this conversation as resolved.
Dismissed
permissions: {}

concurrency:
# Serialize per PR, and serialize all baseline moves in one group. Replaced
# pending runs are safe: labeled merges are scanned cumulatively from the
# tag, so the next run in the group recovers them.
group: required-trunk-changes-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || 'baseline' }}
cancel-in-progress: false
Comment thread
manzoorwanijk marked this conversation as resolved.
# The default queue keeps a single pending run, so a trunk push could
# replace (cancel) a pending forced dispatch; queue: max keeps them all.
queue: max

jobs:
check-pr:
name: Check required changes from trunk
if: github.repository == 'WordPress/gutenberg' && github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
statuses: write
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
# Trunk only. PR code is never checked out, built, or executed.
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: trunk
persist-credentials: false

- name: Setup Node.js and install dependencies
uses: ./.github/setup-node

- name: Check required changes from trunk and post status
run: npm run --workspace @wordpress/validation-tools required-trunk-changes -- check --head-sha "$HEAD_SHA"

move-baseline:
name: Move required trunk baseline
# The baseline moves only through explicit intent: a dispatch with
# move-baseline, or a merged PR carrying the required-update label
# (detected cumulatively on every trunk push). Re-dispatching when the
# tag is already current re-runs the fan-out, which is the retry path
# after an interrupted sweep.
if: >
github.repository == 'WordPress/gutenberg' && (
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.move-baseline)
)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
statuses: write
pull-requests: read
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.move-baseline }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: trunk
persist-credentials: false

- name: Setup Node.js and install dependencies
uses: ./.github/setup-node

- name: Move the baseline tag and fan out
run: npm run --workspace @wordpress/validation-tools required-trunk-changes -- move-baseline --force="$FORCE" --then-fanout
53 changes: 53 additions & 0 deletions docs/contributors/code/required-changes-from-trunk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Required changes from trunk

The `Required changes from trunk` status check ensures open pull requests contain the latest required repo-wide infrastructure changes from `trunk` (toolchain bumps like a Node.js upgrade, or repo-wide formatting and linting changes) before they can be merged. This prevents a pull request based on an older commit from passing CI against an outdated toolchain or reintroducing old-style code.

## How it works

A movable lightweight git tag, `required-trunk-baseline`, points at the last `trunk` commit that every open pull request must contain. The check passes when the tagged commit is an ancestor of the pull request head, and fails otherwise.

The tag moves only through explicit maintainer intent, in one of two ways:

- Dispatching the `Required changes from trunk` workflow with `move-baseline: true`, or
- Merging a pull request that carries the `Require PR update` label.

When the tag moves, open pull requests holding a now-outdated passing status are switched to failing, subject to the limits below.

## Limits

The sweep that follows a baseline move is intentionally bounded:

- Draft pull requests are skipped; they cannot merge anyway and are re-checked when marked ready for review.
- Each run writes at most 400 statuses to stay inside API rate limits. A run that hits the cap (or any write failure) exits nonzero; dispatch the workflow again with `move-baseline: true` until a run finishes cleanly.
- Pull requests that never received a status show as "Expected", which also blocks merging; they get a real status on their next update.
- A pull request checked at the exact moment the baseline moves can briefly keep an outdated status; it converges on the pull request's next event or the next dispatch.

## My pull request has a red "Required changes from trunk" status. What do I do?

Update your branch past the current baseline, using either method:

- Merge the latest `trunk` into your branch, or
- Rebase your branch onto the latest `trunk`.

Pushing the update re-runs the check and turns it green.

## The `Require PR update` label

Committers apply the `Require PR update` label to a pull request whose change invalidates all open pull requests, such as a toolchain bump or a repo-wide formatting change. When such a pull request merges, the baseline moves and all open pull requests must update. The label is detected cumulatively on every `trunk` push, so it still takes effect if workflow runs are coalesced.

## Maintainer runbook

The `Required changes from trunk` workflow exposes one `workflow_dispatch` switch, `move-baseline`: it moves the baseline tag to the current `trunk` HEAD and flips stale passing statuses. Re-dispatching when the baseline is already current re-runs only the flip sweep, which is the retry path after an interrupted run.

### Initial setup

The workflow blocks nothing until these one-time steps are completed, in order:

1. Create the `Require PR update` label.
2. Seed the baseline: dispatch the workflow with `move-baseline: true`.
3. Let the check run unenforced for a while; active pull requests get stamped by their own events.
4. Ask a repository admin to add `Required changes from trunk` (source: GitHub Actions) to the trunk ruleset's required status checks, and to add a tag ruleset for `required-trunk-baseline` restricting creation, update, and deletion to the GitHub Actions app.

To roll the check back, remove `Required changes from trunk` from the required status checks; existing statuses become informational immediately. The tag can stay for a later re-enable.

The implementation lives in `tools/validation/required-trunk-changes/` and `.github/workflows/required-changes-from-trunk.yml`.
1 change: 1 addition & 0 deletions docs/contributors/repository-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Here are some labels you might commonly see:

- [Good First Issue](https://github.com/WordPress/gutenberg/labels/Good%20First%20Issue) - Issues identified as good for new contributors to work on. Comment to note that you intend to work on the issue and reference the issue number in the pull request you submit.
- [Good First Review](https://github.com/WordPress/gutenberg/labels/Good%20First%20Review) - Pull requests identified as good for new contributors who are interested in doing code reviews.
- [Require PR update](https://github.com/WordPress/gutenberg/labels/Require%20PR%20update) - Applied by committers to a pull request whose change requires all open pull requests to update once it merges (see [Required changes from trunk](/docs/contributors/code/required-changes-from-trunk.md)).
- [Needs Accessibility Feedback](https://github.com/WordPress/gutenberg/labels/Needs%20Accessibility%20Feedback) - Changes that impact accessibility and need corresponding review (e.g. markup changes).
- [Needs Design Feedback](https://github.com/WordPress/gutenberg/labels/Needs%20Design%20Feedback) - Changes that modify the design or user experience in some way and need sign-off.
- [[Type] Bug](https://github.com/WordPress/gutenberg/labels/%5BType%5D%20Bug) - An existing feature is broken in some way.
Expand Down
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3221,6 +3221,12 @@
"markdown_source": "../docs/contributors/code/workspace-development.md",
"parent": "code"
},
{
"title": "Required changes from trunk",
"slug": "required-changes-from-trunk",
"markdown_source": "../docs/contributors/code/required-changes-from-trunk.md",
"parent": "code"
},
{
"title": "Agents and Skills",
"slug": "agents-and-skills",
Expand Down
3 changes: 3 additions & 0 deletions docs/toc.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@
"docs/contributors/code/package-runtime-compatibility.md": []
},
{ "docs/contributors/code/workspace-development.md": [] },
{
"docs/contributors/code/required-changes-from-trunk.md": []
},
{ "docs/contributors/code/agents-and-skills.md": [] },
{
"docs/contributors/code/release/README.md": [
Expand Down
1 change: 1 addition & 0 deletions tools/validation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dependency-audit": "dependency-audit --collapse-root-cause \"../../packages/*\"",
"check-local-changes": "node ./check-local-changes.cjs",
"check-root-dependencies": "node ./check-root-dependencies.mjs",
"required-trunk-changes": "node ./required-trunk-changes/index.mjs",
"test-create-block": "node ./test-create-block.mjs",
"validate-package-lock": "node ./validate-package-lock.cjs",
"validate-tsconfig": "node ./validate-tsconfig.mjs"
Expand Down
52 changes: 52 additions & 0 deletions tools/validation/required-trunk-changes/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @ts-check

Comment thread
manzoorwanijk marked this conversation as resolved.
/**
* The `check` subcommand: stamps one PR head with its required changes status.
*/
import {
fail,
getBaseline,
isAncestor,
statusFor,
latestStatus,
postStatus,
} from './utils.mjs';

/** @typedef {import('./types.mjs').CommandOptions} CommandOptions */

/**
* Checks one PR head against the baseline and stamps its status.
*
* @param {CommandOptions} options Command options.
*/
export async function check( { headSha, dryRun } ) {
if ( ! headSha ) {
return fail( 'The check subcommand requires --head-sha.' );
}
let baseline = /** @type {string} */ ( await getBaseline() );
let includesBaseline = await isAncestor( baseline, headSha );

// Re-read the baseline just before posting to shrink the move race window.
const latest = /** @type {string} */ ( await getBaseline() );
if ( latest !== baseline ) {
baseline = latest;
includesBaseline = await isAncestor( baseline, headSha );
}

const { state, description } = statusFor( includesBaseline, baseline );

/* Statuses are append-only and capped per SHA/context; skip no-op writes. */
const current = await latestStatus( headSha );
if (
current &&
current.state === state.toUpperCase() &&
current.description === description
) {
console.log( `Status already current (${ state }); skipping write.` );
return;
}
await postStatus( headSha, state, description, dryRun );
console.log(
`Posted ${ state } for ${ headSha } against baseline ${ baseline }.`
);
}
159 changes: 159 additions & 0 deletions tools/validation/required-trunk-changes/fanout.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// @ts-check

/**
* The `fanout` subcommand: after a baseline move, flips open PRs whose
* passing required changes status references an older baseline to failing.
*/
import {
CONTEXT,
REPO,
graphql,
getBaseline,
statusFor,
postStatus,
} from './utils.mjs';

/** @typedef {import('./types.mjs').CommandOptions} CommandOptions */
/** @typedef {import('./types.mjs').PullRequest} PullRequest */

const PAGE_QUERY = `
query (
$owner: String!
$name: String!
$cursor: String
$context: String!
) {
repository(owner: $owner, name: $name) {
pullRequests(
states: OPEN
baseRefName: "trunk"
first: 100
after: $cursor
orderBy: { field: UPDATED_AT, direction: DESC }
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
headRefOid
isDraft
commits(last: 1) {
nodes {
commit {
status {
context(name: $context) {
state
description
}
}
}
}
}
}
}
}
}
`;

/**
* Lists open PRs targeting trunk, with their latest required changes status.
*
* @return {Promise<PullRequest[]>} Open pull requests.
*/
async function listOpenPRs() {
const [ owner, name ] = REPO.split( '/' );
/** @type {PullRequest[]} */
const prs = [];
let cursor = null;
for (;;) {
const data = await graphql( PAGE_QUERY, {
owner,
name,
cursor,
context: CONTEXT,
} );
const page = data.repository.pullRequests;
for ( const node of page.nodes ) {
prs.push( {
number: node.number,
headRefOid: node.headRefOid,
isDraft: node.isDraft,
status:
node.commits.nodes[ 0 ]?.commit?.status?.context ?? null,
} );
}
if ( ! page.pageInfo.hasNextPage ) {
break;
}
cursor = page.pageInfo.endCursor;
}
return prs;
}

/**
* Flips stale passing statuses to failing against the current baseline.
*
* @param {CommandOptions} options Command options.
*/
export async function fanout( { dryRun } ) {
const baseline = /** @type {string} */ ( await getBaseline() );
const short = baseline.slice( 0, 7 );

const prs = await listOpenPRs();
console.log( `Baseline ${ baseline }; ${ prs.length } open PRs.` );

let written = 0;
let skipped = 0;
let failed = 0;
let budgetExhausted = false;

for ( const pr of prs ) {
/* Drafts cannot merge, and ready_for_review re-stamps them. */
if ( pr.isDraft ) {
skipped++;
continue;
}
const stale =
pr.status?.state === 'SUCCESS' &&
! pr.status.description?.includes( short );
if ( ! stale ) {
skipped++;
continue;
}
const { state, description } = statusFor( false, baseline );
try {
if (
! ( await postStatus(
pr.headRefOid,
state,
description,
dryRun
) )
) {
budgetExhausted = true;
break;
}
written++;
} catch ( error ) {
failed++;
console.error(
`PR #${ pr.number }: ${
error instanceof Error ? error.message : error
}`
);
}
}

console.log(
`Fan-out done: ${ written } written, ${ skipped } skipped, ${ failed } failed.` +
( budgetExhausted
? ' Write budget exhausted; dispatch the workflow again to continue.'
: '' )
);
// Nonzero exit makes an incomplete or lossy sweep visible in the run list.
if ( failed > 0 || budgetExhausted ) {
process.exitCode = 1;
}
}
Loading
Loading