Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## ℹ️ Overview
<!-- The Overview section must be present and filled out: the pr-description-check workflow uses it to validate that the PR description includes a non-empty summary with enough context for reviewers. -->

**REPLACE ME**: Provide the context and description of the change.

Expand Down
65 changes: 65 additions & 0 deletions .github/scripts/validate-pr-description.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Validate that a PR description has a filled Overview section.
* Intended for use with actions/github-script.
*
* @param {{ core: import('@actions/core'), context: import('@actions/github').Context }} params
* @param {{ minOverviewLength?: number }} options
*/
/**
* Revert PRs are exempt from description validation. Detection lives here rather than
* in a workflow `if` condition so the job still completes successfully instead of
* showing as skipped, which can block merges when this check is required.
*/
function isRevertPr(pr) {
const title = pr?.title ?? '';
const headRef = pr?.head?.ref ?? '';
return /^Revert\s/i.test(title) || /^revert[-_]/i.test(headRef);
}

module.exports = async function validatePrDescription({ core, context }, options = {}) {
const minOverviewLength = Number(options.minOverviewLength) || 40;
const pr = context.payload.pull_request;

if (isRevertPr(pr)) {
core.info('Skipping PR description check for revert PR.');
return;
}

const body = pr?.body ?? '';

if (!body.trim()) {
core.setFailed('PR description is empty. Please add a description with enough context for reviewers.');
return;
}

if (/\*\*REPLACE ME\*\*/.test(body)) {
core.setFailed('PR description still contains the "REPLACE ME" placeholder. Please fill in the overview.');
return;
}

const withoutComments = body.replace(/<!--[\s\S]*?-->/g, '');
const overviewSection = withoutComments
.split(/\n(?=## )/)
.find((section) => /^##[^\n]*overview/i.test(section.trim()));

if (!overviewSection) {
core.setFailed(
'PR description must include an "## Overview" section. Use the standard PR template and fill in the overview.'
);
return;
}

const overviewText = overviewSection
.replace(/^##[^\n]*\n?/i, '')
.replace(/\s+/g, ' ')
.trim();

if (overviewText.length < minOverviewLength) {
core.setFailed(
`Overview section is too short (${overviewText.length} characters, minimum ${minOverviewLength}). Please add a brief description of the change.`
);
return;
}

core.info(`PR description check passed (overview: ${overviewText.length} characters).`);
};
44 changes: 44 additions & 0 deletions .github/workflows/callable.pr-description-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: pr-description-check
on:
workflow_call:
inputs:
min_overview_length:
description: Minimum character count required in the Overview section after stripping HTML comments and placeholders.
type: number
required: false
default: 40
secrets:
GH_TOKEN:
required: true

jobs:
pr-description-check:
name: PR description check
runs-on: ubuntu-latest
steps:
- name: Checkout workflow scripts
uses: actions/checkout@v7
with:
# Check out the reusable workflow's own repo/SHA so callers get the
# script version that matches the pinned callable workflow.
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
sparse-checkout: |
.github/scripts
token: ${{ secrets.GH_TOKEN }}

- name: Validate PR description

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there's a new way to reference actions in the same repo without having to checkout the source code. this would mean wrapping the validate-pr-description.js script in a callable action

https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/

 - name: Call local action
        uses: $/ .github/actions/my-custom-action
        with:
          my-input: 'hello'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this to the next time if necessary. But TIL, this is good to know!

uses: actions/github-script@v9
env:
MIN_OVERVIEW_LENGTH: ${{ inputs.min_overview_length }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const script = require('${{ github.workspace }}/.github/scripts/validate-pr-description.js');
// github, context, and core are injected by github-script from this
// job's runtime context (the caller's workflow run / PR payload).
// Revert PRs (title "Revert ..." or revert-* branch) are exempt — see validate-pr-description.js.
await script(
{ github, context, core },
{ minOverviewLength: Number(process.env.MIN_OVERVIEW_LENGTH) }
);
13 changes: 13 additions & 0 deletions .github/workflows/local.pr-description-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: pr-description-check
on:
pull_request:
types:
- opened
- edited
- reopened

jobs:
call-pr-description-check-workflow:
uses: ./.github/workflows/callable.pr-description-check.yaml
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}