Repository navigation
CI: Add "Required changes from trunk" PR check #82272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
837a7f5
Tools: Add PR freshness script
manzoorwanijk ca6bde9
CI: Add PR freshness workflow
manzoorwanijk 3f07cfd
Docs: Document the PR freshness check
manzoorwanijk 38839b7
CI: Avoid persisted git credentials in PR freshness workflow
manzoorwanijk c0e3dd3
Tools: Address review feedback for PR freshness script
manzoorwanijk fbd0eb2
Tools: Fix ancestry test to not rely on git exit codes
manzoorwanijk 6ca48a6
Tools: Retry transient GitHub API failures
manzoorwanijk 19ff8fb
CI: Evaluate baseline moves on every dispatch and refine markers
manzoorwanijk 16a1310
CI: Simplify PR freshness to explicit baseline moves
manzoorwanijk aba91bb
CI: Queue pending freshness runs and paginate the label scan
manzoorwanijk e3eef8d
Tools: Resolve baseline and ancestry via the REST API
manzoorwanijk 4cd9ca1
CI: Rename check to required changes from trunk
manzoorwanijk 0a6334f
Tests: Migrate required trunk changes test to Vitest
manzoorwanijk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
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 | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| // @ts-check | ||
|
|
||
|
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 }.` | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.