Adding Konsist and Ktlint to a GitHub Actions Continuous Integration

This article will focus on adding linter and code checks to a GitHub Actions Code Review workflow. The examples here are based on my Kotlin Multiplatform GitHub Actions repository, but all workflows and jobs are applicable to any Kotlin / Android project.

GitHub - AKJAW/kotlin-multiplatform-github-actions: Repository showcasing GitHub Actions for Kotlin Multiplatform
Repository showcasing GitHub Actions for Kotlin Multiplatform - GitHub - AKJAW/kotlin-multiplatform-github-actions: Repository showcasing GitHub Actions for Kotlin Multiplatform

However, before diving into the implementation, I'd like to provide some arguments why it's a good idea to do this.

Why are such automations needed?

Enforcing formatting and guidelines usually happens at the Code Review stage. You open a Pull Request, and a teammate informs you that a something is formatted incorrectly, or that the codebase uses a different convention for naming or packaging.

Verifying this manually takes more time, as the person doing the Code Review needs to pay extra attention to these things. While additionally trying to understand the changes in the PR. This leads to increased Code Review time and then additional time for fixing all the issues pointed out.

Besides the increased feedback loop, there is still a reliance on the human memory, the Reviewer might just forget about the conventions or be in a hurry, thus allowing the introduction of these "inconsistencies" into the codebase.

Automating such steps in the Continuous Integration pipeline should result in a healthier codebase, where every Pull Request follows the project guidelines. This eliminates the human forgetfulness aspect and also cuts down the feedback loop, because as soon as the PR is opened the verification is run and the author knows that some things need to be fixed.

Automating code and architecture checks in the Continuous Integration pipeline can significantly reduce Code Review time and prevent human forgetfulness, ensuring a healthier codebase that follows the project guidelines.

Konsist

I've recently started playing around Konsist, and I was blown away by its ease of use and power. You basically write tests which asserts something on your Kotlin files / classes / properties. The added benefit is that the tests are debuggable, meaning that it's easy to create the test inside the debugger evaluation window.

I won't go into the details of how to set up Konsist, because the official documentation is a much better resource for that. I can just say that I put Konsist into a separate module to decrease the build / configuration time.

As for calling Konsist inside GitHub Actions, it's as simple as running all tests inside the Konsist module:

name: Konsist

on:
  workflow_call:

jobs:
  Konsist:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-java@v3
        with:
          distribution: "adopt"
          java-version: "11"
  
      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - run: ./gradlew :konsist:testDebugUnitTest

Ktlint

While Konsist focuses on more Architectural / Structural guidelines, Ktlint focuses on code style conventions / standards. Both of them can work together to create a more predictable and healthy codebase.

In the example repository, Ktlint is added through a Gradle plugin, meaning that it is installed alongside the project and can be run through a Gradle Task:

name: Ktlint

on:
  workflow_call:

jobs:
  Ktlint:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-java@v3
        with:
          distribution: "adopt"
          java-version: "11"
  
      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - run: ./gradlew ktlintCheck

If your project doesn't use a Gradle plugin, but the Ktlint CLI, then it can be used on the GitHub machine like this:

...
jobs:
  Ktlint:
    runs-on: ubuntu-latest

    steps:
      ...

      - name: Ktint set up
        run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/0.50.0/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/local/bin/

      - run: ktlint '!**/build/**'

💡

To save time on linting errors, a pre-commit hook could be installed which auto formats the code before committing.

Running Konsist and Ktlint before other verifications

As you might've noticed, both the above workflows are declared as workflow_call, allowing them to be called from other workflows. If the other verifications like UnitTests or Builds ale also workflow_calls, then they can be set up in the following way:

name: Code review

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

on:
  workflow_dispatch:
  push:
    branches:
      - main
  pull_request:
    types: [opened, ready_for_review, synchronize]
    branches:
      - main

jobs:
  Konsist:
    uses: ./.github/workflows/konsist.yml

  Ktlint:
    uses: ./.github/workflows/ktlint.yml

  Build:
    needs: [ Konsist, Ktlint ]
    uses: ./.github/workflows/build.yml

  UnitTests:
    needs: [ Konsist, Ktlint ]
    uses: ./.github/workflows/test.yml

Using needs inside the Build and UnitTests jobs forces both of them to wait for Konsist and Ktlint to complete successfully before starting. So for example if Konsist passes, but Ktlint fails then Build and UnitTests are not started, thus saving precious billing minutes and gives faster feedback to the PR author.

As pointed out by Stylianos Gakis on Twitter, when billing minutes are not a problem it might be better to always run all verifications, even when Ktlint or Konsist fails. This gives the fastest feedback loop without having to re-run jobs.

A failed pre-step
Successful execution

Summary

Having a good automation for these types of checks:

  • Enforces good practices across the whole codebase by blocking incorrect Pull Requests
  • Makes reviewing code easier and faster, because less attention needs to be given to the naming / packaging / styling etc
  • Speeds up Pull Request merges, because the CI gives fast feedback about what should be fixed
  • They are run automatically, so there's no need to manually check them before opening a PR

If you'd like to learn more about good GitHub Actions practices for actions and workflows, feel free to check out my previous article:

GitHub Actions Reducing Duplication / Boilerplate
YAML files, which are not that easy to maintain, changes are usually copied and pasted across multiple files. This article shows that there’s a better way.

Kotlin Multiplatform Bonus

Because Kotlin Multiplatform PRs can be iOS only, it makes no sense to run Ktlint or Konsist on them. So if there are no Kotlin changes, then both of the jobs should just be skipped. However, GitHub Actions treats skipped jobs as failed thus blocks merging, this means that the approach needs to be different.

The changes which add the Kotlin Multiplatform Label logic (From my previous article) can be found inside this Pull Request. The basic idea is that the labels are passed in from the Code Review workflow (I'll omit Ktlint because it's the same).

...

jobs:
  ...

  Konsist:
    needs: SetUp
    uses: ./.github/workflows/konsist.yml
    with:
      shouldRunKmp: ${{ needs.SetUp.outputs.shouldRunKmp }}
      shouldRunAndroid: ${{ needs.SetUp.outputs.shouldRunAndroid }}
      shouldRunDesktop: ${{ needs.SetUp.outputs.shouldRunDesktop }}

  ...

Then used like this

name: Konsist

on:
  workflow_call:
    inputs:
      shouldRunKmp:
        required: true
        type: string
      shouldRunAndroid:
        required: true
        type: string
      shouldRunDesktop:
        required: true
        type: string

jobs:
  Konsist:
    runs-on: ubuntu-latest

    steps:
      ...

      - run: |
          if ${{ inputs.shouldRunKmp == 'true' || inputs.shouldRunAndroid == 'true' || inputs.shouldRunDesktop == 'true' }}; then
            ./gradlew :konsist:testDebugUnitTest
          else 
            exit 0
          fi

Bash conditional: either the Gradle task is run, or the job completes successfully.

Another approach could be using an if for the run step:

    steps:
      ...

      - if: ${{ inputs.shouldRunKmp == 'true' || inputs.shouldRunAndroid == 'true' || inputs.shouldRunDesktop == 'true' }}
        run: ./gradlew :konsist:testDebugUnitTest

Step conditional: the step is skipped, but the job completes successfully.

So in cases when it doesn't make sense, Konsist and Ktlint are not executed:

"Skipped" pre-step calls, note the execution time

However, for iOS there is SwiftLint which could be used when the iOS label is applied to the PR, thus ensuring iOS style guidelines. Adding a SwiftLint job to the above workflow would be pretty much the same as for the Konsist job, just the command, parameters and condition would be different.

If you want to learn more about using GitHub Actions for a Kotlin Multiplatform repository, you can read my other article on this topic:

Kotlin Multiplatform GitHub Actions CI Verification using Labels
Having automatic verifications is an important part of Software Development, as the project and team grows it becomes crucial that no regressions are introduced into the “main” branch by mistake.