Makefile - Tutorial and Tips & Tricks
When I'm learning, I usually take notes. I find that it's one of the best ways of remembering what I've seen and being able to come back to it at any time.
Below is a note that I took and revised several times when I took the time to create my first .make
files.
Install the make executableโ
Just run the following commands to install the Make
executable on your host machine:
sudo apt-get update && sudo apt-get -y install make
How to check if a file exists or notโ
You can use the test -s
statement like below:
@test -s .config/phpunit.xml || echo "phpunit.xml didn't exist"
@test -s .config/phpunit.xml && echo "phpunit.xml exists"
Unlike the ifeq
statement, test
should be indented.
We can use the { ... }
notation if we need to run more than one command; f.i.:
@test -s .config/phpunit.xml || { echo "phpunit.xml file is missing, please take actions! Exiting..."; exit 1; }
We can also do this before, f.i., includes an external file:
ifneq ("$(wildcard .env)","")
include .env
endif
Of course, we can also keep it simple i.e. just use the -
before the command to ignore errors so, below, if the file didn't exist, no error will be raised and the script will continue.
-include .env
How to check if a folder exists or notโ
You can use the ifeq
statement with a Linux shell command like below:
ifeq ($(shell test -e vendor/a/b/c || echo -n no),no)
@echo "The folder didn't exist"
endif
ifeq ($(shell test -e vendor/a/b/c && echo -n yes),yes)
@echo "The folder exists"
endif
Unlike the test
statement, ifeq
should be used without indentation. Directly at position column zero.
Running dependent targetsโ
By running make php-cs-fixer
, we'll first run vendor
then update-them
, finally php-cs-fixer
i.e. we can define a list of dependent targets.
php-cs-fixer: vendor update-them
@echo "And finally run php-cs-fixer"
vendor/bin/php-cs-fixer
vendor:
@echo "First get vendors"
update-them:
@echo "Then update vendors"
Running make php-cs-fixer
will output this:
First get vendors
Then update vendors
And finally run php-cs-fixer
Stop the job if a target failsโ
If one of them fails, the script will stop. In the example below, php-cs-fixer
will never print And finally run php-cs-fixer
.
php-cs-fixer: vendor update-them
@echo "And finally run php-cs-fixer"
vendor/bin/php-cs-fixer
vendor:
@echo "First get vendors"
update-them:
@echo "Then update vendors"
exit 2 && echo "Oups, an error has occurred"
Running make php-cs-fixer
will output this:
First get vendors
Then update vendors
And finally run php-cs-fixer
Using shell find option to get all dependenciesโ
Imagine you've a lot of .md
files in the directory. If one change, we'll concat them again. If nothing changes, nothing to do.
This can be done using a shell command for the dependence:
concat: $(shell find *.md -type f)
$(shell concat.sh)
(source https://tech.davis-hansson.com/p/make/#specifying-inputs)
Don't echo the commandโ
Running make helloWorld
like below will output two lines on the console.
helloWorld:
echo "Hello world"
And the output in the console:
echo "Hello world"
Hello world
To avoid the first one i.e. the output of the fired instruction, just prefix it with an at sign (@
).
helloWorld:
@echo "Hello world"
Now, only the output will be echoed; no more the instruction itself.
Using conditional statementsโ
We can make some conditional statement like this but, be careful on the indentation:
init:
ifeq ($(shell expr $(SET_PHP_VERSION) \>= 8), 1)
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Upgrade dependencies to PHP 8"
else
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Downgrade dependencies to PHP 7"
endif
make init SET_PHP_VERSION=7.4
will display the downgrade message while make init SET_PHP_VERSION=8.1
the upgrade one.
Another example:
ifneq ("$(DB_TYPE)","")
echo "The DB_TYPE variable is not empty
endif
ifeq ("$(DB_TYPE)","")
echo "The DB_TYPE variable is empty
endif
Ignore errorโ
Don't stop in case of error: add a -
before the line like :-php --lint index.php
phplint:
-php --lint index.php
echo "Yes, this script will continue..."
In case of linting error in index.php
don't stop the execution of the script and process the next command.
Note: makefile can perhaps display a message like make: [makefile:114: up] Error 1 (ignored)
to inform the user an error has occurred but has been skipped. If you don't want it at all (silent output), this is how to do:
[...]
@-docker network create -d bridge my_network >/dev/null 2>&1 || true
[...]
This command will create a new Docker network called my_network
and if case of error (the network already exists f.i.), we don't care and don't want to see any output.
Make sure you're using Bashโ
By default, make
is using /bin/sh
. This can be upgraded by adding this assignment at the top of the makefile
:
SHELL:=bash
(source: https://tech.davis-hansson.com/p/make/#always-use-a-recent-bash)
Set the default targetโ
By default, the first target defined in the file will be the default one i.e. the one fired when the user will just fire make
on the command line.
default: hello
hello:
@echo "Hello World"
Substitutionโ
Consider make convert INFILE=readme.md
outfile=$(INFILE:.md=.pdf)
COLOR_BLUE:=34
convert:
@printf "\e[1;${COLOR_BLUE}m%s\e[0m\n\n" "INPUT FILE IS ${INFILE}; OUTPUT WILL BE ${outfile}"
The syntax outfile=$(INFILE:.md=.pdf)
will replace .md
by .pdf
so we can, in this example, derive the output file based on the input file.
Tab not spaceโ
The indentation to use when creating a makefile
is the tabulation; not spaces. Using spaces will break the file.
helloWorld:
echo "Hello World"
However it is possible to adapt this behavior using .RECIPEPREFIX
:
.RECIPEPREFIX = >
changeExt:
### This is a comment
> @printf "%s\n" "It works"
(source: https://tech.davis-hansson.com/p/make/#dont-use-tabs)
Using parametersโ
Running a target with a parameter should be done using named parameters like this:
make hello firstname="Christophe"
This will create a variable called firstname
, we then can use it:
hello:
@echo "Hi ${firstname}!"
Make sure parameters are setโ
Imagine we want to run make runsql SQL='SELECT * FROM users LIMIT 10'
i.e. the SQL
argument should be defined otherwise we'll have a problem.
COLOR_RED:=31
COLOR_YELLOW:=33
runsql:
@[ "${SQL}" ] && printf "\e[1;${COLOR_YELLOW}m\n%s\e[0m\n\n" "Running ${SQL}" || ( printf "\e[1;${COLOR_RED}m\n%s\e[0m\n\n" "ERROR - Please set the SQL to execute; consult the help if needed"; exit 1 )
Working with Dockerโ
In a makefile we can exit the command if we need a given Docker container running.
The if
statement below will make sure the sonarqube
container is running; if not because not yet created or in a exit mode f.i., an error statement will be executed and the script will be stopped.
sonar-scan:
ifeq ($(shell docker ps -a -q -f name=sonarqube -f status=running),)
### The sonarqube container didn't exist yet or not running (exit mode f.i.)
@printf $(_RED) "ERROR - Please first run \"make sonar-server\""
exit 1
endif
The if
below will check if the container exists and if not, will create it.
The else
statement knows thus that the container exists but will make sure it's running.
ifeq ($(shell docker ps -a -q -f name=sonarqube),)
### The sonarqube container didn't exist yet, create it.
docker run -d --name sonarqube sonarqube:latest
else
### Make sure the container is running
-@docker container start sonarqube > /dev/null 2>&1
endif
Configure Visual Studio Codeโ
Add the makefile extensionโ
https://marketplace.visualstudio.com/items?itemName=ms-vscode.makefile-tools
Add the .editorconfig fileโ
Make sure your Makefile
file is correctly formatted; add a file called .editorconfig
in your root directory.
[*]
end_of_line = lf
insert_final_newline = true
[Makefile]
indent_style = tab
indent_size = 4
Some tipsโ
How to extend a targetโ
You've an existing target, let's say hello
in our example, and you wish to extend it and add extra actions.
hello
can be defined in the same makefile or in an included one but let's illustrate this with a basic example: we wish to add the Nice to meet you output.
hello:
@echo "Hello world"
hello:
@echo "Nice to meet you"
If we run that file, here is the output.
> make hello
makefile:198: warning: overriding recipe for target 'hello'
makefile:195: warning: ignoring old recipe for target 'hello'
Nice to meet you
The solution: use ::
(this is called an explicit rule) and not a single :
after the recipe; see the next sample:
hello::
@echo "Hello world"
hello::
@echo "Nice to meet you"
hello::
@echo "Did you any plans for this weekend?"
If we run that file, here is the output.
> make hello
Hello world
Nice to meet you
Did you any plans for this weekend?
Recipes are just extended, the second one is appended to the first and so on so the order is important.
Getting the current directory into a variableโ
PWD:=$(shell pwd)
current_dir:
@echo "The current directory is ${PWD}"
Get a list of files and initialize a variableโ
Let's take a real use case: scan a folder called .docker
and retrieve the list of docker-compose*.yml
files there.
The objective is to initialize an environment variable called COMPOSE_FILE
(see https://docs.docker.com/compose/environment-variables/envvars/#compose_file) so, when running docker compose
we can use all files at once (i.e. by not adding the --file file1.yml --file file2.yml
and on)
So, getting the list of docker-compose*.yml
can be done like this:
DOCKER_YAML_FILES := $(shell find ./.docker -name 'docker-compose*.yml')
DOCKER_YAML_FILES := $(shell echo "$(DOCKER_YAML_FILES)" | tr ' ' ':')
The first line will return f.i. docker-compose.yml docker-compose.override.yml docker-compose.mysql.yml
.
The second instruction will replace the space by a colon (:
).
We'll thus obtain docker-compose.yml:docker-compose.override.yml:docker-compose.mysql.yml
.
Now, to run docker compose config
for instance, we just need to do the following i.e. first declare the COMPOSE_FILE
environment variable then run the desired action.
.PHONY: config
config:
COMPOSE_FILE=${DOCKER_YAML_FILES} docker compose config
Getting information from the .env fileโ
Getting a value from a .env
file is easy, just include it then use variables:
DOCKER_IMAGE=cavo789/my_image
include .env
helloDocker:
@echo "The name of the image is ${DOCKER_IMAGE}"
This include
tip will work with any file defining a variable and his value
We can perfectly have a file called Make.config
, not .env
Define variable based on .env environment variablesโ
Let's imagine you've a variable called APP_ENV
in your .env
file.
That variable can be set to local
, test
or whatever you want. Will be set to production
when the application is running in the production environment.
So, based on that variable, we can define a variable like this:
ifeq ($(APP_ENV), production)
CMD:=
else
CMD:=docker compose exec my_docker_image
endif
This means: if we're not running in production, every command will be fired inside our Docker container. If running in production, the command will be executed directly.
Here is an example:
composer-update:
${CMD} composer update --no-interaction
Use a default value if the variable is not definedโ
If the OS environment variable PHP_VERSION
is not defined, set its default value to 8.1
PHP_VERSION := $(or $(PHP_VERSION),8.1)
Another example can be: imagine you've a .env
file with the DOCKER_APP_HOME
variable. But, if the variable is not defined, by using the syntax below, you can set a default value.
echo $(or ${DOCKER_APP_HOME},/var/www/html)
This will allow things like below i.e. target a custom version of a Docker image based on the selected PHP version.
DOCKER_PHPQA:=jakzal/phpqa:php${PHP_VERSION}-alpine
Override a variableโ
Even if the variable is still defined, you can override it by passing it on the command line:
make yamllint PHP_VERSION=8.1
This will start the yamllint
target with PHP_VERSION
set to 8.1
even if the variable is already defined and f.i. set to 7.4
.
How to check if a variable starts with a given value?โ
The use case is: we have a variable called PHP_VERSION
and we need to detect if we need to deal with PHP 7 code or PHP 8 or greater.
IS_PHP_7 := $(shell [[ $(PHP_VERSION) =~ 7[0-9.]+$$ ]] && echo "yes")
doThing:
ifdef IS_PHP_7
@echo "Hey, it's an old version of PHP, why not migrate to PHP 8?"
else
@echo "Nice! You're using PHP 8 or greater"
endif
Since Makefile didn't support regexes, we rely on the shell
for running the regex and to return a non-empty string if the expression is matched. In that case, the IS_PHP_7
variable is defined and the ifdef
construction will be verified.
Verbose modeโ
The idea: don't show informative message when running some targets.
The code below will check the presence of the --quiet
argument / value in the $ARGS
standard variable.
Sample code to demonstrate how to enable/disable verbose mode in a makefile.
Using the "--quiet" argument in ARGS.
- Verbose mode: run
make testme
on the command line - Silent mode: run
make testme ARGS="--quiet"
on the command line
QUIET=$(if $(findstring --quiet,${ARGS}),true,false)
testme:
ifeq ($(QUIET),false)
@printf '\e[1;30m%s\n\e[m' "QUIET MODE NOT ENABLED - We'll show any informative text."
endif
@printf '\e[1;32m%s\n\n\e[m' "This is an important message"
The code above define a global variable QUIET
that will be set to true
or false
depending on the presence of the --quiet
keyword in ARGS
.
Then, use the ifeq
conditional structure to show (or hide) informative message.
By running make testme ARGS="--quiet"
only This is an important message will be displayed.
Working with gitโ
Retrieve some important informationโ
Retrieve some important variables from the shell:
GIT_CURRENT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD)
GIT_ROOT_DIR:=$(shell git rev-parse --show-toplevel)
GIT_URL_REPO:=$(shell git config --get remote.origin.url | sed -r 's:git@([^/]+)\:(.*\.git):https\://\1/\2:g' | grep -Po '.*(?=\.)')
Some git targetsโ
When variables have been initialized, we can do things like this:
git_open_repo:
@sensible-browser ${GIT_URL_REPO}
git_open_pipeline:
@sensible-browser ${GIT_URL_REPO}/pipelines
git_open_wiki:
@sensible-browser ${GIT_URL_REPO}/-/wikis/home
Git - Work in progressโ
Run make git_wip
to quickly push your changes to the remote repository and skip the local hooks:
git_wip:
git add .
git commit -m "wip --no-verify
git push
Working with PHP project and vendorsโ
Updating the vendor folder only when neededโ
Having a target like below (i.e. called vendor
) will result in a check Should the vendor be upgraded or not?. This is done by first running the composer.lock
target. The idea is then to compare the date/time of that file and composer.json
. If there is a difference, the Make
tool will run composer update
and will generate a newer version of composer.lock
and thus a newer version of vendor
too.
If no changes have been made to the composer.json
file, nothing has to be done since vendor
is considered up to date.
Pretty easy.
composer.lock: composer.json
${CMD} composer update --no-interaction
vendor: composer.lock
${CMD} composer install
To run the scenario, just run make vendor
.
PHP - Quality checksโ
The portion below can just be copied/pasted in your own Makefile
to add quality controls features based on the https://github.com/jakzal/phpqa Docker image.
COLOR_BLUE:=34
COLOR_CYAN:=36
COLOR_YELLOW:=33
#### region - Implement `Quality Assurance` features; relying on the jakzal/phpqa Docker image)
#### Define the Docker image to use for PHPQA (https://github.com/jakzal/phpqa)
#! PHP_VERSION should be defined in the `.env` file
PHP_VERSION:=$(or $(PHP_VERSION),8.1)
DOCKER_PHPQA:=jakzal/phpqa:php${PHP_VERSION}-alpine
#### The full "docker run" command to be able to run a command in the jakzal/phpqa container
DOCKER_PHPQA_ARGS:=-it --rm -v ${PWD}:/project -w /project -v ${PWD}/.output/tmp-phpqa:/tmp
#### Get the current user/group ID and define the docker "-u" argument
DOCKER_PHPQA_UID_GID:=-u $(shell id -u):$(shell id -g)
#### Finally, the final command for running a command in the jakzal/phpqa container
DOCKER_PHPQA_COMMAND:=docker run ${DOCKER_PHPQA_ARGS} ${DOCKER_PHPQA_UID_GID} ${DOCKER_PHPQA}
##### Run PHAN and report problems
phan:
clear
#### This is how to define a local variable
$(eval COMMAND := $(shell echo "phan --progress-bar --config-file .config/phan.php"))
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Report PHAN errors in your PHP files (no fix) using $(DOCKER_PHPQA)"
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Running \"${COMMAND}\""
@printf "\e[1;${COLOR_BLUE}m%s\e[0m\n\n" "Note: make sure your /vendor folder is populated..."
@docker run -it ${DOCKER_PHPQA_ARGS} ${DOCKER_PHPQA} phan --version
@echo ""
${DOCKER_PHPQA_COMMAND} ${COMMAND}
##### Run PHP-CS-FIXER on the codebase IN A DRY-RUN MODE
php-cs-fixer-dry-run:
clear
#### This is how to define a local variable
$(eval COMMAND := $(shell echo "php-cs-fixer fix --verbose --config=/project/.config/.php-cs-fixer.php --using-cache=no --diff"))
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Report code formatting issues in your PHP files (no fix) using $(DOCKER_PHPQA)"
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Running \"${COMMAND} --dry-run\""
${DOCKER_PHPQA_COMMAND} ${COMMAND} --dry-run
##### Run PHP-CS-FIXER on the codebase and fix problems
php-cs-fixer-fix:
clear
#### This is how to define a local variable
$(eval COMMAND := $(shell echo "php-cs-fixer fix --verbose --config=/project/.config/.php-cs-fixer.php --using-cache=no --diff"))
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Automatically fix formatting issues in your PHP files using $(DOCKER_PHPQA)"
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Running \"${COMMAND}\""
${DOCKER_PHPQA_COMMAND} ${COMMAND}
##### PHP - Check the syntax of PHP files and report syntax errors if any
phplint:
clear
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Check syntax validity of php files using $(DOCKER_PHPQA)"
#### https://github.com/overtrue/phplint
${DOCKER_PHPQA_COMMAND} phplint --no-cache --exclude=vendor
##### YAML - Check the syntax of yaml files in the directory root folder and report syntax errors if any
yamllint:
clear
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Check syntax validity of yaml files using $(DOCKER_PHPQA)"
#### https://github.com/j13k/yaml-lint
#### (it seems yaml-lint can't search in all the tree structure and *.yml won't get .gitlab-ci.yml; strange)
${DOCKER_PHPQA_COMMAND} yaml-lint .*.yml *.yml .install/CI/*.yml
#### endregion
Some functionsโ
Clean foldersโ
COLOR_YELLOW:=33
clean:
@printf "\e[1;${COLOR_YELLOW}m%s\e[0m\n\n" "Remove node_modules and vendor folders"
rm --recursive --force node_modules/ vendor/
@printf "\e[1;${COLOR_YELLOW}m\n%s\e[0m\n\n" "Empty .cache, .output and storage/logs"
rm --recursive --force .cache/* .output/* storage/logs/*
We can also test for the existence of the folder first:
clean:
test ! -e vendor || rm -rf vendor
Open a web browserโ
open-browser:
@sensible-browser http://www.google.fr
@echo "The site has been opened in your browser"
This is pretty useful when you work with git repositories:
GIT_URL_REPO:=$(shell git config --get remote.origin.url | sed -r 's:git@([^/]+)\:(.*\.git):https\://\1/\2:g' | grep -Po '.*(?=\.)')
git_open_repo:
@sensible-browser ${GIT_URL_REPO}
Self documenting makefileโ
You can use a tip for creating a target like help
. The idea is to scan the current makefile
and extract the list of all verbs and their description.
There are a few different ways to achieve this. I already have written a blog post for this: Linux Makefile - Adding a help screen.
The complexity comes when you're adding some files using include
(like the .env
file) and where the structure can be a bit different that your makefile
.
The solution given here above is working for me.