本稿では、ファストドクターのモバイルアプリのリリースフローを整備した取り組みについてご紹介します。
モチベーション
ファストドクターのモバイルアプリは、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用のスクリプトを掲載しましたので、みなさまの開発フローの改善にお役立て頂ければ幸いです。