diff --git a/.github/workflows/required-changes-from-trunk.yml b/.github/workflows/required-changes-from-trunk.yml index f03f193a179047..c489bbdb201043 100644 --- a/.github/workflows/required-changes-from-trunk.yml +++ b/.github/workflows/required-changes-from-trunk.yml @@ -2,87 +2,106 @@ name: Required changes from trunk on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, edited] + types: [opened, synchronize, reopened, ready_for_review, edited, closed] 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 + mode: + description: 'auto moves the baseline only if a labeled merge earned it, then refreshes; move-baseline forces a move and then refreshes; refresh-pr-statuses only refreshes open PR statuses' + type: choice + default: auto + options: [auto, move-baseline, refresh-pr-statuses] # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. 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 - # 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 +env: + # One source of truth for both jobs. No markers: the baseline moves only + # through a labeled merge or a dispatch, never because a path changed. + PR_BASELINES: '[{"name":"required-trunk-changes","label":"Require PR update"}]' + STATUS_CONTEXT: Required changes from trunk + DESCRIPTION_PASS: Includes required {base} changes. + DESCRIPTION_FAIL: Merge or rebase {base} to include required changes. jobs: check-pr: - name: Check required changes from trunk - if: github.repository == 'WordPress/gutenberg' && github.event_name == 'pull_request_target' + name: Report required trunk changes status + if: > + github.repository == 'WordPress/gutenberg' && + github.event_name == 'pull_request_target' && github.event.action != 'closed' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 permissions: contents: read statuses: write - env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + concurrency: + group: required-trunk-changes-status-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - # Trunk only. PR code is never checked out, built, or executed. - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The head SHA comes from the event. PR code is never checked out, + # built, or executed. + - name: Report the status for this pull request + uses: mawesomedev/pr-baseline-action@8c21a09691ccfdf5ea118cb60cee8ec8b99231a1 # v0.1.1 with: - ref: trunk - persist-credentials: false + base: trunk + baselines: ${{ env.PR_BASELINES }} + status-context: ${{ env.STATUS_CONTEXT }} + description-pass: ${{ env.DESCRIPTION_PASS }} + description-fail: ${{ env.DESCRIPTION_FAIL }} - - 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. + refresh-pr-statuses: + name: Move the required trunk baseline and refresh statuses + # The baseline moves only through explicit intent: a merged pull request + # carrying the label, or a dispatch with mode move-baseline. Every trunk + # push recovers a move whose own run was lost, and continues a refresh + # that stopped on its write budget. if: > github.repository == 'WordPress/gutenberg' && ( - github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' && inputs.move-baseline) + (github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged) || + (github.event_name == 'push' && github.ref_name == 'trunk') || + github.event_name == 'workflow_dispatch' ) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: write statuses: write pull-requests: read - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.move-baseline }} + concurrency: + group: required-trunk-changes-refresh + cancel-in-progress: false + # 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 steps: + # Treeless but complete history: ancestry needs commits, not trees, + # and reading it from the clone costs no API quota. - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: trunk + fetch-depth: 0 + filter: tree:0 persist-credentials: false - - name: Setup Node.js and install dependencies - uses: ./.github/setup-node + - name: Move the baseline and refresh open pull requests + id: pr-baseline + uses: mawesomedev/pr-baseline-action@8c21a09691ccfdf5ea118cb60cee8ec8b99231a1 # v0.1.1 + with: + base: trunk + baselines: ${{ env.PR_BASELINES }} + status-context: ${{ env.STATUS_CONTEXT }} + description-pass: ${{ env.DESCRIPTION_PASS }} + description-fail: ${{ env.DESCRIPTION_FAIL }} + mode: ${{ inputs.mode || 'auto' }} + force: ${{ inputs.mode == 'move-baseline' }} - - name: Move the baseline tag and fan out - run: npm run --workspace @wordpress/validation-tools required-trunk-changes -- move-baseline --force="$FORCE" --then-fanout + - name: Upload the per-pull-request results + if: ${{ always() && steps.pr-baseline.outputs.results-file != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: required-trunk-changes-refresh + path: ${{ steps.pr-baseline.outputs.results-file }} diff --git a/docs/contributors/code/required-changes-from-trunk.md b/docs/contributors/code/required-changes-from-trunk.md index 81e327a52de5a8..f47029890ab92e 100644 --- a/docs/contributors/code/required-changes-from-trunk.md +++ b/docs/contributors/code/required-changes-from-trunk.md @@ -4,23 +4,22 @@ The `Required changes from trunk` status check ensures open pull requests contai ## 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. +A movable git ref, `refs/baselines/required-trunk-changes`, points at the last `trunk` commit that every open pull request must contain. The check passes when that commit is an ancestor of the pull request head, and fails otherwise. While the ref does not exist, nothing is required and every pull request passes. -The tag moves only through explicit maintainer intent, in one of two ways: +The baseline 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. +- Merging a pull request that carries the `Require PR update` label, or +- Dispatching the `Required changes from trunk` workflow with `mode: move-baseline`. -When the tag moves, open pull requests holding a now-outdated passing status are switched to failing, subject to the limits below. +When the baseline moves, every open pull request is restamped with the verdict it now warrants. -## Limits +The ref lives outside `refs/heads/` and `refs/tags/` on purpose: nothing fetches it unless asked, so a move never disturbs a `git pull`, starts no workflow, and no branch cleanup tool removes it. `git ls-remote origin 'refs/baselines/*'` shows where it points. -The sweep that follows a baseline move is intentionally bounded: +## Limits -- 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. +- Each run writes at most 450 statuses. A run that stops on that budget, or on a rate limit, exits nonzero and says so; the next `trunk` push, or a dispatch, continues where it left off. +- A pull request that never received a status shows as "Expected", which also blocks merging. The next refresh or its own next update gives it a real one. +- A pull request whose head moves while a refresh is running is deferred to the next one rather than stamped at a stale head. ## My pull request has a red "Required changes from trunk" status. What do I do? @@ -29,25 +28,26 @@ 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. +Pushing the update re-runs the check and turns it green. The failing status links to the compare view of exactly what your branch is missing. ## 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. +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. ## 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. +The `Required changes from trunk` workflow exposes a `mode` dispatch input: `auto` behaves like a `trunk` push, moving the baseline only if a labeled merge earned it and then refreshing, `move-baseline` forces a move to the current `trunk` HEAD and then refreshes, and `refresh-pr-statuses` only refreshes open pull request statuses, 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. +2. Let a refresh run with no baseline ref. Every open pull request receives a pass, which proves the token and permissions before anything can block. +3. Seed the baseline: dispatch the workflow with `mode: move-baseline`, which seeds it at the current `trunk` HEAD. To seed at an earlier commit, push that commit to the ref directly: `git push origin :refs/baselines/required-trunk-changes`. +4. Re-dispatch with `mode: refresh-pr-statuses` until a run reports no writes, so that no pull request is left unstamped. +5. Ask a repository admin to add the `Required changes from trunk` commit status (source: GitHub Actions) to the trunk ruleset's required status checks. -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. +Rulesets cannot cover `refs/baselines/`, so anyone with write access can move or delete the ref; the tooling itself only ever fast-forwards it. To roll the check back, remove `Required changes from trunk` from the required status checks; existing statuses become informational immediately. -The implementation lives in `tools/validation/required-trunk-changes/` and `.github/workflows/required-changes-from-trunk.yml`. +The workflow lives in `.github/workflows/required-changes-from-trunk.yml` and runs the [`pr-baseline` action](https://github.com/marketplace/actions/pr-baseline), whose [documentation](https://github.com/mawesomedev/mawesome/tree/main/packages/pr-baseline/docs) covers permissions, rate limits and edge cases in full. diff --git a/tools/validation/package.json b/tools/validation/package.json index 0d51f08fc99b0d..e111beac26fa6e 100644 --- a/tools/validation/package.json +++ b/tools/validation/package.json @@ -44,7 +44,6 @@ "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" diff --git a/tools/validation/required-trunk-changes/check.mjs b/tools/validation/required-trunk-changes/check.mjs deleted file mode 100644 index ae3692cd480215..00000000000000 --- a/tools/validation/required-trunk-changes/check.mjs +++ /dev/null @@ -1,52 +0,0 @@ -// @ts-check - -/** - * 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 }.` - ); -} diff --git a/tools/validation/required-trunk-changes/fanout.mjs b/tools/validation/required-trunk-changes/fanout.mjs deleted file mode 100644 index aa8b60df8210ec..00000000000000 --- a/tools/validation/required-trunk-changes/fanout.mjs +++ /dev/null @@ -1,159 +0,0 @@ -// @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} 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; - } -} diff --git a/tools/validation/required-trunk-changes/index.mjs b/tools/validation/required-trunk-changes/index.mjs deleted file mode 100644 index a24f53cdaa9701..00000000000000 --- a/tools/validation/required-trunk-changes/index.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-check - -/** - * Required trunk changes tooling: a movable tag (`required-trunk-baseline`) - * marks the last trunk commit every open PR must contain; statuses embed that - * SHA to expose staleness. - */ -import { parseArgs } from 'node:util'; -import { fail, hasCredentials } from './utils.mjs'; -import { check } from './check.mjs'; -import { moveBaseline } from './move-baseline.mjs'; -import { fanout } from './fanout.mjs'; - -/** @typedef {import('./types.mjs').CommandOptions} CommandOptions */ - -async function main() { - const { positionals, values } = parseArgs( { - allowPositionals: true, - options: { - 'head-sha': { type: 'string' }, - // String, not boolean: a boolean option rejects --force=false. - force: { type: 'string', default: 'false' }, - 'then-fanout': { type: 'boolean', default: false }, - 'dry-run': { type: 'boolean', default: false }, - }, - } ); - - if ( values.force !== 'true' && values.force !== 'false' ) { - return fail( '--force accepts only "true" or "false".' ); - } - - /** @type {CommandOptions} */ - const options = { - headSha: values[ 'head-sha' ], - force: values.force, - thenFanout: values[ 'then-fanout' ] ?? false, - dryRun: values[ 'dry-run' ] ?? false, - }; - - const commands = { check, 'move-baseline': moveBaseline, fanout }; - const command = commands[ positionals[ 0 ] ?? '' ]; - if ( ! command ) { - return fail( - 'Usage: required-trunk-changes [flags]' - ); - } - // Checked late so usage and flag errors do not require credentials. - if ( ! hasCredentials() ) { - return fail( 'GITHUB_REPOSITORY and GITHUB_TOKEN must be set.' ); - } - return command( options ); -} - -main().catch( ( error ) => { - console.error( error ); - process.exit( 1 ); -} ); diff --git a/tools/validation/required-trunk-changes/index.test.js b/tools/validation/required-trunk-changes/index.test.js deleted file mode 100644 index cd8fc579e2c26f..00000000000000 --- a/tools/validation/required-trunk-changes/index.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { expect, test } from 'vitest'; - -const cliPath = fileURLToPath( new URL( './index.mjs', import.meta.url ) ); - -/** - * Runs the CLI with the given arguments and environment overrides. - * - * @param {string[]} args Command-line arguments. - * @param {Record} env Environment variable overrides. - * @return {{ status: number | null, stderr: string, stdout: string }} Result. - */ -function run( args, env = {} ) { - const { status, stderr, stdout } = spawnSync( - 'node', - [ cliPath, ...args ], - { - encoding: 'utf8', - env: { - ...process.env, - GITHUB_REPOSITORY: '', - GITHUB_TOKEN: '', - ...env, - }, - } - ); - return { status, stderr, stdout }; -} - -test( 'prints usage without requiring credentials', () => { - const { status, stderr } = run( [] ); - expect( status ).toBe( 1 ); - expect( stderr ).toContain( 'Usage: required-trunk-changes' ); - expect( stderr ).not.toContain( 'GITHUB_REPOSITORY' ); -} ); - -test( 'rejects an unknown subcommand with usage', () => { - const { status, stderr } = run( [ 'frobnicate' ] ); - expect( status ).toBe( 1 ); - expect( stderr ).toContain( 'Usage: required-trunk-changes' ); -} ); - -test( 'rejects a non-boolean --force value without requiring credentials', () => { - const { status, stderr } = run( [ 'move-baseline', '--force=maybe' ] ); - expect( status ).toBe( 1 ); - expect( stderr ).toContain( '--force accepts only' ); - expect( stderr ).not.toContain( 'GITHUB_REPOSITORY' ); -} ); - -test( 'requires credentials only for a valid subcommand', () => { - const { status, stderr } = run( [ 'fanout' ] ); - expect( status ).toBe( 1 ); - expect( stderr ).toContain( - 'GITHUB_REPOSITORY and GITHUB_TOKEN must be set.' - ); -} ); diff --git a/tools/validation/required-trunk-changes/move-baseline.mjs b/tools/validation/required-trunk-changes/move-baseline.mjs deleted file mode 100644 index 16c1352eeab71f..00000000000000 --- a/tools/validation/required-trunk-changes/move-baseline.mjs +++ /dev/null @@ -1,140 +0,0 @@ -// @ts-check - -/** - * The `move-baseline` subcommand. The baseline moves only through explicit - * intent: a forced dispatch, or a merged PR carrying the required-update label. - */ -import { - REQUIRE_UPDATE_LABEL, - REPO, - fail, - graphql, - getBaseline, - getTrunkHead, - isAncestor, - updateTagRef, -} from './utils.mjs'; -import { fanout } from './fanout.mjs'; - -/** @typedef {import('./types.mjs').CommandOptions} CommandOptions */ - -/** - * Detects a merged, force-labeled PR whose merge commit landed after the - * baseline; scanned cumulatively so coalesced push runs lose nothing. - * - * @param {string} baseline Baseline commit SHA. - * @param {string} head Trunk head commit SHA. - * @return {Promise} Whether such a merge exists. - */ -async function hasLabeledMergeSince( baseline, head ) { - const [ owner, name ] = REPO.split( '/' ); - let cursor = null; - for (;;) { - const data = await graphql( - ` - query ( - $owner: String! - $name: String! - $label: String! - $cursor: String - ) { - repository(owner: $owner, name: $name) { - pullRequests( - labels: [$label] - states: MERGED - baseRefName: "trunk" - first: 50 - after: $cursor - orderBy: { field: UPDATED_AT, direction: DESC } - ) { - pageInfo { - hasNextPage - endCursor - } - nodes { - mergeCommit { - oid - } - } - } - } - } - `, - { owner, name, label: REQUIRE_UPDATE_LABEL, cursor } - ); - const page = data.repository.pullRequests; - for ( const node of page.nodes ) { - const oid = node.mergeCommit?.oid; - if ( ! oid || oid === baseline ) { - continue; - } - if ( - ( await isAncestor( baseline, oid ) ) && - ( await isAncestor( oid, head ) ) - ) { - return true; - } - } - if ( ! page.pageInfo.hasNextPage ) { - return false; - } - cursor = page.pageInfo.endCursor; - } -} - -/** - * Moves (or seeds) the baseline tag when a move is forced or labeled. - * - * @param {CommandOptions} options Command options. - */ -export async function moveBaseline( options ) { - const { force, thenFanout, dryRun } = options; - const baseline = await getBaseline( false ); - - if ( baseline === null ) { - if ( force !== 'true' ) { - console.log( - 'No baseline tag exists and no --force; not seeding.' - ); - return; - } - const head = await getTrunkHead(); - console.log( `Seeding baseline at ${ head }.` ); - if ( ! dryRun ) { - await updateTagRef( head, true ); - } - // A dry-run seed creates no tag; the fan-out would have nothing to read. - if ( thenFanout && ! dryRun ) { - await fanout( options ); - } - return; - } - - const head = await getTrunkHead(); - const move = - force === 'true' || ( await hasLabeledMergeSince( baseline, head ) ); - if ( ! move ) { - console.log( - 'No forced move or labeled merges since the baseline; not moving.' - ); - return; - } - - if ( baseline === head ) { - /* Re-dispatch retry path: still fan out to flip any stale survivors. */ - console.log( 'Baseline already points at trunk HEAD.' ); - } else { - if ( ! ( await isAncestor( baseline, head ) ) ) { - return fail( - `Refusing to move the baseline: ${ head } does not descend from ${ baseline }.` - ); - } - console.log( `Moving baseline: ${ baseline } -> ${ head }` ); - if ( ! dryRun ) { - await updateTagRef( head, false ); - } - } - if ( thenFanout ) { - await fanout( options ); - } -} diff --git a/tools/validation/required-trunk-changes/types.mjs b/tools/validation/required-trunk-changes/types.mjs deleted file mode 100644 index 509fb6f0bcdfa9..00000000000000 --- a/tools/validation/required-trunk-changes/types.mjs +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Shared JSDoc typedefs for the required trunk changes tooling. - */ - -/** @typedef {{ state: 'success' | 'failure', description: string }} StatusPayload */ -/** @typedef {{ state: string, description: string | null }} ContextStatus */ - -/** - * @typedef PullRequest - * @property {number} number PR number. - * @property {string} headRefOid Head commit SHA. - * @property {boolean} isDraft Whether the PR is a draft. - * @property {ContextStatus | null} status Latest required changes status, if any. - */ - -/** - * @typedef CommandOptions - * @property {string | undefined} headSha Head SHA for `check`. - * @property {string} force 'true' forces a baseline move. - * @property {boolean} thenFanout Fan out after a move. - * @property {boolean} dryRun Log writes without posting. - */ - -export {}; diff --git a/tools/validation/required-trunk-changes/utils.mjs b/tools/validation/required-trunk-changes/utils.mjs deleted file mode 100644 index 06b60f879468b0..00000000000000 --- a/tools/validation/required-trunk-changes/utils.mjs +++ /dev/null @@ -1,287 +0,0 @@ -// @ts-check - -/** - * Shared constants and helpers for the required trunk changes tooling: GitHub - * API access, baseline resolution, and rate-limited commit status writes. - */ - -/** @typedef {import('./types.mjs').StatusPayload} StatusPayload */ -/** @typedef {import('./types.mjs').ContextStatus} ContextStatus */ - -export const CONTEXT = 'Required changes from trunk'; -export const TAG = 'required-trunk-baseline'; -const TAG_REF = `refs/tags/${ TAG }`; -export const REPO = process.env.GITHUB_REPOSITORY ?? ''; -const TOKEN = process.env.GITHUB_TOKEN ?? ''; -const API_ROOT = 'https://api.github.com'; -const DOCS_URL = - 'https://github.com/WordPress/gutenberg/blob/trunk/docs/contributors/code/required-changes-from-trunk.md'; - -export const hasCredentials = () => Boolean( REPO && TOKEN ); - -/* Conservative caps against GITHUB_TOKEN primary and secondary rate limits. */ -const MAX_WRITES_PER_RUN = 400; -const MAX_WRITES_PER_MINUTE = 60; - -export const REQUIRE_UPDATE_LABEL = 'Require PR update'; - -/** - * Prints an error and exits. - * - * @param {string} message Error message. - * @return {never} Never returns. - */ -export function fail( message ) { - console.error( message ); - process.exit( 1 ); -} - -/** - * Performs an authenticated GitHub API request. - * - * @param {string} path Request path. - * @param {{ method?: string, body?: string, headers?: Record, allow404?: boolean }} [options] Fetch options. - * @return {Promise} Parsed JSON response. - */ -async function api( path, options = {} ) { - const RETRYABLE_STATUS = [ 502, 503, 504 ]; - const MAX_ATTEMPTS = 3; - for ( let attempt = 1; ; attempt++ ) { - try { - const response = await fetch( `${ API_ROOT }${ path }`, { - ...options, - headers: { - Authorization: `Bearer ${ TOKEN }`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, - }, - } ); - if ( ! response.ok ) { - if ( response.status === 404 && options.allow404 ) { - return null; - } - if ( - RETRYABLE_STATUS.includes( response.status ) && - attempt < MAX_ATTEMPTS - ) { - throw new TypeError( `retryable ${ response.status }` ); - } - throw new Error( - `GitHub API ${ path } failed: ${ - response.status - } ${ await response.text() }` - ); - } - return response.json(); - } catch ( error ) { - // Network failures surface from fetch as TypeError with a cause. - if ( ! ( error instanceof TypeError ) || attempt >= MAX_ATTEMPTS ) { - throw error; - } - await new Promise( ( resolve ) => - setTimeout( resolve, 2000 * attempt ) - ); - } - } -} - -/** - * Performs a GraphQL query. - * - * @param {string} query GraphQL query. - * @param {Record} variables Query variables. - * @return {Promise} The response's `data` value. - */ -export async function graphql( query, variables ) { - const result = /** @type {{ errors?: unknown[], data: any }} */ ( - await api( '/graphql', { - method: 'POST', - body: JSON.stringify( { query, variables } ), - } ) - ); - if ( result.errors ) { - throw new Error( - `GraphQL request failed: ${ JSON.stringify( result.errors ) }` - ); - } - return result.data; -} - -/** - * Resolves the baseline commit SHA from the tag ref via the API. - * - * @param {boolean} [required] Throw when the tag does not exist. - * @return {Promise} Baseline commit SHA, or null when absent. - */ -export async function getBaseline( required = true ) { - const ref = /** @type {any} */ ( - await api( `/repos/${ REPO }/git/ref/tags/${ TAG }`, { - allow404: true, - } ) - ); - if ( ! ref ) { - if ( required ) { - throw new Error( - `The ${ TAG } tag does not exist; seed it via workflow dispatch.` - ); - } - return null; - } - // Peel defensively; the tag should always be lightweight. - if ( ref.object.type === 'tag' ) { - const tag = /** @type {any} */ ( - await api( `/repos/${ REPO }/git/tags/${ ref.object.sha }` ) - ); - return tag.object.sha; - } - return ref.object.sha; -} - -/** - * Tests commit ancestry via the compare API; no local clone needed. - * - * @param {string} ancestor Candidate ancestor commit SHA. - * @param {string} descendant Candidate descendant commit SHA. - * @return {Promise} Whether ancestor is an ancestor of descendant. - */ -export async function isAncestor( ancestor, descendant ) { - const compare = /** @type {any} */ ( - await api( - `/repos/${ REPO }/compare/${ ancestor }...${ descendant }?per_page=1` - ) - ); - // 'ahead' means descendant builds on ancestor; 'identical' is the edge. - return compare.status === 'ahead' || compare.status === 'identical'; -} - -/** - * Resolves the current trunk head commit SHA. - * - * @return {Promise} Trunk head SHA. - */ -export async function getTrunkHead() { - const commit = /** @type {any} */ ( - await api( `/repos/${ REPO }/commits/trunk` ) - ); - return commit.sha; -} - -/** - * Builds the status payload for a required changes result. - * - * @param {boolean} includesBaseline Whether the PR contains the baseline. - * @param {string} baseline Baseline commit SHA. - * @return {StatusPayload} Status state and description. - */ -export function statusFor( includesBaseline, baseline ) { - const short = baseline.slice( 0, 7 ); - return includesBaseline - ? { - state: 'success', - description: `Includes required trunk changes through ${ short }.`, - } - : { - state: 'failure', - description: `Merge or rebase trunk to include required changes through ${ short }.`, - }; -} - -/** - * Reads the latest required changes status on a commit. - * - * @param {string} oid Commit SHA. - * @return {Promise} Latest status for the context. - */ -export async function latestStatus( oid ) { - const [ owner, name ] = REPO.split( '/' ); - const data = await graphql( - ` - query ( - $owner: String! - $name: String! - $oid: GitObjectID! - $context: String! - ) { - repository(owner: $owner, name: $name) { - object(oid: $oid) { - ... on Commit { - status { - context(name: $context) { - state - description - } - } - } - } - } - } - `, - { owner, name, oid, context: CONTEXT } - ); - return data.repository.object?.status?.context ?? null; -} - -/** - * Creates or force-updates the baseline tag ref via the API, so the workflow - * needs no persisted git credentials. - * - * @param {string} sha Commit SHA the tag should point at. - * @param {boolean} create Create the ref instead of updating it. - */ -export async function updateTagRef( sha, create ) { - if ( create ) { - await api( `/repos/${ REPO }/git/refs`, { - method: 'POST', - body: JSON.stringify( { ref: TAG_REF, sha } ), - } ); - return; - } - await api( `/repos/${ REPO }/git/refs/tags/${ TAG }`, { - method: 'PATCH', - body: JSON.stringify( { sha, force: true } ), - } ); -} - -let writesThisRun = 0; -let writesThisMinute = 0; -let minuteWindowStart = Date.now(); - -/** - * Posts a commit status, honoring the per-minute and per-run write budgets. - * - * @param {string} sha Commit SHA in the base repository. - * @param {string} state Status state: success or failure. - * @param {string} description Status description embedding the baseline. - * @param {boolean} dryRun Log the write instead of performing it. - * @return {Promise} False when the per-run budget is exhausted. - */ -export async function postStatus( sha, state, description, dryRun ) { - if ( dryRun ) { - console.log( `[dry-run] ${ sha }: ${ state } (${ description })` ); - return true; - } - if ( writesThisRun >= MAX_WRITES_PER_RUN ) { - return false; - } - if ( writesThisMinute >= MAX_WRITES_PER_MINUTE ) { - const waitMs = 60_000 - ( Date.now() - minuteWindowStart ); - if ( waitMs > 0 ) { - await new Promise( ( resolve ) => setTimeout( resolve, waitMs ) ); - } - minuteWindowStart = Date.now(); - writesThisMinute = 0; - } - await api( `/repos/${ REPO }/statuses/${ sha }`, { - method: 'POST', - body: JSON.stringify( { - state, - context: CONTEXT, - description, - target_url: DOCS_URL, - } ), - } ); - writesThisRun++; - writesThisMinute++; - return true; -}