From Makefile to OrchStep in an afternoon
Your Makefile stopped being about files years ago — it's a task runner fighting tab-sensitive syntax and fake .PHONY targets. Here's the mechanical port to orchstep.yml, target by target.
blog/makefile-to-orchstepmake is a brilliant tool for the job it was designed for: rebuilding C objects when their headers change, based on file timestamps. The thing is, almost nobody uses it for that anymore. The modern Makefile is a task runner — make build, make test, make deploy — wearing a build system's clothes, and the costume chafes:
- Every recipe line must start with a literal tab. Paste two spaces and you get
*** missing separator. Stop.with no hint why. - Targets that don't produce files need
.PHONYormakesilently skips them when a same-named file exists. - Variables are
$(VAR), escaped shell$$, and=vs:=vs?=semantics nobody remembers. - Parallelism is
make -j, which works until two targets race on the same file.
If you're maintaining recipes, not rebuilding artifacts, the port to OrchStep is mechanical — and you keep every shell command. Here's the whole afternoon.
The same pipeline, both ways
APP := checkout
BINDIR := ./bin
.PHONY: deps build test check clean
deps:
go mod download
build: deps
go build -o $(BINDIR)/$(APP)
test: build
go test ./...
check:
golangci-lint run & go vet ./... & wait
clean:
rm -rf $(BINDIR)name: build-pipeline
defaults:
app: checkout
bindir: ./bin
tasks:
deps:
steps:
- { name: fetch, func: shell, do: echo "go mod download" }
build: # depends on deps
steps:
- name: deps
task: deps
- name: compile
func: shell
do: echo "go build -o {{ vars.bindir }}/{{ vars.app }}"
test: # depends on build
steps:
- name: build
task: build
- name: unit
func: shell
do: echo "go test ./..."
check: # real parallelism
steps:
- name: static
parallel:
- { name: lint, func: shell, do: echo "golangci-lint run" }
- { name: vet, func: shell, do: echo "go vet ./..." }
clean:
steps:
- { name: rm, func: shell, do: echo "rm -rf {{ vars.bindir }}" }(The do: lines are echos here so the demo runs anywhere — drop the echo and they're the real commands.)
The five-rule porting table
There are really only five moves, and once you've internalized them the rest is copy-paste:
| Makefile | orchstep.yml |
|---|---|
a target (build:) | a task under tasks: |
| recipe lines (tab-indented) | steps: with func: shell, do: |
prerequisites (build: deps) | a task: step at the top of the task |
$(VAR) | vars.VAR, set in defaults: |
.PHONY: ... | nothing — tasks are "phony" by default |
That last row deletes a whole category of bug. OrchStep tasks have no relationship to the filesystem, so there's no timestamp logic to opt out of with .PHONY. orchstep run build runs build, every time, full stop.
Prerequisites become the first steps
make's build: deps says "run deps before build." OrchStep makes the order explicit — deps is literally the first step of build, called as a task:
build:
steps:
- name: deps
task: deps # run the deps task first
- name: compile
func: shell
do: go build -o {{ vars.bindir }}/{{ vars.app }}orchstep run test walks the chain — deps -> build -> test — the same way make test would, but you can see the chain in the file instead of reconstructing it from scattered prerequisite lists. And --dry-run prints the fully expanded plan across all three tasks, which no make -n will show you for a multi-target chain.
Parallelism that can't race
make -j runs targets concurrently and trusts you not to have two recipes writing the same file. OrchStep's parallel: block runs steps concurrently with isolated variable contexts — each child gets a snapshot, mutations don't cross, and the block waits for all of them before moving on:
check:
steps:
- name: static
parallel:
- { name: lint, func: shell, do: golangci-lint run }
- { name: vet, func: shell, do: go vet ./... }lint and vet run at the same time; if either fails, the block fails after both finish, and their outputs merge back for the next sequential step. It's make -j for the cases where -j is actually safe — independent work — with the data isolation made explicit.
What you gain past parity
The port is one-to-one, but a few things only the OrchStep side can do:
orchstep menu— a fuzzy task picker, so you stop grepping the file for target names.--dry-run— the resolved plan across the whole task chain, every command rendered, before anything runs.retry/timeout— declarative on any step, instead of theuntil-loop crust that flakymakerecipes grow.vars.*with--var— overrideapporbindirper run without=/:=/?=archaeology.- No tab trap. It's YAML. Spaces are fine. The error messages point at the actual line.
When to keep your Makefile
Be honest about the boundary: if you're genuinely compiling artifacts and want incremental, timestamp-driven rebuilds — "only relink if an object changed" — that's make's home turf and OrchStep doesn't replace it. The port pays off when your Makefile is a task runner: .PHONY everywhere, no real file dependencies, recipes that are just shell. That's the Makefile that's outgrown make, and it ports in an afternoon.
Where to go next
- Quick Start — your first workflow in two minutes
- Parallel Execution —
parallel:blocks, isolation, output merging - Variables & Outputs —
defaults:,vars.*, and--var
The whole pipeline is echo-only, so it runs anywhere: orchstep run test.
curl -fsSL https://orchstep.dev/install.sh | sh