name: PR States # Maintains the CI-states block at the bottom of the PR body. Uses # pull_request_target (not pull_request) for fork-PR write access; safe # because we never check out PR head code, only API-read and PATCH the body. # # Three triggers, each covering a gap: # - pull_request_target: push / open / label change (all PRs incl forks). # - workflow_run: pr-test* requested / completed events (incl fork PRs). # `requested` is absent on reruns, but `completed` still fires. # - workflow_dispatch: explicit refreshes for a provided PR number. This # is the only event here that GITHUB_TOKEN can cascade-create. on: pull_request_target: types: [opened, synchronize, reopened, labeled, unlabeled] workflow_run: workflows: ["PR Test Base", "PR Test Extra", "PR Test (AMD)"] types: [requested, completed] workflow_dispatch: inputs: pr_number: description: 'PR number whose CI-states block should be refreshed.' required: true type: string permissions: pull-requests: write actions: read concurrency: # Per-SHA dedupe (PR number for workflow_dispatch). Queues concurrent # fires across the three triggers so they don't race on the body PATCH. group: pr-states-${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || inputs.pr_number }} cancel-in-progress: false jobs: update-pr-body: runs-on: ubuntu-latest steps: - name: Update CI states block in PR body uses: actions/github-script@v8 with: script: | const RETRY_ATTEMPTS = 3; const RETRY_DELAY_MS = 6000; const LABEL_ON_PRESLEEP_MS = 3000; const LABEL_OFF_PRESLEEP_MS = 6000; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // Event-agnostic PR lookup. // - pull_request_target: PR is in the payload. // - workflow_run: pull_requests[] is populated for same-repo // PRs; fork PRs need a head-ref reverse lookup // ("forkOwner:branch") because // listPullRequestsAssociatedWithCommit requires the commit to // be in the default branch. Bail when no open PR matches. // - workflow_dispatch: pr_number is an explicit input. let prNumber; if (context.payload.pull_request) { prNumber = context.payload.pull_request.number; } else if (context.eventName === 'workflow_run') { const wr = context.payload.workflow_run; // Only pull_request runs are the PR gate. The AMD workflow also // accepts workflow_dispatch for a single stage, and such a run on // a PR branch is PR-associated; refreshing off it would rewrite // the block from a run the rows below deliberately ignore. if (wr.event !== 'pull_request') { core.info(`Triggering run event '${wr.event}' is not pull_request; skipping.`); return; } if (wr.pull_requests && wr.pull_requests.length > 0) { prNumber = wr.pull_requests[0].number; } else if (wr.head_repository && wr.head_branch) { const headSpec = `${wr.head_repository.owner.login}:${wr.head_branch}`; const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', head: headSpec, }); // Multiple PRs could share a head ref if a branch was reused; // match by head_sha to pick the right one. const match = prs.find(p => p.head.sha === wr.head_sha) || prs[0]; if (!match) { core.info(`No open PR for head=${headSpec}; skipping.`); return; } prNumber = match.number; } else { core.info(`workflow_run has no PR linkage; skipping.`); return; } } else if (context.eventName === 'workflow_dispatch') { // Reject non-digit pr_number; parseInt('123abc', 10) returns // 123 and would silently truncate trailing garbage. const raw = context.payload.inputs.pr_number; if (!/^\d+$/.test(raw)) { core.setFailed(`workflow_dispatch: invalid pr_number input '${raw}' (must be digits only)`); return; } prNumber = parseInt(raw, 10); } else { core.info(`Unsupported event '${context.eventName}'; skipping.`); return; } const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, }); const sha = pr.head.sha; const labels = pr.labels.map(l => l.name); const hasCI = labels.includes('run-ci'); const hasExtra = labels.includes('run-ci-extra'); // Pre-sleep below only matters when a label was just removed; // other no-label fires don't race against fresh indexing. const justRemovedLabel = context.eventName === 'pull_request_target' && context.payload.action === 'unlabeled'; async function findRunBySha(workflowFile) { const { data } = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: workflowFile, head_sha: sha, // Every row reports the PR gate, so exclude other events for // this SHA: all three workflows also accept workflow_dispatch, // and for AMD that is a single stage rather than the full gate. event: 'pull_request', per_page: 1, }); return data.workflow_runs[0] || null; } async function findRun(workflowFile, { preSleepMs = 0, attempts = 1, delayMs = 0 }) { if (preSleepMs > 0) await sleep(preSleepMs); for (let i = 0; i < attempts; i++) { const run = await findRunBySha(workflowFile); if (run) return run; if (i < attempts - 1) await sleep(delayMs); } return null; } // Query unconditionally: removing a label does NOT cancel an // in-flight workflow, so a run from before removal can still be // live. Label state only picks the fallback text. labelOnOpts // covers fresh-push indexing race; labelOffOpts only pre-sleeps // on just-removed-label to catch removal-after-push. const labelOnOpts = { preSleepMs: LABEL_ON_PRESLEEP_MS, attempts: RETRY_ATTEMPTS, delayMs: RETRY_DELAY_MS }; const labelOffOpts = { preSleepMs: justRemovedLabel ? LABEL_OFF_PRESLEEP_MS : 0, attempts: 1 }; // The three lookups are independent, so the added one costs no // wall-clock time. The AMD lookup never spends the retry budget: // that workflow has an on.pull_request.paths filter, so a missing // run is usually legitimate rather than an indexing race, and the // race resolves itself -- `requested` is in the workflow_run // subscription above, so a real dispatch refreshes this block. const [ptRun, peRun, amdRun] = await Promise.all([ findRun('pr-test.yml', hasCI ? labelOnOpts : labelOffOpts), // pr-test-extra gates on BOTH labels (see check-changes there). findRun( 'pr-test-extra.yml', (hasCI && hasExtra) ? labelOnOpts : labelOffOpts, ), findRun('pr-test-amd.yml', labelOffOpts), ]); // Skipped run = "no real run" -- happens when a label is added // after a commit, because GHA doesn't retrigger on `labeled`. const isReal = (run) => run && run.conclusion !== 'skipped'; const missingCIText = ':x: **Missing `run-ci` label** -- add it to run CI tests.'; const peBlockedByCIText = ':x: **Blocked** -- `run-ci` is required first.'; const notExtraEnabledText = ':warning: **Not enabled** -- add `run-ci-extra` label to opt in.'; const stalePushText = ':warning: **Not run on latest push** -- push again to dispatch.'; // A missing run usually means the paths filter excluded the PR, // but pull_request workflows can also be suppressed by GitHub // (for example, for merge conflicts or skip annotations). const amdNotTriggeredText = ':heavy_minus_sign: **No AMD PR run found for this commit**.'; // Status icon: body otherwise looks uniformly "dispatched" even // for gate-failed runs (call-gate exits on missing label). function runIcon(run) { if (run.status !== 'completed') return ':hourglass_flowing_sand:'; switch (run.conclusion) { case 'success': return ':white_check_mark:'; case 'failure': return ':x:'; case 'cancelled': return ':no_entry_sign:'; case 'timed_out': return ':alarm_clock:'; case 'action_required': return ':warning:'; case 'neutral': return ':grey_question:'; default: return ':grey_question:'; } } const runLink = (run) => `${runIcon(run)} [Run #${run.id}](${run.html_url})`; const ptText = isReal(ptRun) ? runLink(ptRun) : (!hasCI ? missingCIText : '_Not run yet_'); const peText = isReal(peRun) ? runLink(peRun) : !hasCI ? peBlockedByCIText : !hasExtra ? notExtraEnabledText : stalePushText; const amdText = isReal(amdRun) ? runLink(amdRun) : amdNotTriggeredText; const outerStart = ''; const outerEnd = ''; const ptStart = ''; const ptEnd = ''; const peStart = ''; const peEnd = ''; const amdStart = ''; const amdEnd = ''; const newBlock = [ outerStart, '---', '### CI States', '', `Latest PR Test (Base): ${ptStart}${ptText}${ptEnd}`, `Latest PR Test (Extra): ${peStart}${peText}${peEnd}`, `Latest PR Test (AMD ROCm 10): ${amdStart}${amdText}${amdEnd}`, outerEnd, ].join('\n'); const body = pr.body || ''; // \n* absorbs preceding blank lines so repeated PATCHes don't accumulate them. const blockRe = new RegExp(`\\n*(?:---\\n+)?${outerStart}[\\s\\S]*?${outerEnd}`); let newBody; if (blockRe.test(body)) { newBody = body.replace(blockRe, `\n\n${newBlock}`); } else { const sep = body.length === 0 ? '' : (body.endsWith('\n') ? '\n' : '\n\n'); newBody = `${body}${sep}${newBlock}`; } if (newBody !== body) { await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, body: newBody, }); }