pr-labels #8028
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
| name: pr-labels | |
| # `ready-to-merge`: adds/removes the label based on the `ci-complete` job's | |
| # conclusion (which is what branch protection gates on). The `workflow_run` | |
| # event fires after the `ci` workflow finishes; `pull_request_target: | |
| # synchronize` clears the stale label on a new push before CI finishes on | |
| # the new commit. | |
| # | |
| # `backport-check`: when a `backport release/X.Y.Z` label exists (release in | |
| # flight), every PR to main must declare backport intent via that label or | |
| # `backport:skip`. | |
| # | |
| # `first-time-contributor`: labels PRs whose author had not previously | |
| # committed to this repository when the PR was opened. The label is retained | |
| # as a snapshot even if the author's association changes later. | |
| # | |
| # Runs on the default branch via these events, so the GITHUB_TOKEN is | |
| # write-scoped even for PRs from forks — and the workflow file itself can't | |
| # be tampered with by a fork PR. | |
| on: | |
| workflow_run: | |
| workflows: [ci] | |
| types: [completed] | |
| pull_request_target: | |
| types: [synchronize, converted_to_draft, ready_for_review, opened, reopened, labeled, unlabeled] | |
| merge_group: | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: Pull request number to evaluate and backfill | |
| required: true | |
| type: number | |
| permissions: {} | |
| jobs: | |
| ready-to-merge: | |
| if: >- | |
| (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') || | |
| (github.event_name == 'pull_request_target' && | |
| (github.event.action == 'synchronize' || | |
| github.event.action == 'converted_to_draft' || | |
| github.event.action == 'ready_for_review')) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read # For listWorkflowRuns / listJobsForWorkflowRun | |
| contents: read # For listPullRequestsAssociatedWithCommit | |
| pull-requests: write # To add/remove label | |
| steps: | |
| - uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const label = 'ready-to-merge'; | |
| const { owner, repo } = context.repo; | |
| const event = context.eventName; | |
| const payload = context.payload; | |
| async function setLabel(prNumber, present) { | |
| if (present) { | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNumber, labels: [label], | |
| }); | |
| } else { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: prNumber, name: label, | |
| }); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; | |
| } | |
| } | |
| } | |
| async function ciCompleteOk(headSha) { | |
| const { data } = await github.rest.actions.listWorkflowRuns({ | |
| owner, repo, workflow_id: 'ci.yml', head_sha: headSha, per_page: 1, | |
| }); | |
| const run = data.workflow_runs[0]; | |
| if (!run || run.status !== 'completed') return false; | |
| const jobs = await github.paginate( | |
| github.rest.actions.listJobsForWorkflowRun, | |
| { owner, repo, run_id: run.id, per_page: 100 }, | |
| ); | |
| return jobs.find(j => j.name === 'ci-complete')?.conclusion === 'success'; | |
| } | |
| if (event === 'pull_request_target') { | |
| const prNumber = payload.pull_request.number; | |
| const action = payload.action; | |
| core.info(`event=${event} action=${action} pr=#${prNumber}`); | |
| if (action === 'converted_to_draft' || action === 'synchronize') { | |
| await setLabel(prNumber, false); | |
| } else if (action === 'ready_for_review') { | |
| // Refetch — draft state could have flipped again between the event and now. | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, repo, pull_number: prNumber, | |
| }); | |
| const ciOk = await ciCompleteOk(pr.head.sha); | |
| await setLabel(prNumber, ciOk && !pr.draft); | |
| } | |
| return; | |
| } | |
| // workflow_run | |
| const run = payload.workflow_run; | |
| const ciOk = await ciCompleteOk(run.head_sha); | |
| let prNumbers = run.pull_requests?.map(p => p.number) ?? []; | |
| if (prNumbers.length === 0) { | |
| // Fork PRs: workflow_run.pull_requests is empty; resolve via head SHA. | |
| const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, repo, commit_sha: run.head_sha, | |
| }); | |
| prNumbers = prs.filter(p => p.state === 'open').map(p => p.number); | |
| } | |
| core.info(`event=${event} prs=${prNumbers.join(',')} ciOk=${ciOk} runHeadSha=${run.head_sha}`); | |
| for (const prNumber of prNumbers) { | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, repo, pull_number: prNumber, | |
| }); | |
| // workflow_run events can be delivered after a newer push; | |
| // skip if the PR has moved past the SHA we evaluated. | |
| if (pr.head.sha !== run.head_sha) { | |
| core.info(`stale: #${prNumber} head=${pr.head.sha} run=${run.head_sha}; skipping`); | |
| continue; | |
| } | |
| await setLabel(prNumber, ciOk && !pr.draft); | |
| } | |
| first-time-contributor: | |
| if: >- | |
| (github.event_name == 'pull_request_target' && | |
| github.event.action == 'opened') || | |
| github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Check contribution history and label | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} | |
| with: | |
| script: | | |
| const label = 'first-time-contributor'; | |
| const { owner, repo } = context.repo; | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| if (!Number.isSafeInteger(prNumber) || prNumber <= 0) { | |
| core.setFailed(`Invalid pull request number: ${process.env.PR_NUMBER}`); | |
| return; | |
| } | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| const author = pr.user?.login; | |
| if (!author) { | |
| core.setFailed(`Pull request #${prNumber} has no author login`); | |
| return; | |
| } | |
| const { data: commits } = await github.rest.repos.listCommits({ | |
| owner, | |
| repo, | |
| author, | |
| sha: context.payload.repository.default_branch, | |
| per_page: 1, | |
| }); | |
| const isFirstTimeContributor = commits.length === 0; | |
| core.info( | |
| `pr=#${prNumber} author=${author} priorCommits=${commits.length} ` + | |
| `firstTimeContributor=${isFirstTimeContributor}`, | |
| ); | |
| if (!isFirstTimeContributor) { | |
| return; | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: [label], | |
| }); | |
| backport-check: | |
| name: "Admins: Backport label added" | |
| if: | | |
| (github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main') || | |
| (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: read | |
| steps: | |
| - name: Verify backport intent | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| # merge_group ref: refs/heads/gh-readonly-queue/main/pr-<N>-<base_sha> | |
| MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| # merge_group carries no pull_request payload; recover the PR number | |
| # from the queue branch ref (…/pr-<N>-<base_sha>). | |
| if [[ -z "$PR_NUMBER" ]]; then | |
| PR_NUMBER=${MERGE_GROUP_REF##*/pr-} | |
| PR_NUMBER=${PR_NUMBER%%-*} | |
| fi | |
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "::error::Could not determine PR number (merge_group ref: '$MERGE_GROUP_REF')" | |
| exit 1 | |
| fi | |
| # Skip when no release is in flight (`backport:skip` is excluded by the prefix filter). | |
| release_labels=$(gh api "repos/$REPO/labels" --paginate \ | |
| --jq '.[].name | select(startswith("backport release/"))') | |
| if [[ -z "$release_labels" ]]; then | |
| echo "::notice::No active 'backport release/*' labels; nothing to enforce." | |
| exit 0 | |
| fi | |
| pr_labels=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ | |
| --json labels --jq '.labels[].name') | |
| if echo "$pr_labels" | grep -qE '^(backport release/|backport:skip$)'; then | |
| exit 0 | |
| fi | |
| echo "::error::PR needs a 'backport release/X.Y.Z' label, or 'backport:skip' to opt out. Active release labels:" | |
| echo "$release_labels" | sed 's/^/ - /' | |
| exit 1 |