これは ゆめみ Advent Calendar 2020 の9日目の記事です。今回は、ゆめみの Android プロジェクトでよく導入している、GitHub Actions でのプルリクチェックのワークフローを紹介したいと思います
ゆめみの Android グループは、約20以上の多種多様なお客様の Android プロジェクトに携わっています。一部例外はありますが、多くのプロジェクトの CI は、Bitrise と GitHub Actions を併用して構築しています。以前はプルリクのチェックは Bitrise を利用して実施していましたが、最近は GitHub Actions に乗り換えつつあります。
プルリクのチェックでは ktlint、Android Lint、Local unit test を実行し、Danger を利用してチェック結果をコメントさせています。これを実現するワークフローは次の通りです。(この記事の為にコメントを多めにいれています。)
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、danger-android_lint、danger-junit を利用しています。
gem でインストールした Danger をコマンドラインで起動しています。コマンドラインのオプションについては GitHub の runner.rb ファイルを参照ください。(ドキュメントは見つけられませんでした。)
Danger 用のスクリプトファイルは次のものを用意し、上で説明したのワークフローの yaml ファイルと同じディレクトリに格納します。(こちらも、この記事の為にコメントを多めにいれています。)
# 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 あたりでエラーになるので、一応そこで気づくことができます (ビルドできるかのステップを追加してチェックしてもよいのですが、そうすると時間がかかってしまうので追加していません。)
この2ファイルを含むブランチでプルリクエストを作成してみてください。そうするとプルリク上でワークフローが実行中の状態になります。
ワークフローの実行状況や結果は、上図の Details リンクから確認することができます。どこでエラーになったかもここで確認することができます。
Danger のコメント例をいくつか紹介します。ワーニング( マークのコメント)に関しては、エラーと違い警告だけなので、いくらワーニングがあってもプルリクのチェック結果としては成功ステータスとなります。
ktlint または Android Lint のワーニング:
対象のコードにインラインでコメントされます。(ボットがレビューしているようで楽しいです )
Android Lint のエラー:
Local unit test のエラー:
ワークフロー上で何かしらのエラーがある場合:
この場合はワークフローの実行結果画面からエラーの原因を探すことになります。(が、コンパイルエラーの場合が殆どです。)
エラーが無い場合:
成功の旨がコメントされます。
このように、GitHub Actions を利用することで、特別な CI サービスの契約なしに簡単に CI のワークフローを構築することができます。ワークフローがファイルとしてリポジトリで管理でき、実行状況や結果の確認も GitHub 内で完結しているのもいいですね
そのほかの設定
Gradle と gem のキャッシュの作成
紹介したワークフローでは actions/cache で Gradle と gem のキャッシュを利用するようにしていますが、キャッシュはブランチに紐づいています。プルリクで新しいブランチを作成して push しても、その新しいブランチには当然、キャッシュが存在しません。コードの追加 push 時にはキャッシュが適用されはするのですが、キャッシュを十分に利用できておらず微妙な感じです。
ただ actions/cache は、プルリクのブランチにキャッシュが無ければマージ先のブランチのキャッシュを、マージ先に無ければデフォルトブランチのキャッシュを探索し利用する仕様になっています。よって、それらのブランチに更新があったタイミングでキャッシュを作成するワークフローを用意しておけば、プルリクの新規作成のタイミングでもキャッシュを利用できる状態にすることができます
このワークフローは次のとおりです。(内容は先程のワークフローとだいたい同じなので細かい説明は省きます。)
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 に導入方法が書かれていますが、
- マルチモジュールへの対応
- checkstyle 形式のレポートの追加(Danger 用)
- ktlint からの指摘があっても CI(GitHub Actions)上で異常終了させない
というのを考慮して、プロジェクトルートの build.gradle(:app
配下のではなく)に次のように記載します。
// ...
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 タスクがなくエラーになったりするので、次のような判定で回避します。
afterEvaluate {
if (it.name != "awesome_module") {
check.dependsOn ktlint
}
}
次に、プロジェクトルートに .editorconfig ファイルを用意して、ktlint の追加の設定をします。
[*]
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 に書いてあります。
プルリクのマージのブロック
リポジトリの Branch protection rules の設定で、今回のワークフローのジョブ(Check pull request
)のステータスをチェックするようにします。ワークフローの実行中やエラー時はマージボタンを非活性にすることができるので、中途半端な状態でマージされるのを防げます。
Danger(この画面での表示は danger/danger
)もステータスを更新しますが、Danger でエラーの場合は今回のワークフローもエラーになるので、ここでの Danger のステータスのチェックは特に不要です。
おわりに
いかがでしたでしょうか。今回は、ゆめみの Android プロジェクトでよく導入しているプルリクチェックのワークフローを紹介させて頂きました。他にも紹介したいワークフローはまだまだあるのですが、それはまた別の機会に紹介させて頂きます