Github Actions + PythonでGoogle Playの段階リリース状態をチェックする

この記事はand factory.inc Advent Calendar 2022 4日目の記事です。
昨日は　@ticktakclock さんの [【Jetpack Compose】特定の条件のときだけModifierを追加したい](https://qiita.com/ticktakclock/items/9998a94e92c7c13df70a) でした。　　

# 背景 
AndroidアプリのアップデートをGoogle Playへ公開する際、[段階的な公開](https://support.google.com/googleplay/android-developer/answer/6346149?hl=ja)を利用して一定の割合のユーザーにのみアップデートを提供し、その割合を徐々に上げていくリリース方法をとるプロダクトは少なくないと思います。  
Google Playの段階的な公開の割合は手動で変更する必要があるため、短いサイクルで頻繁にリリースを行っていると割合の変更をつい忘れてしまうことがありました。

現在公開されているアプリの状態は[Google Play Android Developer API](https://developers.google.com/android-publisher/api-ref/rest?hl=ja)を用いて確認することができます。
そこで、定期的にGoogle Play Android Developer APIを叩いて段階的な公開の状態をチェックし通知することで、リマインドできるのではないかと考えました。

# 設計
担当しているプロダクトではCI/CDにGithub Actionsを利用していたため、これを利用できないかと考えました。
Github Actionsには幸いにもスケジュールをトリガーにしてタスクを実行できる仕組みがあるので、これを用いて定期実行を行うようにしました。
https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule

```yml
on:
  schedule:
    - cron: '0 6 * * 1-5'　# 毎週月〜金曜日の15時に実行
```

Github ActionsであればAPI通信を行うための言語や環境に関してはある程度好きなものを選択できると思います。
今回は個人的に勉強中だったこともありPythonを選択しました。
リマインド方法に関してはひとまずSlackにメッセージを送信する形にしました。

# 段階的な公開のチェック
まずはローカルの環境を構築します。
Androidプロジェクト内にスクリプトを作成するのでvirtualenvを用います。

```terminal
mkdir scripts
cd scripts
pip3 install virtualenv
virtualenv env
source env/bin/activate
```

必要なパッケージをインストールします。
今回はSlackへの通知を行うのでPython Slack SDKのインストールも行いますが必須ではありません。

```terminal
env/bin/pip install google-api-python-client
env/bin/pip install google-auth
env/bin/pip install slack_sdk
```

実行するスクリプトを作成します。
まずはGoogle Play Android Developer APIへの認証情報を取得します。
今回はサービスアカウントを利用しています。
（[こちら](https://developers.google.com/android-publisher/getting_started?hl=ja#service-account)を参考にサービスアカウントのjsonキーを取得してください）

```py
from google.oauth2 import service_account

credentials = service_account.Credentials.from_service_account_file(
    "service-account.json
)
scoped_credentials = credentials.with_scopes(
    ["https://www.googleapis.com/auth/androidpublisher"]
)
```

取得した認証情報を用いてServiceを作成し、APIアクセスを行います。
`edits.insert`メソッドでeditIdを取得し、`edits.tracks.get`メソッドで指定したtrack（production)を取得します。

```py
from googleapiclient.discovery import build

service = build(
    serviceName="androidpublisher", version="v3", credentials=scoped_credentials
)
edit_id = service.edits().insert(packageName=PACKAGE_NAME).execute()["id"]
production_track = service.edits().tracks().get(packageName=PACKAGE_NAME, editId=edit_id, track="production").execute()
```

取得したtrackは以下のようなレスポンスとなります。

```text
{ # A track configuration. The resource for TracksService.
  "releases": [ # In a read request, represents all active releases in the track. In an update request, represents desired changes.
    { # A release within a track.
      "countryTargeting": { # Country targeting specification. # Restricts a release to a specific set of countries.
        "countries": [ # Countries to target, specified as two letter [CLDR codes](https://unicode.org/cldr/charts/latest/supplemental/territory_containment_un_m_49.html).
          "A String",
        ],
        "includeRestOfWorld": True or False, # Include "rest of world" as well as explicitly targeted countries.
      },
      "inAppUpdatePriority": 42, # In-app update priority of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. in_app_update_priority can not be updated once the release is rolled out. See https://developer.android.com/guide/playcore/in-app-updates.
      "name": "A String", # The release name. Not required to be unique. If not set, the name is generated from the APK's version_name. If the release contains multiple APKs, the name is generated from the date.
      "releaseNotes": [ # A description of what is new in this release.
        { # Localized text in given language.
          "language": "A String", # Language localization code (a BCP-47 language tag; for example, "de-AT" for Austrian German).
          "text": "A String", # The text in the given language.
        },
      ],
      "status": "A String", # The status of the release.
      "userFraction": 3.14, # Fraction of users who are eligible for a staged release. 0 < fraction < 1. Can only be set when status is "inProgress" or "halted".
      "versionCodes": [ # Version codes of all APKs in the release. Must include version codes to retain from previous releases.
        "A String",
      ],
    },
  ],
  "track": "A String", # Identifier of the track.
}
```

複数のリリース（公開中/段階的な公開中など）が含まれているので、その中から段階的公開中のリリース（status = inProgress）のものがあればSlackにメッセージを送信します。

```py
from slack_sdk.webhook.client import WebhookClient

hook_url = "https://hooks.slack.com/"
for r in [r for r in production_track["releases"]]:
    status = r["status"]
    if status == "inProgress":
        name = r["name"]
        percentage = int(float(r["userFraction"]) * 100)
        message = f"製品版 {name} は {percentage}% 段階リリース中"
        client = WebhookClient(hook_url)
        client.send(text=message)
```

scriptの全容は以下のようになります。

```main.py
from google.oauth2 import service_account
from googleapiclient.discovery import build
from slack_sdk.webhook.client import WebhookClient

PACKAGE_NAME = "com.example"
SERVICE_ACCOUNT_JSON = "service-account.json"
HOOK_URL = "https://hooks.slack.com/" # サービスアカウント同様、secretから取得することを推奨します

def main():
    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_JSON
    )
    scoped_credentials = credentials.with_scopes(
        ["https://www.googleapis.com/auth/androidpublisher"]
    )

    service = build(
        serviceName="androidpublisher", version="v3", credentials=scoped_credentials
    )

    edit_id = service.edits().insert(packageName=PACKAGE_NAME).execute()["id"]
    production_track = (
        service.edits()
        .tracks()
        .get(packageName=PACKAGE_NAME, editId=edit_id, track="production")
        .execute()
    )

    for r in [r for r in production_track["releases"]]:
        status = r["status"]
        if status == "inProgress":
            name = r["name"]
            percentage = int(float(r["userFraction"]) * 100)
            message = f"製品版 {name} は {percentage}% 段階リリース中"
            client = WebhookClient(HOOK_URL)
            client.send(text=message)
```

# Github Actionsによる定期実行
次に作成したscriptをGithub Actionsで定期実行します。
Pythonのセットアップ用アクションは用意されているので、パッケージをインストールしてscriptを実行するだけです。

- パッケージの情報は`env/bin/pip freeze　> requirements.txt`で書き出し、main.pyと同じフォルダに置いておきます
- サービスアカウントのキーはBASE64に変換し、Github Actionsの[secret](https://docs.github.com/ja/actions/security-guides/encrypted-secrets)に登録しておきます（SERVICE_ACCOUNT_JSON_BASE64）

```yml
on:
  schedule:
    - cron: '0 6 * * 1-5'

jobs:
  check:

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.10'
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r ./scripts/requirements.txt
      - name: Create service account file from secret
        run: |
          echo "${{ secrets.SERVICE_ACCOUNT_JSON_BASE64 }}" | base64 -d > ./service-account.json
      - name: Check staged roll-out
        run: |
          python ./scripts/main.py
```

# 最後に
今回は公開の割合を変更するフローにCrashlyticsやお問い合わせなどのチェックを含めたかったためSlackに通知するという手段をとりましたが、通知方法を変更したり、自動で段階リリースを進めたりすることも可能かと思います。
Pythonには色々なSDKが用意されているので、やりたいことを実現しやすくてとても良いですね。

# リンク
https://google-auth.readthedocs.io/en/master/

https://googleapis.github.io/google-api-python-client/

https://googleapis.github.io/google-api-python-client/docs/dyn/androidpublisher_v3.html

https://slack.dev/python-slack-sdk/