Skip to main content

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.

Using an AI Coding Assistant?

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:

atmos.yaml
commands:
- name: build
description: Build the application
steps:
- type: shell
command: make build

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 conceptAtmos equivalent
.PHONY target, no dependenciesCustom 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 (...) conditionalGo 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

.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)"

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:.

atmos.yaml
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
atmos build

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:

atmos.yaml
commands:
- name: build-api
description: Build the API service
steps:
- type: shell
command: make build
working_directory: services/api

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:

atmos.yaml
commands:
- name: build-all
description: Build every service
steps:
- name: build-services
type: matrix
matrix:
service: [api, worker, web]
max_concurrency: 4
steps:
- type: shell
command: make build
working_directory: services/{{ .matrix.service }}

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 native steps: 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 --help and atmos <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.tools so 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 ?= default variables into command flags: with defaults
  • Turn dependency chains into steps in order (make's own default) -- reach for dependencies.commands only when a prerequisite is shared by more than one target, or the source used -j
  • Replace $(MAKE) -j with a parallel step; replace a single $(MAKE) -C dir with working_directory: on the step; replace a recursive $(SUBDIRS)-style loop with a matrix step
  • Verify descriptions, flags, and arguments with atmos --help and command-level help
  • Keep the Makefile for as long as you want -- call make <target> from a step, or migrate the logic into steps:, whichever fits
  • Turn file-timestamp targets into step inputs.sources/artifacts.paths

Common Questions