🗒️

Github Actionsを用いたネイティブアプリリリースの自動化

2023/08/02に公開

はじめに

こんにちは、カナリー の小野です。
私たちは現在「Canary」というお部屋探しのアプリを作っています。
https://bluage.co.jp/about/about_canary.html
今回は、昨年から導入したアプリ開発チームのリリースフローをご紹介します。
Canary は React Native と Expo の bare workflow を用いて開発しています。
このフローは、チーム状況に応じてカスタマイズ可能なので、ネイティブアプリチームでない方もぜひご覧ください!!

使用技術

話すこと

  • GitHub Actions を使用したリリースの自動化
  • JS スクリプトを用いたリリースに必要な複数ファイルの更新
  • ストアにアップロードするまでの自動化

話さないこと

  • App Store や Google Play Store へのネイティブアプリのリリース方法
  • ストアから審査に提出する方法
  • GitHub Actions の基本的な設定方法

本記事で紹介するそれぞれのツールやサービスの基本的な設定方法や使用方法については、各公式ドキュメンテーションを参照してください。

自動化を達成したことによるメリット

リリース自動化によって得られたメリットはこちらになります。

  • リリース速度の向上と効率化
    • 自動化によって開発チームが手動で行っていた繰り返し作業を削減でき、リリース作業に充てる時間が短縮されました。
  • リリース回数の増加
    • 自動化により、リリース作業の時間が大幅に短縮され、週あたりのリリース回数が増えました。これにより、新機能の追加やバグの修正をより迅速に提供できるようになりました。
  • ストレスの軽減
    • 以前のリリース作業は手作業の多さからミスが発生しやすく精神的にも疲弊しがちでしたが、リリースの自動化によりそのストレスからも解放されました。

以上のメリットを享受したい方は、ぜひ本記事を参考にリリースの自動化を検討してみてください。
それでは、以前のリリースフローからどのように自動化を達成したのか詳しく見ていきましょう。

以前のリリースフローとその問題点

まず、以前のリリース体制について簡単に紹介します。

以前のリリースフロー

  1. ファイルの編集

    ファイル内のバージョン情報を手動で更新します。

    iOS

    • Expo.plist
      • bare workflow においてビルドの際に使用される設定ファイル
    • project.pbxproj
      • Xcode のプロジェクトファイル

    Android

    • AndroidManifest.xml
      • Android アプリの情報が記されているファイル
    • build.gradle
      • Android アプリケーションをビルドするための設定を含む Gradle スクリプトファイル
  2. PR 提出

    approve をもらい、master にマージします。

  3. ビルドファイルの生成

    生成コマンド

    • iOS
      eas build --profile prod:release --platform ios
    • Android
      eas build --profile prod:release --platform android
  4. ストアに提出

    生成したファイルをそれぞれのストア(App Store または Google Play ストア)に提出し、公開を待ちます。

以上が以前までのリリースフローでした。しかし、このリリースフローには問題点がありました。

問題点

  • 手作業が多いため、リリースに時間がかかる。
  • 工程が多く、エンジニアの生産性を下げる。
  • 手動でのファイル編集のため、人為的なミスのリスクが伴う。

これらの問題を解決するため、私たちはリリースフローの自動化に取り組みました。

現在のリリース方法

リリースフローで自動化したことは以下になります。すべて GitHub Actions を用いて自動化をしました。

  • ファイルの編集とリリース PR の作成
  • リリースノートの作成
  • ビルドファイルの生成と提出

では詳しく見ていきましょう。

ファイルの編集とリリース PR の作成

実行する内容を記した yaml ファイルはこちらです。

prepare-release.yaml
name: Prepare release

on:
# ワークフローのトリガー定義
  workflow_dispatch:
    inputs:
      release:
        description: 'Chose release type. (major, minor, revision)'
        required: true
        default: 'revision'
        type: choice
        options:
          - revision
          - minor
          - major
      ios:
        description: 'include ios'
        type: boolean
        default: true
      android:
        description: 'include android'
        type: boolean
        default: true

# jobsの設定
jobs:
  prepare-release:
    runs-on: macos-latest
    outputs:
      pr_url: ${{ steps.create_pr.outputs.PR_URL }}
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
     # Gitの設定
      - uses: actions/checkout@v3
      - name: Setting Git User
        run: |
          git config user.name github-actions
          git config user.email github-actions@github.com
   # 環境変数の設定
      - name: Set Value
        run: |
          now=$(TZ=UTC-9 date '+%Y%m%d_%H%M%S')
          branch_name="release/${now}"

          echo BRANCH_TITLE=$branch_name >> $GITHUB_ENV
          echo PR_TITLE=$branch_name >> $GITHUB_ENV
   # ブランチの作成と移動
      - name: Git checkout
        run: git checkout -b ${{ env.BRANCH_TITLE }}
   # ファイルの編集
      - name: Update version
        env:
          RELEASE_TYPE: ${{ github.event.inputs.release }}
          ENABLE_IOS: ${{ fromJson(github.event.inputs.ios )}}
          ENABLE_ANDROID: ${{ fromJson(github.event.inputs.android )}}
        run: |
          npx zx ./scripts/zx/replace_new_version.mjs
          git add -u
          git commit -m "update version text"
          git push --set-upstream origin ${{ env.BRANCH_TITLE }}
   # PRの作成
      - name: Create release pr
        run: |
          version=$(jq -r ".ios.version | tostring" ./version.json)
          BODY=v${version}のリリース

          gh pr create -B "master" -t ${{ env.PR_TITLE }} -b ${BODY} --label release --label release:ios --label release:android

簡単にファイルの中身を紹介します。対象コードはファイルのコメントを参照してください。

  1. どのようにワークフローを実行するかの定義workflow_dispatchというイベントを用いて、ブラウザの GitHub 上から手動でワークフローを実行できるようにしています。このイベントは任意のパラメータを設定でき、それによりワークフローの実行時に特定の情報を指定できます。ここでは、リリースタイプ(major, minor, revision)を指定し、iOS と Android のどちらか、あるいは両方のプラットフォームに対してリリースをするかを選択できます。

  2. jobs の設定:次にjobsセクションで、ワークフローの実行する作業を定義します。ここではprepare-releaseという名前のジョブを作成します。ジョブ内部には一連のstepsがあり、これらのステップはジョブが実行するタスクを設定できます。

  3. Git の設定:リポジトリをチェックアウトし、Git のユーザー名とメールを設定します。

  4. 環境変数の設定:現在時刻を取得し、その値をもとにブランチタイトルと、PR タイトルを作成し、環境変数に保存します。

  5. ブランチの作成と移動:先ほど作成したタイトルのブランチを作成し、移動します。

  6. ファイルの編集:ここでバージョン番号の更新が行われます。バージョンの更新は、JS のスクリプトを用います。実行するスクリプトは以下になります。

    replace_new_version.mjs
    #!/usr/bin/env zx
    
    //  環境変数の読み取り
    const releaseType = process.env.RELEASE_TYPE || 'revision'; // major, minor, revision
    const enableAndroid = JSON.parse(process.env.ENABLE_ANDROID) || false;
    const enableIOS = JSON.parse(process.env.ENABLE_IOS) || false;
    
    // バージョン設定の読み取り
    const readVersionConfig = async (path) => {
      const versionConfig = await fs.readJson(path);
      return versionConfig;
    };
    
    // 新しいバージョンの生成
    const getNewVersion = (releaseType, versionConfig) => {
      const version = versionConfig.ios.version;
      const versionArray = version.split('.');
      const major = Number(versionArray[0]);
      const minor = Number(versionArray[1]);
      const revision = Number(versionArray[2]);
    
      let newVersion = '';
    
      switch (releaseType) {
        case 'major':
          newVersion = `${major + 1}.0.0`;
          break;
        case 'minor':
          newVersion = `${major}.${minor + 1}.0`;
          break;
        case 'revision':
          newVersion = `${major}.${minor}.${revision + 1}`;
          break;
        default:
          newVersion = `${major}.${minor}.${revision + 1}`;
          break;
      }
    
      return newVersion;
    };
    
    //  iOSバージョンの更新
    const updateIOSVersion = async (newVersion, versionConfig) => {
      const IOS_EXPO_PATH = 'Expo.plistへのPATH';
      const IOS_PROJECT_PATH = 'project.pbxprojへのPATH';
    
      await $`sed -i "" 's/v${versionConfig.ios.version}/v${newVersion}/' ${IOS_EXPO_PATH}`;
      await $`sed -i "" 's/MARKETING_VERSION = ${versionConfig.ios.version}/MARKETING_VERSION = ${newVersion}/' ${IOS_PROJECT_PATH}`;
    
      const newVersionConfig = { ...versionConfig };
      newVersionConfig.ios.version = newVersion;
    
      return newVersionConfig;
    };
    
    // Androidバージョンの更新
    const updateAndroidVersion = async (newVersion, versionConfig) => {
      const ANDROID_MANIFEST_PATH = 'AndroidManifest.xmlへのPATH';
      const ANDROID_GRADLE_PATH = 'app/build.gradleへのPATH';
    
      const prevCode = String(versionConfig.android.code);
      const newCode = String(versionConfig.android.code + 1);
    
      await $`sed -i "" "s/v${versionConfig.android.version}/v${newVersion}/" ${ANDROID_MANIFEST_PATH}`;
      await $`sed -i "" "s/versionCode ${prevCode}/versionCode ${newCode}/" ${ANDROID_GRADLE_PATH}`;
      await $`sed -i "" "s/"${versionConfig.android.version}"/"${newVersion}"/" ${ANDROID_GRADLE_PATH}`;
    
      const newVersionConfig = { ...versionConfig };
      newVersionConfig.android.version = newVersion;
      newVersionConfig.android.code = Number(newCode);
    
      return newVersionConfig;
    };
    
    // バージョン設定の保存
    const updateVersion = async () => {
      const VERSION_CONFIG_PATH = `version.json`;
    
      const versionConfig = await readVersionConfig(VERSION_CONFIG_PATH);
      let newVersionConfig = { ...versionConfig };
    
      const newVersion = getNewVersion(releaseType, versionConfig);
    
      if (enableIOS) {
        const updatedVersionConfig = await updateIOSVersion(newVersion, versionConfig);
        newVersionConfig = { ...newVersionConfig, ...updatedVersionConfig };
      }
    
      if (enableAndroid) {
        const updatedVersionConfig = await updateAndroidVersion(newVersion, versionConfig);
        newVersionConfig = { ...newVersionConfig, ...updatedVersionConfig };
      }
    
      await fs.writeJson(VERSION_CONFIG_PATH, newVersionConfig, { spaces: 2 });
    };
    updateVersion();
    

    ここでは、以下のタスクを行います。対象コードは、ファイルのコメントを参照してください。

    a. 環境変数の読み取り:最初に、GitHub Actions で入力したリリースタイプと各プラットフォームをリリースに含むかの値を環境変数から読み取っています。
    b. バージョン設定の読み取り: 次に、スクリプトは現在のバージョン設定をversion.jsonファイルから読み取ります。このファイルには、iOS と Android のそれぞれのバージョン情報が含まれています。
    c. 新しいバージョンの生成: そして、リリースの種類(major, minor, revision)に基づいて新しいバージョンを生成します。リリースの種類は環境変数を通じて取得され、それに応じて新しいバージョンが生成されます。
    d. iOS バージョンの更新: iOS のリリースが含まれる場合、スクリプトは次に iOS のバージョン情報を更新します。Expo.plistproject.pbxproj が更新されます。
    e. Android バージョンの更新: Android のリリースが含まれる場合、スクリプトは Android のバージョン情報を更新します。AndroidManifest.xmlbuild.gradleが更新されます。
    f. バージョン設定の保存: 最後に、version.jsonファイルのバージョン番号を更新します。これにより、次回このスクリプトが実行されるときに、新しいバージョン情報が適用されます。

  7. PR の作成:新しいバージョン番号を使って新しい PR を作成します。バージョン番号は、以下のような形式の json ファイルで管理されています。

    version.json
    {
      "android": {
        "version": "バージョン番号",
        "code": "バージョンコード"
      },
      "ios": {
        "version": "バージョン番号",
        "buildNumber": "ビルドナンバー"
      }
    }
    

    また、PR にはrelease, release:ios, release:androidというラベルが自動的に付けられます。同時に、この PR の作成とともに E2E(エンドツーエンド)テストが実行されます。

リリースノートの作成

上記の PR をマージすると 2 つの GitHub Actions が走ります。
1 つ目は、リリースノートを作成する Actions です。

post-release.yaml
name: Post Release

on:
  pull_request:
    types:
      - 'closed'

jobs:
  tag:
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
    env:
      GH_TOKEN: ${{ github.token }}
    steps:
      - uses: actions/checkout@v3
      - name: create release with note
        run: |
          tag=v$(jq -r ".ios.version" ./version.json)
          gh release create $tag --generate-notes

ここでは以下のことを行っています。

  1. どのようにワークフローを実行するかの定義:ここでは、プルリクエストがreleaseラベルを含んでいて、かつマージされた場合にこのワークフローが実行されます。
  2. リリースノートの作成:最新のバージョン番号を取得して新しいタグを作成します。さらに、-generate-notes オプションを使用してリリースノートを自動生成します。

この GitHub Actions によって、リリースノートを自動で生成してくれます。

ビルドファイルの生成と提出

もう 1 つの Actions でビルドファイルの生成と提出をします。

submit.yaml
name: Submit

# どのようにワークフローを実行するかの定義
on:
  pull_request:
    types:
      - 'closed'

jobs:
  submit-ios:
    if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:ios')
    name: Install and build ios
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    runs-on: macos-latest
    environment: production:ios
    steps:
      # リポジトリのチェックアウト
      - uses: actions/checkout@v3
      # Node.jsのセットアップ
      - uses: actions/setup-node@v3
        with:
          node-version-file: '.tool-versions'
          cache-dependency-path: 'yarn.lock'
          cache: yarn
      # ExpoとEASのセットアップ
      - name: Setup Expo and EAS
        uses: expo/expo-github-action@v7
        with:
          expo-version: latest
          eas-version: 3.15.0
          token: ${{ secrets.EXPO_TOKEN }}

      # 依存関係のインストール
      - name: Install dependencies
        run: yarn install --frozen-lockfile

      # ビルドファイルの生成と提出
      - name: Prod iOS Build on EAS
        run: eas build --profile prod:release --platform ios --auto-submit --non-interactive

      - name: Notify slack on success
        if: success()
        env:
          SLACK_WEB_HOOK_URL: SlackのURL
          IS_SUCCEEDED: true
          PLATFORM: IOS
        run: |
          export RELEASE_CHANNEL=v$(jq -r ".ios.version" ./version.json)
          npx zx ./scripts/zx/notify_built.mjs
      # Slackへの通知
      - name: Notify slack on failure
        if: failure()
        env:
          SLACK_WEB_HOOK_URL: 'SlackのURL'
          IS_SUCCEEDED: false
          PLATFORM: IOS
        run: |
          export RELEASE_CHANNEL=v$(jq -r ".ios.version" ./version.json)
          npx zx ./scripts/zx/notify_built.mjs

  submit-android:
    if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:android')
    name: Install and build android
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    runs-on: macos-latest
    environment: production:android
    steps:
      # リポジトリのチェックアウト
      - uses: actions/checkout@v3
      # Node.jsのセットアップ
      - uses: actions/setup-node@v3
        with:
          node-version-file: '.tool-versions'
          cache-dependency-path: 'yarn.lock'
          cache: yarn
      # ExpoとEASのセットアップ
      - name: Setup Expo and EAS
        uses: expo/expo-github-action@v7
        with:
          expo-version: latest
          eas-version: 3.15.0
          token: ${{ secrets.EXPO_TOKEN }}
      # 依存関係のインストール
      - name: Install dependencies
        run: yarn install --frozen-lockfile
      # ビルドファイルの生成と提出
      - name: Prod Android Build on EAS
        run: eas build --profile prod:release --platform android --auto-submit --non-interactive
      # Slackへの通知
      - name: Notify slack on success
        if: success()
        env:
          SLACK_WEB_HOOK_URL: 'SlackのURL'
          IS_SUCCEEDED: true
          PLATFORM: ANDROID
        run: |
          export RELEASE_CHANNEL=v$(jq -r ".android.version" ./version.json)
          npx zx ./scripts/zx/notify_built.mjs
      - name: Notify slack on failure
        if: failure()
        env:
          SLACK_WEB_HOOK_URL: 'SlackのURL'
          IS_SUCCEEDED: false
          PLATFORM: ANDROID
        run: |
          export RELEASE_CHANNEL=v$(jq -r ".android.version" ./version.json)
          npx zx ./scripts/zx/notify_built.mjs
  1. どのようにワークフローを実行するかの定義:このワークフローはプルリクエストがreleaseラベルを含み、かつマージされた場合に起動します。

  2. 事前準備:ビルドファイルの生成に必要な準備をします。これには以下のステップが含まれます。

    1. リポジトリのチェックアウト
    2. Node.js のセットアップ
    3. Expo と EAS のセットアップ
    4. 依存関係のインストール
  3. ビルドファイルの生成と提出:EAS を使用して本番用の iOS/Android ビルドを行います。

    • ここでは、以下 2 つのオプションを設定しています。
      • -auto-submit
        • ビルドファイルの提出を自動で行う。
      • -non-interactive
        • 本番用の iOS/Android ビルドを非対話的に行うことができる。これにより、コマンドラインからの入力なしにビルドを進行できます。
  4. Slack への通知:ビルドの成功・失敗にかかわらず、処理の終了と共に Slack へ通知が送られます。通知の際には以下の環境変数が用いられます。

    実行するスクリプトは以下になります。

    notify_built.mjs
    #!/usr/bin/env zx
    
    const webhookURL = process.env.SLACK_WEB_HOOK_URL;
    const releaseChannel = process.env.RELEASE_CHANNEL;
    const platform = process.env.PLATFORM;
    const isSucceeded = JSON.parse(process.env.IS_SUCCEEDED);
    
    const succeededText = `${releaseChannel}${platform}ビルドが完了しました`;
    const failedText = `${releaseChannel}${platform}ビルドが失敗しました`;
    
    const body = {
      attachments: [
        {
          mrkdwn_in: ['text'],
          color: isSucceeded ? 'good' : 'danger',
          title: isSucceeded ? succeededText : failedText,
          text: 'メッセージ内容の指定',
        },
      ],
    };
    
    await fetch(webhookURL, {
      method: 'post',
      body: JSON.stringify(body),
      headers: { 'Content-Type': 'application/json' },
    });
    

    このスクリプトにより、ビルド完了時に以下のように Slack へ通知が送信されます。

    この Action が完了すると、EAS のauto-submit機能によってビルドファイルが自動的にストアに提出されます。そのため、開発者側で必要となるのは審査申請だけです。

    以上が今回自動化した内容になります。

まとめ

以上が、GitHub Actions を用いたリリースフローの自動化に関する説明でした。自動化によって、アプリ開発チームは手動でのビルドやリリース作業から解放され、別の重要なタスクにリソースを割けるようになりました。

また、ビルド結果をSlack等のチャットツールへ自動通知することで、進捗をリアルタイムで確認できるようになりました。これにより、ビルドで問題が発生した際も迅速な対応が可能になります。

今回紹介した GitHub Actions を用いる手法は、プロジェクトの状況に応じてカスタマイズ可能です。リリースフローを自動化させたい方がいれば、ぜひ参考にしてください!!

Canary Tech Blog

Discussion