RのテストにCircleCIを使う

RでCIというと、ほとんどはTravis CIが使われます。なんといっても、

devtools::use_travis()

とするだけで準備が整うというお膳立てっぷりです。

でも、Travis CIの弱点はCIに使われるイメージが古いことです。最近ようやくデフォルトイメージがUbuntu 14.04になりましたが、最先端を追い求める人々にはつらいです。

その点、CircleCIが便利なのは任意のDockerイメージを使ってCIを回せることです。他のライブラリに依存することがけっこう多いRでは使いどころがありそうなので、ちょっとやり方を調べてみました。

Travis CIのソースを覗く

そもそもTravis CIはlanguage: rを指定したとき、具体的にどんな処理をしてくれているのでしょう。ちょこっとソースを見ておきます。

具体的にはこのへんです。

        def script
          # Build the package
          sh.if '! -e DESCRIPTION' do
            sh.failure "No DESCRIPTION file found, user must supply their own install and script steps"
          end

          tarball_script =
            '$version = $1 if (/^Version:\s(\S+)/);'\
            '$package = $1 if (/^Package:\s*(\S+)/);'\
            'END { print "${package}_$version.tar.gz" }'\

          sh.export 'PKG_TARBALL', "$(perl -ne '#{tarball_script}' DESCRIPTION)", echo: false
          sh.fold 'R-build' do
            sh.echo 'Building package', ansi: :yellow
            sh.echo "Building with: R CMD build ${R_BUILD_ARGS}"
            sh.cmd "R CMD build #{config[:r_build_args]} .",
                   assert: true
          end

          # Check the package
          sh.fold 'R-check' do
            sh.echo 'Checking package', ansi: :yellow
            # Test the package
            sh.echo 'Checking with: R CMD check "${PKG_TARBALL}" '\
              "#{config[:r_check_args]}"
            sh.cmd "R CMD check \"${PKG_TARBALL}\" #{config[:r_check_args]}; "\
              "CHECK_RET=$?", assert: false
          end
          export_rcheck_dir

          if @devtools_installed
            # Output check summary
            sh.cmd 'Rscript -e "message(devtools::check_failures(path = \"${RCHECK_DIR}\"))"', echo: false
          end

          # Build fails if R CMD check fails
          sh.if '$CHECK_RET -ne 0' do
            dump_logs
            sh.failure 'R CMD check failed'
          end

このあともうちょっと処理が続きますが、簡単にするためこの辺までで止めておきます。基本的にやっていることは、

  1. DESCRIPTIONファイルが存在するかチェック
  2. DESCRIPTIONファイルからパッケージ名とバージョンを抜き出してきて環境変数に指定
  3. R CMD build .を実行
  4. R CMD check ${PKG_TARBALL}を実行
  5. Rscript -e "message(devtools::check_failures(path = \"${RCHECK_DIR}\"))"を実行

という感じです。順を追ってこれをCircleCIでどう書くか見ていきましょう。

CircleCIの基本

詳しい説明は省きますが、.circleci/というディレクトリの下にconfig.ymlという名前の設定ファイルを置きます。簡単なやつだとこんな感じです。

version: 2
jobs:
   build:
     docker:
       - image: rocker/geospatial:latest
     steps:
       - checkout
       - run: R CMD build .

versionはCircleCIのバージョンです。バージョン2を使います。

jobsには実行するジョブを並べます。この下には、後述するWorkflowを使う場合は任意の名前のジョブを指定しますが、使わない場合はbuildという名前固定です。

buildの下に指定しているdockerはCIに使うDockerイメージを指定し、stepsには実行する処理を記述します。

stepsにはコマンドを実行するrun、レポジトリをチェックアウトするcheckoutアーティファクトを保存するstore_artifactsとかがあります。

詳しくは公式ドキュメントとかをご参照ください。

Travis CIがやってた処理を再現

DESCRIPTIONファイルが存在するかチェック

まあなかったらあとでエラーになるので別にいいか、ということでスキップします。

DESCRIPTIONファイルからパッケージ名とバージョンを抜き出してきて環境変数に指定

ここはいきなりちょっと難しいんですが、こんな感じです。

       - run: |
           Rscript --vanilla \
             -e 'dsc <- read.dcf("DESCRIPTION")' \
             -e 'cat(sprintf("export PKG_TARBALL=%s_%s.tar.gz\n", dsc[,"Package"], dsc[,"Version"]))' \
             -e 'cat(sprintf("export RCHECK_DIR=%s.Rcheck\n", dsc[,"Package"]))' \
             >> ${BASH_ENV}

${BASH_ENV}は、環境変数を保管しているファイルへのパスです。ここにexport FOO=barと追記しておけば、以後のステップでその環境変数が設定されるようになります。

Travis CIは謎のPerlスクリプトを使ってDESCRIPTIONから情報を抜き出していましたが、ここはせっかくなのでRでやってみます。read.dcf()Debian Control Files、つまりDESCRIPTIONみたいなファイルを読み取る関数です。

PKG_TARBALLはRのパッケージをビルドした後のtarファイル名です。RCHECK_DIRはチェックを流したときのログが保管されるディレクトリです。

ビルドとテストを実行

ここは単純にコマンドを流すだけです。

       - run: R CMD build .
       
       - run: R CMD check "${PKG_TARBALL}" --as-cran --no-manual

    - run: Rscript -e "message(devtools::check_failures(path = '${RCHECK_DIR}'))"

テストのログをアーティファクトとして保管

ここがちょっと難しいところです。アーティファクトstore_artifactsというディレクティブに指定するんですが、ここではパスに環境変数を使ったりすることができません。こんな感じに固定のパスになります。

       - store_artifacts:
           path: /tmp/Rcheck
           when: always

なので、この前にこの固定のパスにRCHECK_DIRを移動させてきましょう。

       - run:
           command: mv ${RCHECK_DIR} /tmp/Rcheck
           when: always

ちなみに、when: alwaysというのはこれまでのコマンドが成功しても失敗しても必ず実行する、という指定です。デフォルトはon_successなのでこれを指定しておかないとエラーになったときのログとかが見れません。

ここまでのまとめ

基本的にはこんな感じでしょう。(ちょっとここまでの説明と順番が違っていたりしますが気にしないでください)

badgeを付ける

これをREADMEに貼っておきましょう。

[![CircleCI](https://circleci.com/gh/ユーザ名/レポジトリ.svg?style=svg)](https://circleci.com/gh/ユーザ名/レポジトリ)

Workflowを使う

上のconfig.ymlだと1つのバージョンしかテストできません。Travis CIだと、

 r:
   - release
   - devel
   - oldrel

とかいう感じでマトリクスビルドができましたが、CircleCIだとWorkflowというのを使うらしいです。

ドキュメントを見ても&とか<<:の使い方についてちらっとしか書かれていないのでよく分からないんですが、私はこんな感じになりました。いろいろ書き方はあるみたいです。defaults(ここではdefaultにしているというだけで、任意のキー名が使えます)には共通の部分(ビルド、テストの処理は同じ)を指定して、各jobsには変わる部分(イメージはそれぞれ違う)を指定します。それをworkflowで並べて書くという感じです。

あんまり理解できてないのでもっといい書き方あれば教えてください。

defaults: &defaults
  steps:
     # setup
     - checkout
     - run:
         name: Set environmental variables
         command: |
           Rscript --vanilla \
             -e 'dsc <- read.dcf("DESCRIPTION")' \
             -e 'cat(sprintf("export PKG_TARBALL=%s_%s.tar.gz\n", dsc[,"Package"], dsc[,"Version"]))' \
             -e 'cat(sprintf("export RCHECK_DIR=%s.Rcheck\n", dsc[,"Package"]))' \
             >> ${BASH_ENV}

     # build and test
     - run: R CMD build .
     - run: R CMD check "${PKG_TARBALL}" --as-cran --no-manual
     - run: Rscript -e "message(devtools::check_failures(path = '${RCHECK_DIR}'))"

     # store artifacts
     - run:
         command: mv ${RCHECK_DIR} /tmp/Rcheck
         when: always
     - store_artifacts:
         path: /tmp/Rcheck
         when: always

version: 2
jobs:
   "r-release":
     docker:
       - image: rocker/geospatial:latest
     <<: *defaults

   "r-devel":
     docker:
       - image: rocker/geospatial:devel
     <<: *defaults

workflows:
  version: 2
  build_and_test:
    jobs:
      - "r-release"
      - "r-devel"

まとめ

CircleCIわりと普通に使えそうでした。ただし、Dockerのイメージキャッシュは有料オプションになってしまったので、思ったほどは高速化されないかもしれません。 Travis CIでsudo: requiredを使わないといけなくてテストが長時間かかって悩んでいるなら移行を検討すべきな気がしますが、Rユーザ的にはやはりTravis CIが知見がたまっているので、そんなに困ってなければTravis CIでいい気がしました。