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.
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.
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:
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
Another approach could be using an if for the run step:
So in cases when it doesn't make sense, Konsist and Ktlint are not executed:
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: