1
/
5

AndroidアプリのGoogle Playへの段階リリースをFastlaneで自動化する

モバイルエンジニアの久保出です。タイトル通りですが、今回はFastlaneを使ってAndroidアプリをGoogle Playで段階的にロールアウトしていく手法について書きます。

Fastlaneを知ってる人向けで、Fastlane 2.193.1 時点での記述です。

経緯

Wantedlyでは、ユーザーと企業が気軽にマッチングできるVisitというアプリを提供しています。そして学生ユーザー向けのInternという姉妹アプリも提供しています。VisitとInternは同じコードベースで作られており、AndroidではProduct Flavorsの仕組みを使って実現しています。

同じコードベースであるため、どちらもリリースするタイミング全く一緒であるべきなのですが、既存のGoogle Playへのリリースワークフローは手作業が多く、リリースを同時に行うには手作業が2倍必要でした。この手作業は、後述するような段階リリースを行うために、1日おきにリリースの割合を手動で増やしていました。これは作業コストが多く、また忘れやすいという課題が多いものでした。

InternアプリはVisitアプリに比べるとユーザー数が少ないこともあって、積極的にリリースするモチベーションが薄れていきました。結果として、Internアプリのリリースは長期間止まってしまいました。今回、Internアプリのリリースを再開する必要性が出てきたため、再びリリースが止まらないようにリリースワークフローを自動化することにしました。

課題

そもそもなぜリリースを手動にしていたかというと、アプリのアップデートを段階的にユーザーに浸透させたいためです。

モバイルアプリはその仕組み上、ユーザーがアップデートするとロールバックやダウングレードする方法がありません。普段QAチェックなど品質向上の取り組みや、インテグレーションテストの実施でクラッシュやクリティカルなバグがないことは確認していますが、特定条件下では想定外の問題が起きる可能性はあります。もしすべてのユーザーにアップデートを公開すると、そういった問題が起きてしまうと取り返しがつかない事態になります。そのため、アップデートを段階的に行うようにしたいです。

Google Playでは、それを実現できる段階的な公開という機能があります。しかし、この機能は公開する割合を手動もしくはAPIで設定しなければなりません。柔軟性はありますが、手作業を行うかAPIによる自動ワークフローを組み上げる必要があります。

一方App Store Connectでは、1週間かけて自動的に1%, 2%, 5%, 10%, 20%, 50%, 100%と割合を増やしていく段階的リリース機能があります。これが正に求めていたことだったので、同じような段階的リリースをGoogle Playでもできるように、FastlaneとCIでワークフローを組み上げることにしました。

設計

まずはワークフローの設計から考えます。

前提として、既存のFastlaneによるワークフローが存在しており、internalトラックへアプリを自動でアップロードすることはすでに自動化されています。トラックについては後述します。

毎日段階的に割合を増やしていくため、CIで毎日実行されるようなジョブを作ることにしました。ジョブの内容としては、

  • 現在のproductionトラックのロールアウトの割合を取得
  • internalトラックのほうがバージョンが新しければ段階リリースを始める
  • 段階リリース中であれば次の割合に引き上げる
  • リリース済みなら何もしない
  • 変化があったらSlackへ通知する

このフローを毎日実行するようにすれば、段階リリースは実現できそうです。

リリーストラックとは

Google Playのリリースにはトラックという概念があり、internal, alpha, beta, productionといった種類があります。アプリをテストするのに役立つもので、internalでは社内向けの公開、alphaは許可されたユーザー向け、betaは公開テストもできる形、productionは一般に公開される形です。alpha, betaはゲームだとよく使われる印象です。

実装

後ほど解説しますが、まずは実装したFastlaneのコードを全部書きます。このlaneをCIで毎日実行するように組むことで、App Store Connectと同じような段階的リリースが実現できます。

desc "Promotes the internal track to the production track with minimum rollout, or promotes the number of the rollout of the production track."
lane :promote_production do |options|
target_track = "production"
rollout_tick = [0.01, 0.02, 0.05, 0.1, 0.2, 0.4, 1.0] # The last one must be 1.0!

UI.important("Trying to promote the `internal` track to `#{target_track}`. Tick: #{rollout_tick}")

# Setup: https://github.com/fastlane/fastlane/blob/de0d0745ec8e44f3d6f50baa183ae327d8b9d252/supply/lib/supply/reader.rb#L37-L39
require 'supply/client'
Supply.config = { json_key: CredentialsManager::AppfileConfig.try_fetch_value(:json_key_file) }
client = Supply::Client.make_from_config

# Reference: https://github.com/fastlane/fastlane/blob/de0d0745ec8e44f3d6f50baa183ae327d8b9d252/supply/lib/supply/client.rb#L456-L471
# Returns: https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks#Release
client.begin_edit(package_name: package_name(options))
internal_releases = client.track_releases("internal")
target_releases = client.track_releases(target_track)
client.abort_current_edit

UI.message("`internal` track release: #{internal_releases[0].name}")
UI.message("`#{target_track}` track releases:")
target_releases.each { |r| UI.message(" - #{r.name} #{r.status} #{r.user_fraction}") }

# Fail if the target status either inProgress or completed
if !["inProgress", "completed"].include?(target_releases[0].status)
UI.user_error!("Unexpected status: `#{target_track}` track #{target_releases[0].name} #{target_releases[0].status}")

# If the target status is `inProgress`, rollouts to the next percentage
elsif target_releases[0].status == "inProgress"
current_rollout = target_releases[0].user_fraction
next_rollout = rollout_tick.find { |r| r > current_rollout }
UI.important("Trying to rollout `#{target_track}` track from #{current_rollout} to #{next_rollout}")
supply(
package_name: package_name(options),
track: "internal",
track_promote_to: target_track,
rollout: next_rollout.to_s,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
notify_promoted_to_slack(package_name(options), target_track, target_releases[0].name, next_rollout)
UI.success("Success!")

# If having the different version names, promotes internal track to target track from minimum rollout
elsif internal_releases[0].name != target_releases[0].name
first_rollout = rollout_tick[0]
UI.important("Trying to rollout `#{target_track}` track from `internal` track starting from #{first_rollout}")
supply(
package_name: package_name(options),
track: "internal",
track_promote_to: target_track,
rollout: first_rollout.to_s,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
notify_promoted_to_slack(package_name(options), target_track, internal_releases[0].name, first_rollout)
UI.success("Success!")

# NOP
else
UI.success("Nothing to do!")

end
end

def notify_promoted_to_slack(app, track, name, rollout)
require "json"
json_str = JSON[{ app: app, track: track, name: name, rollout: rollout.to_s }]
sh("curl -X POST -d '#{json_str}' '#{ENV["SLACK_URL"]}'")
end

解説

自動的にアップデートの割合を増やしていくには、現在の割合を取得しなければなりません。

Google Play DeveloperにはPublishing APIがあり、リリースの状態を取得したり制御したりできます。Fastlaneは裏ではこれを利用していますが、Fastlaneの標準的なアクションではリリースの割合を取得することができなさそうでした。しかしFastlaneの中には、このAPIをラップしているSupply::Clientが存在し、それにはリリースの割合を取得できそうなメソッドが存在していました。なので、これを使うことにします。

注意:外部から利用することを意図していないかもしれないので、将来の変更で動作しなくなる可能性はあります。

まずはSupply::Clientを作ります。try_fetch_valueは、fastlane/Appfileの中のjson_key_fileを読み取るので、Appfileがある前提です。

require 'supply/client'
Supply.config = { json_key: CredentialsManager::AppfileConfig.try_fetch_value(:json_key_file) }
client = Supply::Client.make_from_config

次に、internalトラックとproductionトラックの現在のリリース状態を取得します。Fastlaneの中でSupply::Client使い方を探ると、begin_editabort_current_editが前後で必要とわかります。track_releasesが返すスキーマはAPI Referenceを参照しましょう。

client.begin_edit(package_name: package_name(options))
internal_releases = client.track_releases("internal")
target_releases = client.track_releases(target_track)
client.abort_current_edit

productionトラックがinProgressつまりリリースのロールアウト中であれば、ロールアウトの割合を引き上げます。現在の割合よりも大きい最初の値をrollout_tickから探します。

elsif target_releases[0].status == "inProgress"
current_rollout = target_releases[0].user_fraction
next_rollout = rollout_tick.find { |r| r > current_rollout }

supplyはGoogle Playへのアップロードやロールアウトの引き上げ、リリーストラックの制御などなんでもできるやつです。internalトラックへのアップロードもこれを使っています。

この場合、internalトラックのアプリをproductionトラックへ昇格し、次段階の割合で公開します。完了したらSlackに通知をしておきます。

  supply(
package_name: package_name(options),
track: "internal",
track_promote_to: target_track,
rollout: next_rollout.to_s,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
notify_promoted_to_slack(package_name(options), target_track, target_releases[0].name, next_rollout)
UI.success("Success!")

割合を引き上げるのはこれで実現できました。

ですが、最初の割合でproductionトラックへの公開をしなければ始まりません。それが次のelsifで、internalトラックでは公開済みだがproductionトラックではまだ公開が始まっていない場合、internalからproductionへの昇格を最小の割合で行います。

  elsif internal_releases[0].name != target_releases[0].name
first_rollout = rollout_tick[0]
UI.important("Trying to rollout `#{target_track}` track from `internal` track starting from #{first_rollout}")

後のフローは割合を増やすのと一緒なので説明は省きます。

Slackへの通知はSlack workflow builderで行っています。設定内容は省略させてもらいますが、最終的にこのような通知が来るようになります。

後はこの作成したlaneをCIから定期的に実行するようにします。以下はCircleCIを使っているのでcronを使ったワークフローの例です。

workflows:
version: 2
promote_production:
triggers:
- schedule:
cron: "0 1 * * *" # 10:00 JST
filters:
branches:
only: develop
jobs:
# Visitアプリの設定で作成したlaneを実行
- promote_production_visit
# Internアプリの設定で作成したlaneを実行
- promote_production_intern

これでワークフローが完成しました!

まとめ

手作業が減ったおかげで、非常にリリースしやすくなりました。自動で段階的にリリースされていくことで、万が一が起きたときの被害も最小限に抑えられるようになって、安心感も出ました。

リリースを手動で中断した場合など考慮してないケースはありますが、それを実装するコストのほうが高いので運用でカバーしようと思います。

今回紹介してないですが、他にも色々とCI/CDで行っています。興味があるとか、もっと知ってみたいという方は、ぜひカジュアルに話を聞きに来てみてください。

Wantedly, Inc.'s job postings
3 Likes
3 Likes

Weekly ranking

Show other rankings