🤖

CircleCIでRustのテストカバレッジbotを自作する

2024/06/17に公開

はじめに

WebRTCプラットフォームSkyWayのR&Dを担当しているtetterです。

最近、Rustでプロトタイピングを行う機会があり、その中でテストカバレッジを自動集計できるようにしたいと考えたのですが、実現方法で悩みました。計測手段として使用したかったCodeCovは、FreeプランではOSSもしくは個人開発での利用に制限されているためです。
https://about.codecov.io/pricing/

プロトタイピング段階ではなるべく有料サービスの利用を増やさず実現したかったので、CodeCovを使用せずにCircleCIでテストカバレッジをレポートするbotを自作してみました。

作ったbotの紹介

まずは作ったものを紹介します。


対象のリポジトリでプルリクエストの作成やコミットのPushをした際、botが自動でカバレッジレポートを作成してコメントしてくれます。

コメント下部のCoverage report detailsリンクからレポートの詳細を確認できます。

詳細を確認したいファイルを選択すると、以下のようなコードベースの分析を確認できます。

コード左のCount列に表示されている数字は、その行がテスト時に何回実行されているかを表しており、一度も実行されなかった箇所は赤くマークされます。

Rustでのカバレッジの取得にはTarpaulingrcovなども活用できますが、今回はSource-based code coverageという方式をサポートしたcargo-llvm-covを採用しています。従来の方式では行単位でカバレッジが分析されますが、Source-based code coverageでは?演算子のearly returnの実行有無など特定の領域単位まで分析できます。
つまり、コード内の分岐も考慮した正確なカバレッジを取得できるというメリットがあります。

従来の方式とcargo-llvm-covの違いについてはこちらの記事がわかりやすいです。
https://qiita.com/dalance/items/69e18fe300760f8d7de0

また、コミットをPushするたびにコメントされると視認性が悪くなってしまうので、過去のコメントは自動的にhideされるようにしています。

実装方法

ここからは実装について説明します。

🔧 事前準備 (GitHub Apps)

プルリクエストへのコメント自動投稿はGitHub Appsから行うので、CIを実装する前に準備しておきます。

GitHub Appの作成

まずは、GitHub Appを作成します。
右上の自分のアイコンからYour Organizations > (対象組織の) Settings > Developer Settings > GitHub Appsと移動し、New GitHub Appを選択します。

ここでは以下の4点を設定します。

  1. Basic information > GitHub App nameに任意のBotの名前を入力する

  2. その下のHomepage URLにダミーのURLを入力する

  3. Webhook > Activeのチェックを外す

  4. Permissions > Repository permissions > Pull requestsRead and writeに変更する

すべて設定したら一番下までスクロールし、Create GitHub Appを選択します。

Private keyの生成

GitHub Appの作成が完了すると、作成したAppの詳細画面に遷移します。
ここで表示されているApp IDは後ほど使用するのでメモしておきます。

そのまま画面を下へスクロールし、Private keys > Generate a private keyを選択します。

生成が完了すると<アプリ名>.yyyy-mm-dd.private-key.pemとしてダウンロードされます。

GitHub Appのインストール

GitHub Appは作成するだけではなく、対象の組織へインストールする必要があります。
左のメニューから Install Appを選択し、Installを選択します。

🔧 事前準備 (CircleCI)

CircleCIはユーザやプロジェクトの登録が済んでいることを前提とします。
https://circleci.com/docs/ja/first-steps/
https://circleci.com/docs/ja/getting-started/

Private key / App IDの設定

botを動作させるためのシークレット情報はCircleCIの環境変数としてセキュアに管理できます。CircleCIのダッシュボードでプロジェクトを選択し、Project Settings > Environment Variablesと移動します。

CircleCIのEnvironment Variablesへ登録する値は1行にする必要があるので、先ほどダウンロードした秘密鍵を以下のコマンドでエンコードします。

cat <アプリ名>.yyyy-mm-dd.private-key.pem | base64

生成された文字列をGITHUB_APPS_KEY_BASE64という名前で登録します。
登録が完了したら元の秘密鍵<アプリ名>.yyyy-mm-dd.private-key.pemは速やかに削除します。

また、先ほど確認したApp IDもGITHUB_APPS_IDという名前で登録しておきます。

実行条件の変更

今回の自動集計はプルリクエストの新規登録/更新時以外で実行すると失敗するので、動作条件を限定します。

Project Settings > Advancedと移動して、Pass secrets to builds from forked pull requestsを有効にします。

📝 CIの実装

これより先では、.circleci以下に設定ファイルと2つのスクリプトファイルを実装します。

.circleci/
 ├── config.yml         // A
 ├── get_token.sh       // B
 └── post_coverage.sh   // C

これらはAからBを呼び出し、BからCを呼び出す関係になっていますが、説明のしやすさを重視してC > B > Aの順番で解説します。

カバレッジの計測とGitHubへのコメント投稿 (C)

post_coverage.sh の実装
post_coverage.sh
#!/usr/bin/env bash

artifact_link="https://output.circle-artifacts.com/output/job/${CIRCLE_WORKFLOW_JOB_ID}/artifacts/${CIRCLE_NODE_INDEX}/coverage-report/index.html"

cargo install cargo-llvm-cov
rustup component add llvm-tools-preview

cov_result=$(cargo llvm-cov | awk '/Filename/,0')
comment="\`\`\`shell-session
${cov_result}
\`\`\`

[Coverage report details](${artifact_link})"

cargo llvm-cov report --html

apt update && apt install -y jq
GITHUB_APPS_TOKEN="$(bash .circleci/get_token.sh)"

github_comment_verion=6.0.4
pr_number=$(basename ${CIRCLE_PULL_REQUEST})

curl -L https://github.com/suzuki-shunsuke/github-comment/releases/download/v${github_comment_verion}/github-comment_${github_comment_verion}_linux_amd64.tar.gz -o linux_amd64.tar.gz
tar -zxvf linux_amd64.tar.gz
./github-comment hide --token ${GITHUB_APPS_TOKEN} --org ${CIRCLE_PROJECT_USERNAME} --repo ${CIRCLE_PROJECT_REPONAME} --pr ${pr_number} --condition 'Comment.HasMeta && Comment.Meta.TemplateKey == "default"'
./github-comment post --token ${GITHUB_APPS_TOKEN} --org ${CIRCLE_PROJECT_USERNAME} --repo ${CIRCLE_PROJECT_REPONAME} --pr ${pr_number} --template "${comment}"

まずは本記事で一番重要なRustでのテストカバレッジの計測方法とGitHubへのコメント投稿の方法について解説します。

ビルドアーティファクトのURL取得

CircleCIにはジョブで生成したデータを一時的に保持できるビルドアーティファクトという機能があります。
https://circleci.com/docs/ja/artifacts/

今回はcargo-llvm-covで以下の2つのレポートを生成します。ビルドアーティファクトは後者を格納するために使用します。

  1. 概要レポート (テキスト形式)
  2. 詳細レポート (HTML形式)

コメント内にリンクを含める必要があるので、以下のように格納先のURLを取得します。

artifact_link="https://output.circle-artifacts.com/output/job/${CIRCLE_WORKFLOW_JOB_ID}/artifacts/${CIRCLE_NODE_INDEX}/coverage-report/index.html"

なお、データのアップロード方法については後ほど説明します。

テストカバレッジの計測

まず、cargo-llvm-covを使用するために以下の手順で準備します。

cargo install cargo-llvm-cov
rustup component add llvm-tools-preview

続いて、テストカバレッジレポートの生成方法です。

テキスト形式のレポートは以下で生成しています。

cov_result=$(cargo llvm-cov | awk '/Filename/,0')
comment="\`\`\`shell-session
${cov_result}
\`\`\`

cargo llvm-covでカバレッジを計測しており、それ以外はコメントを成形するためのものです。

HTML形式のレポートは以下のように生成します。

cargo llvm-cov report --html

HTML形式のレポートを生成するならcargo llvm-cov --htmlのみでも可能ですが、すでにテストカバレッジを計測済みであればreportを使用することで再計測をスキップできます。

プルリクエストへのコメント投稿

今回、プルリクエストへのコメントを投稿するためにgithub-commentというプロジェクトを利用させていただきました。

過去のコメントをhideしてから新たなコメントを投稿するために、以下の2行を実行します。

./github-comment hide --token ${GITHUB_APPS_TOKEN} --org ${CIRCLE_PROJECT_USERNAME} --repo ${CIRCLE_PROJECT_REPONAME} --pr ${pr_number} --condition 'Comment.HasMeta && Comment.Meta.TemplateKey == "default"'
./github-comment post --token ${GITHUB_APPS_TOKEN} --org ${CIRCLE_PROJECT_USERNAME} --repo ${CIRCLE_PROJECT_REPONAME} --pr ${pr_number} --template "${comment}"

参考文献

https://engineer.retty.me/entry/go-coverage
https://note.com/weekly_report/n/n46f398a4a0f4

GitHub Apps Tokenの取得 (B)

get_token.sh の実装
get_token.sh
#!/usr/bin/env bash

private_key(){
    echo ${GITHUB_APPS_KEY_BASE64} | base64 --decode
}

base64url() {
    openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

sign() {
    openssl dgst -binary -sha256 -sign <(printf '%s' "$(private_key)")
}

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${GITHUB_APPS_ID}" "${iat}" "${exp}" | base64url)"

signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"
jwt="${header}.${payload}.${signature}"

installation_id="$(curl --location --silent --request GET \
    --url "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/installation" \
    --header "Accept: application/vnd.github+json" \
    --header "X-GitHub-Api-Version: 2022-11-28" \
    --header "Authorization: Bearer ${jwt}" \
    | jq -r '.id'
)"

token="$(curl --location --silent --request POST \
    --url "https://api.github.com/app/installations/${installation_id}/access_tokens" \
    --header "Accept: application/vnd.github+json" \
    --header "X-GitHub-Api-Version: 2022-11-28" \
    --header "Authorization: Bearer ${jwt}" \
    | jq -r '.token'
)"

echo "${token}"

続いて、GitHubへのコメント投稿に必要なGITHUB_APPS_TOKENの取得について紹介します。
実装はほとんど参考文献のものをお借りしています。トークンやJWTなどの仕組みについてとても丁寧に解説されていますので、詳しく知りたい方はこちらをご確認ください。参考文献の実装はGitHub Actionsベースのため、本記事ではCircleCIに関する箇所を説明します。

まず、GitHub Appの秘密鍵はBase64でエンコードされた状態でCircleCIの環境変数として保存したので、取得後にデコードします。

private_key(){
    echo ${GITHUB_APPS_KEY_BASE64} | base64 --decode
}

なお、App IDもCircleCIの環境変数として取り出します。

payload="$(printf "${template}" "${GITHUB_APPS_ID}" "${iat}" "${exp}" | base64url)"

さらに、ユーザ名とリポジトリ名はCIRCLE_PROJECT_USERNAMECIRCLE_PROJECT_REPONAMEを指定します。

installation_id="$(curl --location --silent --request GET \
    --url "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/installation" \
...

参考文献

https://zenn.dev/tmknom/articles/github-apps-token

キャッシュの活用とCIの実行 (A)

config.yml の実装
config.yml
commands:
  record_build_env:
    steps:
      - run:
          name: Record build environment to use as cache key
          command: |
            echo $OS_VERSION | tee /tmp/build-env
            rustc --version | tee /tmp/build-env
            echo $CIRCLECI_CACHE_VERSION | tee /tmp/cache-ver
  save_cache_:
    steps:
      - save_cache:
          key: cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}-{{ checksum "Cargo.lock" }}
          paths:
            - ~/.cargo
            - target
  restore_cache_:
    steps:
      - restore_cache:
          keys:
            - cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}-{{ checksum "Cargo.lock" }}
            - cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}

jobs:
  ...

  coverage_report:
    docker:
      - image: rust:latest
    environment:
      CIRCLECI_CACHE_VERSION: 1
    steps:
      - checkout
      - record_build_env
      - restore_cache_
      - run: 
          name: Post Coverage Report
          command: |
            # test ${CI_PULL_REQUEST} || exit 0  # skip if not PR
            bash .circleci/post_coverage.sh
      - store_artifacts:
          path: target/llvm-cov/html
          destination: coverage-report
      - save_cache_

  ...

workflows:
  my_workflow:
    jobs:
      ...

      - coverage_report:
          filters:
            branches:
              ignore: /main|master/

      ...

最後に、実行時間を抑えるためにキャッシュを活用してCIを実行する手順について紹介します。

ビルドアーティファクトの保存

先ほど生成したHTML形式の詳細レポートをCircleCIにアップロードして保存します。target/llvm-cov/htmlに結果が格納されていますので、以下のようにパスを指定します。

- store_artifacts:
    path: target/llvm-cov/html
    destination: coverage-report

キャッシュの活用

最後に、CIの実行時の無駄を省くためにキャッシュを活用します。

save_cache_:
  steps:
    - save_cache:
        key: cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}-{{ checksum "Cargo.lock" }}
        paths:
        - ~/.cargo
        - target
restore_cache_:
  steps:
    - restore_cache:
        keys:
          - cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}-{{ checksum "Cargo.lock" }}
          - cache-cargo-target-{{ checksum "/tmp/cache-ver" }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "/tmp/build-env" }}

~/.cargotargetをキャッシュとして保存することで、一部データのインストールやビルドをスキップできます。

参考文献

https://laysakura.github.io/2020/03/06/rust-circle-ci/

おわりに

長くなりましたが、以上で実装は完了です。

もしも別言語で実装したい場合、cargo-llvm-covを対象言語のカバレッジ取得方法へ差し替えれば使用できます。また、別のCIプラットフォームを使用したい場合、環境変数 (CIRCLE_XXX) やビルドアーティファクトの保存方法を対象のプラットフォームに対応するものへ差し替える必要があります。

余談ですが、自動でテストカバレッジが計測されてプルリクエストへ表示されるようになったことで、テストを書くことに対する意識が大幅に向上した気がします。やはり0%が並んでいると多少焦りを覚えるので、とりあえず小さなところからでもテストを書こうという気が生まれます。また、カバレッジが増え始めて黄色や緑色になり始めると、もっともっとカバレッジを増やしたいという気になります。

テストを書きたいけど習慣が身につかないというチームに向けてかなりオススメできます。

宣伝

SkyWayでは一緒に開発を進めてくれる仲間を募集中です。詳細は以下のリンクをご覧ください。
https://hrmos.co/pages/nttcom0033/jobs/1692872

Discussion