Skip to content

Monorepo

Organize frontend, backend, and infrastructure checks into groups, then add repository-wide Markdown and YAML checks.

Prerequisites: the tools referenced in the configuration must be available, with project configuration in the appropriate directories. This example expects frontend/, backend/, and infrastructure/.

Download monorepo.pkl and save it as hk.pkl.

Configuration

pkl
/// Example configuration for a monorepo with multiple languages
/// Frontend: JavaScript/TypeScript with React
/// Backend: Rust
/// Infrastructure: Terraform
/// Uses groups to organize steps by component
amends "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Config.pkl"

import "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Builtins.pkl"

// Frontend linters (JavaScript/TypeScript)
local frontend = new Group {
  // Inherited by frontend steps unless a child overrides `dir`.
  dir = "frontend"
  steps {
    ["prettier"] = (Builtins.prettier) {
      batch = true
    }
    ["eslint"] = (Builtins.eslint) {
      batch = true
    }
    ["stylelint"] = (Builtins.stylelint) {
      // Override the group dir for a step that scans files from the repo root.
      dir = "."
      glob = List("frontend/**/*.css", "frontend/**/*.scss", "packages/design-system/**/*.scss")
    }
  }
}

// Backend linters (Rust)
local backend = new Group {
  // Inherited by all backend steps.
  dir = "backend"
  workspace_indicator = "Cargo.toml"
  steps {
    ["cargo_fmt"] = Builtins.cargo_fmt
    ["cargo_clippy"] = Builtins.cargo_clippy
    ["cargo_check"] = (Builtins.cargo_check) {
      // Enable explicitly with --profile slow.
      profiles = List("slow")
    }
  }
}

// Infrastructure linters (Terraform)
local infrastructure = new Group {
  dir = "infrastructure"
  exclude = List("**/.terraform/**")
  steps {
    ["terraform"] = (Builtins.terraform) {
      glob = "**/*.tf"
    }
    ["tflint"] = (Builtins.tf_lint) {
      glob = "**/*.tf"
      // Child exclude replaces the group exclude, so repeat common exclusions.
      exclude = List("**/.terraform/**", "modules/vendor/**")
    }
  }
}

// Shared linters (apply to all components)
local shared = new Mapping<String, Step> {
  ["markdown"] = (Builtins.markdown_lint) {
    glob = List("**/*.md")
    exclude = List("**/node_modules/**", "**/target/**")
  }
  ["yaml"] = (Builtins.yamllint) {
    glob = List("**/*.yaml", "**/*.yml")
    exclude = List("**/node_modules/**")
  }
}

hooks {
  ["pre-commit"] {
    fix = true
    stash = "git"
    steps {
      ["frontend"] = frontend
      ["backend"] = backend
      ["infrastructure"] = infrastructure
      ...shared
    }
  }
  ["check"] {
    steps {
      ["frontend"] = frontend
      ["backend"] = backend
      ["infrastructure"] = infrastructure
      ...shared
    }
  }
}

Understand the boundaries

Each group can provide common defaults such as dir, prefix, and workspace_indicator. A child keeps an explicitly defined property; child values replace rather than merge with group values. Builtins may already define these properties.

Groups also affect scheduling: children can run concurrently within a group, but groups run in order. If frontend and backend checks should overlap, place the steps in one mapping and use depends only where ordering is required.

Try it

sh
hk validate
hk check --all --plan
hk check --all
hk check --all --profile slow

The slow profile enables the additional Cargo check. It is not enabled automatically in CI.

Adapt it

Change dir values to match your repository, remove components you do not use, and inspect --plan to verify how paths and workspaces are selected. Use hk check --why <step> when a component is unexpectedly skipped.

For tools that discover nested packages, see workspaces.

Nested configs with subprojects

Instead of describing every component in the root hk.pkl, each subproject can own its own hk.pkl next to its code. The root config lists the subproject directories (literals or globs):

pkl
// hk.pkl (repo root)
amends "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Config.pkl"

subprojects = List("frontend", "backend", "packages/*")

hooks {
  ["check"] {}
  ["pre-commit"] {
    fix = true
    stash = "git"
  }
}
pkl
// frontend/hk.pkl
amends "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Builtins.pkl"

local linters = new Mapping<String, Step> {
  // aube resolves these executables from frontend/node_modules/.bin
  ["eslint"] = (Builtins.eslint) {
    prefix = List("aube", "exec")
  }
  ["prettier"] = (Builtins.prettier) {
    prefix = List("aube", "exec")
  }
}

hooks {
  ["check"] {
    steps = linters
  }
  // Hooks compose by name, so list the steps again for pre-commit.
  ["pre-commit"] {
    steps = linters
  }
}
pkl
// backend/hk.pkl
amends "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Builtins.pkl"

local linters = new Mapping<String, Step> {
  ["cargo-fmt"] = Builtins.cargo_fmt
  ["cargo-clippy"] = Builtins.cargo_clippy
}

hooks {
  ["check"] { steps = linters }
  ["pre-commit"] { steps = linters }
}

The matching mise configuration makes hk, aube, and each component's tools available in the directory where its steps run:

toml
# mise.toml (repo root)
monorepo_root = true

[monorepo]
config_roots = [".", "frontend", "backend"]

[tools]
aube = "latest"
hk = "latest"

[env]
HK_MISE = 1
toml
# frontend/mise.toml
[tools]
node = "lts"
toml
# backend/mise.toml
[tools]
rust = "stable"

On Git 2.54+, prefer installing the mise-aware launcher once per developer machine with hk install --global --mise. For a repository-scoped installation on any supported Git version, use hk install --mise.

When hk runs from the repo root, each subproject's hooks are merged in, scoped to its directory:

  • Step working directories and glob matching are relative to the subdirectory, so frontend/hk.pkl only sees files under frontend/.
  • Step names are prefixed with the directory (e.g. frontend:eslint), which is the name to use with --step or skip_steps.
  • A subproject's env applies to its own steps only.
  • Glob entries like packages/* match any directory containing an hk config file; directories without one are skipped.
  • Hooks compose by name. Steps declared only under check do not automatically run under pre-commit or fix.
  • Define hook-wide settings such as fix, stash, stage, and report in the root config so every subproject uses the same behavior.
  • Only one level of subprojects is supported.

This maps directly onto mise monorepo config roots: the same directories that own a mise.toml can own their hk.pkl.

Use hk check --all --plan to inspect the resolved jobs without executing them. For this example, the plan includes frontend:eslint, frontend:prettier, backend:cargo-fmt, and backend:cargo-clippy.

MIT LicenseCopyright © 2026jdx.dev