GitHub Actions で Android プロジェクトのプルリクのチェックを行う（ゆめみ社の事例）

これは [ゆめみ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/yumemi) の９日目の記事です。今回は、ゆめみの Android プロジェクトでよく導入している、GitHub Actions でのプルリクチェックのワークフローを紹介したいと思います :muscle:

ゆめみの Android グループは、約20以上の多種多様なお客様の Android プロジェクトに携わっています。一部例外はありますが、多くのプロジェクトの CI は、Bitrise と GitHub Actions を併用して構築しています。以前はプルリクのチェックは Bitrise を利用して実施していましたが、最近は GitHub Actions に乗り換えつつあります。

プルリクのチェックでは ktlint、Android Lint、Local unit test を実行し、[Danger](https://danger.systems/ruby/) を利用してチェック結果をコメントさせています。これを実現するワークフローは次の通りです。（この記事の為にコメントを多めにいれています。）

```yaml:.github/workflows/check-pull-request.yml
name: Check pull request

on: pull_request

env:
  # 実行する Gradle コマンド(プロジェクトによって調整してください。)
  GRADLE_KTLINT_TASK: 'ktlint'
  GRADLE_ANDROID_LINT_TASK: 'lintDevelopDebug'
  GRADLE_UNIT_TEST_TASK: 'testDevelopDebugUnitTest'

jobs:
  check:
    name: Check pull request
    runs-on: ubuntu-18.04 # Java や Ruby を利用するので念の為、環境を固定
    steps:
      - name: Check out
        uses: actions/checkout@v2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Set up JDK
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Restore gradle cache # Gradle のキャッシュをリストア
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }}
      - name: Set up Ruby # gem を利用するので Ruby をセットアップ
        uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - name: Get gem info
        env: # Danger で利用する gem をここで列挙
          PACKAGES: danger:6.2.0 danger-checkstyle_format:0.1.1 danger-android_lint:0.0.8 danger-junit:1.0.0
        id: gem-info
        run: |
            echo "::set-output name=dir::$(gem environment gemdir)" # キャッシュするgemのディレクトリ
            echo "::set-output name=packages::$PACKAGES" # install 用の文字列
            echo "::set-output name=key::$(echo $PACKAGES | tr ' ' '-')" # キャッシュのキー文字列
      - name: Restore gem cache # gem のキャッシュをリストア
        uses: actions/cache@v2
        with:
          path: ${{ steps.gem-info.outputs.dir }}
          key: ${{ runner.os }}-gem-${{ steps.gem-info.outputs.key }}
      - name: Run ktlint
        run: ./gradlew $GRADLE_KTLINT_TASK
      - name: Run Android Lint
        run: ./gradlew $GRADLE_ANDROID_LINT_TASK
      - name: Run Unit Test
        run: ./gradlew $GRADLE_UNIT_TEST_TASK
      - name: Set up and run Danger
        if: cancelled() != true # 中断されない限り、エラーでも実行
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 標準で利用できるトークンを利用
          JOB_STATUS: ${{ job.status }} # jobのステータスを Danger へ受け渡す
        run: |
          gem install ${{ steps.gem-info.outputs.packages }}
          danger --dangerfile='.github/workflows/check-pull-request.danger' --remove-previous-comments --fail-on-errors=true
```

このワークフローでは、Danger のプラグインとして [danger-checkstyle_format](https://github.com/noboru-i/danger-checkstyle_format)、[danger-android_lint](https://github.com/loadsmart/danger-android_lint)、[danger-junit](https://github.com/orta/danger-junit) を利用しています。

gem でインストールした Danger をコマンドラインで起動しています。コマンドラインのオプションについては GitHub の [runner.rb](https://github.com/danger/danger/blob/master/lib/danger/commands/runner.rb) ファイルを参照ください。（ドキュメントは見つけられませんでした。）

Danger 用のスクリプトファイルは次のものを用意し、上で説明したのワークフローの yaml ファイルと同じディレクトリに格納します。（こちらも、この記事の為にコメントを多めにいれています。）

```ruby:.github/workflows/check-pull-request.danger
# GitHub Actions の job のステータスを受け取る
job_status = ENV['JOB_STATUS']

# 追加・変更していないコードはコメント対象外とするか
github.dismiss_out_of_range_messages({
  error: false, # エラーは追加・変更していないコードでもコメント
  warning: true,
  message: true,
  markdown: true
})

# ktlint の結果ファイルの解析とコメント
Dir.glob("**/build/reports/ktlint-results.xml").each { |report|
  checkstyle_format.base_path = Dir.pwd
  checkstyle_format.report report.to_s
}

# Android Lint の結果ファイルの解析とコメント
Dir.glob("**/build/reports/lint-results*.xml").each { |report|
  android_lint.skip_gradle_task = true # 既にある結果ファイルを利用する
  android_lint.report_file = report.to_s
  android_lint.filtering = false # エラーは追加・変更したファイルでなくてもコメント
  android_lint.lint(inline_mode: true) # コードにインラインでコメントする
}

# 最終結果でレポートするワーニング数は Android Lint と ktlint のみの合計としたいのでここで変数に保存
lint_warning_count = status_report[:warnings].count

# Local unit test の結果ファイルの解析とコメント
Dir.glob("**/build/test-results/*/*.xml").each { |report|
  junit.parse report
  junit.show_skipped_tests = true # スキップしたテストをワーニングとする(状況により適宜変更)
  junit.report
}

# プルリクの body が空の場合はエラー
fail 'Write at least one line in the description of PR.' if github.pr_body.length < 1

# プルリクが大きい場合はワーニング
warn 'Changes have exceeded 500 lines. Divide if possible.' if git.lines_of_code > 500

# 追加で独自のチェックをする場合はこのあたりで実施する
# ...

# Danger でエラーがある場合は既に何かしらコメントされているのでここで終了
return unless status_report[:errors].empty?

# GitHub Actions のワークフローのどこかでエラーがあった場合はその旨をコメントして終了
return markdown ':heavy_exclamation_mark:Pull request check failed.' if job_status != 'success'

# 成功時のコメント(もし不要な場合は省いてもいいと思います)
comment = ':heavy_check_mark:Pull request check passed.'
if lint_warning_count == 0
  markdown comment
else
  # ktlint と Android Lint のワーニング数の合計をレポート
  markdown comment + " (But **#{lint_warning_count}** warnings reported by Android Lint and ktlint.)"
end
```

仮に Android プロジェクトのコンパイルが通らない場合は Android Lint あたりでエラーになるので、一応そこで気づくことができます :eyes:（ビルドできるかのステップを追加してチェックしてもよいのですが、そうすると時間がかかってしまうので追加していません。）

この2ファイルを含むブランチでプルリクエストを作成してみてください。そうするとプルリク上でワークフローが実行中の状態になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/c10b2e92-7216-b443-a50e-72bc44c1f9fd.png" width=700 />

ワークフローの実行状況や結果は、上図の *Details* リンクから確認することができます。どこでエラーになったかもここで確認することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/68a5e937-3002-eb28-b432-4c522607781e.png" width=500 />

Danger のコメント例をいくつか紹介します。ワーニング（:warning: マークのコメント）に関しては、エラーと違い警告だけなので、いくらワーニングがあってもプルリクのチェック結果としては成功ステータスとなります。

#### ktlint または Android Lint のワーニング：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/2bf0ede7-18f7-9d8b-5376-2338c4745228.png" width=700 />

対象のコードにインラインでコメントされます。（ボットがレビューしているようで楽しいです :robot:）

#### Android Lint のエラー：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/7c3846d0-2f23-6c8c-cfc4-27a3cc64e5ca.png" width=700 />

#### Local unit test のエラー：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/33e4aa9a-0300-45fc-3125-1ec950841044.png" width=700 />

#### ワークフロー上で何かしらのエラーがある場合：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/85613ab5-bab6-f143-9368-76331c895815.png" width=700 />

この場合はワークフローの実行結果画面からエラーの原因を探すことになります。（が、コンパイルエラーの場合が殆どです。）

#### エラーが無い場合：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/5cf4d553-cfc9-692d-9103-b6283fc1655a.png" width=700 />

成功の旨がコメントされます。

このように、GitHub Actions を利用することで、特別な CI サービスの契約なしに簡単に CI のワークフローを構築することができます。ワークフローがファイルとしてリポジトリで管理でき、実行状況や結果の確認も GitHub 内で完結しているのもいいですね :smiley: 

# そのほかの設定

## Gradle と gem のキャッシュの作成

紹介したワークフローでは [actions/cache](https://github.com/actions/cache) で Gradle と gem のキャッシュを利用するようにしていますが、キャッシュはブランチに紐づいています。プルリクで新しいブランチを作成して push しても、その新しいブランチには当然、キャッシュが存在しません。コードの追加 push 時にはキャッシュが適用されはするのですが、キャッシュを十分に利用できておらず微妙な感じです。

ただ actions/cache は、プルリクのブランチにキャッシュが無ければマージ先のブランチのキャッシュを、マージ先に無ければデフォルトブランチのキャッシュを探索し利用する仕様になっています。よって、それらのブランチに更新があったタイミングでキャッシュを作成するワークフローを用意しておけば、プルリクの新規作成のタイミングでもキャッシュを利用できる状態にすることができます :thumbsup:

このワークフローは次のとおりです。（内容は先程のワークフローとだいたい同じなので細かい説明は省きます。）

```yaml:.github/workflows/generate-cache.yml
name: Generate cache

on:
  push:
    branches: # デフォルトブランチや主たるマージ先ブランチを指定
      - master
      - develop*

jobs:
  create:
    name: Generate cache
    runs-on: ubuntu-18.04
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: Set up JDK
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Restore gradle cache
        id: gradle-cache
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }}
      - name: Download dependencies
        if: steps.gradle-cache.outputs.cache-hit != 'true' # キャッシュが無い場合だけ実行
        run: ./gradlew androidDependencies
      - name: Set up Ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - name: Get gem info
        env:
          PACKAGES: danger:6.2.0 danger-checkstyle_format:0.1.1 danger-android_lint:0.0.8 danger-junit:1.0.0
        id: gem-info
        run: |
          echo "::set-output name=dir::$(gem environment gemdir)"
          echo "::set-output name=packages::$PACKAGES"
          echo "::set-output name=key::$(echo $PACKAGES | tr ' ' '-')"
      - name: Restore gem cache
        id: gem-cache
        uses: actions/cache@v2
        with:
          path: ${{ steps.gem-info.outputs.dir }}
          key: ${{ runner.os }}-gem-${{ steps.gem-info.outputs.key }}
      - name: Set up Danger
        if: steps.gem-cache.outputs.cache-hit != 'true' # キャッシュが無い場合だけ実行
        run: |
          gem install ${{ steps.gem-info.outputs.packages }}
```

## ktlint の導入

基本的には [ktlint の README](https://github.com/pinterest/ktlint#without-a-plugin) に導入方法が書かれていますが、

- マルチモジュールへの対応
- checkstyle 形式のレポートの追加（Danger 用）
- ktlint からの指摘があっても CI（GitHub Actions）上で異常終了させない

というのを考慮して、プロジェクトルートの *build.gradle*（`:app` 配下のではなく）に次のように記載します。

```gradle:build.gradle

// ...

subprojects { // 配下のモジュール全てに適用される

    configurations { ktlint }

    dependencies { ktlint "com.pinterest:ktlint:0.39.0" }

    task ktlint(type: JavaExec, group: "verification") {
        description = "Check Kotlin code style."
        main = "com.pinterest.ktlint.Main"
        classpath = configurations.ktlint
        args "--android", "--color", "--reporter=plain", "--reporter=checkstyle,output=${buildDir}/reports/ktlint-results.xml", "src/**/*.kt"
        ignoreExitValue true
    }

    task ktlintFormat(type: JavaExec, group: "formatting") {
        description = "Fix Kotlin code style deviations."
        main = "com.pinterest.ktlint.Main"
        classpath = configurations.ktlint
        args "-F", "--android", "src/**/*.kt"
        ignoreExitValue true
    }

    afterEvaluate {
        check.dependsOn ktlint
    }
}
```

ktlint から指摘があったらワークフローを失敗ステータスにする厳しい運用にする場合は `ignoreExitValue true` の記載は削除してください。

もしモジュールによって動作を変えたい場合は、`it.name` でモジュール名が取得できるので、判定処理をいれるとよいでしょう。例えばライブラリ導入用のモジュールがあったりすると、Gradle の check タスクがなくエラーになったりするので、次のような判定で回避します。

```gradle
afterEvaluate {
    if (it.name != "awesome_module") {
        check.dependsOn ktlint
    }
}
```

次に、プロジェクトルートに *.editorconfig* ファイルを用意して、ktlint の追加の設定をします。

```:.editorconfig
[*]
insert_final_newline = true

[*.{kt, kts}]
max_line_length = 128
disabled_rules = import-ordering
```

`insert_final_newline` を有効にすると、ファイルの末尾が改行文字であるかチェックされるようになります。Android Studio のコードフォーマッタもこの設定を参照しているので、ファイルの末尾が改行文字ではない場合は自動的に補完されるようになります。（この例では、Java のファイルなど、Kotlin 以外のファイルでも自動的に補完したいので `[*]` で全ファイルを対象にしています。）

`max_line_length` は、コード一行の最大文字数の設定です。デフォルトだと `100` なのですが、弊社では `128` にしています。このあたりはプロジェクトによって調整してください。

`disabled_rules` には、無効にする ktlint のルールを指定します。この例では `import-ordering`（コードの import 文の並び順のチェック）を無効にしています。現状、Android Studio のコードフォーマッタで並び替えた順序が ktlint の指摘の対象になってしまうからです。このあたり、最新バージョンの ktlint を利用（私が試したのは `0.39.0` まで）したり、フォーマッタの設定を調整したり、*.editorconfig* ファイルを調整したりすれば回避できるのかもしれません。（私は何をしても上手くいきませんでした..）

また、Android Studio のコードフォーマッタも、なるべく ktlint の指摘が少なくなるように設定されてあるものを利用する方がよいでしょう。おすすめの設定は [ktlint の README](https://github.com/pinterest/ktlint#option-3) に書いてあります。

## プルリクのマージのブロック

リポジトリの *Branch protection rules* の設定で、今回のワークフローのジョブ（`Check pull request`）のステータスをチェックするようにします。ワークフローの実行中やエラー時はマージボタンを非活性にすることができるので、中途半端な状態でマージされるのを防げます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/ea294774-bd23-090b-1ba4-7b0c6ffffe9b.png" width=400 />

Danger（この画面での表示は `danger/danger`）もステータスを更新しますが、Danger でエラーの場合は今回のワークフローもエラーになるので、ここでの Danger のステータスのチェックは特に不要です。

# おわりに

いかがでしたでしょうか。今回は、ゆめみの Android プロジェクトでよく導入しているプルリクチェックのワークフローを紹介させて頂きました。他にも紹介したいワークフローはまだまだあるのですが、それはまた別の機会に紹介させて頂きます :alien: