Unofficial Introduction To extendr (Appendix I): Setup GitHub Actions CI

Integrate R and Rust with extendr

Rust
extendr
Author

Hiroaki Yutani

Published

August 1, 2021

extendr is a project that provides an interface between R and Rust. In the series of posts, I explained how to use extendr, but, this time, let me pick a complementary topic. The CI setup is important to develop a package and it’s not difficult to tweak the existing GitHub Actions (GHA) settings to compile Rust code. By using GHA, you can even provide precompiled binaries via GitHub releases.

Setup Rust toolchain

A GitHub repository for R package development typically has such a YAML for testing:

https://github.com/r-lib/actions/blob/master/examples/check-standard.yaml

To test a package using extendr, it’s as easy as to just add the steps to setup Rust toolchain. Note that the runners might already have Rust toolchain installed, but these steps ensure the intended toolchain is used.

- name: Set up Rust
  uses: actions-rs/toolchain@v1
  with:
    toolchain: stable
    default: true

- name: Additional Rust set up for Windows
  if: runner.os == 'Windows'
  run: |
    rustup target add i686-pc-windows-gnu
    rustup target add x86_64-pc-windows-gnu

If you want to run tests also on the nightly toolchain, you can include the channel in the build matrix like this1:

- {os: windows-latest, r: 'release', rust: 'stable'}
- {os: macOS-latest,   r: 'release', rust: 'stable'}
- {os: ubuntu-20.04,   r: 'release', rust: 'stable',  rspm: "..."}
- {os: ubuntu-20.04,   r: 'devel',   rust: 'stable',  rspm: "..."}
- {os: ubuntu-20.04,   r: 'release', rust: 'nightly', rspm: "..."}

and specify the channel in the actions-rs/toolchain@v1 step:

- name: Set up Rust
  uses: actions-rs/toolchain@v1
  with:
    toolchain: ${{ matrix.config.rust }}
    default: true

You might need more setups depending on the crates you use, but basically that’s all you need to do.

Provide precompiled binaries for Windows

As you might have already noticed, the setup instruction for Windows is a bit complex compared to other OSes (i.e., Linux and macOS). So, it might be worth considering providing the precompiled static libraries, just like rwinlib does for many C++ libraries.

Other motivation is that some of CRAN machines don’t have Rust toolchain. If the package author wants to submit their package to CRAN, such a mechanism is needed.

(edit: it seems macOS is also the case, but I don’t find what’s the best way to solve this. I’ll probably write another post for this.)

There probably isn’t a single standard way to achieve this, but let me share what I did for my package, string2path here. YMMV, of course.

I used softprops/action-gh-release action to publish the binaries as a GitHub release. The setting would be like this:

# rename the binaries before uploading so that we can distinguish them easily.
- name: Tweak staticlib
  if: runner.os == 'Windows'
  run: |
    mv ./check/string2path.Rcheck/00_pkg_src/string2path/src-i386/rust/target/i686-pc-windows-gnu/release/libstring2path.a \
      i686-pc-windows-gnu-libstring2path.a
    mv ./check/string2path.Rcheck/00_pkg_src/string2path/src-x64/rust/target/x86_64-pc-windows-gnu/release/libstring2path.a \
      x86_64-pc-windows-gnu-libstring2path.a

- name: Release
  uses: softprops/action-gh-release@v1
  # only run this on a tag event
  if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/')
  with:
    fail_on_unmatched_files: true
    files: |
      i686-pc-windows-gnu-libstring2path.a
      x86_64-pc-windows-gnu-libstring2path.a

With this setup, you can publish the binaries by pushing tags. For example, let’s create windows_20210801-3 tag and push it.

git tag windows_20210801-3
git push origin windows_20210801-3

Then, the GHA will publish the corresponding release like this:

Next, tweak src/Makefile.win as follows to allow users to download the binaries when cargo is not available. There might be more nicer code to choose the latest release automatically, but I think it’s safe to specify a fixed tag name, though it’s a bit tiresome to update this manually every time you update Rust code.

(edit: I found this violates the CRAN policy. A package is not allowed to write “anywhere else on the file system apart from the R session’s temporary directory. You need to set CARGO_HOME envvar to some temporary directory to avoid this.)

CRATE = foo   # your crate name
BASE_TAG = windows_20210801-3  # the tag you want to use

# c.f. https://stackoverflow.com/a/34756868
# Note that this assignment (`:=`) is not available on Solaris, so you need to
# add "GNU make" to SystemRequirements field on DESCRIPTION, even though this
# can never compile on Solaris anyway...
CARGO_EXISTS := $(shell cargo --version 2> /dev/null)

# ..snip...

$(STATLIB):
ifdef CARGO_EXISTS
    cargo build --target=$(TARGET) --lib --release --manifest-path=./rust/Cargo.toml
else
    mkdir -p $(LIBDIR)
    curl -L -o $(STATLIB) https://github.com/yutannihilation/$(CRATE)/releases/download/$(BASE_TAG)/$(TARGET)-lib$(CRATE).a
endif

One caveat is that this won’t work when cargo is installed but with the GNU toolchain (extendr requires the MSVC toolchain on Windows). I guess some friendlier check can be done in configure.win, but this post won’t look into the details.

Other topics I couldn’t cover

  • sccache: Builds can be faster by using sccache, a ccache-like compiler caching tool for Rust. A blog post describes how to use this on GHA, but I don’t think I understand it to the extent where I can explain it here in clear words, sorry…
  • How to run tests on Rust’s side?: This post doesn’t explain how to run Rust tests (i.e., cargo test) or lints (i.e., cargo fmt, and cargo clippy). I even don’t figure out what tests should live in R or in Rust.

Example

Here’s the real example of the settings on my repo:

https://github.com/yutannihilation/string2path/blob/main/.github/workflows/check-pak.yaml

Footnotes

  1. On Windows, you need to use 'stalble-msvc', not 'stable-gnu', but msvc should be the default so you can just specify 'stable'↩︎