Skip to main content
from-makefile.md13.6 KB
View on GitHub

Migrating from Makefiles

This guide shows how to move Make targets to Atmos. Find the correct shape for the Makefile below. Then follow the matching steps. For the full tutorial, see atmos.tools/migration/makefile.

This guide covers the make orchestration layer only: targets, target dependencies, variables, and conditionals. If the Makefile also selects a Terraform environment through -var-file or per-environment directories, also use from-native-terraform.md. That guide covers the Terraform-specific steps: backend generation, .tfvars files, and workspace mapping.

Find the Shape of the Makefile

ShapeSteps
Independent leaf targets (build, test, lint, clean)Shape A
Target chains with dependencies (deploy: build test)Shape B
Recursive or parallel Make ($(MAKE) -C dir, make -j)Shape C

Most Makefiles mix all three shapes. Treat each target on its own. Then combine the results.

Shape A: Independent Leaf Targets

Before:

<!-- editorconfig-checker-disable -->
.PHONY: build test lint clean help

help: ## Show available targets
@awk 'BEGIN {FS=":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

build: ## Compile the deployable artifact
go build -o bin/handler ./cmd/handler

test: build ## Run unit tests
go test ./...

lint: ## Run static analysis
golangci-lint run ./...

clean: ## Remove build artifacts
@rm -rf bin/
<!-- editorconfig-checker-enable -->

Steps:

  1. Turn each leaf target into a custom command in atmos.yaml. If the Makefile is large, put the commands in a separate file. See Split commands across files below.
  2. Turn the silent-recipe @ prefix into show: { command: false }. Make's @ only suppresses the echoed command line -- the recipe's own stdout/stderr still prints. output: none is not the same thing: it sends the step's stdout and stderr to the void, which would hide real command output (for example, go test diagnostics). Reserve output: none for a step whose output genuinely needs to be discarded entirely.
  3. Delete the help target. Atmos generates the same information from each command's description: field. Run atmos --help or atmos <command> --help to see it.
  4. When one target depends on another leaf target, such as test: build, add a step that runs the dependency's command. Use a type: atmos step with command: build to call the other custom command -- type: atmos preserves step-level stack context and gives you structured output handling (captured stdout/stderr, exit code) that a type: shell step invoking atmos build does not.
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
steps:
- type: atmos
command: build
- type: shell
command: go test ./...

- name: lint
description: Run static analysis
steps:
- type: shell
command: golangci-lint run ./...

- name: clean
description: Remove build artifacts
steps:
- type: shell
command: rm -rf bin/
show:
command: false

Shape B: Target Chains with Dependencies

Before:

<!-- editorconfig-checker-disable -->
ENV ?= dev

deploy: build test ## Plan and apply the given ENV (default: dev)
cd terraform && terraform apply -var-file=envs/$(ENV).tfvars
<!-- editorconfig-checker-enable -->

Steps:

  1. Turn ENV ?= dev into a command flags: entry with default: "dev".
  2. 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) -- so ordered steps, not dependencies.commands, are the default-preserving match for a plain target list. Reach for command-level dependencies.commands: [build, test] instead only when: a prerequisite is shared by more than one target (it dedups a shared dependency to a single run, the same guarantee make already gives for free, independent of concurrency), the prerequisites are genuinely independent, or the source target actually used -j. Here, build is shared -- Shape A's test already depends on it via its own atmos build step -- so dependencies.commands is the right call for deploy. Convert Shape A's test from its atmos build step to dependencies.commands: [build] at the same time, so the scheduler still orders build before test even though both now run through the same concurrent-by-default mechanism, instead of listing build and test as a flat, unordered sibling list on deploy.
  3. Move the Terraform-specific line, terraform apply -var-file=envs/$(ENV).tfvars, to from-native-terraform.md Shape B. That guide shows how the Terraform side maps to stacks. Here, the line becomes a single type: atmos step, because terraform apply is a native Atmos verb.
  4. Turn ifeq ($(ENV),prod) conditionals into a Go template conditional inside a custom command: {{ if eq .Flags.env "prod" }}...{{ end }}. This is the same pattern used for --verbose and other boolean flags. Inside a workflow, use when: !cel 'stack == "prod"' on the step instead.
commands:
- name: test
description: Run unit tests
dependencies:
commands: [build]
steps:
- type: shell
command: go test ./...

- name: deploy
description: Plan and apply the given environment (default dev)
flags:
- name: env
shorthand: e
default: "dev"
dependencies:
commands: [build, test]
steps:
- type: atmos
command: terraform apply infra -s {{ .Flags.env }}

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.

infra is a placeholder Atmos component name, not the terraform verb repeated. Move the target's Terraform code to components/terraform/infra/ (the default components.terraform.base_path is components/terraform), then swap infra for whatever the user actually names the component.

Shape C: Recursive or Parallel Make

Before:

<!-- editorconfig-checker-disable -->
SERVICES := vpc eks rds

build-all:
for dir in $(SERVICES); do $(MAKE) -C services/$$dir build; done

build-parallel:
$(MAKE) -j4 build-all
<!-- editorconfig-checker-enable -->

Steps:

  1. build-all's recipe is a shell for loop, not multiple Make targets -- make -j only parallelizes independent targets within a single make invocation, it does not parallelize commands inside one recipe's shell script. So $(MAKE) -j4 build-all still runs vpc, eks, and rds one at a time, in order, exactly like plain build-all would. -j4 here does nothing.
  2. Turn $(MAKE) -C dir target recursion over a fixed set of directories into a matrix step. Define a service axis, and call the per-service command once for each value. Using max_concurrency on the matrix step is a deliberate upgrade over the sequential source behavior, not a literal translation of -j4 -- call it out to whoever is reviewing the migration, since it changes execution behavior (all services build concurrently instead of one at a time).
  3. The per-directory build target here is a different recipe than Shape A's top-level build command -- it lives in each services/<dir>/ directory, not at the repo root. Give it its own custom command, build-service, with a service flag/argument that maps into the services/<name> path. Do not reuse Shape A's build command as-is; it has no way to receive a per-service value.
commands:
- name: build-service
description: Build a single service
flags:
- name: service
required: true
steps:
- type: shell
command: go build -o bin/{{ .Flags.service }} ./services/{{ .Flags.service }}

- name: build-all
description: Build every service
steps:
- name: build-services
type: matrix
matrix:
service: [vpc, eks, rds]
max_concurrency: 4
steps:
- type: atmos
command: build-service --service {{ .matrix.service }}

Common Problems

Tabs, .PHONY, and file-timestamp caching

Atmos steps have no tab requirement. Do not confuse .PHONY with a caching feature. If a Makefile target is not .PHONY and uses file timestamps to skip work when inputs have not changed, turn it into step inputs.sources/artifacts.paths -- with no explicit when:, that implicitly means when: checksum.changed, and the step is skipped when nothing has changed since its last successful run. It does not carry over automatically; tell the user to add inputs/artifacts to the migrated step themselves. Content hashing (the 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 think everything changed even when it didn't; use when: timestamp.changed instead for Make's exact mtime semantics. Task's sources:/generates: feature maps to the same fields. See from-taskfile.md for more detail. 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.

Silent recipes and command echo

@command suppresses the echo of one command line -- it does not touch the recipe's own output. It maps to show: { command: false } on that one step, not output: none. output: none discards the step's stdout and stderr entirely, which is a much bigger change in behavior than Make's @ prefix ever was; reserve it for steps whose output genuinely needs to be thrown away.

Split commands across files

When a Makefile uses include foo.mk to split its content across files, split the Atmos config the same way. Put the extra commands in a file such as atmos.d/commands.yaml or .atmos.d/commands.yaml. Atmos auto-discovers atmos.d//.atmos.d/ in the config directory (and, as a lower-priority fallback, at the git/worktree root) -- no import: entry is needed for this specific location. Use import: only when splitting across a directory Atmos does not auto-discover. See Imports.

Complex $(eval) and $(call) macros

Do not try to convert deeply macro-driven Makefiles into flags and arguments one by one. Put genuinely dynamic logic in a shell or script step, or in a script that the step calls. Atmos custom commands replace task orchestration. They do not replace a general-purpose macro language.

What Not To Do

  • Do not confuse .PHONY with a caching feature -- it is not one. Do not drop file-timestamp caching without adding the matching inputs/artifacts fields to the migrated step; it is a direct match, not a gap, but it does not carry over on its own. Remember the scope difference too: it gates one step, not the whole recipe -- combine multiple command lines into a single step if the freshness decision must cover all of them.
  • Do not describe dependencies.commands as matching Make's default prerequisite order -- make builds prerequisites one at a time, in the order listed, unless -j is given. dependencies.commands runs concurrently by default, which changes that order. Ordered steps are the default-preserving match for a plain target: dep1 dep2; reach for dependencies.commands only when a prerequisite is shared by more than one target (it dedups a shared dependency to a single run regardless of concurrency, the way make already does), the prerequisites are genuinely independent, or the source used -j.
  • Do not turn every private or helper target into its own discoverable command by default. If the helper is called from only one recipe, put its logic in a step inside the command or workflow that needs it. If it needs to be called from more than one recipe, or invoked directly for debugging, make it a custom command with internal: true instead -- it stays runnable but is excluded from atmos --help listings and completion suggestions.
  • Do not treat "wrap atmos commands in the Makefile" as the final state. It is a valid bridge during early migration. Leaf targets should become custom commands. A target chain usually stays a custom command too, using dependencies.commands for its prerequisites -- do not prescribe a workflow for every dependency chain. Reserve workflows for fixed, multi-step orchestration across more than one component.
  • Do not invent when: conditions that check flag values on workflow steps. The when: field checks CEL context values, such as stack, ci, and local. Flag-based conditionals belong in the custom command's own Go templates.