Linting commit messages for forked pull requests Jump to heading

Every local hook you install is advice to people who already have your repository set up. A contributor working from a fork has their own clone, their own configuration, and none of your hooks β€” so the first time your convention is tested against someone outside the team is the moment it turns out never to have been enforced at all. The fix is a pipeline check over the commit range, and the complications are all about permissions. This recipe covers both, within commit message hooks and templates.

When to use this approach Jump to heading

  • Your repository accepts contributions from outside the organisation.
  • Commit messages feed release notes, changelogs or a compliance trail.
  • You already have a local hook and want the same rule to apply universally.
  • Contributors currently discover the convention through review comments.
  • If the repository is private and every author has your hooks installed, the pipeline check is still worth having as a backstop but is less urgent.

Step 1 β€” Fetch enough history to see the range Jump to heading

A shallow checkout cannot compute the merge base, so the loop silently validates nothing or everything.

# .github/workflows/commit-lint.yml
name: commit-lint
on: pull_request            # runs with a read-only token on forks β€” that is fine here
jobs:
  messages:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # the merge base is not available in a shallow clone
# Verification: locally, confirm the range you think you are checking
git fetch origin main
git rev-list --count "$(git merge-base origin/main HEAD)"..HEAD
The range a pull request actually introducesThe commits to validate are those between the merge base and the branch tip, not everything on the branch and not only the tip. A shallow clone has no merge base, so the range computation fails before any message is read.validate C1 to C3 β€” not A, B or the mergemainABfork branchB*C1C2C3B* is the merge base: everything after it belongs to the contributor

Step 2 β€” Validate every commit, not just the tip Jump to heading

Checking only HEAD passes a branch whose first two commits ignore the convention entirely.

      - name: Lint each commit in the range
        run: |
          base="$(git merge-base "origin/${{ github.base_ref }}" HEAD)"
          status=0
          while read -r sha; do
            subject="$(git log -1 --format=%s "$sha")"
            case "$subject" in "Merge "*|"fixup!"*|"squash!"*) continue ;; esac
            if ! printf '%s' "$subject" | grep -qE '^[a-z]+(\([a-z0-9-]+\))?: .{1,62}$'; then
              echo "::error::${sha:0:8} β€” ${subject}"
              status=1
            fi
          done < <(git rev-list "$base"..HEAD)
          exit $status
# Verification: run the identical loop against your own branch before pushing
base=$(git merge-base origin/main HEAD)
git rev-list "$base"..HEAD --format='%h %s' | grep -v '^commit'

What changed: the job now reports each offending commit by id and subject, which is the difference between a contributor fixing three messages and a contributor asking what is wrong.

Step 3 β€” Report where the contributor is looking Jump to heading

Annotations in the log are easy to miss. A single summary comment, edited in place on each push, is read.

      - name: Summarise for the author
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          {
            echo "### Commit message check"
            echo
            echo "These commits do not match the convention:"
            echo
            git log --format='- %h %s' "$(git merge-base origin/${{ github.base_ref }} HEAD)"..HEAD
            echo
            echo "Expected: type(scope): subject β€” see CONTRIBUTING.md"
          } >> "$GITHUB_STEP_SUMMARY"

SAFETY WARNING β€” do not reach for pull_request_target to get a writable token for comments on forks. That trigger runs with repository secrets in a context an untrusted contributor can influence, and it is the single most exploited misconfiguration in forge pipelines. Use the job summary, or post from a separate workflow triggered by the completed run, which never checks out untrusted code with a privileged token.

Two ways to report to a fork contributorA job summary needs no elevated permissions and is attached to the failing run. A comment posted with pull_request_target requires a privileged token in a context the contributor influences, which is a well-known escalation path.pull_request_target commentJob summarytoken neededwrite, with secretsnoneexploited in the wildrepeatedlynot applicablevisible to authorin the threadon the failing checksetuppermissions and carethree linesif you need a thread comment, post it from a separate run-completed workflow

Step 4 β€” Make the rule identical on both sides Jump to heading

Two implementations of one convention drift. Share the configuration file the hook already uses so the pipeline enforces the same thing.

      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci --ignore-scripts        # no lifecycle scripts from an untrusted branch
      - run: npx --no -- commitlint --from "$(git merge-base origin/${{ github.base_ref }} HEAD)" --to HEAD
# Verification: the same command locally produces the same verdict
npx --no -- commitlint --from "$(git merge-base origin/main HEAD)" --to HEAD

--ignore-scripts matters on a fork build: installing dependencies from an untrusted branch otherwise runs whatever its lifecycle scripts contain, with your runner’s network access.

Step 5 β€” Decide what the squash message must satisfy Jump to heading

If the contribution will be squashed, the message that lands is the pull request title. Validate it too, or you are enforcing a rule on commits that never reach the default branch.

      - name: The title becomes the squashed subject
        run: |
          printf '%s\n' "${{ github.event.pull_request.title }}" \
            | grep -qE '^[a-z]+(\([a-z0-9-]+\))?: .{1,62}$' \
            || { echo "::error::Pull request title must match the convention"; exit 1; }
Which text survives to the default branchWith a squash merge the individual commit subjects are discarded and the pull request title becomes the message on the default branch. Validating only the commits enforces the convention on text that is thrown away.Fork commitsthree subjectsvalidatedSquash mergesubjects discardedPR titlebecomes the subjectmust be validatedDefault branchone committhis is what persistsvalidate the text that survives, not only the text that is reviewed

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does the check pass locally and fail in the pipeline? Jump to heading

Usually a different range. Locally you compare against your own origin/main, which may be behind; the pipeline computes the merge base against the target branch as it is now. Print the range in both places before assuming the rule differs.

Should a message failure block the merge? Jump to heading

Make it a required check only if you would genuinely refuse the contribution over it. For community projects a reporting check plus a maintainer who fixes the title at squash time is usually the better balance β€” the convention still holds on the default branch.

Can we fix the messages for the contributor? Jump to heading

Not on their branch without write access to their fork, and pushing to a contributor’s branch is rarely worth the permission. Squash merging sidesteps it entirely: the maintainer edits the title, and the landed commit conforms.