Continuous Integration
Deno's built-in tools make it easy to set up Continuous Integration (CI)
pipelines for your projects. Testing, linting and formatting of code can all be
done with the corresponding commands deno test
, deno lint
and deno fmt
. In
addition, you can generate code coverage reports from test results with
deno coverage
in pipelines.
On this page we will discuss:
Setting up a basic pipeline
This page will show you how to set up basic pipelines for Deno projects in GitHub Actions. The concepts explained on this page largely apply to other CI providers as well, such as Azure Pipelines, CircleCI or GitLab.
Building a pipeline for Deno generally starts with checking out the repository and installing Deno:
name: Build
on: push
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x # Run with latest stable Deno.
To expand the workflow just add any of the deno
subcommands that you might
need:
# Check if the code is formatted according to Deno's default
# formatting conventions.
- run: deno fmt --check
# Scan the code for syntax errors and style issues. If
# you want to use a custom linter configuration you can add a configuration file with --config <myconfig>
- run: deno lint
# Run all test files in the repository and collect code coverage. The example
# runs with all permissions, but it is recommended to run with the minimal permissions your program needs (for example --allow-read).
- run: deno test --allow-all --coverage=cov/
# This generates a report from the collected coverage in `deno test --coverage`. It is
# stored as a .lcov file which integrates well with services such as Codecov, Coveralls and Travis CI.
- run: deno coverage --lcov cov/ > cov.lcov
Cross-platform workflows
As a Deno module maintainer, you probably want to know that your code works on all of the major operating systems in use today: Linux, MacOS and Windows. A cross-platform workflow can be achieved by running a matrix of parallel jobs, each one running the build on a different OS:
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-22.04, macos-12, windows-2022 ]
steps:
- run: deno test --allow-all --coverage cov/
Note: GitHub Actions has a known issue with handling Windows-style line endings (CRLF). This may cause issues when running
deno fmt
in a pipeline with jobs that run onwindows
. To prevent this, configure the Actions runner to use Linux-style line endings before running theactions/checkout@v3
step:git config --system core.autocrlf false
git config --system core.eol lf
If you are working with experimental or unstable Deno APIs, you can include a matrix job running the canary version of Deno. This can help to spot breaking changes early on:
jobs:
build:
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.canary }} # Continue in case the canary run does not succeed
strategy:
matrix:
os: [ ubuntu-22.04, macos-12, windows-2022 ]
deno-version: [ v1.x ]
canary: [ false ]
include:
- deno-version: canary
os: ubuntu-22.04
canary: true
Speeding up Deno pipelines
Reducing repetition
In cross-platform runs, certain steps of a pipeline do not need to run for each
OS necessarily. For example, generating the same test coverage report on Linux,
MacOS and Windows is a bit redundant. You can use the if
conditional keyword
of GitHub Actions in these cases. The example below shows how to run code
coverage generation and upload steps only on the ubuntu
(Linux) runner:
- name: Generate coverage report
if: matrix.os == 'ubuntu-22.04'
run: deno coverage --lcov cov > cov.lcov
- name: Upload coverage to Coveralls.io
if: matrix.os == 'ubuntu-22.04'
# Any code coverage service can be used, Coveralls.io is used here as an example.
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }} # Generated by GitHub.
path-to-lcov: cov.lcov
Caching dependencies
As a project grows in size, more and more dependencies tend to be included. Deno will download these dependencies during testing and if a workflow is run many times a day, this can become a time-consuming process. A common solution to speed things up is to cache dependencies so that they do not need to be downloaded anew.
Deno stores dependencies locally in a cache directory.
In a pipeline the cache can be preserved between workflows by setting the
DENO_DIR
environment variable and adding a caching step to the workflow:
# Set DENO_DIR to an absolute or relative path on the runner.
env:
DENO_DIR: my_cache_directory
steps:
- name: Cache Deno dependencies
uses: actions/cache@v2
with:
path: ${{ env.DENO_DIR }}
key: my_cache_key
At first, when this workflow runs the cache is still empty and commands like
deno test
will still have to download dependencies, but when the job succeeds
the contents of DENO_DIR
are saved and any subsequent runs can restore them
from cache instead of re-downloading.
There is still an issue in the workflow above: at the moment the name of the
cache key is hardcoded to my_cache_key
, which is going to restore the same
cache every time, even if one or more dependencies are updated. This can lead to
older versions being used in the pipeline even though you have updated some
dependencies. The solution is to generate a different key each time the cache
needs to be updated, which can be achieved by using a lockfile and by using the
hashFiles
function provided by GitHub Actions:
key: ${{ hashFiles('deno.lock') }}
To make this work you will also need a have a lockfile in your Deno project,
which is discussed in detail here.
Now, if the contents of deno.lock
are changed, a new cache will be made and
used in subsequent pipeline runs thereafter.
To demonstrate, let's say you have a project that uses the logger from
deno.land/std
:
import * as log from "https://deno.land/std/log/mod.ts";
In order to increment this version, you can update the import
statement and
then reload the cache and update the lockfile locally:
deno cache --reload --lock=deno.lock --lock-write deps.ts
You should see changes in the lockfile's contents after running this. When this
is committed and run through the pipeline, you should then see the hashFiles
function saving a new cache and using it in any runs that follow.
Clearing the cache
Occasionally you may run into a cache that has been corrupted or malformed, which can happen for various reasons. It is possible to clear a cache from the GitHub Actions UI, or you can simply change the name of the cache key. A practical way of doing so without having to forcefully change your lockfile is to add a variable to the cache key name, which can be stored as a GitHub secret and can be changed if a new cache is needed:
key: ${{ secrets.CACHE_VERSION }}-${{ hashFiles('deno.lock') }}