10 min read

Building an Automated GitHub CI Workflow Based on Conventional Commits

Automatically create changelogs, release notes and properly version-tagged Docker images with a CI workflow. Introducing the Conventional Commits specification for git commits.
Building an Automated GitHub CI Workflow Based on Conventional Commits
Photo by Mohammad Rahmani / Unsplash

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>): <description>

Structure of a commit message according to Conventional Commits specification

🤖
This syntax is easy to follow and understand for humans and machine-readable for computers and automations.

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.
      • 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.
⚠️
You can use the ! 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 on GitHub (will bump patch version)

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. from 1.0.5 to 2.0.0).
  • 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 to 1.1.0).
  • 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 to 1.0.6).
  • 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.
Conventional Commits
A specification for adding human and machine readable meaning to commit messages

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

  1. Create a new .github/workflows/conventional-commits.yml file in the root of your GitHub repository
  2. Paste the below CI pipeline code into this new workflow file.
  3. 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).
⚠️
In order to push your new Docker images onto DockerHub, you must create two repository secrets on GitHub. Those reflect your DockerHub username and API token for proper authentication.

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.
Creating DockerHub Secrets on GitHub

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.

Features • GitHub Actions
Easily build, package, release, update, and deploy your project in any language—on GitHub or any external system—without having to run code yourself.
name: CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: write
  packages: write
  pull-requests: write

jobs:

  changelog:
    name: Changelog
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest

    outputs:
      skipped: ${{ steps.changelog.outputs.skipped }}
      tag: ${{ steps.changelog.outputs.tag }}
      clean_changelog: ${{ steps.changelog.outputs.clean_changelog }}
      version: ${{ steps.changelog.outputs.version }}

    steps:
      - uses: actions/checkout@v4

      - name: Conventional Changelog Action
        id: changelog
        uses: TriPSs/conventional-changelog-action@v5
        with:
          preset: "conventionalcommits"
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Create Release
        uses: softprops/action-gh-release@v2
        if: ${{ steps.changelog.outputs.skipped == 'false' }}
        with:
          name: ${{ steps.changelog.outputs.tag }}
          tag_name: ${{ steps.changelog.outputs.tag }}
          body: ${{ steps.changelog.outputs.clean_changelog }}
          token: ${{ secrets.GITHUB_TOKEN }}

  deploy:
    name: Deploy Image
    needs: changelog
    if: github.event_name != 'pull_request' && needs.changelog.outputs.skipped == 'false'
    runs-on: ubuntu-latest

    steps:
      - name: Get repository name
        id: get_repo
        run: echo "REPO_NAME=$(echo ${{ github.repository }} | cut -d'/' -f2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
    
      - name: Checkout
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4

      - name: Login to Dockerhub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v2	  

      - name: Extract version parts
        id: extract_version
        run: |
          VERSION=${{ needs.changelog.outputs.version }}
          MAJOR_MINOR=$(echo $VERSION | cut -d'.' -f1,2)
          echo "MAJOR_MINOR_TAG=${MAJOR_MINOR}.x" >> $GITHUB_ENV

      - name: Setup Docker Metadata
        uses: docker/metadata-action@v5
        id: meta
        with:
          images: |
            docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ env.REPO_NAME }}
          tags: |
            latest
            ${{ needs.changelog.outputs.version }}
            ${{ env.MAJOR_MINOR_TAG }}

      - name: Build and Push Docker Image
        uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5
        with:
          context: .
          file: docker/Dockerfile
          platforms: linux/amd64, linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
              VERSION=${{ needs.changelog.outputs.version }}

  release:
    name: Release
    needs: changelog
    if: github.event_name != 'pull_request' && needs.changelog.outputs.skipped == 'false'
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
  
      - name: Debug Changelog Outputs
        run: echo ${{ needs.changelog.outputs.tag }}
        
      - name: Create Release
        uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          tag_name: ${{ needs.changelog.outputs.tag }}
          prerelease: false
          draft: false
          generate_release_notes: true
          name: ${{ needs.changelog.outputs.tag }}
          body: |
            <details>
              <summary>🤖 Autogenerated Conventional Changelog</summary>
  
            ${{ needs.changelog.outputs.clean_changelog }}
            </details>

CI Workflow for Conventional Commits

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.

Example CI Workflow on GitHub

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.

🛑
If you do not want to publicly expose your Docker images on DockerHub, please create a private repository on DockerHub beforehand with the same name as your GitHub repository.

Otherwise, the CI may create a public repository on DockerHub.
New Release published on GitHub
Example Image Tags released on DockerHub
💡
Using and pinning a series tag like 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:

      - name: Build and Push Docker Image
        uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5
        with:
          context: .
          file: docker/Dockerfile
          platforms: linux/amd64, linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
              VERSION=${{ needs.changelog.outputs.version }}

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:

ARG VERSION=0
ENV VERSION=$VERSION

Relevant code for VERSION reuse in 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
💡
Displaying the currently running container version helps users as well as developers. Both are constantly aware about the exact version in use and can reference it in issue tickets.

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.