GitHub Actions

Build and run specs

To continuously test our example application — both whenever a commit is pushed and when someone opens a pull request, add this minimal workflow file:

.github/workflows/ci.yml

  1. on:
  2. push:
  3. pull_request:
  4. branches: [master]
  5. jobs:
  6. build:
  7. runs-on: ubuntu-latest
  8. steps:
  9. - name: Download source
  10. uses: actions/checkout@v2
  11. - name: Install Crystal
  12. uses: crystal-lang/install-crystal@v1
  13. - name: Run tests
  14. run: crystal spec

To get started with GitHub Actions, commit this YAML file into your Git repository under the directory .github/workflows/, push it to GitHub, and observe the Actions tab.

Quickstart

Check out Configurator for install-crystal action to quickly get a config with the CI features you need. Or continue reading for more details.

This runs on GitHub’s default “latest Ubuntu” container. It downloads the source code from the repository itself (directly into the current directory), installs Crystal via Crystal’s official GitHub Action, then runs the specs, assuming they are there in the spec/ directory.

If any step fails, the build will show up as failed, notify the author and, if it’s a push, set the overall build status of the project to failing.

Tip

For a healthier codebase, consider these flags for crystal spec:
--order=random --error-on-warnings

No specs?

If your test coverage isn’t great, consider at least adding an example program, and building it as part of CI:

For a library:

  1. - name: Build example
  2. run: crystal build examples/hello.cr

For an application (very good to do even if you have specs):

  1. - name: Build
  2. run: crystal build src/game_of_life.cr

Testing with different versions of Crystal

By default, the latest released version of Crystal is installed. But you may want to also test with the “nightly” build of Crystal, and perhaps some older versions that you still support for your project. Change the top of the workflow as follows:

  1. jobs:
  2. build:
  3. strategy:
  4. fail-fast: false
  5. matrix:
  6. crystal: [0.35.1, latest, nightly]
  7. runs-on: ubuntu-latest
  8. steps:
  9. - name: Download source
  10. uses: actions/checkout@v2
  11. - name: Install Crystal
  12. uses: crystal-lang/install-crystal@v1
  13. with:
  14. crystal: ${{ matrix.crystal }}
  15. - ...

All those versions will be tested for in parallel.

By specifying the version of Crystal you could even opt out of supporting the latest version (which is a moving target), and only support particular ones.

Testing on multiple operating systems

Typically, developers run tests only on Ubuntu, which is OK if there is no platform-sensitive code. But it’s easy to add another system into the test matrix, just add the following near the top of your job definition:

  1. jobs:
  2. build:
  3. strategy:
  4. fail-fast: false
  5. matrix:
  6. os: [ubuntu-latest, macos-latest]
  7. runs-on: ${{ matrix.os }}
  8. steps:
  9. - ...

Installing Shards packages

Most projects will have external dependencies, “shards”. Having declared them in shard.yml, just add the installation step into your workflow (after install-crystal and before any testing):

  1. - name: Install shards
  2. run: shards install

Latest or locked dependencies?

If your repository has a checked in shard.lock file (typically good for applications), consider the effect that this has on CI: shards install will always install the exact versions specified in that file. But if you’re developing a library, you probably want to be the first to find out in case a new version of a dependency breaks the installation of your library — otherwise the users will, because the lock doesn’t apply transitively. So, strongly consider running shards update instead of shards install, or don’t check in shard.lock. And then it makes sense to add scheduled runs to your repository.

Installing binary dependencies

Our application or some shards may require external libraries. The approach to installing them can vary widely. The typical way is to install packages using the apt command in Ubuntu.

Add the installation step somewhere near the beginning. For example, with libsqlite3:

  1. - name: Install packages
  2. run: sudo apt-get -qy install libsqlite3-dev

Enforcing code formatting

If you want to verify that all your code has been formatted with crystal tool format, add the according check as a step near the end of the workflow. If someone pushes code that is not formatted correctly, this will break the build just like failing tests would.

  1. - name: Check formatting
  2. run: crystal tool format --check

Consider also adding this check as a Git pre-commit hook for yourself.

Using the official Docker image

We have been using an “action” to install Crystal into the default OS image that GitHub provides. Which has multiple advantages. But you may instead choose to use Crystal’s official Docker image(s), though that’s applicable only to Linux.

The base config becomes this instead:

.github/workflows/ci.yml

  1. jobs:
  2. build:
  3. runs-on: ubuntu-latest
  4. container:
  5. image: crystallang/crystal:latest
  6. steps:
  7. - name: Download source
  8. uses: actions/checkout@v2
  9. - name: Run tests
  10. run: crystal spec

Some other options for containers are crystallang/crystal:nightly, crystallang/crystal:0.36.1, crystallang/crystal:latest-alpine.

Caching

The process of downloading and installing dependencies (shards specifically) is done from scratch on every run. With caching in GitHub Actions, we can save some of that duplicated work.

The safe approach is to add the actions/cache step (before the step that uses shards) defined as follows:

  1. - name: Cache shards
  2. uses: actions/cache@v2
  3. with:
  4. path: ~/.cache/shards
  5. key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }}
  6. restore-keys: ${{ runner.os }}-shards-
  7. - name: Install shards
  8. run: shards update

Important

You must use the separate key and restore-keys. With just a static key, the cache would save only the state after the very first run and then keep reusing it forever, regardless of any changes.

But this saves us only the time spent downloading the repositories initially.

A “braver” approach is to cache the lib directory itself, but that works only if you fully rely on shard.lock (see Latest or locked dependencies?):

  1. - name: Cache shards
  2. uses: actions/cache@v2
  3. with:
  4. path: lib
  5. key: ${{ runner.os }}-shards-${{ hashFiles('**/shard.lock') }}
  6. - name: Install shards
  7. run: shards check || shards install

Note that we also made the installation conditional on shards check. That saves even a little more time.

Publishing executables

If your project is an application, you likely want to distribute it as an executable (“binary”) file. For the case of Linux x86_64, by far the most popular option is to build and link statically on Alpine Linux. This means that you cannot use GitHub’s default Ubuntu container and the install action. Instead, just use the official container:

.github/workflows/release.yml

  1. jobs:
  2. release_linux:
  3. runs-on: ubuntu-latest
  4. container:
  5. image: crystallang/crystal:latest-alpine
  6. steps:
  7. - uses: actions/checkout@v2
  8. - run: shards build --production --release --static --no-debug

These steps would be followed by some action to publish the produced executable (bin/*), in one of the two ways (or both of them):

Distributing executables for macOS (search for examples) and Windows (search for examples) is also possible.