Make and just
Write Makefiles that rebuild only what changed and justfiles that run project commands: rules, variables, pattern rules, parallel builds and the tab bugs.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Build the default target | make |
| Build a specific target | make build |
| Dry run: print commands without running | make -n build |
| Parallel build using all cores | make -j"$(nproc)" |
| Parallel with grouped output per target | make -j8 -O |
| Keep going after an error | make -k |
| Force rebuild of everything | make -B |
| Override a variable | make build VERSION=1.2.3 |
| Run in another directory | make -C ./api test |
| Use a different file | make -f build.mk |
| Show why a target is being rebuilt | make --debug=b build |
| Print the full database of rules and variables | make -p -n | less |
| Print one variable’s value | make -s print-VERSION (with the rule below) |
| Warn on undefined variables | make --warn-undefined-variables |
| List targets | make -pRrq : 2>/dev/null | awk -F: '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {print $1}' | sort -u |
| List just recipes | just --list or just -l |
| Run a just recipe with arguments | just deploy staging |
| Show a recipe’s commands | just --show deploy |
| Evaluate a just variable | just --evaluate version |
| Dry run a just recipe | just --dry-run deploy |
| Format a justfile | just --fmt --unstable |
| Pick a recipe interactively | just --choose |
Behaviour below is GNU make 4.4 and just 1.40 or later unless a version is given. macOS ships GNU make 3.81; run make --version and install a newer one from Homebrew (gmake) if any 4.x feature is needed. References: the GNU make manual and the just manual.
How make decides what to run#
Make reads a Makefile of rules. A rule says: this target is made from these prerequisites by this recipe. When asked for a target, make recursively brings each prerequisite up to date, then runs the recipe if the target file does not exist or is older than any prerequisite. Everything else in the language exists to generate those rules concisely.
target: prerequisite1 prerequisite2
recipe line # begins with a literal TAB, not spaces
another line # each line runs in its own shell
Three consequences shape every Makefile. A target that is not a file (test, clean) has no timestamp, so mark it .PHONY or make will report “nothing to be done” the day a file called test appears. A recipe that does not create the target it names runs on every invocation. Each recipe line runs in a separate /bin/sh -c, so cd dir on one line has no effect on the next; chain with && or use .ONESHELL.
.PHONY: all build test clean
all: build test
build: bin/my-app # phony alias for a real file target
bin/my-app: $(wildcard *.go) go.sum # rebuilt only when a source or go.sum changed
go build -o $@ .
clean:
rm -rf bin/ # deletes build output
Variables#
VERSION ?= $(shell git describe --tags --always --dirty) # ?= only if not already set (env or command line)
GOFLAGS := -trimpath # := expands now, once
LDFLAGS = -X main.version=$(VERSION) # = expands every time it is used (recursive)
CGO_ENABLED ::= 0 # POSIX spelling of :=
BIN := bin/my-app
CFLAGS += -Wall # append
override CFLAGS += -O2 # append even when CFLAGS came from the command line
export GOFLAGS # pass to recipe shells
= variables are re-expanded on every reference, which is how $(LDFLAGS) above picks up a VERSION set later, and also how X = $(shell slow-command) becomes a slow command run dozens of times. Use := for anything with $(shell ...) or $(wildcard ...).
Precedence: command-line make VAR=x beats a Makefile assignment (unless override), which beats an environment variable, which beats ?=. make -e reverses environment and Makefile, and is a bad idea.
test: GOFLAGS += -race # target-specific: applies to test and everything it triggers
test:
go test $(GOFLAGS) ./...
print-%: # make print-VERSION shows the value
@echo '$*=$($*)'
$(VAR) and ${VAR} are identical to make; $VAR is $(V) followed by AR. In recipes, a shell variable needs $$: for f in *.go; do echo $$f; done.
Automatic variables#
| Variable | Meaning |
|---|---|
$@ | The target |
$< | The first prerequisite |
$^ | All prerequisites, deduplicated, without order-only ones |
$+ | All prerequisites, with duplicates |
$? | Prerequisites newer than the target |
$* | The stem matched by % in a pattern rule |
$| | Order-only prerequisites |
$(@D), $(@F) | Directory and file part of $@; same for $<, $^, $* |
%.o: %.c %.h | build/ # after | : order-only; created if missing, timestamp ignored
$(CC) $(CFLAGS) -c $< -o $@
build/:
mkdir -p $@
docs/%.html: docs/%.md
pandoc $< -o $@
OBJS := $(patsubst %.c,%.o,$(wildcard src/*.c))
my-app: $(OBJS)
$(CC) -o $@ $^
Order-only prerequisites (after |) are the right way to depend on a directory: a directory’s mtime changes whenever a file is added to it, so a normal prerequisite on build/ would rebuild every object every time.
Pattern and static pattern rules#
A pattern rule (%.o: %.c) applies to any target matching it. A static pattern rule limits it to a listed set:
$(OBJS): %.o: %.c # only the files in OBJS, built from their .c
$(CC) -c $< -o $@
Make has a large set of built-in rules (.c to .o, and so on). They slow down search and occasionally do surprising things; disable them in projects that do not use them:
MAKEFLAGS += --no-builtin-rules --no-builtin-variables
.SUFFIXES:Functions#
SRCS := $(wildcard cmd/*/main.go)
CMDS := $(patsubst cmd/%/main.go,bin/%,$(SRCS)) # cmd/api/main.go -> bin/api
NAMES := $(notdir $(patsubst %/main.go,%,$(SRCS))) # api worker
UPPER := $(shell echo $(NAMES) | tr a-z A-Z)
HAS_GO := $(if $(shell command -v go),yes,)
PLATS := linux/amd64 linux/arm64
OSES := $(foreach p,$(PLATS),$(firstword $(subst /, ,$(p))))
FILTERED := $(filter-out %_test.go,$(wildcard *.go))
SORTED := $(sort $(NAMES)) # also deduplicates
ifeq ($(HAS_GO),)
$(error go is not installed)
endif
ifneq ($(origin CI),undefined) # variable came from the environment
GOFLAGS += -mod=readonly
endif
define build-cmd # multi-line macro, used with call/eval
bin/$(1): cmd/$(1)/main.go
go build -o $$@ ./cmd/$(1)
endef
$(foreach n,$(NAMES),$(eval $(call build-cmd,$(n))))$(shell) output has newlines converted to spaces. $(info text) prints while parsing and is the fastest way to debug a variable; $(warning) adds the file and line; $(error) stops. $(file >name,text) writes a file from within make, useful for long argument lists that exceed the shell limit. $(value VAR) shows the unexpanded definition.
Includes, directories and recursion#
include config.mk # error if missing
-include local.mk # silently skipped if missing
include $(wildcard mk/*.mk)
config.mk: config.mk.in # make remakes included files first, then restarts
./configure
For a multi-directory project, one top-level Makefile that includes per-directory fragments keeps the full dependency graph in one process and lets -j work across it. Recursive $(MAKE) -C dir splits the graph, so make cannot know that lib/ must finish before app/ unless you order the targets; use $(MAKE) rather than make so -j, -n and -k propagate through the jobserver.
SUBDIRS := lib app
.PHONY: $(SUBDIRS)
app: lib # explicit ordering between subdirectory targets
$(SUBDIRS):
$(MAKE) -C $@
Silent, parallel and the special targets#
.DEFAULT_GOAL := build # instead of "first rule wins"
.DELETE_ON_ERROR: # remove a target whose recipe failed; otherwise a half-written file looks up to date
.ONESHELL: # whole recipe in one shell; cd and variables persist between lines
.SHELLFLAGS := -eu -o pipefail -c # with .ONESHELL, otherwise only the last line's status counts
SHELL := bash
.SILENT: clean # no echo for these targets; @ does it per line
.NOTPARALLEL: # serialise this whole Makefile (rarely right; fix the dependencies instead)
.SECONDARY: # keep intermediate files
.PRECIOUS: %.o # keep even when interrupted
.EXTRA_PREREQS := Makefile # every target also depends on the Makefile (4.3+)
@ in front of a recipe line stops make echoing it; - ignores its failure. make -s silences everything; make -j runs independent targets in parallel and only works when prerequisites are declared correctly, which is why a build that passes serially and fails with -j has a missing dependency, not a make bug. make --shuffle (4.4+) randomises prerequisite order to find those. .WAIT between two prerequisites (4.4+) forces order in one prerequisite list without adding a dependency edge:
all: build .WAIT test # test does not start until build is done, even with -j
-O (--output-sync) groups the output of each target so parallel logs are readable. Interrupting a parallel make with Ctrl-C leaves any target that was being written; .DELETE_ON_ERROR covers that.
A Makefile for a Go project#
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := build
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables --no-builtin-rules
MODULE := $(shell go list -m)
NAME := $(notdir $(MODULE))
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
GOFILES := $(shell find . -name '*.go' -not -path './vendor/*')
BIN := bin/$(NAME)
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64
export CGO_ENABLED ?= 0
.PHONY: build
build: $(BIN) ## Build for the host platform
$(BIN): $(GOFILES) go.mod go.sum | bin/
go build -trimpath -ldflags '$(LDFLAGS)' -o $@ .
bin/ dist/:
mkdir -p $@
.PHONY: release
release: $(foreach p,$(PLATFORMS),dist/$(NAME)-$(subst /,-,$(p))) ## Cross-compile every platform
dist/$(NAME)-%: $(GOFILES) go.mod go.sum | dist/
GOOS=$(word 1,$(subst -, ,$*)) GOARCH=$(word 2,$(subst -, ,$*)) \
go build -trimpath -ldflags '$(LDFLAGS)' -o $@ .
.PHONY: test lint vet fmt tidy cover
test: ## Unit tests with the race detector
go test -race -count=1 ./...
cover: ## Coverage report in the browser
go test -coverprofile=cover.out ./... && go tool cover -html=cover.out
vet:
go vet ./...
lint: vet ## golangci-lint (must be installed)
golangci-lint run ./...
fmt:
gofmt -l -w $(GOFILES)
tidy:
go mod tidy
git diff --exit-code go.mod go.sum # fails in CI when tidy changed something
.PHONY: run
run: $(BIN)
$(BIN) $(ARGS)
.PHONY: clean
clean: ## Remove build output
rm -rf bin/ dist/ cover.out
.PHONY: help
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " %-12s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
go build already tracks its own dependencies and caches, so the $(GOFILES) prerequisite exists only to skip the go build call when nothing changed; the cost of getting it wrong is a spurious rebuild, never a stale binary. -count=1 bypasses the test cache. The help target reads the ## comments; keep them on the same line as the target.
A Makefile for a container project#
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
ENGINE ?= $(shell command -v podman || command -v docker)
REGISTRY ?= registry.example.com/my-team
NAME := my-app
VERSION ?= $(shell git describe --tags --always --dirty)
IMAGE := $(REGISTRY)/$(NAME)
PLATFORMS ?= linux/amd64,linux/arm64
CONTEXT := .
SOURCES := Containerfile $(shell git ls-files src/ 2>/dev/null)
.PHONY: build push run shell scan lint clean help
build: .build-$(VERSION) ## Build the image for the host platform
.build-$(VERSION): $(SOURCES) # stamp file: rebuild only when sources change
$(ENGINE) build --pull -t $(IMAGE):$(VERSION) -t $(IMAGE):latest \
--label org.opencontainers.image.version=$(VERSION) \
--label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \
-f Containerfile $(CONTEXT)
rm -f .build-*
touch $@
push: build ## Push version and latest tags (writes to the registry)
$(ENGINE) push $(IMAGE):$(VERSION)
$(ENGINE) push $(IMAGE):latest
multiarch: ## Multi-arch manifest with podman
$(ENGINE) build --platform $(PLATFORMS) --manifest $(IMAGE):$(VERSION) -f Containerfile $(CONTEXT)
$(ENGINE) manifest push --all $(IMAGE):$(VERSION)
run: build ## Run locally on port 8080
$(ENGINE) run --rm -it -p 8080:8080 -v ./config:/etc/my-app:Z,ro $(IMAGE):$(VERSION)
shell: build
$(ENGINE) run --rm -it --entrypoint sh $(IMAGE):$(VERSION)
lint: ## Lint the Containerfile
hadolint Containerfile
scan: build ## Vulnerability scan
trivy image --exit-code 1 --severity HIGH,CRITICAL $(IMAGE):$(VERSION)
clean: ## Remove local image and stamp files
-$(ENGINE) rmi $(IMAGE):$(VERSION) $(IMAGE):latest
rm -f .build-*
help:
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " %-12s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
Images are not files, so the stamp file pattern gives make a timestamp to compare. The leading - on rmi lets clean succeed when the image is already gone. See Docker for the Containerfile itself.
justfile#
just runs recipes; it does not track files or timestamps. Every recipe runs every time it is asked for. That removes .PHONY, tab-versus-space traps and stale-target surprises, and makes it the better fit for “project commands” (just test, just deploy staging) as opposed to builds where incremental rebuilds matter.
# Comments above a recipe become its --list description
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load # read .env into the environment
set positional-arguments # recipe args also as $1, $2 in the body
name := "my-app"
version := `git describe --tags --always --dirty` # backticks: evaluated once at load
registry := env("REGISTRY", "registry.example.com/my-team") # env with default
image := registry / name + ":" + version # / joins paths, + concatenates
# Default recipe when just is run with no arguments
default:
@just --list
# Build the binary
build:
go build -trimpath -ldflags '-X main.version={{version}}' -o bin/{{name}} .
# Run tests; pass extra flags: just test -run TestFoo
test *args:
go test -race -count=1 ./... {{args}}
# Deploy to an environment
[confirm("Deploy to production?")]
deploy env="staging": build
./scripts/deploy.sh "{{env}}" "{{version}}"
# Build and push the container image
[group("image")]
push: (image-build "linux/amd64")
podman push {{image}}
[group("image")]
image-build platform:
podman build --platform {{platform}} -t {{image}} -f Containerfile .
# Wipe build output
[no-exit-message]
clean:
rm -rf bin/ dist/
# Runs in the recipe's own directory rather than the justfile's
[no-cd]
_helper:
pwd
[private]
_internal:
echo hidden from --list
# A recipe written in another language
[script("python3")]
report:
import json, sys
print(json.dumps({"name": "{{name}}", "version": "{{version}}"}))
# Platform-specific variants of one recipe
[linux]
open:
xdg-open http://localhost:8080
[macos]
open:
open http://localhost:8080Indentation in a justfile can be spaces or tabs, as long as one recipe is consistent. {{expr}} interpolates just expressions; $VAR is a shell variable and passes through unchanged. Each recipe line runs in its own shell like make, unless the recipe starts with a shebang (#!/usr/bin/env bash), in which case the whole body is one script. Recipes prefixed with _ are hidden from --list; [private] does the same for any name.
Parameters: deploy env="staging" gives a default; test *args accepts zero or more; +args requires one or more. Dependencies with arguments use parentheses: push: (image-build "linux/amd64"). && after a recipe name declares dependencies that run after it. Recipes can also be invoked from a recipe body with just other-recipe.
Useful settings, all set with set name or set name := value:
| Setting | Effect |
|---|---|
shell | Interpreter for recipe lines; ["bash", "-euo", "pipefail", "-c"] for strict mode |
dotenv-load, dotenv-path | Load .env (or a named file) before running |
export | Export every just variable as an environment variable |
positional-arguments | Recipe parameters also arrive as $1, $2 |
working-directory | Run recipes somewhere other than the justfile’s directory |
fallback | Search parent directories when a recipe is not found |
allow-duplicate-recipes | Later definitions override earlier ones (for imports) |
quiet | Do not echo recipe lines, same as @ on every line |
ignore-comments | Treat # lines in recipe bodies as comments, not commands |
Functions worth knowing: env("NAME"), env("NAME", "default"), justfile_directory(), invocation_directory(), os(), arch(), path_exists("f"), shell("cmd"), datetime("%F"), uuid(), sha256_file("f"), without_extension("a.tar"), trim(), replace(), uppercase(). Modules: mod ci loads ci.just or ci/mod.just and exposes just ci::lint; import 'shared.just' inlines a file.
just --list # recipes with their doc comments, grouped
just --summary # names only, for completion scripts
just --show deploy # recipe source after expansion
just --evaluate # every variable's value
just --evaluate image
just --dry-run deploy production # print the commands
just --set version 1.2.3 build # override a variable
just version=1.2.3 build # same, shorter
just --justfile ../justfile --working-directory . test
just --fmt --unstable # rewrite the justfile in canonical format
just --fmt --check --unstable # CI: fail if not formatted
just --choose # fzf picker
just --completions zsh > ~/.zfunc/_justWhen to use which#
Use make when outputs are files and the point is to skip work: compiling, generating code, rendering documents, building images from a stamp. The dependency graph and -j are the feature. Use just when the point is a discoverable, documented set of commands with arguments, and every run should run: tests, deploys, linting, container lifecycle, developer onboarding. Many repositories carry both: a Makefile for the build graph and a justfile whose recipes call make for the incremental parts. A justfile is also a better fit for scripts that need arguments, confirmation prompts, .env loading or a non-shell language, all of which are awkward in make.
Portability: make is on every Linux and macOS machine (as GNU make 3.81 on macOS, which lacks .ONESHELL semantics from 3.82, $(file), .EXTRA_PREREQS and .WAIT). just is a single binary that must be installed (dnf install just, brew install just, cargo install just) and its --fmt is still behind --unstable.
Oneliners#
# Which make and version
make --version | head -1
# Why is this target rebuilding
make --debug=b bin/my-app 2>&1 | grep -E 'Must remake|newer than'
# Dry run showing the exact commands, including those hidden by @
make -n build
# Show every variable make knows and where it came from
make -p -n -f /dev/null 2>/dev/null | grep -E '^# (makefile|environment|command line)' -A1 | head -50
# Print a single variable without a print-% rule
make -f Makefile -f <(printf 'show:\n\t@echo $(VERSION)\n') show
# Targets in the Makefile that are not .PHONY and not files (candidates for .PHONY)
comm -23 <(make -pRrq : 2>/dev/null | awk -F: '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {print $1}' | sort -u) <(ls | sort)
# Time each target in a parallel build
make -j8 -O --trace build 2>&1 | ts '%H:%M:%.S'
# Find lines indented with spaces where a tab is expected
grep -nP '^ +\S' Makefile
# Convert leading spaces to a tab in recipe lines (rewrites the file)
sed -i -E 's/^ {2,8}/\t/' Makefile
# Show the shell make will use and its flags
make -p -n -f /dev/null 2>/dev/null | grep -E '^(SHELL|\.SHELLFLAGS) '
# Run a target with a variable override and verbose shell
make build VERSION=1.2.3 SHELL='bash -x'
# Remake everything, ignoring timestamps
make -B -j"$(nproc)"
# Check a Makefile parses without running anything
make -n -f Makefile >/dev/null
# List recipes with descriptions, JSON, for scripting
just --dump --dump-format json | jq '.recipes | keys'
# Run a recipe from a subdirectory of the project
just --fallback test
# Does the justfile parse
just --list >/dev/null
# Check justfile formatting in CI
just --fmt --check --unstable
# Show the shell commands a recipe expands to
just --dry-run deploy production
# Run a recipe with an environment file other than .env
just --dotenv-filename .env.staging deployScripts#
Detect targets that rebuild on every run: run make twice and report anything that still executed the second time.
#!/usr/bin/env bash
set -euo pipefail
target=${1:-all}
make -s "$target" >/dev/null
second=$(make -n "$target" 2>&1 | grep -v -E '^make(\[[0-9]+\])?: (Nothing to be done|Entering|Leaving)' || true)
if [[ -n $second ]]; then
printf 'these commands would run again after a clean build of %s:\n%s\n' "$target" "$second"
exit 1
fi
printf '%s is stable: nothing to do on the second run\n' "$target"Print the dependency graph of a Makefile in DOT format for dot -Tsvg.
#!/usr/bin/env bash
set -euo pipefail
{
echo 'digraph make {'
echo ' rankdir=LR; node [shape=box];'
make -pRrq : 2>/dev/null |
awk '/^# Not a target/ {skip=1; next}
/^[a-zA-Z0-9_.\/-]+:( |$)/ && !skip { split($0, a, ":"); n=split(a[2], deps, " "); for (i=1; i<=n; i++) printf " \"%s\" -> \"%s\";\n", a[1], deps[i] }
{skip=0}'
echo '}'
} > deps.dot
printf 'wrote deps.dot; render with: dot -Tsvg deps.dot -o deps.svg\n'Run every just recipe in a check group and summarise pass and fail, for a pre-push hook.
#!/usr/bin/env bash
set -euo pipefail
mapfile -t recipes < <(just --dump --dump-format json | jq -r '.recipes[] | select(.attributes[]? .group == "check") | .name')
[[ ${#recipes[@]} -gt 0 ]] || { echo 'no recipes in group "check"' >&2; exit 1; }
rc=0
for r in "${recipes[@]}"; do
if just "$r" >"/tmp/just-$r.log" 2>&1; then printf 'ok %s\n' "$r"
else printf 'FAIL %s (see /tmp/just-%s.log)\n' "$r" "$r"; rc=1; fi
done
exit "$rc"Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
*** missing separator. Stop. | Recipe line indented with spaces, or a stray line that is neither rule nor assignment | grep -nP '^ +\S' Makefile; convert to a tab or set .RECIPEPREFIX := > and use > |
*** missing separator on a line with a tab | Editor inserted a non-breaking space or CRLF line endings | cat -A Makefile | grep -n 'M-BM- |\^M$'; sed -i 's/\r$//' |
| Target rebuilds every time | Target name is not the file the recipe creates, or a prerequisite is phony or a directory | make --debug=b; make the recipe write $@, use | for directories, add a stamp file |
Nothing to be done for 'test' | A file or directory named test exists | Add test to .PHONY |
No rule to make target 'x', needed by 'y' | Prerequisite file missing and no rule can build it; often a deleted header still listed in a generated .d file | rm the stale dependency file, or use -MP with gcc to emit phony targets for headers |
Circular x <- y dependency dropped | A target lists itself, directly or through pattern rules | make -pn | grep -E '^(x|y):'; usually a % rule that matches its own output |
Works serially, fails with -j | Missing dependency edge; a target used a file another target creates | make --shuffle=random -j8 to reproduce; add the prerequisite or .WAIT |
cd has no effect, variable set on one line is empty on the next | Each recipe line is a separate shell | Join with && \, or .ONESHELL: with .SHELLFLAGS := -eu -o pipefail -c |
$VAR expands to nothing or AR | $V then AR | $(VAR) in make, $$VAR for a shell variable |
$(shell ...) runs many times, build slow | Recursive = assignment | := |
| Recipe fails but make continues | Only the last line’s status matters without .ONESHELL; - prefix; or a pipeline without pipefail | SHELL := bash, .SHELLFLAGS := -eu -o pipefail -c |
make: *** No targets. Stop. | Makefile not found, or wrong case (makefile, GNUmakefile are also searched) | make -f, ls -la Makefile |
Variable from .env not visible | Make does not read .env | include .env then export, or use just with set dotenv-load |
just: Recipe 'x' could not be run because just could not find the shell | set shell names a shell not installed | just --evaluate shows nothing; check command -v bash |
just: Variable 'x' not defined | {{x}} used before assignment or with a typo; or $x was meant | Use $x for shell variables, {{x}} only for just variables |
just: Recipe 'deploy' got 1 argument but takes 2 | Missing parameter default | deploy env="staging" or *args |