GitHub Actions + Tophat で Android PR レビューを効率化

GitHub の PR コメントに自動投稿された Tophat リンクをクリックすると、Cloudflare R2 にアップロードされた APK を Android エミュレーターが自動でダウンロード・インストールし、アプリが起動するまでの一連の流れを記録した動画です。

背景

Android アプリの PR レビューでは、コードの変更内容だけでなく、実際の動作を確認することが重要です。

課題

実際の動作を確認するには、ビルドした APK ファイルをレビュアーに配布します。しかし、Android APK は依存ライブラリやリソースを含むと、容易に GitHub のコメント添付上限 25MB を超えてしまうため、PR コメントに直接添付できません。

レビュアーがアプリを試すには、いくつかの方法がありました。PR ブランチをローカルでビルドする方法は時間がかかります。Google Play Console を使った配布では設定が煩雑で配布まで時間がかかります。ファイル共有サービス経由で APK を送信する方法は手動操作が多く煩雑です。

これらはすべて手間がかかり、PR レビューの速度を低下させます。

解決策

Cloudflare R2(無料枠)+ Tophat のワンクリックインストールで、PR レビュー時のアプリ配布を簡単にしました。

レビュアーは PR コメントのリンクをクリックするだけで、自動的にエミュレーターにアプリがインストールされます。

アーキテクチャ

GitHub Actions → Tophat ワークフロー

フロー

  1. GitHub PR 作成 → GitHub Actions トリガー
  2. GitHub Actions でビルド & APK 生成
  3. Cloudflare R2 にアップロード
  4. GitHub PR コメント に Tophat リンク自動投稿
  5. レビュアー がリンククリック → Tophat が R2 から APK 取得
  6. Tophat が自動でエミュレーターにインストール & 起動

実装方法

1. Cloudflare R2 バケット作成

R2 コンソールでバケットを作成し、R2.dev サブドメインを有効化します。これにより、公開アクセス可能な URL が発行されます。

GitHub リポジトリの Secrets に以下の環境変数を設定します。

Cloudflare R2 関連の設定は次の通りです。

R2_BUCKET_NAME=your-bucket-name
R2_ENDPOINT_URL=https://xxxxxxxx.r2.cloudflarestorage.com
R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev
R2_ACCESS_KEY_ID=xxxxx
R2_SECRET_ACCESS_KEY=xxxxx

APK 署名関連の設定(オプション)は次のようになります。

DEBUG_KEYSTORE_BASE64=xxxxx
DEBUG_KEYSTORE_PASSWORD=xxxxx
DEBUG_KEY_ALIAS=xxxxx
DEBUG_KEY_PASSWORD=xxxxx

R2_PUBLIC_URL について

R2 バケットの設定で「R2.dev サブドメインを許可」を有効化すると発行される公開 URL です。バケット名は含めず、https://pub-xxx.r2.dev の形式で設定します。ワークフロー内で /android/... と結合されます。

2. GitHub Actions ワークフロー

.github/workflows/pr-review.yml を作成します。

name: PR Review - Build & Deploy APK

on:
  pull_request:
    types: [opened, synchronize]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    permissions:
      contents: read
      pull-requests: write

    steps:
      - name: Checkout code
        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

      - name: Set up JDK 17
        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'gradle'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Setup Debug Keystore
        run: |
          echo "${{ secrets.DEBUG_KEYSTORE_BASE64 }}" | base64 -d > debug.keystore
          ls -l debug.keystore

      - name: Build Debug APK
        env:
          CI: true
          DEBUG_KEYSTORE_PASSWORD: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }}
          DEBUG_KEY_ALIAS: ${{ secrets.DEBUG_KEY_ALIAS }}
          DEBUG_KEY_PASSWORD: ${{ secrets.DEBUG_KEY_PASSWORD }}
        run: ./gradlew assembleDebug --no-daemon --stacktrace

      - name: Find APK
        id: find_apk
        run: |
          APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
          if [ -z "$APK_PATH" ]; then
            echo "Error: APK not found"
            exit 1
          fi
          echo "apk_path=$APK_PATH" >> $GITHUB_OUTPUT
          echo "apk_name=$(basename $APK_PATH)" >> $GITHUB_OUTPUT
          echo "Found APK: $APK_PATH"

      - name: Cache AWS CLI
        id: cache-aws-cli
        uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
        with:
          path: ~/.local/aws-cli
          key: ${{ runner.os }}-aws-cli-${{ hashFiles('**/awscliv2.zip') }}
          restore-keys: |
            ${{ runner.os }}-aws-cli-

      - name: Install AWS CLI v2
        if: steps.cache-aws-cli.outputs.cache-hit != 'true'
        run: |
          curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
          unzip -q awscliv2.zip
          ./aws/install -i ~/.local/aws-cli -b ~/.local/bin

      - name: Add AWS CLI to PATH
        run: |
          echo "$HOME/.local/bin" >> $GITHUB_PATH
          aws --version

      - name: Upload to Cloudflare R2
        id: upload
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
          R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
          R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
        run: |
          set -euo pipefail

          APK_PATH="${{ steps.find_apk.outputs.apk_path }}"
          APK_NAME="${{ steps.find_apk.outputs.apk_name }}"

          # Generate unique filename with PR number and commit SHA
          PR_NUMBER="${{ github.event.pull_request.number }}"
          COMMIT_SHA="${{ github.event.pull_request.head.sha }}"
          SHORT_SHA="${COMMIT_SHA:0:7}"
          TIMESTAMP=$(date +%Y%m%d-%H%M%S)
          UNIQUE_NAME="pr-${PR_NUMBER}-${SHORT_SHA}-${TIMESTAMP}.apk"

          echo "Uploading ${APK_NAME} to R2 as ${UNIQUE_NAME}..."

          # Upload to R2
          aws s3 cp "$APK_PATH" "s3://${R2_BUCKET_NAME}/android/${UNIQUE_NAME}" \
            --endpoint-url "$R2_ENDPOINT_URL" \
            --content-type "application/vnd.android.package-archive" \
            --no-progress

          # Generate public URL
          PUBLIC_URL="${R2_PUBLIC_URL}/android/${UNIQUE_NAME}"

          # Generate Tophat install URL
          TOPHAT_URL="http://localhost:29070/install/http?url=${PUBLIC_URL}"

          echo "Upload successful!"
          echo "Public URL: ${PUBLIC_URL}"

          echo "public_url=$PUBLIC_URL" >> $GITHUB_OUTPUT
          echo "tophat_url=$TOPHAT_URL" >> $GITHUB_OUTPUT
          echo "unique_name=$UNIQUE_NAME" >> $GITHUB_OUTPUT

      - name: Post PR Comment
        if: success()
        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
        env:
          PUBLIC_URL: ${{ steps.upload.outputs.public_url }}
          TOPHAT_URL: ${{ steps.upload.outputs.tophat_url }}
          UNIQUE_NAME: ${{ steps.upload.outputs.unique_name }}
        with:
          script: |
            const publicUrl = process.env.PUBLIC_URL;
            const tophatUrl = process.env.TOPHAT_URL;
            const uniqueName = process.env.UNIQUE_NAME;
            const commitSha = context.payload.pull_request.head.sha.substring(0, 7);
            const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;

            const body = `## 📱 ビルド完了

            GitHub Actions でビルドが完了しました。

            **Commit:** \`${commitSha}\` | **Build:** [View Logs](${runUrl})

            ### 🚀 ワンクリックインストール(Tophat 必須)

            <a href="${tophatUrl}">エミュレーターで起動</a>

            > **注意**: このリンクを使用するには Tophat が必要です。
            >
            > 初回のみ: [Tophat をダウンロード](https://github.com/Shopify/tophat/releases)(macOS 15+ 必要)
            `;

            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });

3. Tophat セットアップ

macOS 15+ が必要です。Tophat Releases から最新版をダウンロードしてインストールします。

ちなみに、Tophat は Android だけでなく iOS にも対応しています。

使い方はとても簡単です。まず Tophat を起動すると、localhost:29070 でサーバーが起動されます。次に PR コメントのリンクをクリックします。すると自動的に APK ダウンロード、エミュレーターインストール、アプリ起動が実行されます。

4. GitHub の制約対応

GitHub はセキュリティ上、tophat:// のようなカスタム URL スキームをサニタイズします。

そのため、Tophat の HTTP サーバー(localhost:29070)を利用します。

# GitHub で動作する形式
http://localhost:29070/install/http?url=...

まとめ

GitHub Actions、Cloudflare R2、Tophat を組み合わせることで、Android アプリの PR レビュー時の APK 配布を完全自動化できました。

GitHub の 25MB 制限を回避しつつ、レビュアーはリンクをクリックするだけでアプリを試せるようになりました。レビューサイクルが速くなり、開発効率が向上しています。

参考リンク