Enforce Conventional Commits with GitHub
Ensure all pull request commits follow the conventional commit standard
TLDR: skip to the Implementation Runbook to get right to the step-by-step instructions.
Introduction
In my previous article, Continuous Delivery for Gems, I detailed how to set up an automated release process using the googleapis/release-please-action
and the rubygems/release-gem
GitHub actions.
A critical aspect of that setup is the consistent use of Conventional Commits. This article is the follow-up I promised, where we'll dive into how to enforce this vital practice on your Pull Requests (PRs) using GitHub Actions.
Why use Conventional Commits?
Conventional Commits provide a standardized format for your commit messages. This isn't just about keeping a tidy Git history; it is one way of adding machine readable meaning to your commit comments.
As highlighted in Continuous Delivery for Gems:
I strongly recommend that your project enforce that all commit messages conform to the Conventional Commits specification. This is crucial because the release-please action uses these commit types (like
fix:
,feat:
,feat!:
orBREAKING CHANGE:
) to automatically determine the correct semantic version bump (patch, minor, major) and to generate accurate CHANGELOG entries.
There are alternatives to using Conventional Commits to accomplish this same outcome. For instance, some tools use PR labels to identify the change type and correct semantic version bump. Each mechanism has pluses and minuses.
The release-please
action only supports conventional commits, so that is what I went with.
Enforcing these conventions before code is merged into your main branch is key to ensuring your Continuous Delivery pipeline runs accurately. This article will guide you through setting up a GitHub Action to do just that.
What to know about Conventional Commits
The simplest conventional commit is in the form type: description
where type
indicates the type of change and description
is your usual commit message (with some limitations).
Types include:
feat
,fix
,docs
,test
,refactor
, andchore
. The full list of allowed types is defined in the .commitlintrc.yml file with thetype-enum
rule.As a personal preference, I usually add the following restrictions on the description. The description (1) must not start with an upper case letter, (2) must be no more than 100 characters, and (3) must not end with punctuation.
Examples of valid commits:
feat: add the --merges option to Git::Lib.log
fix: exception thrown by Git::Lib.log when repo has no commits
docs: add conventional commit announcement to README.md
Commits that include a breaking change must include an exclamation mark before the colon:
feat!: removed Git::Base#commit_force
The commit messages will drive how the version is incremented for each release:
Major increment: a release containing any breaking changes
Minor increment: a release containing a new feature with no breaking changes
Patch increment: a release containing neither breaking changes nor new features
The full conventional commit message format is:
<type>[optional scope][!]: <description>
[optional body]
[optional footer(s)]
optional body
may include multiple lines of descriptive text limited to 100 chars for each lineoptional footers
only usesBREAKING CHANGE: <description>
where description should describe the nature of the backward incompatibility.
Use of the BREAKING CHANGE:
footer flags a backward incompatible change even if it is not flagged with an exclamation mark after the type
. Other footers are allowed by not acted upon.
See the Conventional Commits specification for more details.
Why Enforce Conventional Commits on Pull Requests?
Enforcing Conventional Commits directly on Pull Requests offers several advantages:
Quality Control & Automation Readiness: Ensures that all commits landing in your main branch are correctly formatted, which is crucial for
release-please
and other tools that rely on Conventional Commits.Consistency: Maintains a clean, consistent, and actionable Git history.
Preventing CD Glitches: Avoids scenarios where
release-please
might ignore commits, create incorrect version bumps, or generate incomplete changelogs due to non-conforming messages in the main branch.
Now, let's get to the practical part: setting up the enforcement.
Implementation Runbook
These are the steps that I took to enforce that all PR commits conform to the Conventional Commit standard.
1. Configure your Conventional Commit format
The workflow we will create in the next step will validate our commit messages using commitlint, a popular linter for commit messages.
This tool requires a configuration file to define the exact commit message format allowed for your project.
Add the file .commitlintrc.yml
in the root directory of your project with the following content:
---
extends: '@commitlint/config-conventional'
rules:
# See: https://commitlint.js.org/reference/rules.html
#
# Rules are made up by a name and a configuration array. The configuration
# array contains:
#
# * Severity [0..2]: 0 disable rule, 1 warning if violated, or 2 error if
# violated
# * Applicability [always|never]: never inverts the rule
# * Value: value to use for this rule (if applicable)
#
# Run `npx commitlint --print-config` to see the current setting for all
# rules.
#
header-max-length: [2, always, 100] # Header can not exceed 100 chars
type-case: [2, always, lower-case] # Type must be lower case
type-empty: [2, never] # Type must not be empty
# Supported conventional commit types
type-enum: [2, always, [build, ci, chore, docs, feat, fix, perf, refactor, revert, style, test]]
scope-case: [2, always, lower-case] # Scope must be lower case
# Error if subject is one of these cases (encourages lower-case)
subject-case: [2, never, [sentence-case, start-case, pascal-case, upper-case]]
subject-empty: [2, never] # Subject must not be empty
subject-full-stop: [2, never, "."] # Subject must not end with a period
body-leading-blank: [2, always] # Body must have a blank line before it
body-max-line-length: [2, always, 100] # Body lines can not exceed 100 chars
footer-leading-blank: [2, always] # Footer must have a blank line before it
footer-max-line-length: [2, always, 100] # Footer lines can not exceed 100 chars
These rules are documented in the commitlint rules reference. While they can be changed to your preference, the type-enum value is designed to match the commit types that the release-please-action
knows how to deal with.
2. Define the enforcement workflow
Next we will define the GitHub Actions workflow that does the actual enforcement. This workflow will trigger when a PR is created or updated for the default branch and fail if any commit on the PR does not conform to the rules defined in .commitlintrc.yml
.
Add the file .github/workflows/enforce_conventional_commits.yml
to the project with the following content. Make sure the branch that triggers the workflow (in the on:
section) is correct for your project (usually ‘master’ or ‘main’).
---
name: Conventional Commits
permissions:
contents: read
on:
pull_request:
branches:
- main # Or 'master', or your project's default branch
jobs:
commit-lint:
name: Verify Conventional Commits
# Skip this job if this is a release PR
if: (github.event_name == 'pull_request' && !startsWith(github.event.pull_request.head.ref, 'release-please--'))
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Check Commit Messages
uses: wagoid/commitlint-github-action@v6
with: { configFile: .commitlintrc.yml }
3. Update your project’s developer documentation
Adopting conventional commits in a shared project will require contributors to your project to change their behavior. You don’t want this to be a surprise for your developers.
I recommend putting a temporary announcement message toward the top of your README.md which links out to a more detailed message describing why the decision was made and what is expected of contributors.
Here is an example I used in the ruby-git project that you could tailor for your project.
README.md:
## 📢 We've Adopted the use of Conventional Commits 📢
To enhance our development workflow, enable automated changelog
generation, and pave the way for Continuous Delivery, the `ruby-git`
project has adopted the [Conventional Commits standard](https://www.conventionalcommits.org/en/v1.0.0/)
for all commit messages.
Going forward, all commits to this repository **MUST** adhere to the
Conventional Commits standard. Commits not adhering to this standard
will cause the CI build to fail. PRs will not be merged if they include
non-conventional commits.
A git pre-commit hook may be installed to validate your conventional
commit messages before pushing them to GitHub by running `bin/setup` in
the project root.
Read more about this change in the
[Commit Message Guidelines section of
CONTRIBUTING.md](CONTRIBUTING.md#commit-message-guidelines)
The Commit Message Guidelines section is longer and repeats much of the information in this article. Rather than paste it all here, you can read it in the project’s CONTRIBUTING document.
Conclusion
By implementing this GitHub Action, you create a powerful gatekeeper that ensures every commit landing in your main branch is already compliant with Conventional Commits.
As discussed in Continuous Delivery for Gems, this ensures the release-please-action
can provide:
Reliable Versioning:
release-please
can confidently parse these commits to determine the correct semantic version bump (patch
,minor
,major
).Accurate Changelogs: The generated
CHANGELOG.md
will be accurate and reflect all relevant changes because no commit will be ignored due to formatting issues.Smooth Automation: Your automated release process becomes more robust and less prone to manual intervention or errors caused by malformed commit messages.
Additional Considerations
While this workflow provides a gatekeeper, the feedback it provides that one or more of your commits does not conform is a little late for this developer. Rather than wait to get this feedback on multiple commits when your PR is created, it would be better to receive this feedback immediately when creating the commit locally.
In my next article, I will step you through setting up a pre-commit hook using the same commitlint configuration file to ensure that commits are properly formatted before the commit is created.