Tutorial 1 โ Replace your Makefile¶
Time: 10 minutes. You'll need: a small Go project (or any project with a Makefile). You'll end up with: a commands.perch that's shorter, cross-platform, and drives both local dev and CI from the same file.
We'll convert this Makefile:
APP_NAME := myapp
BIN_DIR := ./bin
MAIN := ./cmd/myapp
.PHONY: build test lint clean release ci
build:
mkdir -p $(BIN_DIR)
go build -ldflags='-s -w' -o $(BIN_DIR)/$(APP_NAME) $(MAIN)
test:
go test -race ./...
lint:
go vet ./...
@which staticcheck > /dev/null && staticcheck ./... || true
clean:
rm -rf $(BIN_DIR)
release: build
GOOS=linux go build -o $(BIN_DIR)/linux/$(APP_NAME) $(MAIN)
GOOS=darwin go build -o $(BIN_DIR)/darwin/$(APP_NAME) $(MAIN)
# ๐ windows here would need a second target with .exe and different syntax
ci: lint test release
Step 1 โ Scaffold¶
This writes a starter commands.perch. Open it and clear the body โ we're going to rewrite from scratch.
Step 2 โ Globals¶
Things you reference in multiple commands are declared bare at the top level:
name "myapp"
about "Build, test, lint, release myapp"
version "0.1.0"
APP_NAME = "myapp"
BIN_DIR = "./bin"
MAIN = "./cmd/myapp"
While you're here, declare what this file needs from the host. A requires
block makes the dependency explicit and lets perch --check prove the file
will work before you run it โ and refuses any undeclared shell bin at
runtime:
Step 3 โ build¶
In Make:
In perch:
command build
description "Compile the binary"
do
mkdir "${BIN_DIR}"
go build "-ldflags=-s -w" -o ${BIN_DIR}/${APP_NAME} ${MAIN}
end
end
Try it:
Two improvements over Make:
mkdiris a first-class op โ works on Windows too.mkdir -pdoesn't exist there.${VAR}and shell$VARdon't fight. perch substitutes before the shell sees anything.
Step 4 โ test, lint, clean¶
command test
description "Run tests with race detection"
do
go test -race ./...
end
end
command lint
description "Run go vet plus staticcheck if available"
do
go vet ./...
if exists "${HOME}/go/bin/staticcheck"
${HOME}/go/bin/staticcheck ./...
end
end
end
command clean
description "Remove build artifacts"
do
rm "${BIN_DIR}"
print "Cleaned ${BIN_DIR}/"
end
end
Notice if exists "...": this is what Make's which staticcheck > /dev/null && ... is trying to express, except now it's a real block op. The || true ugly-hack is gone.
Step 5 โ release with cross-compile¶
In Make this was three near-identical lines. In perch it's one parameterised command + one release that calls it three times:
command build_for
description "Compile for one specific target OS"
arg target
type string
default "darwin"
description "Target OS"
end
do
mkdir "${BIN_DIR}/${target}"
with_env "GOOS=${target}"
go build "-ldflags=-s -w" -o ${BIN_DIR}/${target}/${APP_NAME} ${MAIN}
end
end
end
command release
description "Cross-compile for all three OSes"
do
build_for "-target=darwin"
build_for "-target=linux"
build_for "-target=windows"
end
end
Invoking another command is just its bare name โ no recursive-make tricks, no $(MAKE) -C weirdness.
Step 6 โ ci¶
And in .github/workflows/ci.yml:
That's the whole CI job. The matrix lives in commands.perch, not in YAML.
Step 7 โ Reap the cross-platform benefit¶
The Make version silently broke on Windows. Let's prove perch's version doesn't. Add a Windows-aware lint:
command lint
description "Run go vet plus staticcheck if available"
do
go vet ./...
if os == "windows"
if exists "${USERPROFILE}/go/bin/staticcheck.exe"
${USERPROFILE}/go/bin/staticcheck.exe ./...
end
end
if os == "darwin"
if exists "${HOME}/go/bin/staticcheck"
${HOME}/go/bin/staticcheck ./...
end
end
if os == "linux"
if exists "${HOME}/go/bin/staticcheck"
${HOME}/go/bin/staticcheck ./...
end
end
end
end
Same file. Three platforms. Zero Makefile-per-OS dance.
Bonus โ your team's muscle memory carries over¶
perch --init writes a shebang at the top of commands.perch and sets the file executable. That means your team can invoke commands the same way they invoked Make targets:
# Before (Make):
make build
make test
make ci
# After (perch):
./commands.perch build
./commands.perch test
./commands.perch ci
Same shape, same muscle memory, plus all the things Make didn't have: typed args, per-command --help, --check static validation, --scan security audit, a web UI, MCP for AI agents. If you prefer the perch prefix that's also still there (perch build, perch -f commands.perch build) โ pick whichever your team finds cleaner.
What you learned¶
- Globals replace Makefile variables. Interpolation is
${name}not$(name). - Each Make target maps to a
command NAME ... endblock. - Ops (
mkdir,rm,if exists "...",if os == "...") replace shell incantations and platform-conditional Makefile-snippet hackery. - Bare command invocation replaces recursive Make.
- One file drives both local dev and CI.
- The shebang +
+xpermissions make./commands.perch testwork โ noperchprefix required onceperchis on$PATH.
Next¶
โ Tutorial 2: Ship a tool โ bundle a commands.perch into a portable single-file binary with perch --build.