CI that keeps up

How do I run Biome lint and format checks in CI?

To run Biome lint and format checks in CI, pin the exact Biome version in package.json. Run Biome’s formatter, linter, and assist actions as one check that fails the CI job. Give each project in a monorepo its root configuration, and apply safe fixes with the same Biome. Teams usually meet these requirements with biome ci in a CI job and biome check --write in a Git hook. Dagger’s official Biome module meets the same requirements in one place, and dagger check runs it the same way on a laptop and in Cloud Checks.

What a reliable Biome CI setup needs

Biome’s own docs give four requirements for Biome results that are the same on a laptop and in CI:

  1. Pin the exact Biome version. On Biome’s install page, the -E flag “pins the exact version in your package.json”. The page says that this is “so every contributor and your CI run the same version of Biome.” The versioning page says why: “Even patch releases can result in slightly different formatting or linting results.”
  2. Run the formatter, linter, and assist as one check that fails CI. Biome has two commands that run all checks, biome check and biome ci. Biome’s CI recipe says that biome ci “should be used in CI”. It has no --write option, and on GitHub it prints diagnostics as GitHub annotations.
  3. Use the root configuration for each project. Since v2, Biome supports monorepos with one root configuration (big projects). A nested configuration sets "root": false, or uses "extends": "//" to extend from the root configuration.
  4. Apply safe fixes with the same Biome. biome check --write applies formatting and safe fixes. Biome applies unsafe fixes only with --unsafe. Biome’s Git hooks recipe runs biome check --write --staged before each commit.

When Biome fits instead of ESLint and Prettier

Biome formats and lints with one tool and one configuration. Biome’s migration guide ports ESLint and Prettier configurations with biome migrate eslint and biome migrate prettier. The guide also says that “some rule options are not implemented and some rules deliberately differ”. The migration cannot load some plugins and shared configurations, and you configure their rules by hand in biome.json.

If the project depends on ESLint rules or plugins that Biome does not have, keep ESLint and Prettier. How do I run ESLint the same way locally and in CI? and How do I enforce Prettier formatting in CI? cover those tools.

Meet them with biome ci, a CI job, and a hook

By hand, install Biome with the exact version, as Biome’s install page shows: npm i -D -E @biomejs/biome. This guide’s project then has "@biomejs/biome": "2.6.2" in devDependencies, with no ^. Its src/login.ts has a lint error and a line that Biome formats differently. Biome reads nested configurations itself, so one run from the root covers each project in a monorepo (big projects). Run the CI command from the project root:

npx biome ci

The run reported the lint/suspicious/noDoubleEquals rule and a format difference in src/login.ts. It ended with this summary and exited with code 1:

Checked 3 files in 29ms. No fixes applied.
Found 2 errors.
ci ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ✖ Some errors were emitted while running checks.

Apply the safe fixes:

npx biome check --write
Checked 3 files in 2ms. Fixed 1 file.
Found 1 error.

Biome formatted src/login.ts. The == lint error remains, because its fix is unsafe.

In CI, Biome provides a first-party GitHub Action, biomejs/setup-biome. When you give it no version, it reads the version of @biomejs/biome from the lockfile, and then from package.json. The job then runs biome ci. Biome’s CI recipe also shows a GitLab CI job that runs biome ci in the ghcr.io/biomejs/biome:latest image. For the hook, the Git hooks recipe shows Lefthook, Husky with lint-staged, pre-commit, and a shell script.

Where the hand-built setup strains

The hand-built setup meets each requirement in more than one place, and each place must stay in step:

  • Exact version. On GitHub Actions, setup-biome reads the pinned version from the lockfile. A CI job that uses the Biome image needs an image tag that matches package.json. The latest tag in the recipe follows the newest release instead. With a pinned tag, each upgrade changes two files.
  • Safe fixes. A Git hook is optional. git commit --no-verify skips it, so CI is the first place that some problems show.
  • CI syntax. Each CI system has its own job syntax: an action on GitHub, an image and a script on GitLab CI. A move to another CI system means a rewrite of the Biome job.

Meet them with the Dagger Biome module

The Dagger Biome module meets the four requirements with one definition that runs on a laptop and in CI. A Dagger module is a package of functions that build, test, or check a project. The Dagger team keeps a catalog of official modules for common tools. Complete the Quickstart first, which creates dagger.toml. Install the Biome module from the project root:

dagger module install dagger.io/js/biome
dagger.io/js/biome ➡️ https://github.com/dagger/biomejs#v0.1.0
Installed module "biomejs" in /work/dagger.toml

The module installs under the name biomejs. Its check, its flags, and its settings all use that name. The command adds the module to dagger.toml:

[modules.biomejs]
source = "dagger.io/js/biome"

The project does not need a Dagger module of its own. The Biome module reads what it needs from biome.json, package.json, and the lockfile. The next four sections show how it meets each requirement.

Pin the exact Biome version

The module runs the Biome version that the project installs, so Dagger and your editor use the same release. If a package.json exists but does not list @biomejs/biome, the check fails with this message:

biome is not installed: add @biomejs/biome to the devDependencies of package.json (or of a package.json between it and the project)

If the project has no package.json, the module source runs Biome through npx with no version. Add the dependency to pin the Biome version.

The module installs dependencies in a separate step. That step reads only the package manifests, lockfiles, and package manager configuration, as the module source shows. A change to a source file does not run the install again.

In this guide’s single-project repository, the first dagger check took 13.8 seconds. A rerun with no changes took 1.0 seconds. Both times include the CLI connection. These numbers come from one machine, so use them only as an example.

The module reference covers the install root in a monorepo and the settings for the Node.js image, the package manager, install flags, and environment variables.

Run the formatter, linter, and assist as one check that fails CI

A check is a function that passes or fails, such as a test or a linter. dagger check runs the checks. The module gives each Biome project one check, lint. List the check:

dagger check -l
MODULE   BIOMEJS-BIOME-PROJECT  CHECK  DESCRIPTION
biomejs  *                      lint   Lint this project.
# Use --all to expand collections and list each item.

The check has the name lint, but it runs biome check. So it also fails on files that Biome would format differently. In this excerpt from a run, Biome found a lint error in src/login.ts:

== CHECKS ==  ✘ 1 failed
✘ dag://?check=biomejs/projects/lint 12.9s ERROR
  ┃ [/opt/dagger-biomejs/biome] src/login.ts:2:12 lint/suspicious/noDoubleEquals  FIXABLE  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ┃ [/opt/dagger-biomejs/biome] 
  ┃ [/opt/dagger-biomejs/biome]   × Using == may be unsafe if you are relying on type coercion.
  ┃ [/opt/dagger-biomejs/biome] Checked 3 files in 3ms. No fixes applied.
  ┃ [/opt/dagger-biomejs/biome] Found 2 errors.

== RUN LOCALLY ==
dagger check "dag://?check=biomejs/projects/lint"

The command under RUN LOCALLY runs the same check again. The rules, formatter options, and ignored files stay in biome.json, and Biome reads them from there.

The module runs biome check, not biome ci. Both commands apply the same formatter, linter, and assist actions. The module does not print GitHub annotations, which biome ci adds on GitHub.

Use the root configuration for each project

A Biome project is a directory with a root biome.json or biome.jsonc. The Biome module reference gives these rules:

  • A configuration with "root": false belongs to the nearest root configuration above it.
  • The module skips configuration files in directories that .gitignore ignores.

In a monorepo, each package can be its own Biome project. This example has two, apps/web and apps/admin, and each has its own root biome.json. Add --all to list one row for each project:

dagger check biomejs -l --all
MODULE   BIOMEJS-BIOME-PROJECT  CHECK  DESCRIPTION
biomejs  apps/admin             lint   Lint this project.
biomejs  apps/web               lint   Lint this project.

Check every project, or check one project:

dagger check --biomejs
dagger check --biomejs-biome-project=apps/admin

When a check covers several projects, it reports each project that fails.

Apply safe fixes with the same Biome

Each Biome project has a fix function. It runs biome check --write with the project’s own Biome. It returns the repaired source as a changeset, a set of file changes that Dagger can write to your working tree. fix is a regular function, not a check, so dagger check does not run it. Fix the root project and write the changes to disk:

dagger -c 'biomejs | projects | get . | fix | export .'

In this run, Biome formatted src/login.ts. git diff shows the change:

diff --git a/src/login.ts b/src/login.ts
index 3685142..e39dbba 100644
--- a/src/login.ts
+++ b/src/login.ts
@@ -1,4 +1,6 @@
 export function isAdmin(role: string): boolean {
-  if (role == "admin") { return true }
+  if (role == "admin") {
+    return true;
+  }
   return false;
 }

This is the same change that npx biome check --write made. The == stays, because --write applies only safe fixes.

fix returns its changes even when diagnostics remain that Biome cannot fix. Run dagger check again to see them. To fix only some files, pass files. To limit the changeset to files that match patterns, pass fixFilter. Both take paths relative to the project root.

Run Biome locally and with Cloud Checks

dagger check runs the same Biome check on any machine that has the Dagger CLI. Commit the configuration and the lock file:

git add dagger.toml dagger.lock biome.json package.json
git commit -m "Check code with Biome through Dagger"

Dagger writes dagger.lock when it runs. The file pins the Git ref of each module and the images that the workspace uses, so every run uses the same versions.

To run the check in CI, use Cloud Checks. Cloud Checks run your checks on Dagger’s Cloud Engines for every push and pull request to a GitHub repository. You do not need a CI workflow file. The Git origin remote of the project must point to the GitHub repository. Turn on Cloud Checks from the project root:

dagger cloud checks on

In an interactive terminal, the command offers to complete the prerequisite steps, such as a Dagger Cloud sign-in and the GitHub connection. To see the results, run dagger workspace activity. If the Biome check fails in Cloud Checks, run dagger check on your machine to get the same diagnostics. Then run the fix function to apply Biome’s safe fixes.

Bottom line: Pin the exact Biome version, and run its formatter, linter, and assist actions as one check that fails CI. Apply safe fixes with the same Biome. By hand, that means an exact pin, biome ci in a CI job, and biome check --write in a Git hook. The Dagger Biome module meets these requirements in one place, and dagger check runs it the same way locally and in Cloud Checks.