Github  ActionsでマルチモジュールAndroidプロジェクトのCI環境を整えよう(ビルド/Slack通知/Danger/ktlint)

# はじめに

2019年ごろにBeta版が出たGithub Actions、気になってはいたもののまだ触ったことがなかったので今回Androidリポジトリで動くCIをGithub Actionsで構築してみました。

これまでCIはBitriseしか使ったことがなかったのですが、無料版だと1回のビルド時間が10分までだったり、チームメンバーが合計3人までしか使用できないので、これを機にGithub Actionsに乗り換えることにしました。

マルチモジュールで気をつけるところはDanger周りくらいで、基本的にはシングルモジュールと変わらないかと思います。

まだまだ改善できそうなところは多いので、是非教えていただければ幸いです。

※こちらは趣味で一緒に開発しているメンバー向けの解説記事でもあります。

# Github Actions基礎知識
## サービス概要

- 20jobまで並列実行できる
- 1度のビルド時間のリミットは6時間(Bitriseの36倍ですねw)  

この時点でもうBitriseから乗り移ろうと決めたのですが、開発をする上で欠かせないGithub上で完結するのも良いですよね。
またチームメンバーに上限が特段設けられてないのもありがたいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/62896ea0-7879-3841-baae-664a3452c80b.png)

publicなら無料。最高ですね。

詳しくは [公式サイト](https://help.github.com/ja/actions/getting-started-with-github-actions/about-github-actions#usage-limits)をご覧ください。

## この記事で出てくる構文
[Github Actionsのワークフロー構文](https://help.github.com/ja/actions/reference/workflow-syntax-for-github-actions?algolia-query=%E6%A7%8B%E6%96%87#)に山ほどありますが、今回使用している構文のみピックアップして軽く紹介します。

### `name:` 
その名の通り、ワークフローやjobに名前がつけられます。

### `on:`
workflowをトリガーするGithubのイベントの名前。

```yaml
on: push
```  
のように1つのイベントでも、 

```yaml
on: [push, pull_request]
```  
のように2つでも指定できる。
指定できるイベントは[ワークフローをトリガーするイベント](https://help.github.com/ja/actions/reference/events-that-trigger-workflows)に詳しく記載されています。

### `jobs:` 
1つのワークworkflowの実行は複数のjobから成るため、その集合を表します。  

```yaml
jobs: 
  setup_environment:
    name: setup
  build_gradle: 
    needs: setup_environment
    name: build
```  

のように書くと、 `setup` や　`build` はjobのid、nameは下図のように実際に表示されるjob名となります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/7d405007-101b-3955-0777-3a6bbfc1f5eb.png" width=200/>

また、 `needs` で依存関係を指定することもできます。

### `runs-on:`
ジョブを実行するマシンの種類を指定します。

```yaml
runs-on: ubuntu-latest
```

### `uses:`
jobで実行されるアクションを示します。
簡単に言うと、誰かがつくったGithub Actionsのプラグインをここで指定することで使用できるようになるイメージです。

`with:` を添えることで必要な入力を指定することもできます。

```yaml
- uses: actions/checkout@v2
```

この例だと、
`{user名}/{action名}@{version}`
なので、
actions公式が出している、ブランチを切り替えてくれるversion2のアクションということですね。

### `run:` 
OSのシェルを利用してCLIで動くコマンドを実行できます。

```yaml
- name: Build Gradle
  run: ./gradlew assemble
```

また、複数行のコマンドを打つときは

```yaml
- name: Clean & Build Gradle
  run: |
    ./gradlew clean
    ./gradlew assemble
```

のように書くことができます。

### その他
あとは `env` で環境変数を指定できたり、 `if` でjobの実行を条件分岐したりするのですが、
量が多いため割愛します。


# やってみる

まずは動かしてみたい。　

## ビルド
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/c5f5e4d3-ffb0-51c0-08a9-d46828687be0.png" width=200/>



Github Actionsのテンプレを使えば、簡単にAndroid他のworkflowを構築することができます。
Andrdoiのテンプレはこんな感じです。

```yaml
name: Android CI

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: set up JDK 1.8
      uses: actions/setup-java@v1
      with:
        java-version: 1.8
    - name: Build with Gradle
      run: ./gradlew build
```

master branchに対するPRとpushがトリガーでworkflowが走り、

pushしたbranchへcheckout -> JDK1.8のsetup(これがないと./gradlewコマンドが使えない) -> Gradle Buildの順番でstepが実行されていますね。
`./gradlew build` のところは適宜 assembleDebugとか testDebugUnitTest等で置き換えたりしてもokです。

実際、初期のリポジトリであればこのymlを `.github/workflows` 配下におけば動きます。

しかし、今回はFirebaseを一部使用していたため、.gitignoreでgit管理から外している `google-services.json` を生成してあげる必要がありました。
API_KEYなど、センシティブな情報があるためです。

## google-services.jsonファイルを生成する
[こちらの記事](https://qiita.com/sudo5in5k/items/5b6da5dbba3fc2514319)を参考に、Base64化した `google-services.json` をデコードするやり方でやってみます。

こちらの記事を丸写しするだけでは動かないので、少し補足させていただきます。

### Base64化
`google-services.json` があるプロジェクトで、  

```shell

cat /Users/{user_name}/AndroidStudioProjects/{project_name}/app/google-services.json | base64
```
を実行し、base64化しましょう。
{user_name}, {project_name}は適宜補完してください。

### Base64をGithubのSecretsにセキュアな環境変数として登録する
上記の記事参照です。

### ymlの編集

```yaml
- name: Generate google-services.json
  env:
    GOOGLE_SERVICES_BASE64: ${{ secrets.GOOGLE_SERVICES_BASE64 }}
  run: echo $GOOGLE_SERVICES_BASE64 | base64 --decode --ignore-garbage > $GITHUB_WORKSPACE/app/google-services.json
``` 

こんな感じです。
ここで注意しないといけないのが、出力先が `./app/google-services.json` だと動かないです。
実際に動かしてみればわかるのですが、 assembleでの `app:processDebugGoogleServices` では `//home/runner/work/{project_name}/{project_name}/app/google-services.json` を見に行っています。

Githubのデフォルトの環境変数で
`$GITHUB_WORKSPACE` (//home/runner/work/{project_name}/{project_name})が用意されているのでこちらを使いましょう。


## ktlint, Android Lintを回してDangerでPRにコメントをつける

ktlint, Android Lint, Dangerの詳しい解説は省きますが、自分の設定や気をつけること等はさらっと書いていこうと思います。

### ktlint
[ktlint](https://github.com/pinterest/ktlint#editorconfig)は、Kotlinのコード規約に沿っているかをチェックしてくれたり、フォーマットしてくれるツールです。

こちらも色々調べると出てくると思うので詳しい説明は省きます。

設定的には、自分はprojectレベルの build.gradleにこんな感じで書いてます。

```Groovy:build.gradle(Project)
allprojects {
    repositories {
        google()
        jcenter()
        maven { url "https://jitpack.io" }
    }

    configurations {
        ktlint
    }

    dependencies {
        ktlint Dep.Ktlint.ktlint
    }

    // ここ、タスク名をktlintにするとdependenciesと競合して動かなかった。
    task ktlintMain(type: JavaExec, group: "verification") {
        description = "Check Kotlin code style."
        classpath = configurations.ktlint
        main = "com.pinterest.ktlint.Main"
        args "src/**/*.kt", "--android", "--color", "--reporter=plain",
                "--reporter=checkstyle,output=${buildDir}/reports/ktlint-results.xml"
        // lintエラーが起きた時に続行するか。続行しないと落ちる
        ignoreExitValue true
    }

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

### Android Lint
[lint チェックでコードを改善する](https://developer.android.com/studio/write/lint?hl=ja)
の通り、Android Studioに標準整備されているlintです。
上記のktlintのような設定は不要で、

```shell

./gradlew lint
```  
で動きます。

### Danger

Dangerは、上記のktlintやAndroid Lintの結果をみて、PRで自動コメントをつけてくれるツールです。
これにより、コード規約違反や乱れを人力で指摘する手間が省け、健全なコードを保てます。

今回CI環境を整えるにあたって、Dangerの設定が1番骨が折れました。

- マルチモジュール対応
- bundleが見つからないエラー

シングルモジュールのDangerは
[Android開発のコードレビューbotを乗り換えた話](https://techlife.cookpad.com/entry/2017/06/28/190000)や、
[Android の開発環境へ Danger を導入するメモ [GitHub x Bitrise 編]](https://qiita.com/hkusu/items/e1bd99efa92263f448ff)
を見ていただければわかると思います。


#### マルチモジュール対応
[マルチモジュールのプロジェクトでdanger-android_lintを使う](https://phicdy.hatenablog.com/entry/danger-android-lint-for-multi-module)
にとても助けられました。同じ要領でktlintもできます。


シングルモジュールの場合、ktlintやAndroid Lintのレポートファイルは、app moduleの `build/reports` 配下に生成されます。
マルチモジュールの場合は各モジュールにレポートファイルが生成されます。

Android lintの結果をDangerでPRコメントするためには [danger-android_lint](https://github.com/loadsmart/danger-android_lint) を使用するのが一般出来なのですが、report fileを1つしか指定できないため、普通にやると1つのmoduleに対してしかコメントできません。

方法としては、2つあるそうです。

- 生成されたreport fileをマージして1つのファイルにする
- 予めgradle taskを実行して各report fileからDangerを実行する

上記の記事を参考に後者でやってみるとktlint版でもうまくいきました。

```ruby:DangerFile
# ktlint
checkstyle_format.base_path = Dir.pwd
Dir["**/reports/ktlint-results.xml"].each do |file|
  checkstyle_format.report file
end

# android lint
android_lint.skip_gradle_task = true
android_lint.filtering = true
Dir["*/build/reports/lint-results*.xml"].each do |file|
  android_lint.report_file = file
  android_lint.lint(inline_mode: true)
end
```

普通であれば
`android_lint.gradle_task = "lintDebug"` のようにするのですが、
CIのstepで先にgradle taskを実行しておく感じです。

難しかった。

#### Gemfile
bundleでDangerを設定した場合はGemfileも忘れず。

```ruby:Gemfile
gem "danger"
gem "danger-checkstyle_format"
gem "danger-android_lint"
```

### ymlの編集

[Github Actions (v2) でDanger + ktlintを実行させる](https://qiita.com/tarumzu/items/87e4d9801d0a413c80f1)を参考にしました。

bundleでDnagerを設定した場合は少し記事と異なるので補足です。

```yaml
- name: run danger
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    gem install bundler:1.17.2
    bundle update
    bundle install
    bundle exec danger
```

Gemfile.lockの `BUNDLED WITH` でバージョンが指定されていたため 1.17.2のインストールをいているのと、
`bundle update` しないと `octokit` の依存関係？とコンフリクトして動かなかったです。

Bundle周り全然わからん... めっちゃ大変だった。


ちなみに、Dangerは現状ではPRトリガーでしか動かず、 pushトリガーだと
`Not a GitHubActions Pull Request - skipping danger run.`
とスキップされてしまうのでご注意ください。

#### キャッシュ

```yaml
- name: Cache Gems
  uses: actions/cache@v1
  with:
    path: vendor/bundle
    key: ${{ runner.os }}-danger-${{ env.cache-name }}-gems-${{ hashFiles('**/Gemfile.lock') }}
    restore-keys: |
      ${{ runner.os }}-danger-${{ env.cache-name }}-gems-
      ${{ runner.os }}-danger-${{ env.cache-name }}-
      ${{ runner.os }}-danger-
```
キャッシュを利用すると良いかもしれません。

#### おまけ

[danger-lgtm](https://github.com/leonhartX/danger-lgtm)
lintエラーがない場合にランダムでLGTM画像を貼ってくれるので面白いです

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/f9c19ac9-3f5c-d60b-a0d4-5c4348a13e53.png)


## Slackへ結果を通知する
こんな感じ

<img width="624" alt="スクリーンショット 2020-04-12 18.47.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/021f9149-8b8e-06da-ce70-0b2ad891c573.png">

[Slack Notify - GitHub Marketplace](https://github.com/marketplace/actions/slack-notify)
こちらのActionを使うとすごく簡単にSlackへの通知ができました。

基本的な使用法は上記を見ていただきたいのですが、
こちらも補足です。

### 失敗した場合と成功した場合でメッセージを変える
Github Actionsの構文で `if:` があります。
falseの場合に以下のstepを実行しないため、
`always()`, `failure()`, `success()` 等の、workflowのステータスと組み合わせれば失敗した場合と成功した場合でメッセージや色を変えることができます。

### commit messageを取得する
Slackへの通知に限ったことではないのですが、commit messageを取得して、どのcommitが失敗/成功したかを表示したかったので調べてみました。

Github ActionsではREST APIのレスポンスに近い、contextなるものが用意されています。
詳しくは[公式](https://help.github.com/ja/actions/reference/context-and-expression-syntax-for-github-actions)をご覧ください。

これにより、 

```yaml
${{github.repository}}
```  
のようにしていろんな情報が取れます。

#### pushイベントの場合

```yaml
${{github.event.head_commit.message}}
```  

でとれます。

### pull_requestの場合
DangerはPRトリガーでしか現状動かないため、[こちら](https://github.community/t5/GitHub-Actions/Accessing-commit-message-in-pull-request-event/td-p/40856#)の方法で取得します。

```yaml
- name: get commit message
  run: echo ::set-env name=commitmsg::$(git log --format=%B -n 1 ${{ github.event.after }})
```

## 並列にしてみる

これまで紹介してきたstepは大きく4つに分けれると思っています。

- JDKなどのセットアップ
- ビルド/テスト
- Lint, Dangerでコメント
- Slackへの通知

そして依存関係的には

①JDKなどのセットアップ
②ビルド/テスト   
  Lint, Dangerでコメント
③Slackへの通知

の順番ですかね。

詳細はyml全文を見ていただきたいのですが、

- 各jobでcheckoutしないといけない
- google-services.jsonはキャッシュ等に保存せず必要なjobで冗長的に生成する（センシティブな情報を含めるためキャッシュするのは推奨されていない）

を気をつければ簡単だと思います。


# yml全文
```yaml
name: Android CI

on: pull_request

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      # usesでplugin的なものが使える
      - uses: actions/checkout@v2
      # ./gradlewを使えるようにする
      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      # Gradleの依存関係をキャッシュ/リストアしてビルド時間を短くする
      - name: Gradle Cache
        uses: actions/cache@v1
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
  build:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      # .gitignoreしているファイルを生成する
      - name: Generate google-services.json
        env:
          GOOGLE_SERVICES_BASE64: ${{ secrets.GOOGLE_SERVICES_BASE64 }}
        run: echo $GOOGLE_SERVICES_BASE64 | base64 --decode --ignore-garbage > $GITHUB_WORKSPACE/app/google-services.json
      # Build
      - name: Build with Gradle
        run: ./gradlew assembleDebug
      # Unit Test
      - name: Unit Test
        run: ./gradlew testDebugUnitTest

  danger:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      # .gitignoreしているファイルを生成する
      - name: Generate google-services.json
        env:
          GOOGLE_SERVICES_BASE64: ${{ secrets.GOOGLE_SERVICES_BASE64 }}
        run: echo $GOOGLE_SERVICES_BASE64 | base64 --decode --ignore-garbage > $GITHUB_WORKSPACE/app/google-services.json
      - name: Setup ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
          architecture: 'x64'
      - name: run ktlintMain
        run: ./gradlew ktlintMain
      - name: run andoidLint
        run: ./gradlew lintDebug
      - name: Cache Gems
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-danger-${{ env.cache-name }}-gems-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-danger-${{ env.cache-name }}-gems-
            ${{ runner.os }}-danger-${{ env.cache-name }}-
            ${{ runner.os }}-danger-
      - name: run danger
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gem install bundler:1.17.2
          bundle update
          bundle install
          bundle exec danger

  notification:
    needs: [build, danger]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: get commit message
        run: |
          echo ::set-env name=commitmsg::$(git log --format=%B -n 1 ${{ github.event.after }})
      # 失敗したときのSlackへの通知
      - name: Slack Notification Failure
        if: failure()
        uses: rtCamp/action-slack-notify@v2.0.1
        env:
          SLACK_CHANNEL: notify-android
          SLACK_ICON: https://github.com/actions.png?size=48
          SLACK_COLOR: '#ff0000'
          SLACK_TITLE: ':fire: Build Failure :fire:'
          SLACK_MESSAGE: |
            Build failure!
            See more detail to check github.
            commit -> `$commitmsg`
          SLACK_USERNAME: Github Actions
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
      # 成功した時のSlackへの通知
      - name: Slack Notification Success
        if: success()
        uses: rtCamp/action-slack-notify@v2.0.1
        env:
          SLACK_CHANNEL: notify-android
          SLACK_ICON: https://github.com/actions.png?size=48
          SLACK_TITLE: ':rocket: Build Success :rocket:'
          SLACK_MESSAGE: |
            Build success! yattane!
            commit -> `$commitmsg`
          SLACK_USERNAME: Github Actions
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
```