mise and Make¶
The framework treats toolchain management and task running as two separate concerns that meet at a clean boundary:
- mise owns runtime pinning and toolchain resolution. It tells the shell which Ruby, Python, Node, Go, etc., to use for any given directory.
- Make (or
just) provides the universal task-runner entry point — themake testthat every engineer reflexively types when joining an unfamiliar codebase.
mise tasks are the actual implementation; Make is a thin wrapper that
calls them. Both are committed to the project, both are
team-shareable, and the combination gives engineers three discoverable
surfaces (IDE run configs, terminal mise run, make invocations)
that all converge on the same task definitions.
mise as the primary toolchain manager¶
mise replaces the constellation of version managers (asdf, nvm, rbenv,
pyenv) with a single tool that handles all of them. A project's
mise.toml declares its runtime versions:
# mise.toml at project root
[tools]
ruby = "3.3.4"
python = "3.12.5"
node = "22.8.0"
bun = "1.1.27"
# Project-specific tools, pinned the same way
biome = "1.9.4"
ruff = "0.6.9"
just = "1.36.0"
When you cd into the project, mise's shell hook activates the pinned
versions. When you leave, they deactivate. Subprocesses inherit the
correct versions automatically because mise modifies $PATH rather
than relying on shell aliases.
Why mise over alternatives¶
- vs. asdf — mise is faster (Rust vs Bash), supports
mise.tomlnatively (asdf only reads.tool-versions), and has cleaner semantics around tool installation and shimming. - vs. per-language tools (rbenv, pyenv, nvm) — one tool to learn, one config format to remember, one set of commands. The per-language tools each work fine; running four of them is needless cognitive overhead.
- vs. devbox / nix / flox — those are more powerful but substantially more complex. mise hits the sweet spot for engineers who want pinned runtimes without committing to a Nix-style declarative-everything model.
mise tasks¶
Beyond runtime pinning, mise can also run project-defined tasks:
# mise.toml
[tasks.test]
description = "Run the test suite"
run = "bundle exec rspec"
[tasks.lint]
description = "Run all linters"
run = ["biome check", "ruff check", "rubocop"]
[tasks.dev]
description = "Run the development server"
run = "bin/rails server"
[tasks.build]
description = "Build production artifacts"
depends = ["test", "lint"]
run = "./bin/build"
The advantages over a Makefile-only approach:
- TOML syntax most engineers can read without training.
- Built-in dependency declaration via
depends. - Per-task environment overrides (
mise run --env staging deploy). - Discoverability via
mise tasks ls. - No tab-vs-space issues.
- Tasks can be
*.shfiles inmise/tasks/for complex logic.
You invoke them with mise run <task>:
mise run test # exec the test task
mise run --watch test # re-run on file changes
mise tasks ls # list all available tasks
Make as the wrapper layer¶
Make has been around since 1976 and remains the closest thing to a
universal "run the project's thing" entry point in software. A new
engineer joining a project, looking at the README, has a near-100%
chance of recognizing make test and a less-than-100% chance of
recognizing mise run test, bundle exec rake test, bun run test,
or uv run pytest.
The framework leans into this with a thin-wrapper pattern: every
project gets make <target> for free without losing the advantages
of mise tasks.
Why Make still matters in 2026¶
Make's enduring value is not its incremental-build feature — modern language tooling handles dependency tracking better. The value is discoverability and convention:
- Every Unix engineer knows
makemight do something useful in an unfamiliar repository. - CI configurations across every platform (GitHub Actions, GitLab CI,
CircleCI, Jenkins) have first-class support for
makeinvocations. - Editor integrations and IDE "build" buttons frequently default to
looking for a Makefile target named
build,test, orrun. - Make is preinstalled on essentially every Linux distribution, every macOS install with Command Line Tools, and every container base image larger than Alpine.
Make's enduring weakness is equally real: arcane syntax, errors fail silently by default, tab-vs-space indentation is load-bearing, and recursive variable expansion produces bugs that are hard to diagnose. The framework's recommendation — thin wrappers, not real build logic — sidesteps almost all of this.
The thin-wrapper pattern¶
For new projects in this stack, the recommended Makefile is six to
ten lines that delegate to mise tasks:
# Makefile
# ─────────────────────────────────────────────────────────────────
# Thin wrapper around mise tasks. Real task definitions live in mise.toml.
# This file exists to provide the universal `make <target>` entry point.
# ─────────────────────────────────────────────────────────────────
.SHELLFLAGS := -ec
.DEFAULT_GOAL := help
.PHONY: help test lint fmt build run deploy clean
help: ## Show this help (default)
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_-]+:.*##/ \
{ printf " \033[36m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
test: ## Run the test suite
@mise run test
lint: ## Run all linters
@mise run lint
fmt: ## Format the codebase
@mise run fmt
build: ## Build the production artifact
@mise run build
run: ## Run the application locally
@mise run dev
deploy: ## Deploy to staging
@mise run deploy
clean: ## Remove build artifacts and caches
@mise run clean
Three small things in that file are doing real work:
.SHELLFLAGS := -ecmakes Make's invoked shell exit on any command failure. Without this,make testcan pass even whenmise run testinside it fails. This is the single most common Makefile bug..PHONY: ...tells Make these targets are not file names. Without it, if a file or directory namedtestever appears in the project root,make testbecomes a no-op.- The
help:target uses an awk one-liner that scans the Makefile for lines matching<target>: ## descriptionand prints them. This is a 25-year-old Makefile idiom and the closest thing the format has to self-documentation. Engineers runningmakewith no arguments get a useful list of what the project supports without reading the file.
Tabs, not spaces
Make is the one place in this entire framework where the
indentation rule is tab characters, not spaces. Every recipe
line must start with a tab. Spaces produce the cryptic ***
missing separator error. The framework's .editorconfig handles
this automatically by setting indent_style = tab for Makefile.
When the wrapper isn't enough¶
The thin-wrapper pattern works because most projects' task lists are
flat — test is a leaf, lint is a leaf, build is a leaf. When a
project genuinely needs Make's task-graph features (target
dependencies, conditional rebuild based on file timestamps, parallel
execution with make -j), the wrapper can grow without becoming
unmaintainable as long as you stay disciplined:
- Keep complex logic out of recipes. If a target needs more than
three lines of shell, write a script and call it:
deploy: ; @./bin/deploy.sh. - Avoid recursive Make. Calling
makefrom inside a recipe (especially across subdirectories) is a long-recognized antipattern that breaks parallelism, dependency tracking, and error propagation. - Use
$$for shell variables. A literal$VARgets expanded by Make first, then by the shell. Always write$$VARfor shell variables,$(VAR)for Make variables. - Pin tool versions through mise, not Make. Recipes call
ruff,biome,kubectlexpecting them on PATH. mise's shims ensure the right version is found.
just — the modern alternative¶
If you'd rather skip Make's quirks entirely and don't want to commit
to mise tasks as the only entry point, just (casey/just) is a
Make replacement designed specifically for the task-runner use case.
It uses recipe-and-target syntax with sane defaults: spaces are fine,
errors fail by default, recipes run in their own shell, and the help
target is built in. Available via mise (mise use -g just@latest) or
Homebrew.
# justfile
# Run `just` (no args) to see the list of recipes.
default:
@just --list
test:
mise run test
lint:
mise run lint
# Recipe with a parameter — just supports this natively
deploy env="staging":
mise run deploy {{env}}
The framework's recommendation:
- Make for the wrapper layer in projects that benefit from universal recognition (most projects).
- just for projects whose maintainers prefer it and don't need Make's ubiquity (often Rust projects, internal tooling, projects without external contributors).
Both are valid; neither is wrong; the framework expects mise tasks underneath either.
Inheriting an existing Makefile¶
Existing codebases often have substantial Makefiles — the kind with 200 lines, target-specific variables, conditional includes, pattern rules, and a five-year history of small additions. "Rewrite this in mise.toml" is rarely the right move. The framework's guidance for inherited Makefiles:
- Audit for the common bugs. Add
.SHELLFLAGS := -ecif it's missing. Add.PHONYdeclarations for every target that isn't a real file. These two changes alone close most silent-failure paths. - Add a
help:target using the awk pattern above. Walking into a project wheremake helpproduces a useful list is one of the cheapest onboarding wins available. - Layer mise underneath, don't replace. Add a
mise.tomlthat pins the tools the Makefile invokes. Recipes that callrufforkubectlcontinue to work, but now everyone gets the same pinned version. - Refactor incrementally. When you touch a target for an unrelated
reason, consider whether its embedded shell logic could move to
bin/<target>.shand become a one-liner Make recipe. Don't do this as a standalone refactor; piggy-back on real changes.
What Make is bad at
Cross-platform shell incompatibilities (BSD vs. GNU find/sed/awk — use scripts in a known language). Anything with non-trivial control flow (early returns, nested conditionals — use a script). Anything with secrets (recipes log to terminal by default; secret-loading belongs in the application's config layer). Iterating over dynamic file lists where the iteration logic itself is complex (use a script, call it from Make).
The pattern is the same: when Make starts to feel like a programming language, it isn't one, and you'll get a better result by writing the logic in a real one and calling it from a thin Make recipe.