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

背景

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

参考リンク