Migrating from Make
This guide moves a Makefile's tasks into a main.lask, one task at a time, without a day where nothing works. It assumes you know Make and have skimmed the Quick Reference; everything here is linked back to it or to the specification.
Should you migrate?
Make answers one question very well: which files are out of date, and what rebuilds them? Lask does not answer it at all — it has no file targets and no notion of freshness. So look at what your Makefile actually does:
- Mostly
.PHONYtargets —test,lint,build,deploy,clean— used as a list of named commands. This is what Lask is for, and what this guide covers. - Mostly file rules —
%.o: %.c, generated sources, a dependency graph Make walks to skip work. Keep Make for those. You can still call it from Lask (see Step 1) and move only the phony part.
Most real Makefiles are a mix, and the migration below lets you stop at any point with both files working.
What you get
- No tools on the host. Each command runs in an image pinned by digest in
lask.lock.json. A new contributor installs Lask and Docker, andgo,golangci-lintand the rest come from the lock — not from a "Prerequisites" section in the README. - Checks before anything runs.
lask checkresolves every task name, argument and type. A typo in a target name is an error in your editor, not amake: *** No rule to make targethalfway through a release. - Real arguments.
make deploy ENV=prodbecomeslask run deploy --env prod, with a default, a type,--helptext and shell completion, and a misspelt--evnis rejected instead of silently ignored. - Concurrency you write down.
async/awaitin the task itself, rather thanmake -jfor the whole invocation.
A complete example
A typical Makefile for a Go service:
APP ?= hello
VERSION ?= $(shell git describe --tags --always)
REGISTRY := registry.example.com
ENV ?= dev
.PHONY: all ci test lint build image deploy clean
all: build
ci: lint test build
test:
go test ./...
lint:
golangci-lint run
build:
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=$(VERSION)" -o bin/$(APP) ./cmd/$(APP)
image: build
docker build -t $(REGISTRY)/$(APP):$(VERSION) .
deploy: image
docker push $(REGISTRY)/$(APP):$(VERSION)
./scripts/deploy.sh $(ENV) $(VERSION)
clean:
-rm -r binThe same thing as main.lask:
// Every Go command runs in this image, with the build cache kept in a
// named volume between runs.
go = #golang:1.25-alpine{
env: {"CGO_ENABLED": "0"},
volumes: ["lask-go-cache:/root/.cache"]
}
lint = #golangci/golangci-lint:v2.5.0-alpine
command { "go" } on go
command { "golangci-lint" } on lint
command { "git", "docker", "rm" } on #local
registry = "registry.example.com"
// What `git describe` says this commit is, worked out once per run.
version = $ git describe --tags --always
|> trim
// Run the unit tests.
test(): String = $ go test ./...
// Run the linters.
lint_go(): String = $ golangci-lint run
// Build the binary.
//
// @param app The command under ./cmd to build.
// @param version Version stamped into the binary.
// @param os Target operating system; the container is Linux.
// @return The path of the binary.
// @complete os linux darwin windows
build(--app = "hello", --version: String = version, --os = "linux"): String = do {
$ GOOS=#{os} go build -trimpath -ldflags "-X main.version=#{version}" -o bin/#{app} ./cmd/#{app}
"bin/#{app}"
}
// Build and tag the container image.
//
// @return The image reference.
image(--app = "hello", --version: String = version): String = do {
build(app = app, version = version)
ref = "#{registry}/#{app}:#{version}"
$ docker build -t #{ref} .
ref
}
// Deploy a version.
//
// @param env Where to deploy.
// @complete env dev staging prod
deploy(--env = "dev", --app = "hello", --version: String = version) = do {
ref = image(app = app, version = version)
$ docker push #{ref}
$[#local] ./scripts/deploy.sh #{env} #{version}
}
// Lint and test in parallel, then build.
ci(): String = do {
all([async lint_go(), async test()])
build()
}
// Remove build output; a missing directory is not an error.
clean(): CommandResult = $* rm -r binAnd a lask.json next to it, so that a production deploy has to be confirmed:
{"confirm": {"deploy": {"when": {"env": ["prod"]}}}}Running it:
$ lask sync # once, and whenever an image changes: pulls and pins
$ lask check # nothing runs
$ lask run ci # was: make ci (or make -j ci)
$ lask run build --os darwin # a binary for your Mac, built in the Linux image
$ lask run deploy --env prod # was: make deploy ENV=prod — now asks first
$ lask run deploy --helpThe rest of this guide explains each change, and how to get from the first file to the second in small steps.
Migrating step by step
Step 1: Wrap the Makefile
Start with a main.lask that only calls Make. Nothing changes about how the work is done, but every target now has a checked name, --help, and shell completion, and you can start calling lask run from CI.
command { "make" } on #local
// Run the unit tests.
test(): String = $ make test
// Build the binary.
build(--version: String = "dev"): String = $ make build VERSION=#{version}#local is the host: these commands use whatever make and go are installed there, exactly as before. It has to be written out — a command whose environment is not declared is a static error, never a silent fall back to the host.
Step 2: Move one task at a time
Take a target, copy its recipe into the task body, and translate the Make syntax (reference below). Keep it on #local for now, so that a difference in behaviour can only come from the translation:
command { "go" } on #local
test(): String = $ go test ./...Run both and compare. Delete the target from the Makefile once nothing calls it — grep for make <target> in scripts, CI files and docs.
Step 3: Pin the environments
Now change where the tools come from. Move each command word from #local to an image, run lask sync to pull it and record its digest, and the host no longer needs that tool:
-command { "go" } on #local
+command { "go" } on #golang:1.25-alpineOnly the command line changes; the tasks that run go stay as they are. lask envs list shows every image the module uses and whether it is pinned. Commit lask.lock.json: it is what makes your laptop and CI run the same image.
Some tools are better left on the host, and that is fine. In the example, docker and git stay on #local: docker build needs the host's daemon, and git describe reads the host's repository and credentials.
Step 4: Remove the Makefile — or keep what Make is good at
When only file rules remain, keep them and call Make from the task that needs them ($ make bin/parser.c). When nothing remains, delete the Makefile, and leave a short note in the README for people whose fingers still type make test.
Reference: Make to Lask
| Make | Lask |
|---|---|
test: (a .PHONY target) | test() = ... |
| A recipe of several lines | do { ... }, one $ per line |
$(VAR) | #{var} |
VAR ?= default, make t VAR=x | t(--var = "default"), lask run t --var x |
VAR ?= $(ENV_VAR) | --var = get_env_or("ENV_VAR", "default") |
$(if $(VAR),,$(error VAR is required)) | t(--var: String) — no default, so it must be given |
VAR := $(shell cmd) | var = $ cmd (top level, evaluated once on first use) |
$$HOME | $HOME — only #{...} is Lask's |
target: dep1 dep2 | call dep1() and dep2() in the body |
make -j | async / await, all([...]) |
-cmd (ignore failure) | $* cmd, or try / catch |
make -k | try / catch around each step |
@cmd (don't echo) | — every command is logged to stderr; run prints nothing to stdout |
export VAR | env: {"VAR": ...} on the environment |
include other.mk | import { task } from "./other.lask" |
A hand-written help target | doc comments, shown by lask run <task> --help |
.DEFAULT_GOAL | — name the task: lask run build |
| "requires Go 1.25" in the README | command { "go" } on #golang:1.25-alpine |
Differences worth knowing
Arguments instead of variables
A Make variable can be set from the command line, from the environment, or in the file, and a misspelt one is silently ignored. A Lask keyword parameter has one default, written next to the task, and the CLI rejects a parameter the task does not declare:
$ make deploy EVN=prod # deploys to dev
$ lask run deploy --evn prod # error: unknown parameterWhen a value really should come from the environment, say so in the default: --region = get_env_or("AWS_REGION", "us-east-1"). Use get_env instead when the variable is required, so that a missing one fails with its name.
A parameter declared without a default, deploy(--env: String), is required: lask run deploy without --env stops with an error before anything runs, which replaces the ifndef ... $(error ...) guard of a Makefile.
Parameters keep their _ in the code and take - on the command line: build(--dry_run = false) is lask run build --dry-run. A Bool parameter is a flag, so --dry-run alone is true.
Prerequisites are calls, and each call runs
deploy: image becomes image() in the body of deploy. The ordering is the same, but one rule is different: Make runs each target at most once per invocation; Lask runs a task every time it is called. With a diamond such as
release: test image
image: build
test: buildmake release builds once, while the literal translation builds twice. Two ways out:
- Call shared steps from one place. Let
releasecallbuild()once and pass the result on, and lettestandimagetake what they need as parameters instead of callingbuildthemselves. - Compute it in a top-level value. A top-level value is evaluated at most once, on first use, which is why
versionin the example runsgit describeonce however many tasks read it.
Concurrency is in the task, not on the command line
make -j4 ci parallelizes whatever Make finds independent, and a Makefile that was never written for -j breaks under it. In Lask you say what runs in parallel:
ci(): String = do {
all([async lint_go(), async test()])
build()
}async starts a call and returns a handle; await or all waits, and a failure inside is raised there. Avoid naming a task all — it would shadow the built-in all.
Each line is still its own process
As in Make, each $ line is a separate process, so cd does not carry over to the next line: write $ cd web && npm ci. Unlike Make, a command string runs to the end of its line, so a pipe into a Lask function goes on the next line:
version = $ git describe --tags --always
|> trimContainers do not see the host's environment
A command in an image starts with the image's own variables, not yours. export GOFLAGS in a Makefile, or a variable set in your shell, does not reach it. Pass what it needs with the env run option:
command { "go" } on #golang:1.25-alpine{env: {"GOFLAGS": get_env_or("GOFLAGS", "")}}For credentials, bind the value with !! so it is masked in the log — see Input, output, secrets.
Container builds produce Linux binaries
make build on a Mac produces a Mac binary; the same go build in a Linux image produces a Linux one. This is usually what you want for an image or a server, and a cross-compiler such as Go's makes the other case one parameter, as build --os darwin does in the example.
Caches live in volumes
The container is fresh on every run, so a tool's download and build caches are thrown away unless you keep them. Mount a named volume on the cache directory, as the example does for Go:
go = #golang:1.25-alpine{volumes: ["lask-go-cache:/root/.cache"]}Scripts are run with an explicit environment
A command declaration names program words, not paths, so ./scripts/deploy.sh cannot be declared there. Name its environment at the call instead: $[#local] ./scripts/deploy.sh, or run it through an interpreter you have declared, such as $ sh scripts/deploy.sh.
run prints nothing
lask run writes nothing to stdout — commands' output goes to the log on stderr. When a task's result is the point, as in make print-version, use lask eval:
$ lask eval build # "bin/hello"Dangerous targets can ask first
There is no Make equivalent of the confirm entry in lask.json: lask run deploy --env prod stops and asks you to type prod. Where there is no terminal, such as in CI, it fails with exit code 4 unless --confirm is given — so a deploy job states that it means it.
Checklist
- [ ]
lask checkpasses, and runs in CI before anything else. - [ ]
lask.lock.jsonis committed, and CI runslask sync --frozen. - [ ] Every tool that should not be on the host is declared on an image;
lask envs listshows no#localyou did not intend. - [ ] Every task that had a
helpline has a doc comment. - [ ] No target is reached twice through prerequisites without your knowing.
- [ ] Variables that came from the environment are read with
get_env_ororget_env, and passed to containers withenv. - [ ] Scripts, CI configuration and docs no longer call
make <target>for a target you removed.