使用 GitHub Actions 为 R 包配置持续集成服务

2020年的时候我写过一篇《[使用 GitLab Runner 为 R 包配置持续集成服务](https://hpdell.github.io/%E7%BC%96%E7%A8%8B/RPackage-GitlabRunner/)》的博客,主要介绍了在 GitLab 平台上,通过部署 Docker 容器并在中期内安装依赖环境的方法进行R包持续集成服务的配置。随着对 Docker 理解的加深,以及进阶用法的掌握,之前文章中所写的方法已经比较过时了。而且现在 GitHub Actions 已经十分强大,也支持私有库,也支持自己部署 Runners,并提供 macOS 和 Windows 等环境,因此完全可以将 R 包的持续集成方法迁移到 GitHub 上。本文就介绍一下如何使用 GitHub Actions 进行 R 包的持续集成。

# GitHub Actions 配置简介

GitHub Actions 中,也是通过任务(jobs)来指定流水线(workflow)工作的方式。详细说明可以参考[官方文档](https://docs.github.com/en/enterprise-cloud@latest/actions/quickstart)。简单地讲,一个 GitHub Actions 文件(yml 格式)是一个流水线,一个流水线上有很多任务,每个任务有很多步骤,可以运行在不同的操作系统上。因此,一个流水线配置文件一般是这样的:

```yml
name: GitHub Actions Demo
on: [push]
jobs:
  Explore-GitHub-Actions:
    runs-on: ubuntu-latest
    steps:
      - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event."
      - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!"
      - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}."
      - name: Check out repository code
        uses: actions/checkout@v3
      - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
      - run: echo "🖥️ The workflow is now ready to test your code on the runner."
      - name: List files in the repository
        run: |
          ls ${{ github.workspace }}
      - run: echo "🍏 This job's status is ${{ job.status }}."

```

这个文件中,`Explore-GitHub-Actions` 就是名为 `GitHub Actions Demo` 的流水线上的一个任务。这个任务的配置中,通过 `runs-on` 指定任务运行的主机系统,可以选择[由 GitHub 提供的虚拟环境](https://github.com/actions/virtual-environments),也可以在自己的主机上部署一个 Runner,具体的方法可以参考[相关文档](https://docs.github.com/en/enterprise-cloud@latest/actions/hosting-your-own-runners/about-self-hosted-runners)。

> 任务的配置中也可以通过 `container` 选项指定环境,此时这个任务就运行在容器中,而不是主机上。此处我们无需使用这种方式。

每一个任务都由很多步骤(steps)组成,每一个步骤中可以是通过 `run` 选项指定的一系列脚本,也可以通过 `uses` 选项指定一个已经发布的 Action ,或者使用 `uses` 指定一个 Docker 镜像。

* 如果使用 `run` 指定脚本,那么可以后面直接跟一行脚本内容,也可以使用 `|` 引导多行脚本内容。
* 如果使用 `uses` 指定 Action ,格式为 `所有者/仓库名@提交或标签` ,例如 `actions/checkout@v3` 就是官方提供的用于检出仓库的 Action。这里也可以自定义 Action 把一组脚本封装起来,以实现一定的功能。自定义的 Action 既可以由一些命令组成,也可以由 Dockerfile 和相应的脚本文件组成。如果是后者,在运行任务之前会先执行镜像构建。
* 如果使用 `uses` 指定 Docker 镜像,格式为 `docker://镜像标签` 。此时,`runs-on` 必须指定 Linux 环境。镜像可以发布在 Docker Hub 中,也可以发布在 GitHub Packages Container registry 中。

下面我们详细介绍以下各种持续集成的使用方式。

# 基于 actions 的持续集成

这种方式适用于任何系统。在 GitHub Runners 提供的 [macOS](https://github.com/actions/virtual-environments/blob/main/images/macos/macos-11-Readme.md) 和 [Windows](https://github.com/actions/virtual-environments/blob/main/images/win/Windows2022-Readme.md) 环境上,我们可以借助以下几个 action 编写测试:

- [r-lib/actions/setup-r](https://github.com/r-lib/actions) 部署 R 环境
- [r-lib/actions/setup-r-dependicies](https://github.com/r-lib/actions/tree/v2-branch/setup-r-dependencies) 安装 R 依赖库(Linux 需要提前安装第三方依赖库)
- [r-lib/actions/check-r-package](https://github.com/r-lib/actions/tree/v2-branch/check-r-package) 进行 R 包编译和测试

以 macOS 为例,我们可以编写这样一个任务

```yml
jobs:
  build_macos:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up R release
        uses: r-lib/actions/setup-r@v2
        with:
          r-version: 'release'
      - name: Install R dependencies
        uses: r-lib/actions/setup-r-dependencies@v2
        with:
          extra-packages: any::rcmdcheck, any::roxygen2
          needs: check
      - name: R build and check
        uses: r-lib/actions/check-r-package@v2
        with:
          args: 'c("--no-manual", "--as-cran")'
          build_args: 'c("--no-manual", "--resave-data")'
          error-on: '"error"'
          check-dir: '"check"'
```

这种方法的优势是比较方便,泛用性强,即插即用,而且可以复用。但是在 Linux 上,在安装 R 依赖包的时候,由于需要编译安装,需要耗费较长的时间。如果要缩短时间,一种方法是可以使用 cache 功能把依赖包缓存起来。但这样需要耗费较大的空间(GitHub 缓存空间大小有一定限制)。另一种方法是下面介绍的使用 Docker 容器进行持续集成。

# 基于 Docker 的持续集成

由于 macOS 和 Windows 的限制,这种方法基本上只能用于 Linux 系统。目前 CRAN 主要检查 Debian 和 Fedora 两个系统,因此只要创建这两个系统送的镜像即可。这里以 Debian 为例。

## 构建编译环境

在之前的文章中,我们是通过直接部署一个 Debian 的容器,然后在容器控制台中进行环境部署。事实上,这个过程可以通过编写 Dockerfile 完成。在这个 Dockerfile 文件中,我们通过脚本进行 R 的安装与依赖库的安装。

下面是一个示例的 Dockerfile。

```dockerfile
FROM debian:10

ARG DEBIAN_FRONTEND=noninteractive  # 设定当前终端为非交互模式,避免 apt 进入交互
ENV TZ=Etc/UTC                      # 设定当前时区选项,避免在安装相关库时出现时区选择情况

RUN apt-get -qq update && \
    apt-get -qq install -y \
    gnupg \
    build-essential \
    gfortran \
    devscripts

### 以下两行用于添加 R 镜像源
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-key '95C0FAF38DB3CCAD0C080A7BDC78B2DDEABC47B7'
RUN echo "deb http://cloud.r-project.org/bin/linux/debian buster-cran40/" >> /etc/apt/sources.list

### 安装 R 以及其他依赖库
RUN apt-get -qq update && \
    apt-get -qq install -y \
    r-base r-base-dev \
    libgsl-dev
    libgit2-dev \
    libcurl4-openssl-dev \
    libxml2-dev \
    libssl-dev

### 安装依赖的 R 包
RUN Rscript -e "install.packages(c('devtools', 'Rcpp', 'RcppArmadillo'))"

COPY entrypoint.sh /entrypoint.sh

ENTRYPOINT [ "/entrypoint.sh" ]

```

如果要部署 Fedora 的环境,无非就是使用 dnf 包管理器,同时依赖库的安装会发生一些变化。

需要注意的是,这个镜像我们没有指定 `WORKDIR` ,这是因为 GitHub Actions 在运行容器的时候会指定工作目录,且指定为仓库所在的目录。所以这里无需指定工作目录。

## 执行测试

假设我们构建的镜像标签为 `myrpackage-test-debian-release:latest` ,我们可以使用两种方式执行测试。

### 使用 r-libs/action/check-r-package

对于一般的R包,可以使用现成的 [r-libs/action/check-r-package](https://github.com/r-lib/actions/tree/v2-branch/check-r-package) 来执行测试脚本。如果我们使用自定义的容器进行测试,那么就需要让这个 action 运行在我们的容器中。可以使用 `jobs.<job-id>.container` 选项来实现这一点。例如我们可以编写下面这行的 job。

```yml
job: 
  check-linux:
    runs-on: ubuntu-latest
    container:
      image: myrpackage-test-debian-release:latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Build and Check package
        uses: r-lib/actions/check-r-package@v2
        with:
          args: 'c("--no-manual", "--as-cran")'
          build_args: 'c("--no-manual", "--resave-data")'
          error-on: '"error"'
          check-dir: '"check"'

```

### 使用自定义测试脚本

如果在执行 R 包检查前有一些额外的步骤,那么我们可以将这些步骤连同执行 R 包检查的部分写在 `entrypoint.sh` 的脚本中,在容器创建完成后运行脚本进行检查。下面是一个具体的示例。

```shell
#!/bin/sh -l

mkdir build
cd build
cmake .. -DWITH_R=ON && \
    cmake --build . --config Release --target r_package && \
    ctest -R Test_R_Package --output-on-failure

exit $?
```

由于这是一个使用 CMake 进行管理的 R 包,所以全都是用 CMake 进行处理的。对于一般的 R 包,就可以写如下脚本

```shell
#!/bin/sh -l

R CMD build PCAKGE && R CMD check PACKAGE_VERSION.tar.gz --as-cran

exit $?
```

只要能够保证工作目录是正确的,并且命令可以运行即可。

由于我们已经通过 Dockerfile 构建好了镜像,在配置流水线任务时,只需要采用 `uses` 选项指定 Docker 镜像即可。当然,首先还是要使用 `actions/checkout@v3` 检出仓库。因此这个任务的编写如下:

```yml
jobs:
  build_debian:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: docker://myrpackage-test-debian-release:latest
```

这样,我们就创建了一个由两个步骤组成的任务,第一个步骤检出仓库,第二个步骤构建 Docker 容器并执行相应的操作。由于我们已经在 `entrypoint.sh` 中编写好了操作,容器运行起来后就会执行脚本,以实现持续集成的目的。

# 基于自定义 Runner 的持续集成

事实上,虽然 [r-lib/actions/setup-r](https://github.com/r-lib/actions) 可以在 Windows 上安装 R 环境,但是如果 R 包使用了一些其他库(例如 gsl 等),环境的部署就会非常麻烦了。即使 GitHub Runner 的 Windows 环境安装了 vcpkg 和 conda 等包管理器,但是编译 R 包需要将依赖库安装在 R 的根目录上,使用脚本依然比较困难。但是由于 GitHub 支持自定义 Runner,我们可以在一台 Windows 主机或者虚拟机上部署自定义 Runner 并执行任务。

> 这里说“需要将依赖库安装在 R 的根目录上”,主要是指例如 GSL 等第三方依赖库,需要将这些依赖库安装在 R_HOME 路径下。这在 Windodws 系统上是比较难以做到的,并不是说不行。主要有两种方式:
>
> - 由于 Windows Server 2022 自带了 zip tar gzip 等解压缩工具,可以下载预编译的二进制库并放到 R_HOME 中。
> - 在 RTools 4.2 版本中已经由官方提供了 GSL 等库,所以可以使用 RTools 4.2。
>
> 但如果以上条件并不满足,还是使用自定义的 Runner 进行持续集成比较好。

部署 Runner 的方法很简单,进入仓库的设置界面,选择 Actions > Runner ,点击 New self-hosted runner 按钮,就会出现非常详细的说明。

![image.png](/media/d1ffff96-7840-47cb-a90f-a9c0e2068757_image.png)

部署好后,按照以下方法配置任务

```yml
jobs:
  build_windows:
    runs-on: [self-hosted, Windows, X64]
    steps:
      - uses: actions/checkout@v3
      - name: R build and check
        run: |
          R CMD build .
          R CMD check 包名_版本.tar.gz
```

其中 `runs-on` 中列出的是自定义 Runner 的标签,通过这些标签 GitHub 会选出相应的 Runner 去执行任务。这些标签会在部署时由部署脚本提示输入。

# 流水线触发条件

通常我们可以使用如下触发条件,以支持在 master 分支更新或者有 Pull Request 时进行自动检查。

```yml
on:
  push:
    branches: [ master ]
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ master ]
```

这样的话,就最好遵循 Git 或 GitHub 推荐的工作流进行开发。

如果希望通过流水线自动将 R 包发布在 CRAN 上,可以编写相应的任务,并在任务配置中添加一个条件

```yml
jobs:
  upload_cran:
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/')
    steps:
      ...
```

这样就只有在打 Tag 的时候才会进行上传。

# 使用 Matrix 在多种环境下执行持续集成

事实上,CRAN 不仅要求 R 包在 Debian Fedora Windows macOS 系统上测试,还要分别测试 release devel old-release 三个 R 版本,甚至还要求分别测试 gcc 和 clang 两种编译器。但是不同版本的 R 进行测试的脚本是一样,只是环境不一样而已。所以我们可以使用 Matrix 让我们的测试运行在不同的环境下。

以 Linux 上测试 release 和 devel 为例,我们的测试需要以下两个维度:

- OS: Debian, Fedora
- R Version: release, devel

那么我们可以这样编写 job

```yml
job: 
  check-linux:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        os: [debian, fedora]
        version: [release, devel]
      fail-fast: false
    container:
      image: myrpackage-test-${{ matrix.os }}-${{ matrix.version }}:latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Build and Check package
        uses: r-lib/actions/check-r-package@v2
        with:
          args: 'c("--no-manual", "--as-cran")'
          build_args: 'c("--no-manual", "--resave-data")'
          error-on: '"error"'
          check-dir: '"check"'
```

为了执行这个 job ,我们需要提前构建好以下四个镜像

- myrpackage-test-debian-release:latest
- myrpackage-test-debian-devel:latest
- myrpackage-test-fedora-release:latest
- myrpackage-test-fedora-devel:latest

这样 GitHub 会同时根据我们的配置在以上四个容器中进行测试。