Migrating Tasks from Makefiles
Use Atmos as a task runner for the build, test, lint, release, and maintenance tasks your team currently runs with Make. This guide maps Make targets, variables, and prerequisites to Atmos custom commands and workflows, keeping the scripts and tools behind those tasks.
Install the atmos-migration skill so Claude Code, Cursor, GitHub Copilot, and other AI coding
assistants can apply this guide directly to your repository:
atmos ai skill install atmos-migration
See AI Agent Skills for details.
Start with Your Existing Tasks
Task-runner adoption starts with an atmos.yaml file in your project. Stacks, components,
Terraform, and cloud credentials are optional; none are required for the tasks in this guide.
The examples use a Go application, but the same mapping works for other languages, documentation
builds, release scripts, and local development tools.
You can migrate one task at a time. To start, expose an existing task through Atmos:
Run atmos build from the project directory. When you are ready, replace make build with
the task's own commands. The examples below show that next step. They assume your existing
application, scripts, and required tools are available.
Key Concepts at a Glance
| Makefile concept | Atmos equivalent |
|---|---|
.PHONY target, no dependencies | Custom command |
VAR ?= default, $(VAR) | Command flags: with a default: |
target: dep1 dep2 (dependency order) | Steps in order (make's own default is sequential); command dependencies.commands only when a prerequisite is shared by more than one target, or the source used -j |
Recipe shell lines (@-silenced, tab-indented) | Shell steps; show.command: false suppresses command echo |
ifeq (...) conditional | Go template {{ if }} in a command, or when: in a workflow |
$(MAKE) -j (parallel targets) | parallel step |
$(MAKE) -C dir target (one invocation, one dir) | working_directory: on the step |
Recursive make looping over $(SUBDIRS) | matrix step |
Self-documenting help target (awk-parsed) | Free: atmos --help and each command's description: |
Before and After
- Before (Makefile)
- After (atmos.yaml)
.PHONY: build test lint clean deploy
ENV ?= dev
build:
go build -o bin/handler ./cmd/handler
test: build
go test ./...
lint:
go vet ./...
clean:
@rm -rf bin/
deploy: build test
./scripts/deploy.sh "$(ENV)"
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
- name: test
description: Run unit tests
dependencies:
commands: [build]
steps:
- type: shell
command: go test ./...
- name: lint
description: Run static analysis
steps:
- type: shell
command: go vet ./...
- name: clean
description: Remove build artifacts
steps:
- type: shell
command: rm -rf bin/
show:
command: false
- name: deploy
description: Deploy the application to the selected environment (default dev)
flags:
- name: env
shorthand: e
default: "dev"
dependencies:
commands: [build, test]
steps:
- type: shell
command: ./scripts/deploy.sh "{{ .Flags.env }}"
The deployment step calls your existing scripts/deploy.sh script and passes the selected
application environment as its first argument. --env is a custom command flag; it does not
select an Atmos stack. Replace the script with the command your project already uses.
Independent Targets Become Custom Commands
Turn each .PHONY target that has no dependency into its own
custom command. In the example above, these are build,
lint, and clean; test also has a build prerequisite. Give each command a description:. Then atmos --help shows the same
information as a hand-written, awk-parsed help target, so you can delete that target. Turn
VAR ?= default into a flags: entry with a matching default:.
Target Chains Become Ordered Custom-Command Steps
A type: atmos step runs the atmos binary itself, so it can call another custom command
such as build or test. To reuse a command
from another command or workflow step, prefer type: atmos with command: build over
type: shell with command: atmos build: type: atmos auto-applies -s <stack> from the
step's stack: field and renders through Atmos's structured step output.
For a chain such as deploy: build test, steps calling each prerequisite in order is the
default-preserving match — GNU Make's own default is to build a target's prerequisites one at a
time, in the order listed, not concurrently (-j is required for that). Do not reach for
dependencies.commands here just because it exists; it runs concurrently by default, which
changes the order unless you've confirmed the prerequisites are genuinely independent.
dependencies.commands earns its place for a different reason: make only builds a shared
prerequisite once per invocation, even when more than one target depends on it — plain steps
lose that property, since each caller's type: atmos/type: shell step runs it again.
dependencies.commands
dedups a dependency shared by more than one command to a single run, the same guarantee make
gives you for free, independent of whether you also want the concurrency. If order still matters
between the shared dependents (test must still finish before deploy's own steps run, and
build before test), declare that edge directly on the later command's own
dependencies.commands, rather than listing every prerequisite as a flat, sibling list on the
caller — the scheduler then waits for it, concurrency aside:
commands:
- name: build
steps:
- type: shell
command: go build ./...
- name: test
dependencies:
commands: [build]
steps:
- type: shell
command: go test ./...
- name: deploy
dependencies:
commands: [build, test]
steps:
- type: shell
command: ./scripts/deploy.sh dev
build still runs exactly once for the whole atmos deploy invocation — test's own edge on
build orders it correctly ahead of test, and deploy's own steps wait for both to finish.
Parallel and Recursive Make Become parallel and matrix
$(MAKE) -j runs independent targets at the same time. It maps to a
parallel step.
$(MAKE) -C dir target (or --directory=dir) changes the working directory for one
make invocation. It does not iterate over multiple directories, so it does not map to
matrix. Use working_directory: on the step instead:
A recursive Makefile pattern that loops $(MAKE) -C $@ over a list such as $(SUBDIRS) is the
case that actually enumerates multiple directories:
SUBDIRS := api worker web
.PHONY: subdirs $(SUBDIRS)
subdirs: $(SUBDIRS)
$(SUBDIRS):
$(MAKE) -C $@
That pattern maps to a matrix step, with each $(SUBDIRS)
entry becoming a matrix value:
File-Timestamp Targets Become inputs/artifacts
Make skips a target's recipe when every prerequisite (the target's own inputs) is older than
the target file it already produced (the target's own artifacts) — comparing mtimes. The
step-level inputs.sources and
artifacts.paths fields are the direct match:
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
inputs:
sources: ["cmd/**/*.go"]
artifacts:
paths: ["bin/handler"]
With no explicit when:, declaring inputs/artifacts on a step is enough — it implicitly means
when: checksum.changed, and the step is skipped when the hash of the matched source files
matches the hash recorded after the last successful run. That default is a deliberate upgrade
over Make's own mtime comparison: a fresh git clone/CI checkout resets every file's mtime,
which makes Make (and the mtime-based timestamp.changed fact) think everything changed even
when it didn't. Use when: timestamp.changed instead if you want Make's exact mtime semantics.
See inputs for the full field reference. It does not carry over on its
own — add inputs/artifacts to the migrated step yourself.
The scope is different, too: Make's freshness check gates the target's entire recipe, but
inputs/artifacts are declared per step — skipping one step does not stop later steps in the
same command from running. If a target's recipe has more than one command line and the freshness
decision must gate all of them together, combine them into a single shell/script step rather
than spreading inputs/artifacts across several steps.
What Stays the Same
- Your application code, scripts, and CLIs. Atmos wraps them. It does not replace them.
- Environment variables you already export. Move them into a command or step
env:map. - The Makefile itself, for as long as you want. A custom command can call
make <target>directly as its one step — Atmos becomes a consistent facade over the tools you already have, not a mandatory rewrite of them. Move a target's own recipe logic into nativesteps:only when you want to use Atmos step types, tool dependencies, or interactive prompts.
What You Gain
- Discoverable commands. Add descriptions, typed flags, defaults, and per-command help for
your team's tasks with
atmos --helpandatmos <command> --help. - Typed steps. Combine shell commands with HTTP requests, containers, parallel execution, matrices, and other step types.
- Interactive workflows. Add prompts, confirmations, and structured output where your tasks need them.
- Tool dependencies. Declare required CLI versions with
dependencies.toolsso Atmos installs them before running the command. See Toolchain Configuration.
Migration Checklist
- List every Makefile target. Mark each one as independent, or part of a chain
- Turn independent targets into custom commands in
atmos.yaml - Turn
VAR ?= defaultvariables into commandflags:with defaults - Turn dependency chains into steps in order (
make's own default) -- reach fordependencies.commandsonly when a prerequisite is shared by more than one target, or the source used-j - Replace
$(MAKE) -jwith aparallelstep; replace a single$(MAKE) -C dirwithworking_directory:on the step; replace a recursive$(SUBDIRS)-style loop with amatrixstep - Verify descriptions, flags, and arguments with
atmos --helpand command-level help - Keep the Makefile for as long as you want -- call
make <target>from a step, or migrate the logic intosteps:, whichever fits - Turn file-timestamp targets into step
inputs.sources/artifacts.paths