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:

```yaml title=”.github/workflows/ci.yml” on: push: pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest steps:

  1. - name: Download source
  2. uses: actions/checkout@v2
  3. - name: Install Crystal
  4. uses: crystal-lang/install-crystal@v1
  5. - name: Run tests
  6. run: crystal spec
  1. To get started with [GitHub Actions](https://docs.github.com/en/actions/guides/about-continuous-integration#about-continuous-integration-using-github-actions), commit this YAML file into your Git repository under the directory `.github/workflows/`, push it to GitHub, and observe the Actions tab.
  2. > tip "Quickstart"
  3. Check out [**Configurator for *install-crystal* action**](https://crystal-lang.github.io/install-crystal/configurator.html) to quickly get a config with the CI features you need. Or continue reading for more details.
  4. This runs on GitHub's [default](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources) "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](https://github.com/crystal-lang/install-crystal), then runs the specs, assuming they are there in the `spec/` directory.
  5. 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.
  6. > tip
  7. For a healthier codebase, consider these flags for `crystal spec`:
  8. `--order=random` `--error-on-warnings`
  9. ### No specs?
  10. If your test coverage isn't great, consider at least adding an example program, and building it as part of CI:
  11. For a library:
  12. ```yaml
  13. - name: Build example
  14. 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:

```yaml hl_lines=”6 14” jobs: build: strategy: fail-fast: false matrix: crystal: [0.35.1, latest, nightly] runs-on: ubuntu-latest steps:

  1. - name: Download source
  2. uses: actions/checkout@v2
  3. - name: Install Crystal
  4. uses: crystal-lang/install-crystal@v1
  5. with:
  6. crystal: ${{ matrix.crystal }}
  7. - ...
  1. All those versions will be tested for *in parallel*.
  2. 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.
  3. ### Testing on multiple operating systems
  4. 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](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources) into the test matrix, just add the following near the top of your job definition:
  5. ```yaml hl_lines="6 7"
  6. jobs:
  7. build:
  8. strategy:
  9. fail-fast: false
  10. matrix:
  11. os: [ubuntu-latest, macos-latest]
  12. runs-on: ${{ matrix.os }}
  13. steps:
  14. - ...

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:

```yaml title=”.github/workflows/ci.yml” hl_lines=”4-5 9” jobs: build: runs-on: ubuntu-latest container: image: crystallang/crystal:latest steps:

  1. - name: Download source
  2. uses: actions/checkout@v2
  3. - name: Run tests
  4. run: crystal spec
  1. Some [other options](https://hub.docker.com/r/crystallang/crystal/tags) for containers are `crystallang/crystal:nightly`, `crystallang/crystal:0.36.1`, `crystallang/crystal:latest-alpine`.
  2. ## Caching
  3. 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.
  4. The safe approach is to add the [actions/cache](https://github.com/actions/cache) step (**before the step that uses `shards`**) defined as follows:
  5. ```yaml
  6. - name: Cache shards
  7. uses: actions/cache@v2
  8. with:
  9. path: ~/.cache/shards
  10. key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }}
  11. restore-keys: ${{ runner.os }}-shards-
  12. - name: Install shards
  13. run: shards update

danger “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:

```yaml title=”.github/workflows/release.yml” hl_lines=”5 8” jobs: release_linux: runs-on: ubuntu-latest container: image: crystallang/crystal:latest-alpine steps:

  1. - uses: actions/checkout@v2
  2. - 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.