【Android】 CircleCI + Roborazzi + Showkase で VRT 試しました（改）

## はじめに
２０２３年３月からAndroidアプリエンジニアをしている KSND（[GitHub](https://github.com/kosenda)、[X](https://twitter.com/ksnd_dev) ）といいます。

以前自分が書いた以下の記事では `AWS S3` を使っていましたが、`AWS S3` を使わなくて済むようにするなど改善点がたくさんあり書き直したいと思ったので今回この記事を書きました。

https://qiita.com/KSND/items/8b285c54f1b56d598740


## 想定読者
- `Jetpack Compose` を使っているAndroidアプリのプロジェクトに `VRT`（画像回帰テスト） を導入したい方
- `VRT` 導入にあたって `GitHub Actions` ではなく、`CircleCI` を使わないといけない何かしらの理由のある方


## 実際に試した際の比較画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587421/6b8f15aa-49be-ad4c-a0d4-71b999842cc5.png)


## VRT試したレボジトリ
https://github.com/kosenda/hiragana-converter

（よかったらスターいただけるとかなり嬉しいです...）


## 使用したライブラリやサービス
### Circle CI
https://circleci.com/docs/ja/about-circleci/

### Roborazzi
https://github.com/takahirom/roborazzi

### Showkase
https://github.com/airbnb/Showkase

### GitHub CLI
https://docs.github.com/ja/github-cli/github-cli/about-github-cli


## VRTの大まかな流れ

大きくは `PR変更時のビルド`、`マージ後のビルド` で処理が分かれます。

#### `PR変更時のビルド`
1. VRT対象かを確認
2. スクリーンショットを `screenshots_〜` ブランチから `CircleCI` の `Cache`　を使用して取得
3. Roborazziの比較用のオプションを使用して、ユニットテスト実行
4. 作成された比較画像のみにし　`compare_〜` ブランチを作成してGitHubにpush
5. 比較結果をコメント投稿（もしくは編集）

#### `マージ後のビルド`
1. Roborazziのスクリーンショット作成用のコマンドを実行
2. 作成されたスクリーンショットのみにし　`screenshots_〜` ブランチを作成してGitHubにpush
3. 古くなったVRT用のブランチを削除


## VRT導入の手順
以下の流れで説明します
1. [`Roborazzi`、`Showkase`をプロジェクトに導入](#1-roborazzishowkaseをプロジェクトに導入)
2. [`config.yml`の設定](#2-configymlの設定)
3. [`CircleCI`　`GitHubの設定`](#3-circlecigithubの設定)

### 1. `Roborazzi`、`Showkase`をプロジェクトに導入

- [Roborazzi の README にある Build setup](https://github.com/takahirom/roborazzi?tab=readme-ov-file#build-setup) の箇所に記載されている通り依存関係を設定
- [Showkase の README にある Instaallation](https://github.com/airbnb/Showkase?tab=readme-ov-file#installation) の箇所に記載されている通り依存関係を設定
- appモジュールにShowkaseのルートモジュールを実装したクラスを作成(例: `app/src/main/java/ksnd/hiraganaconverter/ShowkaseRootModule.kt`)
```kotlin:ShowkaseRootModule.kt
import com.airbnb.android.showkase.annotation.ShowkaseRoot
import com.airbnb.android.showkase.annotation.ShowkaseRootModule

@ShowkaseRoot
class ShowkaseRootModule : ShowkaseRootModule
```
- private にしている `Preview関数` と `PreviewParameterProvider` から private を削除
- 永遠と続くアニメーションやダイアログを表示する　`Preview関数` に　`@ShowkaseComposable(skip = true)` を設定しVRTから外す（簡単にVRTの対象にすることができないためスキップしてます）
- Previewのテストを追加
::: note warn  
最初は `Showkase.getMetadata()` が Not Found 状態になりますが、ビルドすれば解消されます
:::

```kotlin:PreviewTest
// ref: https://github.com/DroidKaigi/conference-app-2023/pull/217
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel6)
class PreviewTest(
    private val param: Pair<ShowkaseBrowserComponent, Int>,
) {

    @Test
    fun previewScreenshot() {
        val (showkaseBrowserComponent, count) = param
        val componentName = showkaseBrowserComponent.componentName.replace(" ", "")
        val filePath = DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + "/" + componentName + "_" + count + ".png"
        captureRoboImage(filePath) {
            // ライトモード、ダークモードの設定(Previewに設定するだけではShowkaseで切り替えできません)
            val newConfiguration = Configuration().apply {
                this.uiMode = if (componentName.contains(other = "dark", ignoreCase = true)) {
                    Configuration.UI_MODE_NIGHT_YES
                } else {
                    Configuration.UI_MODE_NIGHT_NO
                }
            }
            CompositionLocalProvider(LocalConfiguration provides newConfiguration) {
                showkaseBrowserComponent.component()
            }
        }
    }

    companion object {
        @ParameterizedRobolectricTestRunner.Parameters
        @JvmStatic
        fun components(): Iterable<Array<Any?>> {
            // PreviewParameterProviderを使用する場合componentNameが同じになるためカウント追加
            val countMap = mutableMapOf<String, Int>()
            return Showkase.getMetadata().componentList.map { showkaseBrowserComponent ->
                val componentName = showkaseBrowserComponent.componentName
                val count = countMap.getOrDefault(key = componentName, defaultValue = 0)
                countMap[componentName] = count + 1
                arrayOf(showkaseBrowserComponent to count)
            }
        }
    }
}
```

- `./gradlew recordRoborazzi<ビルドバリアント(Debugなど)>` を実行し、SUCCESSで `app/build/outputs/roborazzi`に画像が生成されていることを確認

::: note info  
作成される画像のサイズを小さくしたい場合は、 [Roborazzi の README にある roborazzi.record.resizeScale](https://github.com/takahirom/roborazzi?tab=readme-ov-file#roborazzirecordresizescale) を設定し画像を粗くすることでできます
:::




### 2. `config.yml`の設定


#### `config.yml` 完成品(長すぎるので折りたたんでいます)

<details><summary>config.yml全体</summary>

```yml
version: 2.1

orbs:
  gh: circleci/github-cli@2.3.0

executors:
  android:
    docker:
      - image: cimg/android:2024.01

commands:
  check_is_skipping_vrt:
    steps:
      - gh/install
      - run:
          name: Gh login
          command: echo "$GITHUB_ACCESS_TOKEN" | gh auth login --with-token
      - run:
          name: Save IS_SKIPPING_VRT to env
          command: |
            is_skipping_vrt=false
            if [[ "$(echo $(git log -1 --pretty=%B))" == *"[skip vrt]"* ]]; then
              is_skipping_vrt=true
            else
              if [ -z "$CIRCLE_PULL_REQUEST" ] || [ "$(gh pr view $CIRCLE_BRANCH --json labels | jq '.labels | any(.name == "skip vrt")')" = "true" ]; then
                is_skipping_vrt=true
              fi
            fi
            echo "export IS_SKIPPING_VRT=$is_skipping_vrt" >> $BASH_ENV

  get_screenshots:
    steps:
      - run:
          name: Save BASE_BRANCH_NAME to env
          command: |
            pr=$(echo https://api.github.com/repos/${CIRCLE_PULL_REQUEST:19} | sed "s/\/pull\//\/pulls\//")
            base=$(curl -s -H "Authorization: token ${GITHUB_ACCESS_TOKEN}" $pr | jq '.base.ref')
            echo "export BASE_BRANCH_NAME=${base}" >> $BASH_ENV
      - run:
          name: Save IS_EXIST_SCREENSHOTS to env
          command: |
            is_exist=false
            if [ $IS_SKIPPING_VRT = "false" ]; then
              is_exist=$(echo -n "$(git fetch origin screenshots_$BASE_BRANCH_NAME && echo true || echo false)")
            fi
            echo "export IS_EXIST_SCREENSHOTS=${is_exist}" >> $BASH_ENV
      - run:
          name: Checkout screenshots branch
          command: |
            if [ $IS_EXIST_SCREENSHOTS = "true" ]; then
              git fetch origin screenshots_$BASE_BRANCH_NAME
              git checkout screenshots_$BASE_BRANCH_NAME
            fi
      - run:
          name: Save SCREENSHOTS_TIMESTAMP to env
          command: |
            if [ $IS_EXIST_SCREENSHOTS = "false" ]; then
              echo "no-op" > timestamp
            fi
            echo "export SCREENSHOTS_TIMESTAMP=$(echo -n $(cat timestamp))" >> $BASH_ENV
      - run:
          name: Zip screenshots
          command: |
            mkdir -p ./temp/zip
            if [ $IS_EXIST_SCREENSHOTS = "true" ]; then
              zip -r ./temp/zip/screenshots.zip ./app/build/outputs/roborazzi
            fi
      - save_cache:
          paths:
            - ./temp/zip
          key: screenshots-{{ checksum "timestamp" }}
      - run:
          name: Checkout pr branch
          command: git checkout $CIRCLE_BRANCH
      - run:
          name: Restore timestamp file
          command: echo $SCREENSHOTS_TIMESTAMP > timestamp
      - restore_cache:
          key: screenshots-{{ checksum "timestamp" }}
      - run:
          name: Unzip screenshots
          command: |
            unzip ./temp/zip/screenshots.zip || true
            rm -rf ./temp || true

  set_locale_properties:
    steps:
      - run:
          name: Set local.properties
          command: |
            LOCAL_PROPERTIES_PATH=./local.properties
            echo "apiKey=$.Environment.DEBUG_AD_APPLICATION_ID" >> $LOCAL_PROPERTIES_PATH

  restore_and_save_gradle_cache:
    steps:
      - restore_cache:
          key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum  "app/build.gradle.kts" }}-{{ checksum "gradle/libs.versions.toml" }}
      - save_cache:
          paths:
            - ~/.gradle
          key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum  "app/build.gradle.kts" }}-{{ checksum "gradle/libs.versions.toml" }}

  unit_test:
    parameters:
      build_variant:
        type: string
    steps:
      - run:
          name: Unit test
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              roborazzi_option="-Proborazzi.test.compare=true"
            fi
            ./gradlew test<< parameters.build_variant >> $roborazzi_option --stacktrace

  setting_git_config:
    steps:
      - run:
          name: Setting git config
          command: |
            git config --global user.name "$GITHUB_NAME"
            git config --global user.email "$GITHUB_EMAIL"

  push_screenshots_branch:
    steps:
      - setting_git_config
      - run:
          name: Push screenshots branch
          command: |
            git push origin --delete screenshots_$CIRCLE_BRANCH || true
          
            git checkout --orphan screenshots_$CIRCLE_BRANCH
            git rm --cached -rf .
          
            add_files=$(find . -type f -path "./app/build/outputs/roborazzi/*")
            for file in $add_files; do
              git add -f $file
            done
          
            echo $(date +%s) > timestamp
            git add -f timestamp
          
            echo -e "version: 2.1\njobs:\n no-op:\n  machine: true\n  steps:\n  - run: no-op\nworkflows:\n build:\n  jobs:\n   - no-op:\n      filters:\n       branches:\n        only: no-op" > .circleci/config.yml
            git add -f .circleci/config.yml
          
            git commit -m "Add screenshot"
            git clean -df
            git push origin HEAD:screenshots_$CIRCLE_BRANCH -f

  push_compare_branch:
    steps:
      - setting_git_config
      - run:
          name: Push compare branch
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              git push origin --delete compare_$CIRCLE_BRANCH || true
            
              fileSize=$(echo $(find ./app/build/outputs/roborazzi -type f | grep -e '.*_compare.png' | wc -l | sed -e 's/ //g'))
              if [ $fileSize -ne 0 ]; then
                git checkout --orphan compare_$CIRCLE_BRANCH
                git rm --cached -rf .
            
                add_files=$(find . -type f -path "./app/build/outputs/roborazzi/*" -name "*_compare.png")
                for file in $add_files; do
                  git add -f $file
                done
            
                echo -e "version: 2.1\njobs:\n no-op:\n  machine: true\n  steps:\n  - run: no-op\nworkflows:\n build:\n  jobs:\n   - no-op:\n      filters:\n       branches:\n        only: no-op" > .circleci/config.yml
                git add .circleci/config.yml
            
                git commit -m "Add screenshot diff"
                git clean -df
                git push origin HEAD:compare_$CIRCLE_BRANCH -f
              fi
            fi

  comment_screenshot_diff:
    steps:
      - run:
          name: Create comment
          command: |
            echo "Snapshot diff report" > comment
            echo "| File name | Image |" >> comment
            echo "|-------|-------|" >> comment
            
            files=$(find . -type f -path "./app/build/outputs/roborazzi/*" -name "*_compare.png")
            for file in $files; do
              fileName=$(basename "$file" | sed -r 's/(.{20})/\1<br>/g')
              echo "| [$fileName](https://github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/blob/compare_$CIRCLE_BRANCH/$file) | ![](https://github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/blob/compare_$CIRCLE_BRANCH/$file?raw=true) |" >> comment
            done
      - run:
          name: Comment screenshot diff
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              prNumber=$(echo $CIRCLE_PULL_REQUEST | sed "s:.*/::")
              url="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${prNumber}/comments"
              commentId=$(echo -n $(curl -s -H "Authorization: token $GITHUB_ACCESS_TOKEN" $url -v | jq '.[] | select(.body | test("^Snapshot diff report.*")) | .id'))
              failedComment="$(echo -e "Snapshot diff report\n:warning: **Failed to show Snapshot diff**\n$CIRCLE_BUILD_URL")"
              
              if [ -n "$commentId" ]; then
                comment="$(cat ./comment)"
                endpoint="/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/comments/$commentId"
                gh api --method PATCH -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $endpoint -f body="$comment" ||
                gh api --method PATCH -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $endpoint -f body="$failedComment"
              else
                gh pr comment "$CIRCLE_PULL_REQUEST" -F ./comment || gh pr comment "$CIRCLE_PULL_REQUEST" -b "$failedComment"
              fi
            fi

  cleanup_old_branch:
    parameters:
      prefix:
        type: string
      maximum_seconds_past:
        type: integer
        default:  2592000 # 30 days
    steps:
      - run:
          name: Cleanup old branch
          command: |
            git branch -r --format="%(refname:lstrip=3)" | grep << parameters.prefix >> | while read -r branch; do
              last_commit_date_timestamp=$(git log -1 --format=%ct "origin/$branch")
              now_timestamp=$(date +%s)
              if [ $((now_timestamp - last_commit_date_timestamp)) -gt << parameters.maximum_seconds_past >> ]; then
                git push origin --delete "$branch"
              fi
            done || true

jobs:
  unit_test:
    executor: android
    steps:
      - checkout
      - check_is_skipping_vrt
      - get_screenshots
      - set_locale_properties
      - restore_and_save_gradle_cache
      - unit_test:
          build_variant: ProdDebug
      - push_compare_branch
      - comment_screenshot_diff

  save_screenshots:
    executor: android
    steps:
      - checkout
      - set_locale_properties
      - restore_and_save_gradle_cache
      - run:
          name: Create screenshots
          command: ./gradlew recordRoborazziProdDebug --stacktrace
      - push_screenshots_branch
      - cleanup_old_branch:
          prefix: compare_
      - cleanup_old_branch:
          prefix: screenshots_
          maximum_seconds_past: 15552000 # 180 days

workflows:
  test:
    jobs:
      - unit_test
      - save_screenshots:
          filters:
            branches:
              only:
                - main
                - develop
```
</details>

（以下手順です）

- `orbs` に `GitHub CLI`を追加
```yml:config.yml
orbs:
  gh: circleci/github-cli@2.3.0
```
- commandsに以下 ８つ のコマンド追加

##### 1. VRT対象かチェック
- 以下いずれかの場合にVRTをスキップします
  - 最後のコミットメッセージに `[skip vrt]` が含まれている
  - `CIRCLE_PULL_REQUEST` が空の時 = PRがなくコメント表示できない
  - PRに`skip vrt`ラベルが設定されている
```yml:config.yml
  check_is_skipping_vrt:
    steps:
      - gh/install
      - run:
          name: Gh login
          command: echo "$GITHUB_ACCESS_TOKEN" | gh auth login --with-token
      - run:
          name: Save IS_SKIPPING_VRT to env
          command: |
            is_skipping_vrt=false
            if [[ "$(echo $(git log -1 --pretty=%B))" == *"[skip vrt]"* ]]; then
              is_skipping_vrt=true
            else
              if [ -z "$CIRCLE_PULL_REQUEST" ] || [ "$(gh pr view $CIRCLE_BRANCH --json labels | jq '.labels | any(.name == "skip vrt")')" = "true" ]; then
                is_skipping_vrt=true
              fi
            fi
            echo "export IS_SKIPPING_VRT=$is_skipping_vrt" >> $BASH_ENV
```

##### 2. スクリーンショットの取得
- スクリーンショットは `screenshots_〜` というブランチに保存しているのでチェックアウトして `CircleCI` の `Cache` にZIP化をして保存した後、元のPRにチェックアウトして `Cache` から取り出しUNZIP化することでスクリーンショットを取得します
- ZIP化しているのは、`Cache` の保存は基本的に使い回すためにありますが、今回は使い回すことがほとんどできないためなるべく容量を減らすためにしています
- `timestamp`を使用しているのは、`CircleCI` の `Cache` は、キーの値が同じ場合は値が上書きされないことから、スクリーンショットを作成するときに付与することで必要なスクリーンショットを特定できるようにするためしています
```yml:config.yml
  get_screenshots:
    steps:
      - run:
          name: Save BASE_BRANCH_NAME to env
          command: |
            # ref: https://discuss.circleci.com/t/how-to-retrieve-a-pull-requests-base-branch-name-github/36911
            pr=$(echo https://api.github.com/repos/${CIRCLE_PULL_REQUEST:19} | sed "s/\/pull\//\/pulls\//")
            base=$(curl -s -H "Authorization: token ${GITHUB_ACCESS_TOKEN}" $pr | jq '.base.ref')
            echo "export BASE_BRANCH_NAME=${base}" >> $BASH_ENV
      - run:
          name: Save IS_EXIST_SCREENSHOTS to env
          command: |
            is_exist=false
            if [ $IS_SKIPPING_VRT = "false" ]; then
              is_exist=$(echo -n "$(git fetch origin screenshots_$BASE_BRANCH_NAME && echo true || echo false)")
            fi
            echo "export IS_EXIST_SCREENSHOTS=${is_exist}" >> $BASH_ENV
      - run:
          name: Checkout screenshots branch
          command: |
            if [ $IS_EXIST_SCREENSHOTS = "true" ]; then
              git fetch origin screenshots_$BASE_BRANCH_NAME
              git checkout screenshots_$BASE_BRANCH_NAME
            fi
      - run:
          name: Save SCREENSHOTS_TIMESTAMP to env
          command: |
            if [ $IS_EXIST_SCREENSHOTS = "false" ]; then
              echo "no-op" > timestamp
            fi
            echo "export SCREENSHOTS_TIMESTAMP=$(echo -n $(cat timestamp))" >> $BASH_ENV
      - run:
          name: Zip screenshots
          command: |
            mkdir -p ./temp/zip
            if [ $IS_EXIST_SCREENSHOTS = "true" ]; then
              zip -r ./temp/zip/screenshots.zip ./app/build/outputs/roborazzi
            fi
      - save_cache:
          paths:
            - ./temp/zip
          key: screenshots-{{ checksum "timestamp" }}
      - run:
          name: Checkout pr branch
          command: git checkout $CIRCLE_BRANCH
      - run:
          name: Restore timestamp file
          command: echo $SCREENSHOTS_TIMESTAMP > timestamp
      - restore_cache:
          key: screenshots-{{ checksum "timestamp" }}
      - run:
          name: Unzip screenshots
          command: |
            unzip ./temp/zip/screenshots.zip || true
            rm -rf ./temp || true
```

##### 3. ユニットテストの実行
- `PR変更時のビルド`の時はRoborazziのcompareのオプションを使用することで比較画像を取得します
```yml:config.yml
  unit_test:
    parameters:
      build_variant:
        type: string
    steps:
      - run:
          name: Unit test
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              roborazzi_option="-Proborazzi.test.compare=true"
            fi
            ./gradlew test<< parameters.build_variant >> $roborazzi_option --stacktrace
```

##### 4. git の config 設定
- こちらは `CircleCI` の `Environment Variables` に設定する前提です
```yml:config.yml
  setting_git_config:
    steps:
      - run:
          name: Setting git config
          command: |
            git config --global user.name "$GITHUB_NAME"
            git config --global user.email "$GITHUB_EMAIL"
```

##### 5. スクリーンショット用のブランチ ( `screenshots_〜` )を作成しpush
- 途中で、`config.yml`を追加しているのは、ブランチ生成時に `config.yml` がないとCircleCIのログに警告が出てしまうのを抑えるためです
```yml:config.yml
  push_screenshots_branch:
    steps:
      - run:
          name: Push screenshots branch
          command: |
            git push origin --delete screenshots_$CIRCLE_BRANCH || true
          
            git checkout --orphan screenshots_$CIRCLE_BRANCH
            git rm --cached -rf .
          
            add_files=$(find . -type f -path "./app/build/outputs/roborazzi/*")
            for file in $add_files; do
              git add -f $file
            done
          
            echo $(date +%s) > timestamp
            git add -f timestamp
          
            echo -e "version: 2.1\njobs:\n no-op:\n  machine: true\n  steps:\n  - run: no-op\nworkflows:\n build:\n  jobs:\n   - no-op:\n      filters:\n       branches:\n        only: no-op" > .circleci/config.yml
            git add -f .circleci/config.yml
          
            git commit -m "Add screenshot"
            git clean -df
            git push origin HEAD:screenshots_$CIRCLE_BRANCH -f
```

##### 6. 比較画像用のブランチ ( `compare_〜` )を作成しpush
```yml:config.yml
  push_compare_branch:
    steps:
      - run:
          name: Push compare branch
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              git push origin --delete compare_$CIRCLE_BRANCH || true
            
              fileSize=$(echo $(find ./app/build/outputs/roborazzi -type f | grep -e '.*_compare.png' | wc -l | sed -e 's/ //g'))
              if [ $fileSize -ne 0 ]; then
                git checkout --orphan compare_$CIRCLE_BRANCH
                git rm --cached -rf .
            
                add_files=$(find . -type f -path "./app/build/outputs/roborazzi/*" -name "*_compare.png")
                for file in $add_files; do
                  git add -f $file
                done
            
                echo -e "version: 2.1\njobs:\n no-op:\n  machine: true\n  steps:\n  - run: no-op\nworkflows:\n build:\n  jobs:\n   - no-op:\n      filters:\n       branches:\n        only: no-op" > .circleci/config.yml
                git add .circleci/config.yml
            
                git commit -m "Add screenshot diff"
                git clean -df
                git push origin HEAD:compare_$CIRCLE_BRANCH -f
              fi
            fi
```

##### 7. 比較結果をコメント投稿（もしくは編集）
- このコマンドでしかGitHub CLIを使わないのでここでインストールやログインを行っています
- `Snapshot diff report`が先頭についているコメントをPR内から探して、あった場合はそのコメントのIDを使用してコメントの編集をして、ない場合はコメント投稿をしています
- コメントのテキストが長すぎると失敗するため、エラー回避としてエラー用のコメントを表示しています
```yml:config.yml
  comment_screenshot_diff:
    steps:
      - gh/install
      - run:
          name: Gh login
          command: echo "$GITHUB_ACCESS_TOKEN" | gh auth login --with-token
      - run:
          name: Create comment
          command: |
            echo "Snapshot diff report" > comment
            echo "| File name | Image |" >> comment
            echo "|-------|-------|" >> comment
            
            files=$(find . -type f -path "./app/build/outputs/roborazzi/*" -name "*_compare.png")
            for file in $files; do
              fileName=$(basename "$file" | sed -r 's/(.{20})/\1<br>/g')
              echo "| [$fileName](https://github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/blob/compare_$CIRCLE_BRANCH/$file) | ![](https://github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/blob/compare_$CIRCLE_BRANCH/$file?raw=true) |" >> comment
            done
      - run:
          name: Comment screenshot diff
          command: |
            if [ $IS_SKIPPING_VRT = "false" ]; then
              prNumber=$(echo $CIRCLE_PULL_REQUEST | sed "s:.*/::")
              url="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${prNumber}/comments"
              commentId=$(echo -n $(curl -s -H "Authorization: token $GITHUB_ACCESS_TOKEN" $url -v | jq '.[] | select(.body | test("^Snapshot diff report.*")) | .id'))
              failedComment="$(echo -e "Snapshot diff report\n:warning: **Failed to show Snapshot diff**\n$CIRCLE_BUILD_URL")"
              
              if [ -n "$commentId" ]; then
                comment="$(cat ./comment)"
                endpoint="/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/comments/$commentId"
                gh api --method PATCH -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $endpoint -f body="$comment" ||
                gh api --method PATCH -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $endpoint -f body="$failedComment"
              else
                gh pr comment "$CIRCLE_PULL_REQUEST" -F ./comment || gh pr comment "$CIRCLE_PULL_REQUEST" -b "$failedComment"
              fi
            fi
```

##### 8. 古くなったブランチを削除
- `parameters` で どのブランチを削除するかと更新されない期間を指定して削除します
```yml:config.yml
  cleanup_old_branch:
    parameters:
      prefix:
        type: string
      maximum_seconds_past:
        type: integer
        default:  2592000 # 30 days
    steps:
      - run:
          name: Cleanup old branch
          command: |
            git branch -r --format="%(refname:lstrip=3)" | grep << parameters.prefix >> | while read -r branch; do
              last_commit_date_timestamp=$(git log -1 --format=%ct "origin/$branch")
              now_timestamp=$(date +%s)
              if [ $((now_timestamp - last_commit_date_timestamp)) -gt << parameters.maximum_seconds_past >> ]; then
                git push origin --delete "$branch"
              fi
            done || true
```

- jobの設定
  - 以下のように上記で設定したコマンドを追加してください
    - `×` をつけた箇所はVRTとは関係なく説明不要かと思ったのでこの記事では説明しません
  - 以下の２つの`ProdDebug` はビルドバリアントです
```yml:config.yml
jobs:
    unit_test:
    executor: android
    steps:
      - checkout # ×
      - check_is_pr
      - get_screenshots
      - set_locale_properties # ×
      - restore_and_save_gradle_cache # ×
      - unit_test:
          build_variant: ProdDebug
      - push_compare_branch
      - comment_screenshot_diff

  save_screenshots:
    executor: android
    steps:
      - checkout # ×
      - set_locale_properties # ×
      - restore_and_save_gradle_cache # ×
      - run:
          name: Create screenshots
          command: ./gradlew recordRoborazziProdDebug --stacktrace
      - push_screenshots_branch
      - cleanup_old_branch:
          prefix: compare_
      - cleanup_old_branch:
          prefix: screenshots_
          maximum_seconds_past: 15552000 # 180 days
```

- workflowの設定
  - スクリーンショット保存はベースブランチとならなければ無駄になってしまうのでベースブランチの対象となるようなブランチのみに設定します 
```yml:config.yml
workflows:
  test:
    jobs:
      - unit_test
      - save_screenshots:
          filters:
            branches:
              only:
                - main
                - develop
```


### 3. `CircleCI`、`GitHub`の設定
以下の設定が必要になります
1. [write権限付きSSHキーを登録&設定](#1-write権限付きsshキーを登録設定)
2. [GitHubで適切なアクセストークン作成&設定](#2-githubで適切なアクセストークン作成設定)
3. [PRが作成された時にビルドが走るようにする](#3-prが作成された時にビルドが走るようにする)
4. [Gitのconfigに設定するユーザーとEメール設定](#4-gitのconfigに設定するユーザーとeメール設定)

##### 1 write権限付きSSHキーを登録&設定
CircleCIからGitHubにアクセスする際使用するSSHキーがデフォルトだとread権限のみなのでpushさせるにはwrite権限が必要になります
- [circleci docs - Add additional SSH keys to CircleCI](https://circleci.com/docs/add-ssh-key)を参考にSSHキーを作成
- 作成したSSHキーの**秘密鍵**を`CircleCI`の`Project Settings`の`SSH Keys`の`Additional SSH Keys`に登録してください(hostnameは`Github.com`)
- 作成したSSHキーの**公開鍵**をGitHubのプロジェクトの設定にある`Deploy keys`に`Allow write access`のチェックをつけて設定(titleは任意項目です)　
::: note warn
元々設定しているread権限のSSHキーは**削除が必要**です  
:::
##### 2 GitHubで適切なアクセストークン作成&設定
- 以下の写真のように`repo`、`write:org`、`read:org`を設定したパーソナルアクセストークンを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587421/d8b4e43d-5b1e-881e-3fd4-24af8653c441.png)
- 作成したパーソナルアクセストークンを`CircleCI`の`Project Settings`の`Environment Variables`に`GITHUB_ACCESS_TOKEN`という名前で登録

##### 3 PRが作成された時にビルドが走るようにする
PRが作成される前にビルドが走るとPRのURLが取れずにCIが落ちます
- `CircleCI`の`Project Settings`の`Advanced Settings`の`Only build pull requests`にチェックがついていない場合はチェックをつける

##### 4 Gitのconfigに設定するユーザーとEメール設定
- `CircleCI`の`Project Settings`の`Environment Variables`に`GITHUB_NAME`という名前でユーザー名を、`GITHUB_EMAIL`という名前でEメールを登録

## 感想
試し始めた今年の年初からかなり長い時間かかりましたが、ある程度まともに動くVRTを作ることができたのではないかと思います。
この記事がたくさんの人の参考になったらかなり嬉しいです。
CircleCIを使用したAndroidのVRTの記事などは他に見たことがないので、改善点などがあったらすごく聞きたいです...


## 参考
- [Bash move * to subfolder fail: cannot move to a subdirectory of itself](https://stackoverflow.com/questions/43260052/bash-move-to-subfolder-fail-cannot-move-to-a-subdirectory-of-itself)
- [circleci Discuss - How to Retrieve a Pull Request’s Base Branch Name [GitHub]](https://discuss.circleci.com/t/how-to-retrieve-a-pull-requests-base-branch-name-github/36911)
- [DevelopersIO - [GitHub Actions] 指定のBranchの存在チェックをする（git-fetchコマンド）](https://dev.classmethod.jp/articles/github-actions-check-if-specific-branch-is-existing/)
- [GitHub - DroidKaigi/conference-app-2023](https://github.com/DroidKaigi/conference-app-2023)
- [GitHub - takahirom/roborazzi-compare-on-github-comment-sample](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample)
- [スタディサプリ小学・中学講座にRoborazziを導入して半年が経過しました](https://blog.studysapuri.jp/entry/2024/3/26/introduce-roborazzi-2)