diff --git a/.github/workflows/required-changes-from-trunk.yml b/.github/workflows/required-changes-from-trunk.yml new file mode 100644 index 00000000000000..f03f193a179047 --- /dev/null +++ b/.github/workflows/required-changes-from-trunk.yml @@ -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. +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 + +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 diff --git a/docs/contributors/code/required-changes-from-trunk.md b/docs/contributors/code/required-changes-from-trunk.md new file mode 100644 index 00000000000000..81e327a52de5a8 --- /dev/null +++ b/docs/contributors/code/required-changes-from-trunk.md @@ -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`. diff --git a/docs/contributors/repository-management.md b/docs/contributors/repository-management.md index d35b2c8e518df3..45635d4f741455 100644 --- a/docs/contributors/repository-management.md +++ b/docs/contributors/repository-management.md @@ -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. diff --git a/docs/manifest.json b/docs/manifest.json index 27331a9a73212a..0e30ed683697c4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -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", diff --git a/docs/toc.json b/docs/toc.json index e2a4aa1a162e7a..0a03151daaf802 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -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": [ diff --git a/tools/validation/package.json b/tools/validation/package.json index 17387e57e991d1..7fabd208bb7ee4 100644 --- a/tools/validation/package.json +++ b/tools/validation/package.json @@ -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" diff --git a/tools/validation/required-trunk-changes/check.mjs b/tools/validation/required-trunk-changes/check.mjs new file mode 100644 index 00000000000000..ae3692cd480215 --- /dev/null +++ b/tools/validation/required-trunk-changes/check.mjs @@ -0,0 +1,52 @@ +// @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 new file mode 100644 index 00000000000000..aa8b60df8210ec --- /dev/null +++ b/tools/validation/required-trunk-changes/fanout.mjs @@ -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} 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 new file mode 100644 index 00000000000000..a24f53cdaa9701 --- /dev/null +++ b/tools/validation/required-trunk-changes/index.mjs @@ -0,0 +1,57 @@ +// @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 new file mode 100644 index 00000000000000..cd8fc579e2c26f --- /dev/null +++ b/tools/validation/required-trunk-changes/index.test.js @@ -0,0 +1,57 @@ +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 new file mode 100644 index 00000000000000..16c1352eeab71f --- /dev/null +++ b/tools/validation/required-trunk-changes/move-baseline.mjs @@ -0,0 +1,140 @@ +// @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 new file mode 100644 index 00000000000000..509fb6f0bcdfa9 --- /dev/null +++ b/tools/validation/required-trunk-changes/types.mjs @@ -0,0 +1,24 @@ +/** + * 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 new file mode 100644 index 00000000000000..06b60f879468b0 --- /dev/null +++ b/tools/validation/required-trunk-changes/utils.mjs @@ -0,0 +1,287 @@ +// @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; +}