How do I run deno test, lint, fmt, and check in CI?
A good Deno setup for CI runs deno fmt --check, deno lint, deno check, and deno test on one pinned Deno release. It grants the tests only the permissions they need, and it caches the dependencies between runs. In a Deno workspace, it runs the tools from the workspace root, so that they cover every member. You can meet each requirement with the Deno CLI and a CI job. Dagger’s official Deno module meets the same requirements in one place, and runs the same checks on a laptop and in CI.
What a good Deno CI setup needs
Deno’s own docs give five requirements. This guide uses them to judge each approach, Dagger included:
- Run the four built-in tools. Deno’s CI guide runs
deno fmt --check,deno lint, anddeno test.deno check“type-checks your TypeScript (or JavaScript) code without running it”, which the docs call “useful in CI pipelines”. - Use one Deno release everywhere. The CI guide’s example asks for
v2.x, the latest stable release.denoland/setup-denoalso takes an exact version, or reads one from.dvmrcor.tool-versions. One pinned release gives the same formatter, linter, and type checker on every machine. - Grant the tests only the permissions they need. Deno code has no access to the file system, the network, or the environment unless you grant permissions. The CI guide’s example uses
--allow-all, but adds that “it is recommended to run with the minimal permissions your program needs”. - Cache the dependencies between runs. The CI guide says that a download of the dependencies in every run “can become a time-consuming process”. It tells you to cache them.
- Run a Deno workspace from its root. In a Deno workspace, the docs say: “To run tests across all workspace members, simply execute deno test from the workspace root”.
deno check,deno fmt, anddeno lintalso cover every member from the root.
Run the Deno tools by hand in a CI job
The Deno CLI meets each requirement. The examples in this guide use a repository with one standalone project and one Deno workspace with two members:
apps/api/deno.json standalone project
packages/deno.json Deno workspace: { "workspace": ["./core", "./ui"] }
packages/core/deno.json member, "name": "@acme/core"
packages/ui/deno.json member, imports "@acme/core"
In the standalone project, run the four tools. -P gives the tests the permissions that deno.json declares:
deno fmt --check
deno lint
deno check
deno test -P
Checked 3 files
Checked 2 files
Check main.ts
Check main_test.ts
Warning Permissions in the config file is an experimental feature and may change in the future.
Check main_test.ts
running 2 tests from ./main_test.ts
greet ... ok (163µs)
reads config file ... ok (585µs)
ok | 2 passed | 0 failed (5ms)
For the Deno workspace, run the same four commands once from packages. They cover core and ui, and the imports between the members resolve as they do in the workspace.
In CI, a GitHub Actions job installs Deno with denoland/setup-deno and then runs the same commands. Set deno-version to an exact release, such as v2.9.3, or set deno-version-file. Set cache: true to save the dependency cache. By default, the cache key includes a hash of the deno.lock files (caching dependencies). For a repository with several projects, the job runs the commands once in each project directory and once in each workspace root.
Where the hand-built setup strains
The commands are short, but each requirement lives in a different place:
- The Deno release is in two places. The CI job sets
deno-version, and each laptop has its own Deno. If they differ,deno fmt --checkordeno lintcan pass on a laptop and fail in CI. - The list of projects is a script. The CI job must run the tools in each standalone project and each workspace root. A new project means an edit to the job.
- The permissions are in two places. A test run with
--allow-allin CI can pass while the same test fails on a laptop that runs it with-P. - The cache stays in CI. The setup-deno cache does not reach the laptops where developers run the same checks.
Meet them with the Dagger Deno module
The Dagger Deno module meets the same five requirements, with the Deno image, the permissions, and the four tools defined in one place. A Dagger module is a package of functions that test, lint, or build a project. The Dagger Engine runs those functions in containers, on a laptop or in CI.
Complete the Quickstart in the repository first. Then install the module from the repository root:
dagger module install dagger.io/js/deno
dagger.io/js/deno ➡️ https://github.com/dagger/deno#v0.3.0
Installed module "deno" in /work/dagger.toml
The command adds the module to dagger.toml, the workspace configuration for everyone who uses the repository:
[modules.deno]
source = "dagger.io/js/deno"
The command also writes dagger.lock. This file pins the images, Git references, and module addresses that the workspace resolves (dagger.toml reference). Commit both files. A Dagger workspace is the directory that holds dagger.toml. It is a different thing from a Deno workspace.
The next sections show how the module meets each requirement.
Run the four built-in tools
A check is a function that passes or fails, such as a test run or a linter. The module has a check for each tool. List the checks of the workspace:
dagger check -l
MODULE DENO-PROJECT DENO-WORKSPACE CHECK DESCRIPTION
deno * format-check Check this project's formatting with `deno fmt --check`.
deno * stale staleness check: format this project with `deno fmt`
deno * lint Lint this project with `deno lint`.
deno * test Run this project's tests with `deno test`.
deno * type-check Type-check this project with `deno check`.
deno * format-check Check formatting of every member of this workspace with `deno fmt --check`.
deno * stale staleness check: format every member of this workspace with `deno fmt`
deno * lint Lint every member of this workspace with `deno lint`.
deno * test Run tests in every member of this workspace with `deno test`.
deno * type-check Type-check every member of this workspace with `deno check`.
# Use --all to expand collections and list each item.
Each column is a collection: a set of items that the module finds in the repository at run time. A * stands for every item in the collection. Run every check in the workspace:
dagger check
== CHECKS == ✔ 10 passed
✔ dag://?check=deno/projects/format-check 4.2s OK
✔ dag://?check=deno/projects/format/stale 4.2s OK
✔ dag://?check=deno/projects/lint 4.2s OK
✔ dag://?check=deno/projects/test 4.6s OK
✔ dag://?check=deno/projects/type-check 4.6s OK
✔ dag://?check=deno/workspaces/format-check 4.2s OK
✔ dag://?check=deno/workspaces/format/stale 4.2s OK
✔ dag://?check=deno/workspaces/lint 4.2s OK
✔ dag://?check=deno/workspaces/test 4.6s OK
✔ dag://?check=deno/workspaces/type-check 4.6s OK
Dagger runs the checks in parallel, and the command exits with a non-zero code if a check fails (Checking your code). In this guide’s example repository, the first dagger check after a change to apps/api/main.ts took 11.4 seconds. A rerun with no changes took 5.4 seconds. Both times are for the whole command, in one run on one machine.
When a check fails, the report shows the Deno output. All checks run, so you see every failure in one run. In this run, apps/api/main.ts had a type error and bad indentation:
✘ dag://?check=deno/projects/type-check 0.7s ERROR
✘ withExec deno check . 0.3s ERROR
┃ Check main.ts
┃ Check main_test.ts
┃ TS2322 [ERROR]: Type 'number' is not assignable to type 'string'.
┃ const x: string = 5;
┃ ^
┃ at file:///src/apps/api/main.ts:2:11
┃
┃ error: Type checking failed.
! exit code: 1
== RUN LOCALLY ==
dagger check "dag://?check=deno/projects/format-check"
dagger check "dag://?check=deno/projects/format/stale"
dagger check "dag://?check=deno/projects/lint"
dagger check "dag://?check=deno/projects/test"
dagger check "dag://?check=deno/projects/type-check"
== REGENERATE ==
dagger generate -y "dag://?generator=deno/projects/format"
The report gives one command for each failed check. Run a command to run that check again. The REGENERATE section gives the command that fixes the formatting.
The module has two checks for formatting. format-check runs deno fmt --check. The stale check comes from the format generator, a function that changes files and returns the changes as a changeset. The stale check fails when deno fmt would change a file. To fix the formatting, run the generator. Add --no-apply to print the changes and not write them:
dagger generate --no-apply --deno
== GENERATORS == ✔ 2 passed
✔ dag://?generator=deno/projects/format 0.1s OK
✔ dag://?generator=deno/workspaces/format 0.1s OK
apps/api/main.ts +1 -1
1 file changed, +1 -1 lines
Generated changes were not applied (--no-apply).
In a terminal, dagger generate shows the diff and asks before it writes the files. To write the files with no prompt, add -y. deno fmt uses the fmt options in your deno.json, such as lineWidth and semiColons (deno.json reference).
Use one Deno release everywhere
The module runs Deno in denoland/deno:alpine-<version>, and version defaults to 2.9.3. Set version to the Deno release that your team uses. Every project then runs on the same release, on every machine:
dagger module settings deno version 2.9.3
Dagger stores settings in dagger.toml under [modules.deno.settings], so they apply to everyone who uses the repository. A setting applies to every project and Deno workspace that the module checks. To list the settings, run dagger module settings deno:
MODULE KEY VALUE DESCRIPTION
deno version 2.9.3 Deno version for the default base image (deno…
deno base Base container for Deno commands. Must have `…
deno permissions Permissions `test` and `compile` run with: "c…
deno testArgs Extra `deno test` flags, e.g. ["--doc", "--pa…
deno typeCheckArgs Extra `deno check` flags, e.g. ["--allow-impo…
deno typeCheckTargets What `type-check` checks, relative to each pr…
To use a different image, set base to an image that has deno on its PATH, such as docker.io/denoland/deno:debian. When you set base, also set version to the release in that image. The module uses version to decide how it passes permissions. The module reference describes testArgs, typeCheckArgs, and typeCheckTargets.
Grant the tests only the permissions they need
The permissions setting controls the permissions that deno test gets:
config(the default) runsdeno test -P. Deno then uses the permissions in yourdeno.json.allrunsdeno test -A, which grants all permissions.nonegrants no permissions.
With a version before 2.5.0, config makes test use -A. With the default setting and no permissions in deno.json, a test that reads a file fails:
┃ reads config file => ./main_test.ts:7:6
┃ error: NotCapable: Requires read access to "/src/apps/api/deno.json", run again with the --allow-read flag
To fix it, give the tests the permissions they need in apps/api/deno.json. Deno reads permissions from its config file:
{
"tasks": {},
"test": {
"permissions": {
"read": true
}
}
}
With this config, the same test passes with the default permissions setting. Deno prints a warning that permissions in the config file are an experimental feature. The tests get the same permissions on a laptop and in CI, because both read them from deno.json. If you do not want to keep permissions in deno.json, grant all permissions to the tests:
dagger module settings deno permissions all
To remove a setting and go back to the default, run dagger module settings -u deno permissions.
Cache the dependencies between runs
The module sets DENO_DIR to a cache volume (README). A cache volume is a directory whose contents persist across runs on the Dagger Engine. Every check of every project uses the same volume, so Deno downloads each dependency once for each engine. There is no cache key to maintain.
Run a Deno workspace from its root
The module puts each directory that has a deno.json or deno.jsonc in one of two collections:
- Deno workspaces. A config with a non-empty
workspacearray is a Deno workspace, keyed by its directory. Its checks run from the workspace root and cover every member. The members are not keys of their own. - Projects. Every other config is a standalone project, keyed by its directory. A project at the repository root has the key
..
In the example, packages/core and packages/ui do not show as projects. The module checks them through packages, and the imports between members resolve as they do in the Deno workspace. A new project or workspace gets its checks with no change to dagger.toml. Add --all to list one row per project and per Deno workspace:
dagger check -l --all
MODULE DENO-PROJECT DENO-WORKSPACE CHECK DESCRIPTION
deno apps/api format-check Check this project's formatting with `deno fmt --check`.
deno packages format-check Check formatting of every member of this workspace with `deno fmt --check`.
deno apps/api lint Lint this project with `deno lint`.
deno packages lint Lint every member of this workspace with `deno lint`.
deno apps/api stale staleness check: format this project with `deno fmt`
deno packages stale staleness check: format every member of this workspace with `deno fmt`
deno apps/api test Run this project's tests with `deno test`.
deno packages test Run tests in every member of this workspace with `deno test`.
deno apps/api type-check Type-check this project with `deno check`.
deno packages type-check Type-check every member of this workspace with `deno check`.
To run every check on one project, give its directory relative to the repository root:
dagger check --deno-project=apps/api
== CHECKS == ✔ 5 passed
✔ dag://?check=deno/projects/format-check 0.2s OK
✔ dag://?check=deno/projects/format/stale 0.2s OK
✔ dag://?check=deno/projects/lint 0.2s OK
✔ dag://?check=deno/projects/test 0.2s OK
✔ dag://?check=deno/projects/type-check 0.2s OK
To run one check on one Deno workspace, use the two flags together:
dagger check --deno-workspace=packages --check=lint
== CHECKS == ✔ 1 passed
✔ dag://?check=deno/workspaces/lint 0.2s OK
To run deno test on every project and Deno workspace, select the module and the check:
dagger check --deno --check=test
== CHECKS == ✔ 2 passed
✔ dag://?check=deno/projects/test 0.1s OK
✔ dag://?check=deno/workspaces/test 0.1s OK
To select more than one key, repeat the flag.
Run the Deno checks locally and with Cloud Checks
Cloud Checks run your checks on Dagger’s Cloud Engines on each push and pull request, with no CI workflow file. Cloud Engines are Dagger Engines that Dagger Cloud runs for you. The repository must be on GitHub, and its origin remote must point to that repository. Turn on Cloud Checks from the repository root:
dagger cloud checks on
Commit dagger.toml and dagger.lock, then push. Each push runs the same checks that dagger check runs on your machine, with the same Deno release and the same permissions. The results show as GitHub check statuses and in Dagger Cloud (changelog). To see recent runs from the terminal, run dagger workspace activity. If a check fails in the cloud, run dagger check locally to get the same failure.
Bottom line: Run deno fmt --check, deno lint, deno check, and deno test on one pinned Deno release, with only the permissions the tests need. Cache the dependencies, and run each Deno workspace from its root. Dagger’s Deno module meets the same requirements in one place. It finds every project and workspace, pins the release with version, and caches DENO_DIR. It runs the same checks locally and with Cloud Checks.
Related guides
- How do I run Vitest in CI, with sharding and merged coverage?: the same requirements for a Node project that tests with Vitest.
- How do I run Jest in CI and split the tests with –shard?: the same requirements for a Node project that tests with Jest.
- How do I enforce Prettier formatting in CI?: format checks for a Node project.