Passing Dynamic Values Into Make
A Makefile that only works with hardcoded values is not very useful in a real pipeline. Make supports three ways to supply variable values, in increasing order of precedence:
- Makefile default — The value defined in the file itself.
- Environment variable — A variable exported in the shell before calling
make. - Command-line override — A value passed directly to
makeon the command line.
The ?= operator is the key to this flexibility. It means "use this value unless the variable is already set in the environment." This lets you define sensible local defaults while allowing CI/CD platforms (GitHub Actions, GitLab CI, Jenkins) to inject their own values through environment variables without any Makefile changes — a Makefile with a build, push, and deploy target, each reading a registry, version, and push-flag variable this way, behaves identically whether it's run by hand on a laptop with the defaults, or from a CI job that exports its own registry and version before calling make deploy.
A Realistic CI/CD Pattern
In a CI workflow, the platform-specific config reduces to almost nothing: set a few environment variables (registry, version, a push flag) and invoke make deploy. The Makefile does not need to know it is running in GitHub Actions, GitLab CI, or anywhere else. It just reads environment variables. This makes the same Makefile work identically on a developer's laptop and in any CI/CD system.
A common interview question is: "How do you make your build process portable across different CI/CD platforms?" The answer is to use a Makefile as the single entry point. The CI/CD platform (GitHub Actions, GitLab CI, Jenkins, CircleCI) calls make build, make test, make deploy. The platform-specific YAML only handles checkout, credentials, and environment variables — all the actual logic lives in the Makefile. Switching CI/CD providers becomes a YAML change, not a logic rewrite.