Monorepo: one ship, many crews
Muster the frontend, backend, and infrastructure checks into groups, each gang of hands to its own station, then post Markdown and YAML checks that keep watch over the whole ship.
Before ye sail: every tool the configuration calls on must be aboard, with each project's own configuration in its proper directory. This example expects frontend/, backend/, and infrastructure/.
Download monorepo.pkl and save it as hk.pkl, the charts yer ship sails by.
The charts
/// 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.4.0/hk@2.4.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/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
}
}
}Mind the boundaries between the gangs
Each group, a gang of hands, can hand its children common defaults such as dir, prefix, and workspace_indicator. A child step keeps any property it sets explicitly; a child's value replaces the group's rather than merging with it. Mind that builtins, the standing crew, may already set these properties themselves.
Groups also shape how the work is scheduled: within a group, the children can all haul at once, but the groups themselves run one after another, in order. If ye want the frontend and backend checks hauling side by side, put their steps in one mapping and use depends only where the order is truly required.
Take her out
hk validate
hk check --all --plan
hk check --all
hk check --all --profile slowThe slow profile (the slow watch) enables the extra Cargo check. It is not enabled automatically in CI: the harbour-master never calls up the slow watch on its own.
Refit her for yer own ship
Change the dir values to match yer ship, strike off any components ye don't use, and read the --plan output, the passage plan, to verify how paths and workspaces are selected. When a component is skipped and ye didn't expect it, ask hk check --why <step>.
For tools that find nested packages on their own, see workspaces.
Quarters with charts of their own: nested configs with subprojects
Ye needn't chart every component in the root hk.pkl: each subproject can keep its own hk.pkl right beside its code, a quarter of the ship with charts of its own. The root config lists the subproject directories (literal names or globs):
// hk.pkl (repo root)
amends "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/Config.pkl"
subprojects = List("frontend", "backend", "packages/*")
hooks {
["check"] {}
["pre-commit"] {
fix = true
stash = "git"
}
}// frontend/hk.pkl
amends "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/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
}
}// backend/hk.pkl
amends "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.4.0/hk@2.4.0#/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 has the quartermaster provision hk, aube, and each component's tools in the directory where its steps run:
# mise.toml (repo root)
monorepo_root = true
[monorepo]
config_roots = [".", "frontend", "backend"]
[tools]
aube = "latest"
hk = "latest"
[env]
HK_MISE = 1# frontend/mise.toml
[tools]
node = "lts"# backend/mise.toml
[tools]
rust = "stable"On Git 2.54+, ye'd best install the mise-aware launcher once per developer machine, with hk install --global --mise. For an installation scoped to this one ship, on any supported Git version, use hk install --mise.
When hk runs from the repo root, each subproject's hooks are merged in, and each quarter's hands keep to its own directory:
- Step working directories and glob matching are relative to the subdirectory, so
frontend/hk.pklonly sees the cargo underfrontend/. - Each hand's name is prefixed with its directory (e.g.
frontend:eslint), and that's the name to call with--steporskip_steps. - A subproject's
env, its standing orders, applies to its own steps only. - Glob entries like
packages/*match any directory holding an hk config file; directories without one are skipped. - Hooks compose by name. Steps declared only under
checkdo not automatically run underpre-commitorfix: each pipe calls only the hands listed for it. - Set hook-wide settings such as
fix,stash,stage, andreportin the root config, the master chart, so every subproject sails by the same rules. - Only one level of subprojects is supported: no quarters within quarters.
This maps straight onto mise monorepo config roots: the same directories that keep a mise.toml for the quartermaster can keep their own hk.pkl.
Use hk check --all --plan to read the passage plan, the resolved jobs, without running any of them. For this example, the plan includes frontend:eslint, frontend:prettier, backend:cargo-fmt, and backend:cargo-clippy.
