From c46ea44f43785157f92f4f9612d68e429ed2aa8c Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Mon, 8 Jun 2026 00:00:51 +0200 Subject: [PATCH] ci(pr-checks): conventional-commit title check, unmergeable-PR flagging, pin actions by SHA (#3336) * ci(pr-checks): add Conventional Commits PR-title check, pin actions by SHA Add a check-title job that fails the PR when the title is not Conventional Commits format (type(scope): summary), via an inline github-script regex. Pin the workflow's actions to their latest release commit SHAs: actions/checkout v6.0.3 and actions/github-script v9.0.0. * ci(pr-checks): flag unmergeable PRs in the PR-checks workflow Add a check-mergeable job to the (renamed) PR checks workflow: on PR events, poll the PR's mergeable state and, when it conflicts with the base, remove 'ready for review', add a red 'merge conflict' label (auto-created), and comment; clear the label once mergeable again. Single-PR, no push trigger. Add ready_for_review to the trigger types. * ci(pr-checks): drop the comment from check-mergeable, label swap only * ci(pr-checks): least-privilege workflow permissions contents:read for base-ref checkout, pull-requests:read for pulls.get mergeability, issues:write for label + comment management. Drops the unused pull-requests:write (labels and PR comments go through the issues API). --- .github/workflows/pr-description-check.yml | 87 ++++++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index 9ac05b373..d527d6938 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -1,28 +1,103 @@ -name: ci / PR description check +name: ci / PR checks on: pull_request_target: - types: [opened, edited, synchronize, reopened] + types: [opened, edited, synchronize, reopened, ready_for_review] # pull_request_target runs in the base-repo context (has secrets). # The checkout below pins to the base branch so no fork code is executed. # The script only reads context.payload and calls the GitHub API. +# Least privilege: contents:read for the base-ref checkout, pull-requests:read +# for pulls.get (mergeability), issues:write for label + comment management +# (PR labels and comments both go through the issues API). permissions: + contents: read + pull-requests: read issues: write - pull-requests: write jobs: check-description: name: Check PR description runs-on: ubuntu-latest - # Skip bots — they open PRs programmatically and have their own process. + # Skip bots: they open PRs programmatically and have their own process. if: github.event.pull_request.user.type != 'Bot' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.base_ref }} sparse-checkout: .github/scripts - - uses: actions/github-script@v7 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: return require('./.github/scripts/check-pr-description.js')({github, context, core}) + + check-title: + name: Check PR title (Conventional Commits) + runs-on: ubuntu-latest + # Skip bots: they open PRs programmatically and have their own process. + if: github.event.pull_request.user.type != 'Bot' + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = context.payload.pull_request.title || ""; + // Conventional Commits: type(optional-scope)(optional !): summary + const re = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w .\/-]+\))?!?: .+/; + if (!re.test(title)) { + core.setFailed( + `PR title is not in Conventional Commits format:\n "${title}"\n\n` + + `Expected: type(scope): summary\n` + + `Example: fix(search): handle empty query\n` + + `Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.` + ); + } else { + core.info(`PR title OK: ${title}`); + } + + check-mergeable: + name: Flag unmergeable PRs + runs-on: ubuntu-latest + # Skip bots: they open PRs programmatically and have their own process. + if: github.event.pull_request.user.type != 'Bot' + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const number = context.payload.pull_request.number; + const READY = "ready for review"; + const CONFLICT = "merge conflict"; + + // Ensure the conflict label exists (red). Ignore if already present. + try { + await github.rest.issues.getLabel({ ...repo, name: CONFLICT }); + } catch { + await github.rest.issues.createLabel({ + ...repo, name: CONFLICT, color: "B60205", + description: "Conflicts with the base branch; needs a rebase before review.", + }).catch(() => {}); + } + + // mergeable is computed asynchronously and is often null right after + // an event, so poll a few times until GitHub has resolved it. + let pr = null; + for (let i = 0; i < 5; i++) { + const { data } = await github.rest.pulls.get({ ...repo, pull_number: number }); + if (data.mergeable !== null) { pr = data; break; } + await new Promise(r => setTimeout(r, 3000)); + } + if (!pr || pr.draft) return; + const labels = pr.labels.map(l => l.name); + + if (pr.mergeable === false) { + if (labels.includes(READY)) { + await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: READY }).catch(() => {}); + } + if (!labels.includes(CONFLICT)) { + await github.rest.issues.addLabels({ ...repo, issue_number: number, labels: [CONFLICT] }); + } + } else if (pr.mergeable === true) { + if (labels.includes(CONFLICT)) { + await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: CONFLICT }).catch(() => {}); + } + }