Flutterアプリの定期リリースを支える自動化

本稿では、ファストドクターのモバイルアプリのリリースフローを整備した取り組みについてご紹介します。

モチベーション

ファストドクターのモバイルアプリは、2022年夏にFlutterでのフルリプレースを実施し、それ以降は機能の開発が完了次第随時リリースをするという戦略を取っていました。

この戦略はシンプルであり、開発に関わっているステークホルダーが少ない状況下でうまく機能していました。しかし、組織の拡大に伴い以下のような問題が発生するようになりました。 複数機能の開発スケジュールの調整をしたり、バックエンドのリリース・QAとの整合性を取ったりという必要性が増し、調整コストが肥大化 リリースが不定期なため、いつPull Requestをマージすれば良いか分からずopenされたままのPull Requestが多数 この状況を改善するために、以下の要件を念頭に定期的なリリースとそれを支える仕組みを導入しました。

  • 複数開発者(3~8名程度を想定)による並行開発を止める事なく実現する
  • 複数種類のreleaseのプロセスが並行して走る場合(例:次versionとhotfixなど)も対応可能なworkflowを組む

ブランチ戦略

まず、Gitを用いた開発フローのoverviewについて説明します。

上図のように、developブランチをアップストリームとしてfeatureブランチを作成し、Pull Request経由でマージします。

バージョンごとに開発期間が設けられており、開発期間が完了するとrelease/x.x.xというブランチが作成され、そのブランチに対してQAが開始されます。QA期間中は開発期間と同じくdevelopブランチに対して修正のpull requestを作ります。QAが完了すると、そのrelease branch上で両ストアへのsubmitを実施し、Gitのtagを打って完了するというのが一連の流れです。

Feature Flag

上記のbranch戦略はTrunk Based Developmentを参考にしており、原則として変更がapproveされ次第すぐにマージするという思想に基づいています。これは、アップストリームとの差分が大きくなるに伴ってconflictの手間が増えることを嫌ったものです。

一方で、定期リリースをしていると下手に変更を取り込んでしまうことで意図せぬ機能のリリースに繋がってしまう恐れもあります。この問題に対しては、Feature Flagを用いて変更を入れ込むことで、動的に機能をON/OFFできるようにしています。

当社では、業界で広く採用されているFirebase Remote Configを利用しており、この仕組みによる副次的な効果としてABテストや段階的なリリースも可能になっています。

CI/CD環境

幾つかの選択肢を比較し、GitHub Actionsを採用しています。CircleCIやCodemagicなどの他ツールに比べて料金面でのメリット、ユーザー管理のシンプルさを基準に選定を進めましたが、将来的には速度やコストパフォーマンスを重視して乗り換える選択肢は有り得ると思っています。

自動化

ここからは、リリースフローのステップごとに用意している自動化の仕組みについて紹介します。

スケジュール

Google Sheetsを用いて以下のようなformatでスケジュールを管理しており、Google App Scriptを用いてステークホルダー全員が確認できるGoogleカレンダーに定期的に同期をしています。また、後述するステップで利用するGitHubのmilestoneも同時に生成します。

 

Google App Script
function addEventsFromSpreadSheet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('schedule');
  const calendar = CalendarApp.getCalendarById('hoge@gmail.com'); 
  const startRow = 2;
  const numRows = sheet.getLastRow() - 1; 
  const numColumns = sheet.getLastColumn(); 
  const dataRange = sheet.getRange(startRow, 1, numRows, numColumns);
  const data = dataRange.getValues();
  const eventAddedNums = 0;
  
  for (var i in data) {
    var row = data[i];
    var title = '[' + row[0] + '] '; 
    row.forEach(function (value, index) {
      if (index == 0) return;            // 'Version'列をスキップ 
      if (index == numColumns - 1) return; // 'Done'列をスキップ
      title += sheet.getRange(1, index + 1).getValue() + ' '; // 列名を取得し、タイトルに追加
    });
    var done = row[numColumns - 1];
    
    if (done) continue; 
    
    var startDate = new Date(row[1]); // Feature Complete 列
    var endDate = new Date(row[4]);   // Release 列
    
    calendar.createAllDayEvent(title, startDate, endDate);
    eventAddedNums++;
    sheet.getRange(parseInt(i) + startRow, numColumns).setValue('TRUE');  // 更新 'Done' 列
  }
  Logger.log('Added ' + eventAddedNums + ' event(s) to Google Calendar.');
}
	

リリースブランチの作成

開発期間が完了すると、下記のGitHub ActionsとPythonスクリプトがQA用のrelease branchを自動で作成します。

GitHub Actions
name: Feature Freeze
on:
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * 1"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v2
      - name: Setup Python
        uses: actions/setup-python@v2
        with:
          python-version: '3.8'
      - name: Create Branch
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 
        run: |
          cd $GITHUB_WORKSPACE
          milestone=$(python ./scripts/lookup_todays_milestone.py)
          if [ -z "$milestone" ]; then
            echo "There are no milestones due today"
            exit 1
          else
            branch_name="release/v$milestone"
            git checkout -b "$branch_name"
            git push origin "$branch_name"
            echo "BRANCH_NAME=$branch_name" >> $GITHUB_ENV
          fi
      - name: Send Slack notification
        if: ${{ success() }}
        run: |
          curl -X POST -H 'Content-type: application/json' --data '{
            "blocks": [
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "`${{ env.BRANCH_NAME }}`ブランチが作成されました。今日からQA開始です:muscle:"
                }
              }
            ]
          }' ${{ secrets.SLACK_WEBHOOK }}
	
Pythonスクリプト
import os
import urllib.request
import json
from datetime import datetime
import sys

def lookup_todays_milestone(today, milestones):
    for milestone in milestones:
        if milestone['due_on']:
            due_date = datetime.strptime(
                milestone['due_on'], '%Y-%m-%dT%H:%M:%SZ').date()
            if due_date == today:
                return milestone
    return None

def fetch_milestones():
    try:
        req = urllib.request.Request(api_url, headers=headers)
        with urllib.request.urlopen(req) as response:
            return json.loads(response.read().decode()) 
    except Exception as e:
        return None

api_url = f'<https://api.github.com/repos/org_name/repo_name/milestones>'
access_token = os.getenv('GITHUB_TOKEN')
headers = {'Authorization': f'Bearer {access_token}'}
milestones = fetch_milestones()
today = datetime.today().date()
target_milestone = lookup_todays_milestone(today, milestones)
if target_milestone:
    print(target_milestone['title'])
else:
    sys.exit()
	

アプリの配布

QA用のアプリ配布は、Firebase App Distributionを利用しています。また、テスターグループを指定して配信するためにgithub workflow dispatchを利用しています。

 

workflowを実行すると以下のGitHub Actionsが起動します。強いこだわりはないのですがFastlaneは導入していません。

GitHub Actions
name: Build App
on:
  workflow_dispatch:
    inputs:
      tester_group:
        type: choice
        required: true
        description: テスターグループ
        options:
          - テスター
      release_note:
        type: string
        required: true
        description: 'リリースノート'

jobs:
  Build-Android:
    name: 'Build Android'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      ANDROID_PACKAGE: 'app-stg-release.apk'
      ANDROID_KEYSTORE: 'app/signingConfigs/release.keystore'
      ARTIFACT_RETENTION_DAYS: 5
      FLUTTER_SDK_VERSION: ${{ vars.FLUTTER_SDK_VERSION }}
      BUILD_OPTIONS: ${{ vars.BUILD_OPTIONS }}
    outputs:
      package_filename: ${{ env.ANDROID_PACKAGE }}

    steps:
      - name: 'Check out repository'
        uses: actions/checkout@v3
      - name: 'Setup Flutter'
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_SDK_VERSION }}
          cache: true
      - name: 'Cache pubspec dependencies'
        uses: actions/cache@v2
        with:
          path: |
            ${{ env.PUB_CACHE }}
            **/.packages
            **/.flutter-plugins
            **/.flutter-plugin-dependencies
            **/.dart_tool/package_config.json
          key: build-pubspec-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            build-pubspec-
      - name: 'Build Android'
        run: |
          echo '${{ secrets.KEYSTORE_BASE64 }}' | base64 -d > android/${{ env.ANDROID_KEYSTORE }}
          export KEY_ALIAS='${{ secrets.KEY_ALIAS }}'
          export KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}'
          export KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}'
          flutter build apk ${{ env.BUILD_OPTIONS }}
      - name: 'Results'
        run: ls -l build/app/outputs/flutter-apk
      - name: 'Upload Artifact'
        uses: actions/upload-artifact@v3
        with:
          name: App-Android
          path: build/app/outputs/flutter-apk/${{ env.ANDROID_PACKAGE }} 
          retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
      - name: 'Clean up keystore'
        if: ${{ always() }}
        run: rm android/${{ env.ANDROID_KEYSTORE }}

  Build-iOS:
    name: 'Build iOS'
    runs-on: macos-latest
    timeout-minutes: 30
    env:
      IOS_PACKAGE: 'Runner.ipa'
      IOS_EXPORT_OPTIONS: 'ExportOptionsAdhoc.plist'
      FLUTTER_SDK_VERSION: ${{ vars.FLUTTER_SDK_VERSION }}
      BUILD_OPTIONS: ${{ vars.BUILD_OPTIONS }}
      ARTIFACT_RETENTION_DAYS: 5
    outputs:
      package_filename: ${{ env.IOS_PACKAGE }}

    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3
      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
          
          # create temporary keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          
          # import certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH
          
          # apply provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\\ Profiles

      - name: 'Setup Flutter'
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_SDK_VERSION }}
          cache: true

      - name: 'Cache pubspec dependencies'
        uses: actions/cache@v2
        with:
          path: |
            ${{ env.PUB_CACHE }}
            **/.packages
            **/.flutter-plugins
            **/.flutter-plugin-dependencies
            **/.dart_tool/package_config.json
          key: build-pubspec-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            build-pubspec-

      - name: 'Build iOS'
        run: flutter build ipa ${{ env.BUILD_OPTIONS }} --export-options-plist=./ios/${{ env.IOS_EXPORT_OPTIONS }}

      - name: 'Results'
        run: ls -l build/ios/ipa
      - name: 'Upload Artifact'
        uses: actions/upload-artifact@v3
        with:
          name: App-iOS
          path: build/ios/ipa/${{ env.IOS_PACKAGE }}
          retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
      - name: 'Clean up keychain and provisioning profile'
        if: ${{ always() }}
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
          rm ~/Library/MobileDevice/Provisioning\\ Profiles/build_pp.mobileprovision

  Distribution:
    name: 'Distribution App'
    needs: [ Build-Android, Build-iOS ]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: >-
      ${{ needs.Build-Android.result }} == 'success'
      ${{ needs.Build-iOS.result }} == 'success'
    steps:
      - name: 'Download Artifact Android'
        uses: actions/download-artifact@v3
        with:
          name: App-Android
      - name: 'Download Artifact iOS'
        uses: actions/download-artifact@v3
        with:
          name: App-iOS
      - name: 'Check'
        run: ls -l
      - name: 'Release Notes'
        run: echo ${{ github.event.inputs.release_note }} > release_notes.txt
      - name: 'Upload Artifact Android'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_ANDROID_APP_ID}}
          serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
          groups: ${{ github.event.inputs.tester_group }}
          releaseNotesFile: release_notes.txt
          file: ${{ needs.Build-Android.outputs.package_filename }}
      - name: 'Upload Artifact iOS'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_IOS_APP_ID}}
          serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
          groups: ${{ github.event.inputs.tester_group }}
          releaseNotesFile: release_notes.txt
          file: ${{ needs.Build-iOS.outputs.package_filename }}
      - name: Send Slack notification
        if: ${{ success() }}
        run: |
            curl -X POST -H 'Content-type: application/json' --data '{
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "新しいstagingアプリが配布されました。\\nリリースノート: ${{ github.event.inputs.release_note }}"
                  }
                }
              ]
            }' ${{ secrets.SLACK_WEBHOOK }}
	

QA期間中のBug Fix

QA期間中に発見されたバグに関しては、通常の機能開発と同じようにdevelopブランチに対してPull Requestを作成します。これは、複数のリリースが並行して動いている場合に反映漏れを防ぐためです。

下記のGitHub Actionsを使うことで、特定のlabelとmilestoneがついたPull Requestをbug fixとみなし、自動でcherry-pickを実施します。

GitHub Actions
name: Auto Cherry-pick
on:
  pull_request:
    types: [closed]
jobs:
  auto-cherrypick:
    if: >
      github.event.pull_request.merged == true &&
      contains(github.event.pull_request.labels.*.name, 'need cherry-pick') &&
      github.base_ref == 'develop'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: release/v${{ github.event.pull_request.milestone.title }}
          fetch-depth: 0
      - name: Cherry-pick changes
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_MERGE_HASH: ${{ github.event.pull_request.merge_commit_sha }}
        run: |
          git fetch --prune origin +refs/heads/*:refs/remotes/origin/*
          git fetch --prune origin +refs/pull/$PR_NUMBER/*:refs/remotes/origin/pull/$PR_NUMBER/*
          git config --global user.email "user.email"
          git config --global user.name "user.name"
          git cherry-pick $PR_MERGE_HASH -x
      - name: Create Pull Request
        id: createpr
        uses: peter-evans/create-pull-request@v3
        env:
          RELEASE_BRANCH: release/v${{ github.event.pull_request.milestone.title }}
        with:
          base: ${{ env.RELEASE_BRANCH }}
          branch: cherrypick/${{ github.event.pull_request.number }}
          delete-branch: true
          title: "[Cherry-pick] PR #${{ github.event.pull_request.number }} into ${{ env.RELEASE_BRANCH }}"
          body: |
            Automatic cherry-pick of the merged commit ${{ github.event.pull_request.merge_commit_sha }} of PR #${{ github.event.pull_request.number }} into release branch `${{ env.RELEASE_BRANCH }}`.
            If this PR stays open, please merge it manually after fixing any possible conflicts.
          labels: github-actions-pr
          milestone: ${{ github.event.pull_request.milestone.number }}
          assignees: ${{ github.event.pull_request.assignee.login }}
      - name: Try to auto-merge Pull Request
        uses: actions/github-script@v2
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.pulls.merge({
              merge_method: "squash",
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: ${{ steps.createpr.outputs.pull-request-number }}
            })
            await github.issues.createComment({
              issue_number: ${{ steps.createpr.outputs.pull-request-number }},
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '🎉🎉🎉'
            })
      - name: Comment on original PR if cherry-pick or auto-merge fails
        if: ${{ failure() }}
        uses: actions/github-script@v2
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.issues.createComment({
              issue_number: ${{ github.event.pull_request.number }},
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '👋 Hey @${{ github.event.pull_request.assignee.login }}, it seems the automatic cherry-pick failed or the cherry-picked PR could not be merged automatically. Please review and handle it manually.\\n\\nRun Log: <https://github.com/$>{{ github.repository }}/actions/runs/${{ github.run_id }}'
            })
	

ストアへの申請

QA期間完了後、以下のgithub actionsを実行し各ストアにバイナリをアップロードします。現在、申請の最終工程は手作業で実施しています。

GitHub Actions
name: 'Release - Build and Deploy'
on: workflow_dispatch
env:
  FLUTTER_SDK_VERSION: ${{ vars.FLUTTER_SDK_VERSION }}
  BUILD_OPTIONS: --dart-define FLAVOR=prod --flavor prod

jobs:
  Build-Android:
    name: 'Build Android'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      ANDROID_PACKAGE: 'app-prod-release.apk'
      ANDROID_KEYSTORE: 'app/signingConfigs/release.keystore'
      PACKAGE_NAME: 'jp.fastdoctor'
    outputs:
      package_filename: ${{ env.ANDROID_PACKAGE }}

    steps:
      - name: 'Check out repository'
        uses: actions/checkout@v3

      - name: 'Setup Flutter'
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_SDK_VERSION }}
          cache: true

      - name: 'Cache pubspec dependencies'
        uses: actions/cache@v2
        with:
          path: |
            ${{ env.PUB_CACHE }}
            **/.packages
            **/.flutter-plugins
            **/.flutter-plugin-dependencies
            **/.dart_tool/package_config.json
          key: build-pubspec-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            build-pubspec-

      - name: 'Build Android'
        run: |
          echo '${{ secrets.KEYSTORE_BASE64 }}' | base64 -d > android/${{ env.ANDROID_KEYSTORE }}
          export KEY_ALIAS='${{ secrets.KEY_ALIAS }}'
          export KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}'
          export KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}'
          flutter build apk ${{ env.BUILD_OPTIONS }}

      - name: 'Results'
        run: ls -l build/app/outputs/flutter-apk

      - name: 'Deploy to Play Store'
        uses: r0adkll/upload-google-play@v1.1.1
        with:
          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          packageName: ${{ env.PACKAGE_NAME }}
          releaseFile: build/app/outputs/flutter-apk/${{ env.ANDROID_PACKAGE }}
          track: internal

      - name: 'Clean up keystore'
        if: ${{ always() }}
        run: rm android/${{ env.ANDROID_KEYSTORE }}

  Build-iOS:
    name: 'Build iOS'
    runs-on: macos-12
    timeout-minutes: 30
    env:
      IOS_PACKAGE: 'Runner.ipa'
      IOS_EXPORT_OPTIONS: 'ExportOptionsStore.plist'

    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Install the Apple certificate and provisioning profile'
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.RELEASE_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          
          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
          
          # create temporary keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          
          # import certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH
          
          # apply provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\\ Profiles

      - name: 'Setup Flutter'
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_SDK_VERSION }}
          cache: true

      - name: 'Cache pubspec dependencies'
        uses: actions/cache@v2
        with:
          path: |
            ${{ env.PUB_CACHE }}
            **/.packages
            **/.flutter-plugins
            **/.flutter-plugin-dependencies
            **/.dart_tool/package_config.json
          key: build-pubspec-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            build-pubspec-

      - name: 'Build iOS'
        run: flutter build ipa ${{ env.BUILD_OPTIONS }} --export-options-plist=./ios/${{ env.IOS_EXPORT_OPTIONS }}

      - name: 'Results'
        run: ls -l build/ios/ipa

      - name: 'Upload Store'
        env:
          APPSTORE_API_KEY_BASE64: ${{ secrets.APPSTOREAPPST_API_KEY_BASE64 }}
          APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
          APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
        run: |
          mkdir ~/private_keys
          echo -n "$APPSTORE_API_KEY_BASE64" | base64 --decode -o ~/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8
          xcrun altool --upload-app --type ios -f build/ios/ipa/${IOS_PACKAGE} --apiKey $APPSTORE_API_KEY_ID --apiIssuer $APPSTORE_ISSUER_ID

      - name: 'Clean up keychain and provisioning profile'
        if: ${{ always() }}
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
          rm ~/Library/MobileDevice/Provisioning\\ Profiles/*.mobileprovision
          rm ~/private_keys/AuthKey_*.p8
	

クリーンアップ

申請後、新しいアプリをリリースしたら該当のmilestoneをcloseします。以下のGitHub Actionsが起動し、リリースブランチの削除やtagの生成、pubspec.yamlのversionのインクリメントが実施されます。

GitHub Actions
name: Close Milestone
on:
  milestone:
    types:
      - closed
jobs:
  closeMilestone:
    runs-on: ubuntu-latest
    env:
      MILESTONE_TITLE: "${{ github.event.milestone.title }}"
      COMMIT_MESSAGE: "Bump app version for the next release"
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
      - name: Create Tag and Push
        run: |
          milestone_title=${{ env.MILESTONE_TITLE }}
          branch_name="release/v${milestone_title}"
          git fetch origin ${branch_name}
          tag_name="release_$milestone_title"
          git checkout -b "$branch_name" origin/"$branch_name"
          git tag "$tag_name"
          git push origin "$tag_name"
          git push origin --delete "$branch_name"
      - name: Bump app version
        run: |
          git checkout develop
          python ./scripts/version_bump.py --patch --build
          git config user.email "youremailaddress"
          git config user.name "github-actions"
          git add pubspec.yaml
          git commit -m "${{ env.COMMIT_MESSAGE }}"
      - name: Create Pull Request
        id: createpr
        uses: peter-evans/create-pull-request@v3
        with:
          base: develop
          branch: release/v${{ env.MILESTONE_TITLE }}
          delete-branch: true
          title: "${{env.COMMIT_MESSAGE}}"
          body: |
            If this PR stays open, please merge it manually after fixing any possible conflicts.
          milestone: ${{ github.event.milestone.number }}
      - name: Approve Pull Request
        uses: actions/github-script@v2
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }} 
          script: |
            await github.pulls.createReview({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: ${{ steps.createpr.outputs.pull-request-number }},
              event: "APPROVE",
            })
      - name: Try to auto-merge Pull Request
        uses: actions/github-script@v2
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.pulls.merge({
              merge_method: "squash",
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: ${{ steps.createpr.outputs.pull-request-number }}
            })
      - name: Comment on original PR if cherry-pick or auto-merge fails
        if: ${{ failure() }}
        uses: actions/github-script@v2
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.issues.createComment({
              issue_number: ${{ steps.createpr.outputs.pull-request-number }},
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Auto merge failed. Log: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'
            })
      - name: Send Slack notification
        run: |
          curl -X POST -H 'Content-type: application/json' --data '{
            "blocks": [
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "${{ env.MILESTONE_TITLE }}のリリース作業(tag, release branch削除)が完了しました:tada: お疲れ様でした:clap:"
                }
              }
            ]
          }' ${{ secrets.SLACK_WEBHOOK }}
	

Versionのbumpには以下のpythonスクリプトを用いています。

Pythonスクリプト
import re
import argparse


"""
pubspec.yamlのversionをupdateするscript.

Usage:
❯ python ./scripts/version_bump.py [--major|--minor|--patch|--build]  
"""

parser = argparse.ArgumentParser()
parser.add_argument('--major', action='store_true', help='Increment major')
parser.add_argument('--minor', action='store_true', help='Increment minor')
parser.add_argument('--patch', action='store_true', help='Increment patch')
parser.add_argument('--build', action='store_true', help='Increment build')
args = parser.parse_args()

# loads version info from pubspec.yaml
path_to_pubspec = "./pubspec.yaml"
with open(path_to_pubspec, 'r') as file:
    contents = file.read()

version_re = r'^version:\\s*([\\'"]?)(.+?)\\1$'
version_match = re.search(version_re, contents, flags=re.MULTILINE)
if version_match:
    version = version_match.group(2)
else:
    print("faild to extract version info")
    exit(1)

parts = version.split('+')
left = parts[0]
right = parts[1] if len(parts) > 1 else "0"

major, minor, patch = map(int, left.split('.'))
if args.major:
    major += 1
    minor = 0
    patch = 0
if args.minor:
    minor += 1
    patch = 0
if args.patch:
    patch += 1

increment = int(right) + 1 if args.build else int(right)
new_version = f"{major}.{minor}.{patch}+{increment}"

# Replace version with new one
updated_contents = re.sub(
    version_re, f'version: {new_version}', contents, flags=re.MULTILINE)
with open(path_to_pubspec, 'w') as file:
    file.write(updated_contents)

print(f"Before: {version}")
print(f"After: {new_version}")
	

効果測定

上記の開発フローを導入して4ヶ月程度経ったタイミングで、定性・定量の両面からどのようなインパクトがあったのかを振り返ってみました。

定性面では、チームで振り返りを実施した中で「次のリリースがいつなのか把握しやすくなった」「QA工数が見積もりやすくなった」「アプリを先行してリリースできるのでバックエンドを待たなくて良くなった」というメンバーからのfeedbackをもらいました。

定量面では、GitHub APIを利用していくつかの指標をモニタリングしています。特に、LeanとDevOpsの科学で紹介されている指標のうち開発・リリースに関わる以下の指標に関しては、改善できたと言えそうです(導入は5月中旬)。

リリース頻度

 

Pull Requestがopenしてからmergeされるまでのリードタイム

 

参考までに、リードタイムの分析に利用したGoogle App Scriptも紹介します。

Google App Script
var repository = 'org/repo';
var token = 'your token';

var start_date = '2023-01-01';
var end_date = '2023-09-30';

function main() {
  const pullRequestData = getAllPullRequests();
  const leadTimes = calculateLeadTimes(start_date, end_date, pullRequestData);
  writeToSpreadsheet(leadTimes);
}

function getAllPullRequests() {
  var pullRequests = [];
  for (var i = 1; i <= 10; i++) {
    var url = '<https://api.github.com/repos/>' + repository + '/pulls?state=closed&per_page=100&page=' + i;
    var options = {
      headers: {
        "Authorization": "Token " + token,
        "Accept": "application/vnd.github.v3+json"
      }
    };
    var response = UrlFetchApp.fetch(url, options);
    var prs = JSON.parse(response);
    for (var j = 0; j < prs.length; j++) {
      var pr = prs[j];
      var prDetails = getPrDetails(pr.number);
      if (!prDetails.labels.some(label => label.name === 'need cherry-pick')) {
        pullRequests.push(pr);
      }
    }
  }
  return pullRequests;
}

function getPrDetails(pr_number) {
  var url = '<https://api.github.com/repos/>' + repository + '/pulls/' + pr_number;
  var options = {
    headers: {
      "Authorization": "Token " + token,
      "Accept": "application/vnd.github.v3+json"
    }
  };
  var response = UrlFetchApp.fetch(url, options);
  return JSON.parse(response);
}

function toDate(str) {
  return new Date(str.replace("T", " ").replace("Z", " UTC"));
}

function getLeadTime(pr) {
  var created_at = toDate(pr.created_at);
  var closed_at = toDate(pr.closed_at);
  return (closed_at - created_at) / (1000 * 60 * 60 * 24);
}

function calculateLeadTimes(start_date, end_date, pullRequests) {
  var leadTimes = {};
  var start = toDate(start_date);
  var end = toDate(end_date);

  for (var i = 0; i < pullRequests.length; i++) {
    var pr = pullRequests[i];
    var created_at = toDate(pr.created_at);
    if (start <= created_at && created_at <= end && pr.merged_at != null) {
      var year = created_at.getFullYear();
      var month = ("0" + (created_at.getMonth() + 1)).slice(-2);
      var weekOfMonth = Math.ceil((created_at.getDate()) / 7);
      var week = year + "-" + month + "-" + weekOfMonth;

      var leadTime = getLeadTime(pr);
      if (!leadTimes[week]) {
          leadTimes[week] = [];
      }
      leadTimes[week].push(leadTime);
    }
  }
  return leadTimes;
}

function writeToSpreadsheet(leadTimes) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.clear();
  sheet.appendRow(["Week", "Average Lead Time"]);

  for (var week in leadTimes) {
    if (!leadTimes.hasOwnProperty(week)) continue;
    var times = leadTimes[week];
    var count = times.length;
    var total = times.reduce(function(a, b){return a + b;}, 0);
    if (count > 0) {
      sheet.appendRow([week, total / count]);
    }
  }
}
	

まとめ

本稿ではFastDoctorのモバイルアプリの開発フローを改善し、定期リリースを実現した話についてご紹介しました。

現在リリース頻度は隔週となっており、状況を見ながらさらに高速化を図っていきたいと考えています。

可能な限りそのまま動くCI/CD用のスクリプトを掲載しましたので、みなさまの開発フローの改善にお役立て頂ければ幸いです。