Timee Product Team Blog

タイミー開発者ブログ

Android Chapter のリリースワークフローを紹介します

はじめに

この記事は Timee Advent Calendar エンジニアリングパート 3日目

担当は Android Chapter の tick-taku です。

来月でタイミーに入社して1年になります。Rails など新しいことにチャレンジしたり DroidKaigi や RubyKaigi など様々なカンファレンスに参加させてもらったりと濃い体験をさせてもらえて、この1年長かったような短かったようなという不思議な気持ちです。

1年間何やったかなと振り返ってみて Hilt やデザインシステムの導入など開発の基盤となることをメインにやっていたな〜と思ったので、この記事では入社直後からやっていた開発における自動化や仕組み作りの一環としてリリースワークフローを整えた話を実装ベースで紹介します。

リリース作業の自動化

タイミーの Android Chapter はこの1年でチームメンバーが3人から8人に増加しました。(嬉しい)

メンバーが増加するにあたって追い風となる反面、手作業で行っていたタスクも多くメンバー間で迷いなくスピーディに実行できるよう効率化する「レール」を敷く必要があると感じ、仕組み作り・自動化に取り組みました。

タイミーではストリームアラインドチームを採用しており基本的に Android エンジニアは各チームに散らばっています。その中でも1スプリントを1週間としているチームも多く、スプリント終了後にリリースを行うルールを採用しています。リリースサイクルが短いためリリース作業も頻繁に発生しており、バイナリ作成など一部 Bitrise で自動化されているものもありましたが基本的には以下のような作業を毎週繰り返していました。

  • リリース用のブランチ作成、アプリバージョンの変更・コミットなどの事前準備
  • リリース作業を行う PullRequest (以下、PR) を作成
  • リリース PR 上でのバイナリの作成・ストアへのアップロードタスクの実行と動作検証
  • 「Next Release」マイルストーンに紐づくPRを目視で確認し、リンクをリリースPRの description や GitHub Releases に記載
  • 各実装 PR に対してマージするタイミングで「Next Release」のマイルストーンを手動でアサイン
  • これらをその週のリリース担当者(ランダムで選出)が作業

そこでこれらを一部自動化する Workflow を作成し、リリース作業の効率化を図りました。その Workflow を紹介する前に、前提となる運用やスクリプトについて紹介します。

GitHub CLI

始めに頻出する GitHub CLI を紹介しておきます。

個人的には一番お世話になっているツールです。そもそも Git 操作を CLI で行うので、その延長で GitHub 上の様々な操作をコマンドで実行できるため非常に便利です。

GitHub CLI

基本的に PR の作成や CI のステータス確認などは GitHub CLI を利用しています。 コードレビューに関してだけはビジュアライズされている方が理解が速いので Web で確認していますが、それも GitHub CLI から見たい PR をブラウザで開くことができるので捗ります。

GitHub Actions においても基本的にはランナーにインストールされており token も secrets に用意されているので利用するハードルも低く相性もいいです。今回も GitHub 上の操作を自動化するために多用しています。

リリース PR の自動作成

まずリリース作業用の PR を自動で作成するスクリプトを用意しました。

#!/bin/bash

new_version="$1"

# リリースに含まれる PR をリストアップ
# クローズされた PR のリストから次のリリースの対象となる Milestone にアサインされたものをフィルタリングします
release_title="Release ${new_version}"
pr_numbers=$(gh pr list -s closed -L 100 --json "milestone,number,labels" -q "[.[] | select(.milestone.title == \\"${release_title}\\")]")
updates=$(echo "$pr_numbers" | jq -r '{
    feature_updates: [.[] | select(.labels[].name == "Update") | .number],
    bug_fixes: [.[] | select(.labels[].name == "BugFix") | .number],
    development_updates: [.[] | select(.labels[].name == "DevelopmentUpdate") | .number],
    others: [.[] | select(all(.labels[].name; . != "Update" and . != "BugFix" and . != "DevelopmentUpdate")) | .number],
}')
updates_body=$(echo "$updates" | jq -r '
"## Updates",
(.feature_updates | map("- #" + tostring) | join("\\n")),
"## Bug Fix",
(.bug_fixes | map("- #" + tostring) | join("\\n")),
"## Development Updates",
(.development_updates | map("- #" + tostring) | join("\\n")),
"## Others",
(.others | map("- #" + tostring) | join("\\n"))
')

pr_body="""
# $release_title

## Release Note
\\`\\`\\`
$(cat releasenotes/whatsnew-ja-JP)
\\`\\`\\`

$updates_body
"""

gh pr create \\
  -B master \\
  -t "$release_title" \\
  -m "$release_title" \\
  -b "$pr_body" \\
  -l "Release"

ストアに申請する際のリリースノートも description 上で確認できるようにしています。

実装 PR の分類とラベルによる自動化

タイミーでは内部へのリリースお知らせなどのために、リリース作業時に各PRを FeatureUpdateBugFix などに分類して description に記載しています。 以前はリリース担当者が手動で振り分け作業を行っていましたが、これが大きな負担となっていました。

そこで PR 作成時に実装者が Update などのラベルをつけることでどれがどの分類なのか自動で振り分けるようにしました。それが上記のスクリプトの updates_body 作成の部分です。

また、手動でラベルをアサインするのも手間なので feature/update/ なら Update ラベルを追加するなど branch 名で自動で付与されるようにもしています。

name: PullRequest bootstrap

on:
  pull_request:
    types:
      - opened

jobs:
  assign_updates_label:
    runs-on: ubuntu-latest
    if: startsWith(github.head_ref, 'feature')
    env:
      PR_NUMBER: ${{ github.event.pull_request.number }}
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/update/')
        run: |
          gh pr edit $PR_NUMBER --add-label "Update"
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/bugfix/')
        run: |
          gh pr edit $PR_NUMBER --add-label "BugFix"
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/development/') || startsWith(github.head_ref, 'feature/development_update/')
        run: |
          gh pr edit $PR_NUMBER --add-label "DevelopmentUpdate"

バージョン管理の改善とアップデートコミットの自動化

以前は Gradle の config 内で直接アプリバージョンを管理しており、リリースのたびに build.gradle に差分が生じていました。

gradle には他にも様々なアプリのコアとなる記述があり、頻繁に gradle を触るのも嫌ですがさらに自動で差分を更新しコミットするのもハードルが高そうです。

これを改善するため、入社時のオンボーディングタスクとしてちょうど VersionCatalog を導入したこともあり、バージョン情報を toml ファイルで管理しました。

[versions]
versionMajor = "1"
versionMinor = "0"
versionPatch = "0"
versionOffset = "0"
def versionMajor = libs.versions.versionMajor.get().toInteger()
def versionMinor = libs.versions.versionMinor.get().toInteger()
def versionPatch = libs.versions.versionPatch.get().toInteger()
def versionOffset = libs.versions.versionOffset.get().toInteger()

versionCode =
    versionMajor * 1000000 + versionMinor * 10000 + versionPatch * 100 + versionOffset
versionName = "$versionMajor.$versionMinor.$versionPatch"

そして toml ファイル内の各バージョンを入力値に更新するスクリプトを用意します。

#!/bin/bash

libs_file_path="gradle/libs.versions.toml"

version="$1"
versions=(${version//./ })

sed -i -e "/versionMajor/s/.*/versionMajor = \\"${versions[0]}\\"/g" $libs_file_path
sed -i -e "/versionMinor/s/.*/versionMinor = \\"${versions[1]}\\"/g" $libs_file_path
sed -i -e "/versionPatch/s/.*/versionPatch = \\"${versions[2]}\\"/g" $libs_file_path
sed -i -e "/versionOffset/s/.*/versionOffset = \\"${versions[3]}\\"/g" $libs_file_path

rm -rf "$libs_file_path-e"

これを Workflow 内で実行することでアプリバージョンのアップデート作業を自動化しました。

sed でなんとでもなると昔から教わってきたので使いがちですが、余計なファイルが出来たりもするしあんまりイケてないのではと最近気付きはじめました...

QA の運用について

タイミーでは PR ごとに QA チェックリストを記載し動作検証を行っています。以前はチェックリストを PR の description に直接記載して PR 単位の動作検証を行っていました。

しかしリリース時の QA でもそれを見ながら検証していたので QA の度に該当の PR を見に行く必要がありました。 非常に手間がかかってしまうので、QA を Notion のデータベースで一元管理しページ内にチェックリストを記載する運用を導入しました。

Milestone が Release となっているものがリリース時の QA 作業対象となっており、リリース時には Notion を参照するだけでQA作業を進められるようになっています。イメージはこんな感じ。

ただし PR 作成時に Notion にわざわざ移動してページを作成するのも大変だったり忘れたりするので、PR が作成されると QA ページを自動で作成し URL を PR にコメントするようにしています。

Notion API によるクエリの実装は こちらを参考に させていただきました。

#!/bin/bash

pr_number="$1"

# 既に同じ number のページが存在する場合は処理を終わらせる
page_id=$(curl -X POST '<https://api.notion.com/v1/databases/'$NOTION_QA_DATABASE'/query>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data '{
    "filter": {
      "property": "PR Number",
      "number": {
        "equals": '$pr_number'
      }
    }
  }' | jq -r .results[0].id)

if [ $page_id != 'null' ]; then
  exit 0
fi

pr=$(gh pr view $pr_number --json "title,milestone,url")

# PR のメタデータを元に QA ページを作成
title='"title": [ { "text": { "content": "'$(echo "$pr" | jq -r .title)'" } } ]'
data='{
  "parent": { "database_id": "'$NOTION_QA_DATABASE'" },
  "properties": {
      "Title": { '$title' },
      "PR Number": { "number": '$pr_number' },
      "PR": { "url": "'$(echo "$pr" | jq -r .url)'" }
  }
}'

qa_url=$(curl -X POST '<https://api.notion.com/v1/pages>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data "$data" \\
  | jq -r .url)

# 作成できた QA ページの URL を PR にコメント
gh pr comment $pr_number -b """
## Make QA :memo:
$qa_url
"""

PR 作成時にスクリプトが走るよう Workflow を作成します。 タイミーでは branch の名前で自動化運用しているものもあり、特定の branch の場合は必要ないので走らせないようにしています。

name: PullRequest bootstrap

on:
  pull_request:
    types:
      - opened

jobs:
  make_qa:
    runs-on: ubuntu-latest
    if: ${{ !startsWith(github.head_ref, 'release') && !startsWith(github.head_ref, 'ladr') && github.head_ref != 'master' }}
    steps:
      - uses: actions/checkout@v4
      - name: Make QA
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          ./.github/script/post_qa.sh ${{ github.event.pull_request.number }}

実装 PR がマージされたら Milestone をアサインする

上述しましたが、タイミーではリリースの差分を把握するために Milestone を利用しています。 なので PR を develop にマージしたら Next Release の Milestone をアサインする必要がありました。

が、これが結構忘れます。なのでリリース作業時に「あれ?これリリースの対象では...?」といった確認を慎重に行う必要があったりとりこぼしが発生したりと、精神的負荷が高い状態でした。

それを解決するために PR がマージされたらその PR に Milestone をアサインする Workflow を用意しました。同時に QA もリリースの対象として可視化されるように Milestone を変更します。すでに Milestone が付いている場合は実行しないようにしています。

name: Assign milestone on merged

on:
  pull_request:
    types:
      - closed

jobs:
  assign-milestone:
    runs-on: ubuntu-latest
    if: |
      github.event.pull_request.merged == true
        && !github.event.pull_request.milestone
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      PR_NUMBER: ${{ github.event.pull_request.number }}
    steps:
      - uses: actions/checkout@v4
      - name: Assign milestone
        run: |
          gh pr edit "$PR_NUMBER" -m "Next Release"
      - name: Assign QA milestone
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          ./.github/script/update_qa_milestone.sh "$PR_NUMBER"
#!/bin/bash

pr_number="$1"

page_id=$(curl -X POST '<https://api.notion.com/v1/databases/'$NOTION_QA_DATABASE'/query>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data '{
    "filter": {
      "property": "PR Number",
      "number": {
          "equals": '$pr_number'
      }
    }
  }' | jq -r .results[0].id)

data='{
  "properties": {
    "Milestone": {
      "select": { "name": "NextRelease" }
    }
  }
}'

curl -X PATCH '<https://api.notion.com/v1/pages/'$page_id'>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data "$data"

リリース準備作業を実行する Workflow

これらを含めリリース準備作業を実行する Workflow を用意します。

name: Prepare Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: "Target release version"
        required: true
        type: string

jobs:
  release:
    runs-on: ubuntu-latest
    env:
      NEW_VERSION: ${{ inputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Modify milestone title
        run: |
          milestone_number=$(gh api repos/${{ github.repository }}/milestones -q ".[] | select(.title == \\"Next Release\\") | .number")
          gh api repos/${{ github.repository }}/milestones/$milestone_number -X PATCH -F title="Release $NEW_VERSION"
          gh api repos/${{ github.repository }}/milestones -X POST -F title="Next Release"

      - name: Prepare release QA
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          ./.github/script/replace_qas_milestone.sh -t NextRelease -v Release

      - name: Make PullRequest
        uses: ./.github/actions/make_release_pull_request
        id: make_pr
        with:
          version: ${{ inputs.version }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: Make release build
        uses: ./.github/actions/bitrise_upload_app
        with:
          pr_number: ${{ steps.make_pr.outputs.pr_number }}
          app_slug: ${{ secrets.APP_SLUG }}
          workflow_id: "upload-app-bundle-to-google-play-store"
          build_trigger_token: ${{ secrets.BUILD_TRIGGER_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: Make app build
        uses: ./.github/actions/bitrise_upload_app
        with:
          pr_number: ${{ steps.make_pr.outputs.pr_number }}
          app_slug: ${{ secrets.APP_SLUG }}
          workflow_id: "upload-apk-firebase-app-distribution"
          build_trigger_token: ${{ secrets.BUILD_TRIGGER_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}
name: Make release pullrequest

inputs:
  version:
    description: "Target release version"
    required: true
    type: string
  github_token:
    description: "GitHub token for github cli"
    required: true

outputs:
  pr_number:
    description: "Release PR's number"
    value: ${{ steps.make_pr.outputs.pr_number }}

runs:
  using: "composite"
  steps:
    - name: Set to env
      run: |
        echo "NEW_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
        echo "GITHUB_TOKEN=${{ inputs.github_token }}" >> $GITHUB_ENV
      shell: bash
    - name: Switch release branch
      run: |
        git switch -c "release/$NEW_VERSION"
      shell: bash
    - name: Increment version
      run: |
        ./.github/script/bump_version.sh "$NEW_VERSION"
        git config user.name "actions-user"
        git config user.email "action@github.com"
        git add .
        git commit -m "Bump version to $NEW_VERSION"
        git push origin $(git branch --show-current)
      shell: bash
    - name: Make Pull Request
      id: make_pr
      run: |
        ./.github/script/make_release_pr.sh "$NEW_VERSION"
        echo "pr_number=$(gh pr list -s open --json number,labels -q '[.[] | select(.labels.[].name == "Release")][0] | .number')" >> $GITHUB_OUTPUT
      shell: bash

簡単に各 step では、

  1. Milestone のタイトルを Next Release から Release x.x.x に変更し次のリリース用の Next Release Milestone を作成
  2. Notion データベース上のリリース対象となる QA の Milestone を更新してピックアップ
  3. リリース PR の作成
  4. production のリリースバイナリを PlayStore のテストトラックにアップロード
  5. staging のデバッグバイナリを Firebase App Distribution にアップロード

のようなことをやっています。 バイナリ作成のワークフローは既に Bitrise に CI が用意されておりそれを実行しています。

[!NOTE] Milestone を Next Release としているのは次のバージョンがいくつになるかリリース作業時に確定するためです。リリース作業中にバージョンを確定させ、gradle 内を更新し Milestone のタイトルを Release x.x.x のようなフォーマットに変更し次のリリース対象となる Next Release Milestone を作成します。

workflow_dispatch で次のバージョンを受け取るようにしており、GitHub Actions のタブから手動で実行することができます。

[!NOTE] 今回は長くなるので紹介していませんがリリース Workflow は通常用と hotfix でわけており、PR を作成するための step やアプリのビルドは共通で使い回すため Composite Action として切り分けています。

これで Workflow を実行すればリリース作業用の PR を勝手に作成してくれるようになり、今まで手動で時間をかけていた作業がボタンぽちーで終わるようになりました。

ちなみに GitHub CLI を利用していると以下のように実行できてとても便利です。

gh workflow run prepare_release.yml -f version=x.x.x

お片づけ

最後にリリースした後についてです。

ストアにリリースし終えたら作業用 PR をマージします。その際に以下を実行する Workflow を用意しています。

  1. GitHub Releases に該当バージョンのリリースを作成
  2. master to develop の PR 作成
  3. リリースしたマイルストーンのクローズ
  4. 検証した Notion の QA ページの Milestone を更新
name: Make Release

on:
  pull_request:
    branches:
      - master
    types: [closed]

jobs:
  release:
    if: github.event.pull_request.merged == true && contains(github.event.pull_request.title, 'Release')
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      PR_NUMBER: "${{ github.event.number }}"
    steps:
      - uses: actions/checkout@v3
      - name: Make Release
        run: |
          ./.github/script/make_release.sh "$PR_NUMBER"
      - name: Make Pull Request to develop
        run: |
          git switch master
          gh pr create -B develop -t "Master" -b "Merge master to develop"
      - name: Close milestone
        run: |
          milestone_number=$(gh pr view "$PR_NUMBER" --json "milestone" -q ".milestone.number")
          gh api repos/Taimee/taimee-android/milestones/$milestone_number -X PATCH -F state=closed
      - name: Update QA Milestone
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          version=$(gh pr view "$PR_NUMBER" --json "title" -q ".title" | awk 'match($0, /([0-9]+\\.[0-9]+\\.[0-9]+(\\.[0-9]+)?)/) {print substr($0, RSTART, RLENGTH)}')
          ./.github/script/replace_qas_milestone.sh -t Release -v $version

[!NOTE] make_release のスクリプトは PR の description をそのまま GitHub Releases にコピペするだけですし、replace_qas_milestone は QA の Milestone をリリースしたバージョンのテキストに更新します。

最後に

以上がタイミー Android Chapter のリリース作業に利用している Workflow の紹介でした。

今まで手作業で30分〜1時間くらいかけて行っていたタスクが長くても10分以内には収まっていたり負担も減っていると感じています。

ただし、- #PRNumber だけでタイトルを補完してくれるのは PR の中だけで GitHub Releases には番号しか見えてなかったりします。

またビルド関連は Bitrise で行っていて GitHub Actions から Bitrise の CI をトリガーする事が多いです。Bitrise.io の QR からインストールできるのは非エンジニアがデモで触ってもらう際に非常に助かっているのですが、GitHub Actions の artifact で似たような事ができるなら費用面やパフォーマンス面を考慮して GitHub Actions に統一も検討できるといいかもしれません。

スクリプトがごり押しだったりまだまだ課題は残っていますし、もっと効率のいい運用がある気がしているのでメンバーのフィードバックを拾い上げて継続的に改善していきたいですね。

ぜひみなさんの オレの考えた最強のリリースワークフロー を教えてください!

明日は我らが Android Chapter のリーダー、murata-san です!お楽しみに