BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jun 6, 2026 OrchStep Team 7 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/makefile-to-orchstep
VIEW SOURCE

make 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 runnermake 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 .PHONY or make silently 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

MAKEFILE
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)
ORCHSTEP.YML
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:

Makefileorchstep.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 the until-loop crust that flaky make recipes grow.
  • vars.* with --var — override app or bindir per 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

The whole pipeline is echo-only, so it runs anywhere: orchstep run test.

#MAKEFILE#MAKE#TASK-RUNNER#MIGRATION#GETTING-STARTED
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY