Andrzej unjello Lichnerowicz

Rocking Rust, Git, and GitHub in 2023 Like a Pro

2023-02-26T19:58:00+02:00

Falling in Love with SourceHut, But Still Tempted by GitHub Actions

SourceHut has recently won my heart with its simplicity and 90’s UI vibe, but I can’t resist the appeal of GitHub’s free workflow automation platform. I’ve been working on a few small projects in Python, TypeScript, and Rust, and have set up CI for all of them. Now that I’ve revived my blog, I thought I’d write about my experience using GitHub Actions for deployment.

I use GitHub Actions to deploy my Static Site Generated site to GitHub Pages or upload packages to various registries (pypi, npm, crates). I then added the Continuous Development part with testing, linting, and so on. In my day job, I always advocate for activities like this to be as close to the developer as possible - ideally as a commit pre-hook. I’ve followed this principle in my own projects, but to make sure I was getting the most out of GitHub Actions, I also added testing on all platforms I wanted to support but didn’t have easy access to. Lastly, I decided to hit the “fund” button - who knows, maybe it’ll be my ticket to an early retirement! :)

Setting up a Pre-Commit Hook

The first step to creating a smooth workflow is to set up a pre-commit hook. This helps to ensure that code is well-formatted and free of errors before it’s pushed to the remote repository. To make this happen, I use pre-commit. It’s an amazing tool that works across different programming languages and makes it easy to run checks locally.

Getting started is pretty simple, just follow the installation instructions on the pre-commit website. Then, you’ll need to define the validation rules for your project. Here’s an example of the rules I use for Rust projects:

repos:
  -   repo: https://github.com/pre-commit/pre-commit-hooks
      rev: v4.4.0
      hooks:
        -   id: fix-byte-order-marker
        -   id: check-case-conflict
        -   id: check-merge-conflict
        -   id: check-symlinks
        -   id: check-yaml
        -   id: check-toml
        -   id: end-of-file-fixer
        -   id: mixed-line-ending
            args: [--fix=lf]
        -   id: trailing-whitespace
  -   repo: https://github.com/doublify/pre-commit-rust
      rev: master
      hooks:
        -   id: fmt
            args: ["--verbose", "--"]
        -   id: cargo-check
        -   id: clippy

Next, install the rules with the pre-commit install command. This will set up the .git/hooks/pre-commit file for you. Keep in mind that this file is not synced to the remote repository, so each developer will need to run the setup locally. To make this easier, I like to create a file with useful commands using a tool called just.

With the following Justfile, you can run just install to create the hooks and just prehook to run checks on demand:

configure:
	pre-commit install

precommit:
	pre-commit run --all-files

Triggering Your Workflow

The next step in setting up your workflow is to determine when it should run. I usually use just a couple of triggers.

For everyday Continuous Integration, I set it up to run on every push or pull request:

on: [push, pull_request]

If I have some expensive tests that take a long time to run, I’ll schedule them to run nightly:

on:
  schedule:
    - cron: "0 0 * * *"

For libraries or websites - anything that gets published - I have a separate workflow that’s triggered by gh release create:

on:
  release:
    types: [created]

Defining your testing strategy matrix

When it comes to testing on different platforms, I love using GitHub Actions. It’s super easy to use and just works for me. Sure, some people may have had issues with the stability of GitHub’s workers, but I haven’t had any problems yet. I’m not doing any heavy compiling, so I might just be lucky.

To test on different platforms, we’ll be using something called a strategy matrix. There are a few ways to define this matrix, like providing separate lists of OS types and hardware architectures and letting GitHub calculate all the possibilities. But, I usually just keep it simple and go for a list of 64-bit Windows, 64-bit Linux, and both Intel and ARM macOS.

jobs:
  dist:
    strategy:
      matrix:
        include:
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            code-target: win32-x64
          - os: windows-latest
            target: aarch64-pc-windows-msvc
            code-target: win32-arm64
          - os: ubuntu-20.04
            target: x86_64-unknown-linux-gnu
            code-target: linux-x64
            container: ubuntu:18.04
          - os: macos-11
            target: x86_64-apple-darwin
            code-target: darwin-x64
          - os: macos-11
            target: aarch64-apple-darwin
            code-target: darwin-arm64

Running validation

If you’ve been following along, now it’s time to actually run the validation checks on our code. But first, for our Ubuntu workflow, you’ll need to install some build tools first:

if: contains(matrix.os, 'ubuntu')
run: apt-get update && apt-get install -y curl build-essential gcc g++

Now, for the actual validation, we’ll be using GitHub Actions. Actions are packages in JavaScript or TypeScript format, and we’ll be using the official actions/checkout to checkout our code. This action is provided by GitHub and is the recommended way to do this.

uses: actions/checkout@v3

Next, we’ll use the action-rs package to handle everything we need for our continuous integration pipeline: installing the toolchain, running formatting checks, clippy checks, and tests. This is how my .github/workflows/ci.yaml looks like:

on: [push, pull_request]

name: CI

jobs:
  dist:
    strategy:
      matrix:
        include:
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            code-target: win32-x64
          - os: windows-latest
            target: aarch64-pc-windows-msvc
            code-target: win32-arm64
          - os: ubuntu-20.04
            target: x86_64-unknown-linux-gnu
            code-target: linux-x64
            container: ubuntu:18.04
          - os: macos-11
            target: x86_64-apple-darwin
            code-target: darwin-x64
          - os: macos-11
            target: aarch64-apple-darwin
            code-target: darwin-arm64

    name: dist (${{ matrix.target }})
    runs-on: ${{ matrix.os }}
    container: ${{ matrix.container }}

    steps:
      - name: Install prerequisites
        if: contains(matrix.os, 'ubuntu')
        run: apt-get update && apt-get install -y curl build-essential gcc g++

      - name: Checkout sources
        uses: actions/checkout@v3

      - name: Install Rust toolchain
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          toolchain: stable
          override: true
          target: ${{ matrix.target }}
          components: rustfmt, clippy, rust-src

      - name: Run cargo check
        uses: actions-rs/cargo@v1
        with:
          command: check

      - name: Run cargo test
        uses: actions-rs/cargo@v1
        with:
          command: test

      - name: Run cargo fmt
        uses: actions-rs/cargo@v1
        with:
          command: fmt
          args: --all -- --check

      - name: Run cargo clippy
        uses: actions-rs/cargo@v1
        with:
          command: clippy
          args: -- -D warnings

Publishing to Crates.io

To have a complete publishing pipeline, we need to create an API token. You can do this by going to the API Tokens page on crates.io and creating a new token. After that, go to your repository’s Settings > Secrets and variables > Actions and create a new repository secret, so that the token is visible to the actions without being exposed publicly. I typically name it CRATES_TOKEN.

There are a few ways to publish packages, but I prefer to create both a tag and a release. First, I create a separate pipeline that is triggered by creating a release.

The YAML code looks like this:

name: Publish Package to crates.io
on:
  release:
    types: [created]
jobs:
  publish:
    name: Publish
    runs-on: ubuntu-latest
    steps:
      - name: Checkout sources
        uses: actions/checkout@v3

      - name: Install stable toolchain
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          toolchain: stable
          override: true

      - run: cargo publish --token ${CRATES_TOKEN}
        env:
          CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }}

There are multiple ways to create a release, including using GitHub’s REST API, but I prefer to use the command line:

$ git tag -a v0.1.0 -m "Tagging v0.1.0"
$ gh release create v0.1.0 -t "Release v0.1.0"