👋

GitHub Actions でモノレポ上の変更があったプロジェクトだけテストを走らせる

2021/04/22に公開1

重要な追記

sparse checkout して、git diff で判定、というのがこの記事の主な趣旨だけど、自分が on.push.paths の存在を知らなくて、これを使うと、次のように sparse checkout するだけでよかった。

# .github/workflows/foo-test.yaml
name: foo-test
on:
  push:
    paths: systems/foo/**
env:
  SPARSE_CHECKOUT_DIR: systems/foo
jobs:
  test:
    runs-on: Ubuntu-20.04
    steps:
      - name: sparse checkout
        run: |
          git clone --filter=blob:none --no-checkout --depth 1 --sparse https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git .
          git sparse-checkout init --cone
          git sparse-checkout add ${SPARSE_CHECKOUT_DIR}
          git checkout ${GITHUB_SHA}
      - run: echo write your tests
        working-directory: ./systems/foo

というわけで、この記事のほとんどは無駄になったけど、頑張って書いたので残しておきます…


モノレポ上でこういう構成になっていたとします。

systems/foo
systems/bar

前提として、 foo と bar は一つの大きなプロジェクトになっていて、お互いに依存がないのがわかっているとします。

このとき、素朴に push 時に両方のテストを走らせると、かなりの無駄になります。例えば android と web を同じリポジトリで管理してる、みたいなときですね。

また、手元の環境だと、素朴に actions@checkout を繰り返すと, デフォルト設定で --depth 1 とはいえ github actions の転送量で無料枠を超えてしまう問題がおきていました。これも同時に倒すことにします。

git sparse-checkout

git 2.16 以降には sparse-checkout といって、指定したリポジトリを一部だけとってくるオプションがあります。普通に使うには面倒な機能ですが、今回の目的で CI 上で使うにはうってつけです。

Git - git-sparse-checkout Documentation

しかし、actions@checkout には、 sparse-checkout の機能がありません。

Options like sparse mode · Issue #172 · actions/checkout

というわけで、 https://github.com/actions/checkout/issues/172#issuecomment-689169138 を参考に、指定したディレクトリだけ clone します。

REPO="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git"
git clone --filter=blob:none --no-checkout --depth 1  --sparse $REPO .
git sparse-checkout init --cone
git sparse-checkout add "folder1" "folder2/folder3"
git checkout ${GITHUB_SHA}

GITHUB_SHA は GitHub Actions の環境変数で、現在のコミットハッシュです。

git diff --exit-code origin/main...${GITHUB_SHA} --relative ...

次のコードは、現在のコミットハッシュと、 origin/main(master) を比較して、差分があれば exit code 1 を返します。このとき、 relative を指定することで、そのディレクトリの diff を見ます。

git diff --exit-code origin/main...${GITHUB_SHA} --relative=systems/foo

これで main と比較した差分検知ができるので、テストを流すかどうかを決めます。

jobs の依存と返り値

github actions は ::set-output name=[key]:[value] で、その step の値を設定できます。

    step:
      - id: foo
        echo "::set-output name=x::1"
## これは ${{steps.foo.outputs.x}} として参照できる

また、GitHub Actions は job 間の依存を宣言し、また後続の job から依存元の outputs を参照できます。

GitHub Actions で Job の Output の値を後続 Job で参照する - notebook

これと先程までの Git を組み合わせて、指定したディレクトリに変化があるかどうかを、後続に渡す job を書けます。

env:
  SPARSE_CHECKOUT_DIR: systems/actionhub
jobs:
  check:
    runs-on: Ubuntu-20.04
    outputs:
      changed: ${{ steps.checkout.outputs.changed }}
    steps:
      - id: checkout
        name: sparse checkout
        run: |
          git clone --filter=blob:none --no-checkout --depth 1 --sparse https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git .
          git sparse-checkout init --cone
          git sparse-checkout add ${SPARSE_CHECKOUT_DIR}
          git checkout ${GITHUB_SHA}
          echo "::set-output name=changed::$(git diff --exit-code origin/main --relative=${SPARSE_CHECKOUT_DIR} > /dev/null || echo $?)"
  test:
    runs-on: Ubuntu-20.04
    needs: [check]
    if: needs.check.outputs.changed == '1'
    steps:
      # your tests

なぜこれが嬉しいかというと、 素朴な steps 参照だと、if: steps.checkout.outputs.changed == '1' を全部の steps に書いて回らないといけないのに対して、 test job に対して if: needs.check.outputs.changed == '1' を一回判定するだけで済みます。

完成

# .github/workflows/foo-test.yaml
name: foo-test
on: [push]

env:
  SPARSE_CHECKOUT_DIR: systems/foo
jobs:
  check:
    runs-on: Ubuntu-20.04
    outputs:
      changed: ${{ steps.checkout.outputs.changed }}
    steps:
      - id: checkout
        name: sparse checkout
        run: |
          git clone --filter=blob:none --no-checkout --depth 1 --sparse https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git .
          git sparse-checkout init --cone
          git sparse-checkout add ${SPARSE_CHECKOUT_DIR}
          git checkout ${GITHUB_SHA}
          echo "::set-output name=changed::$(git diff --exit-code origin/main --relative=${SPARSE_CHECKOUT_DIR} > /dev/null || echo $?)"
  test:
    runs-on: Ubuntu-20.04
    needs: [check]
    if: needs.check.outputs.changed == '1'
    steps:
      - name: sparse checkout
        run: |
          git clone --filter=blob:none --no-checkout --depth 1 --sparse https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git .
          git sparse-checkout init --cone
          git sparse-checkout add ${SPARSE_CHECKOUT_DIR}
          git checkout ${GITHUB_SHA}
      - run: echo write your tests
        working-directory: ./systems/foo

正直まだ微妙な感じがあります。なぜなら sparse checkout を 2 回やってるからですね。これは二回やっても全部に if を付けて回ることを要求によって発生する事故のが危険と判断してます。あとコピペしやすさ。

リポジトリ内の script 化しようとも考えましたが checkout 前なのでコード自体が存在せず、その結果インラインでこのコードを二回貼り付けることになってしまってます。

たぶん、正しくやるには、 actions@checkout が sparse-check をサポートするか、誰かがこれを action 化するとよさそう。残りはみなさんへの宿題とします。

circleci-agent step halt 相当のものがほしい

Discussion