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 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_targetto 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.
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; } 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.
Related Jump to heading
- Commit Message Hooks & Templates β the parent topic and the enforcement layers.
- Enforcing Issue Keys in Commit Messages β the same split between local convenience and real enforcement.
- Limiting Workflow Permissions Per Job β the permissions model behind the warning in Step 3.