Skip to content

ci: add non-blocking conventional-commits PR title check #73

Description

@dnim

Goal

Add a non-blocking CI check for PR titles to follow Conventional Commits.

Reference: https://www.conventionalcommits.org/en/v1.0.0/

Why

With squash merge, the PR title becomes the resulting commit on main, so title quality affects repository history.

Requirements

  1. Validate PR title only (not branch name, not local commit messages)
  2. Check is informational only (must not fail CI)
  3. Enforce:
    • max header length: 120 chars
    • allowed scopes: timer, ui, audio (scope optional)
    • lower-case subject after :
    • conventional commit type format
  4. If invalid, post/update a PR comment with actionable guidance

Implementation instructions

1. Install dependencies

npm i -D @commitlint/cli @commitlint/config-conventional

2. Add commitlint.config.cjs

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'header-max-length': [2, 'always', 120],
    'scope-enum': [2, 'always', ['timer', 'ui', 'audio']],
    'scope-empty': [0],
    'subject-empty': [2, 'never'],
    'subject-case': [2, 'always', ['lower-case']],
  },
};

3. Add workflow .github/workflows/pr-title-conventional.yml

name: PR Title Conventional Check (non-blocking)

on:
  pull_request:
    types: [opened, edited, synchronize, reopened]

jobs:
  pr-title-check:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read

    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version-file: '.node-version'

      - name: Install dependencies
        run: npm ci

      - name: Validate PR title with commitlint
        id: validate
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          set +e
          echo "$PR_TITLE" | npx commitlint > commitlint.out 2>&1
          STATUS=$?
          if [ $STATUS -eq 0 ]; then
            echo "invalid=false" >> "$GITHUB_OUTPUT"
          else
            echo "invalid=true" >> "$GITHUB_OUTPUT"
            {
              echo "<!-- pr-title-conventional-check -->"
              echo "### PR title does not follow Conventional Commits"
              echo
              echo "Current title: \`$PR_TITLE\`"
              echo
              echo "Please use format: \`type(scope): subject\` or \`type: subject\`"
              echo "- Allowed scopes (optional): \`timer\`, \`ui\`, \`audio\`"
              echo "- Max length: 120"
              echo "- Subject should start lower-case"
              echo
              echo "Examples:"
              echo "- \`feat(ui): add compact status line\`"
              echo "- \`fix: correct timer reset on pause\`"
              echo
              echo "Reference: https://www.conventionalcommits.org/en/v1.0.0/"
              echo
              echo "Details:"
              cat commitlint.out
            } > comment.md
          fi
          exit 0

      - name: Upsert PR comment when invalid
        if: steps.validate.outputs.invalid == 'true'
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          EXISTING_ID=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments"             --jq '.[] | select(.body | contains("<!-- pr-title-conventional-check -->")) | .id' | head -n1)

          if [ -n "$EXISTING_ID" ]; then
            gh api "repos/${{ github.repository }}/issues/comments/$EXISTING_ID"               --method PATCH               --field body@comment.md >/dev/null
          else
            gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments"               --method POST               --field body@comment.md >/dev/null
          fi

Acceptance criteria

  • PR title check runs on PR open/edit/sync/reopen
  • Workflow never fails because of title format
  • Invalid title leads to one updatable guidance comment on PR
  • Rules enforced: type, optional scope allowlist (timer, ui, audio), length <= 120, lower-case subject

Notes

  • scope-enum means if scope is present, it must be from allowlist.
  • This is intentionally non-blocking to coach contributors without preventing merges.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions