Monday 4 January 2021

Makefiles Returns

Doing stuff: the good old way

I often write Makefile(s) for my projects, even when they’re in some scripting language. This because the good old, humble make is installed almost everywhere and its simple syntax does one thing and does it well: doing tasks according to dependencies.


What Make Is?

Make is a build system, exactly as SCons. It's used to build C/C++ programs from source files, compiling only the modified ones. That is a precious feature: it's not funny to wait five minutes because you fixed just a line of code and the whole bunch of source files must be recompiled.
Make works by tasks called targets: each target has a name that's is also the name of what it will produce, e.g.:
character.o:
    gcc -o character.o character.c

test_character:character.o
    gcc -o test_character character.o test.c
In this example, the "test_character" target will first check if its dependencies (character.o) has been updated: character.c is automatically considered a dependency for character.o. If the source file has been modified, a new compilation will be done.

A "Release" Script

I don't want to go deep into Make's syntax. This is not a tutorial for that. I show you how it could be useful for a simple, but common task: the new-version release of a Python program as zip archive. The required steps are:
  1. Create a distribution directory
  2. Move all .py file a modules on that directory
  3. Zip the directory with in an archive named packagename-version.zip
  4. ssh-copy the zip to deployment server
  5. delete the file
so, the makefile will be:
PACKAGENAME=derezer
VERSION=1.0
RELEASE_SERVER=ssh://tron@deployment
RELEASE_DIR=/var/deployment/$(PACKAGENAME)/

release:
    mkdir dist
    cp -r module1 dist/
    cp -r module2 dist/
    cp __main__.py dist/
    zip -r $(PACKAGENAME)-$(VERSION).zip
    scp $(PACKAGENAME)-$(VERSION).zip \
        $(SERVER):$(RELEASE_DIR)
    rm -rf dist
    rm -f $(PACKAGENAME)-$(VERSION).zip
Easy peasy, right? Calling make release, the new version of our program will be delivered to the ssh server.
Now let's improve this simple script: would be really useful if it could calculate version and sub-version number itself. A possible way to do it is using the latest tag in git as version, while the number of commits from it to HEAD will be the sub-version number. So, if last tag is "v1" and we committed ten times since it, our version will be "v1.10".
MAJORVERSION := $(shell git describe --abbrev=0)
SUBVERSION := $(shell git --no-pager \
    log --pretty=oneline $(MAJORVERSION)..HEAD | \
     wc -l | sed 's/^ *//')
Using $(shell command), we can save the result of a shell command into a Makefile, taking care of using := rather than a simple equality-symbol. This way, MAJORVERSION is the latest git tag we used, while SUBVERSION is the number of commit from that to HEAD. The last command with sed is used to remove an annoying black space that wc returns.
With this simple Makefile we're able to manage a smart versioning of each release.
Remember: each command in Makefile is execute on its own shell. So, this sequence
task:
    cd distribution
    rm -rf *
will enter on distribution, but will remove everything on directory where make task has been called.
To delete everything on distribution you should do:
task:
    rm -rf distribution/*

Adding a Testing Release

Let's suppose our boss requires us to release a version just for testing. The "testing version" must be labeled with a heading "test_" and sent to another SSH server. We can modify our Makefile this way:
PACKAGENAME=derezer
MAJORVERSION := $(shell git describe --abbrev=0)
SUBVERSION := $(shell git --no-pager \
    log --pretty=oneline $(MAJORVERSION)..HEAD | \
     wc -l | sed 's/^ *//')
VERSION=$(MAJORVERSION)-$(SUBVERSION)
PACKAGENAME=derezer
DISTNAME=$(PACKAGENAME)-$(VERSION)
RELEASE_SERVER=ssh://tron@deployment
RELEASE_DIR=/var/deployment/$(PACKAGENAME)
TEST_SERVER=ssh://tron@testing
TEST_DIR=/mnt/shared/test_repo/$(PACKAGENAME)

build.zip:
    mkdir dist
    cp -r module1 dist/
    cp -r module2 dist/
    cp __main__.py dist/
    zip -r build.zip dist

release:build.zip
    scpy build.zip \
        $(SERVER):$(RELEASE_DIR)/ \
        $(DISTNAME).zip
    
test:build.zip
    scp build.zip \
        $(TEST_SERVER):$(TEST_DIR)/ \
        test_$(DISTNAME).zip

clean:
    rm -rf build.zip

Not having any file called clear we can use it with no problems instead declaring it .PHONY. But see: every time we need to release a new version is just a make release; if is a testing release will be make test. And if "dist" didn't change in the while, it will be used for the same thing.

A Safe Clean

Even using a control version system, if we forgot to commit last changes, a deletion could destroy hours of work. For this reason, we can improve clean safety checking if the "git status" is empty and exit if it's not.
clean:
    STATUS := $(git status | grep "up to date")
    $(if $$(STATUS)

But I could use a bash-script!

True. And more powerful build systems exists, such as SCons or CMake. Ant is another build tool for Java and I'm using CMake at work. BUT make's syntax is easy, you haven't to install it 'cause it's always bundled with "development tools", it's fast and it manage dependencies among targets automatically. Needless to say that make release or make test can hardly be confused with other commands.
So, my suggestion is to give to this venerable tool a try. If you don't like you can always go back to your own way to automatize this kind of activities.

No comments: