Building an Automated GitHub CI Workflow Based on Conventional Commits
Preface of the Problem
In software development, keeping track of changes can be a real headache. Developers spend a lot of time recording every little update, bug fix, and new feature. This manual tracking can lead to mistakes, missed changes, and confusion about what has actually changed in a project. Creating changelogs and preparing for releases becomes a tiring and time-consuming job, taking away precious time that could be spent on writing code and improving the software.
Think about it: Before releasing a new version, you need to list all the changes since the last release. This means going through numerous commit messages, figuring out what each one means, and putting them together in a changelog. This process is not only slow but also easy to mess up if the commit messages are not clear and consistent. Also, if multiple developers are working on the same project and everyone has a different style of documenting code changes.
How can we fix this?
Enter Conventional Commits
Conventional Commits provide a standard way to write commit messages that clearly describe what changes were made. It helps in conveying the nature of changes made in a project clearly and consistently.
By following this specification, each commit message includes the type
of change, its scope
, and a brief description
.
Type, Scope and Description Fields
- type: This is a required field that indicates the nature of the commit.
- Common types, with impact on semantic versioning, include:
feat
: A new feature for the user.fix
: A bug fix for the user.
- Additional types, with no impact on semantic versioning, include:
docs
: Documentation only changes.style
: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).refactor
: A code change that neither fixes a bug nor adds a feature.
- Common types, with impact on semantic versioning, include:
perf
: A code change that improves performance.test
: Adding missing tests or correcting existing tests.chore
: Changes to the build process or auxiliary tools and libraries such as documentation generation.- scope: This is an optional field that provides additional contextual information about the commit. It indicates what part of the codebase was affected by the commit (e.g.,
core
,ui
,api
). - description: A brief summary of the change. It should be concise but descriptive enough to understand the impact of the commit.
!
character in any type field to indicate a breaking change. If defined, the major version will be bumped (e.g. from 1.0.5
to 2.0.0
). Only use if you commit breaking changes.The
!
character must be set directly in front of the :
character, which separates the type(scope)
from the description
field.Example Commit Messages
This way, commit messages might look like the following:
feat(db)!: use postgresql over sqlite3
- This commit message introduces a breaking change via the
!
character. The database scheme is changed from SQLite3 to Postgresql. It will, according to Conventional Commits, bump the major version (e.g. from1.0.5
to2.0.0
).
- This commit message introduces a breaking change via the
feat(login): add OAuth authentication
- This commit message introduces a new OAuth authentication feature. It will, according to Conventional Commits, bump the minor version (e.g. from
1.0.5
to1.1.0
).
- This commit message introduces a new OAuth authentication feature. It will, according to Conventional Commits, bump the minor version (e.g. from
fix(auth): correct user validation logic
- This commit message fixes a small validation bug. It will, according to Conventional Commits, bump the current patch version (e.g. from
1.0.5
to1.0.6
).
- This commit message fixes a small validation bug. It will, according to Conventional Commits, bump the current patch version (e.g. from
chore(deps): update dependency versions
- This commit message reflects some smaller chore work like upgrading pip dependency versions. Such an additional type is not mandated by the Conventional Commits specification, and has no implicit effect in semantic versioning (unless a breaking change is included). Therefore, no version bump.
docs(readme): update installation instructions
- This commit message adjusts the documentation (readme). Such an additional type is not mandated by the Conventional Commits specification, and has no implicit effect in semantic versioning (unless a breaking change is included). Therefore, no version bump.
style(css): improve button hover effect
- This commit message adjusts the CSS style of a button. Such an additional type is not mandated by the Conventional Commits specification, and has no implicit effect in semantic versioning (unless a breaking change is included). Therefore, no version bump.
Developing a CI Pipeline
If all developers of a software project adhere to this Conventional Commits strategy now, we can build and implement a Continous Integration (CI) pipeline to parse those commit messages and decide which CI tasks shall be run.
Typically, the following CI tasks are often used and wanted:
- Create Changelog
- Inspect the incoming commit messages based on Conventional Commits specification. Create or update a changelog file with the introduced changes and optionally bump the version (patch vs. minor vs. major).
- Build and Deploy Docker Images
- Based on the previously run changelog task, may build new Docker images and push them onto DockerHub. Make use of the new bumped version and tag the Docker images properly.
- Create Release
- Based on the previously run changelog task, create a new release on GitHub and outline all introduced changes. Group the changes based on the Conventional Commits' types into `Bug Fixes`, `New Features` and so on.
Prerequisites
- Create a new
.github/workflows/conventional-commits.yml
file in the root of your GitHub repository - Paste the below CI pipeline code into this new workflow file.
- Adjust the CI pipeline to your needs. Especially focus on the
deploy
CI task and define for which platforms you want to build your Docker images (default:linux/amd64, linux/arm64
) and where your Dockerfile is located in the repository (default:./docker/Dockerfile
). Also check the default repo branch the CI will be triggered on (default:main
).
Please visit your GitHub repo settings and create the
DOCKER_HUB_USERNAME
and DOCKER_HUB_ACCESS_TOKEN
secrets. You can find this area at
Repo Settings
> Secrets and variables
> Actions
> Repository secrets
.CI Pipeline Code
The following CI pipeline can be used with GitHub Action runners to automatically create a changelog, build your Docker images (amd64
and arm64
) and publish a new release with all introduced changes on GitHub.
How it works
As previously outlined, the introduced CI pipeline adheres to the Conventional Commits specification. For each new commit in your git repository, the CI task changelog
will parse the commit message (type, scope and description).
If the commit message uses a type
that bumps the minor or major version of your project (e.g. feat
or fix
), it will create the relevant changelogs and trigger the next CI tasks deploy
and release
. The new version is kept track of in a file named package.json
. The introduced code changes will be noted down in a CHANGELOG.md
file.
If the deploy
and release
CI tasks are triggered, your Docker image will be built and pushed onto DockerHub and a new release will be published onto GitHub. Regarding the deploy task, your Docker images will be pushed with the exact minor/major version tag (e.g. 0.1.5
) as well as with a minor series version tag (e.g. 0.1.x
). Also, the latest
tag will be overwritten. The DockerHub username is obtained from your repo's secret DOCKER_HUB_USERNAME
and the DockerHub repository name will match the one on GitHub, where the CI is running on.
Otherwise, the CI may create a public repository on DockerHub.
0.1.x
is helpful, if users want to receive the latest bug fixes and code patches without breaking their running container instances. If a breaking change or new feature is introduced, such code changes must be pushed using commit message types like
feat
or by including the text BREAKING CHANGES
in the commit message's body or by defining !
in the type
field right before the colon. This will bump the version to the next minor/major version and end users must pin a new series tag like 0.2.x
. This protects all users running a lower series tag from breaking code changes.If the commit message uses an additional type like chore
, style
or docs
as an example; it will not trigger a Docker image build/push nor publish a new release. Such commits will still be integrated into your repository but will not introduce a version bump or new changelog entry. You can overwrite this behaviour via skip-on-empty
(read here) if wanted.
Version Announcement
If you were paying close attention to the CI workflow code, you may have noticed a specific build argument VERSION=${{ needs.changelog.outputs.version }}
.
Here the relevant excerpt of the CI workflow:
This way, we are passing the bumped semver patch version from CI changelog
task into the Docker image deploy
build process as environment variable. Then, the VERSION
environment variable can be re-used in the Dockerfile
.
To do so, put the following two directives in your Dockerfile:
The VERSION
environment variable is then also available within the built Docker image. Therefore, you can make use of it programatically. Just read the env variable during container runtime and do whatever you want with it.
Depending on the Docker container's usage, the following may be implemented:
- Display the currently running version on an HTTP website's footer
- Display the currently running version as output of a running script (Python, Golang, Rust, etc.)
- Validate the currently running version against the latest version released and display some warning messages or automatically download the latest updates
Real-World GitHub Examples
If you would like to see some real-world GitHub repositories following this Conventional Commits specification, may inspect these:
- https://github.com/l4rm4nd/VoucherVault
- Adheres to the Conventional Commis strategy for commit messages
- Adheres to this blog post and CI pipeline entirely
- Displays the semver patch version on the HTTP footer of the VoucherVault web application
- https://github.com/immich-app/immich
- Adheres to the Conventional Commits strategy for commit messages
- Uses different and custom CI pipelines though
Frequently Asked Questions
My published releases do not display additional commit types like chore, style, docs, etc. Only fix() and feat() are listed. Is this a bug?
The reason for this behaviour lies in the default configuration of conventional-changelog-conventionalcommits. If you want to change this behaviour, you must create a custom configuration file and tell your CI pipeline's changelog
task to use this custom config via the config-file-path
directive (read here).
Note though that a new v8.0.0 release (switch from CommonJS to ESM) currently bricks custom configuration files. An issue is already created for the TriPSs repository on GitHub. See this.
Can I use this CI pipeline on my private CI/CD instance?
Depends on whether your private instance follows the GitHub Actions syntax. The pipeline should work on Gitea. No information regarding Gitlab or Onedev.
The !
character does not trigger a major release version bump. How to fix?
Ensure that you define the !
character at the end of the type field, before :
. Furthermore, ensure that the CI pipeline task changelog
defines the preset conventionalcommits
. If the preset is missing, the angular
preset is used as default, which does not declare !
as a major version bump event.
Member discussion