Gonçalo Palma
September 28, 2021

Automating Flutter Workflows with the Makefile

While developing our Flutter projects, there are many repeatable tasks - formatting, running unit tests before we create a PR, cleaning the project, and running or even building the app for different flavors.

And although our IDEs can make it easy to perform some of these actions, we may have fallen in love with the Command Line, which you can learn more from in my previous article Flutter and the Command Line — a Love Story, which means that we either created our scripts or we have to type each command by hand each time.

Is there a better way to automate this?

There is! With a Makefile. The Makefile allows us to create a set of different commands in one file to automate our workflows. With it, we can do three things at once:

  1. Have one place where we define all the actions we want for our project while at the same time creating relations between them;
  2. Make it easier to perform those actions with short commands;
  3. Use this tool in any Flutter project we have

But let’s start at the beginning.

What is a Makefile?

On the GNU manual for make it states that the purpose of a Makefile is:

The make utility automatically determines which pieces of a large program need to be recompiled, and issues commands to recompile them. (…) make is not limited to programs. You can use it to describe any task where some files must be updated automatically from others whenever the others change.

In the scope of Flutter or Dart development, we can use it to create and chain tasks.

Imagine the following - each time that we need to build our app for iOS and Android we’ll need to do the following steps manually:

  1. Clean the project
  2. Run lint to see if we don’t have any errors
  3. Run all the tests
  4. Build the project for mobile with the specified flavor
  5. Use Fastlane to ship our app to Test-flight and Firebase App Distribution

By doing this process manually, we have two major drawbacks: first, we will need to run each command by hand every time we need to do a new release, and we will need to verify if each step failed or not before continuing. This can be very time-consuming if we repeat this process several times.

With the makefile, we can separate all these steps into simple tasks, called targets, and chain them together so that in the end we can build and distribute our app by calling make build_mobile_stg.

Creating a basic Makefile

A Makefile can be created quite easily - at the root of our project create a file whose name is Makefile (with no extension). Inside it, we can populate it with targets and variables:

To declare a new target we use the following signature:

target_name: precedent_target_one precedent_target_two
	command_1
	command_2

We should also note that the Makefile only allows Tabs when indenting code.

For our Flutter makefile let’s start by creating one target that can format our code using dart format:

format:
    dart format .

We can now simply run make format, and all of our code will be formatted:

➜  flutter_makefiles git:(master) ✗ make format
dart format .
Formatted 2 files (0 changed) in 0.28 seconds.

As an outcome, we will have two prints in the terminal: the command that we used to format code, and the output of dart format. If we don’t want to output the commands in our Makefile, we can add a @ before it:

format:
    @dart format .

And now make will only display the command’s output:

➜  flutter_makefiles git:(master) ✗ make format
Formatted 2 files (0 changed) in 0.27 seconds.

To make it more explicit to the user, we can add a brief message to this target using echo, so that in the case format is used as a precedent to other targets, we know that it has been called.

format:
    @echo "╠ Formatting the code"
    @dart format .

With the output being:

➜  flutter_makefiles git:(master) ✗ make format
╠ Formatting the code
Formatted 2 files (0 changed) in 0.22 seconds.

Creating new rules

We may come to a point where format is not the only command we want to use in our Flutter projects, so we’ll need to add new targets.

Let’s add one new target:

  1. A clean target that will remove the pubspec.lock file and clean the project
clean: 
    @echo "╠ Cleaning the project..."
    @rm -rf pubspec.lock
    @flutter clean

format:
    @echo "╠ Formatting the code"
    @dart format .

Now to use different targets we can call them after calling make:

However, the Makefile by default should be used for creating new files, via a build or other process. Since we are just running commands, we should tell the Makefile that no files will be generated with the same name of the target.

To do so, we add a .PHONY target at the top of the file where we declare all the targets that don’t generate files with the same name. To learn more about phone targets, you can check the GNU Manual for Phony Targets

.PHONY: clean format

clean: 
    @echo "╠ Cleaning the project..."
    @rm -rf pubspec.lock
    @flutter clean

format:
    @echo "╠ Formatting the code"
    @dart format .

We can safely rerun make clean and make format and check that their outcome hasn’t changed.

Chaining rules

Now that we know how to create new commands, how can we chain commands?

Let’s say that we want to create:

The precedence can be declared in the line in which we declare the target’s name:

.PHONY: clean format upgrade 

# Other targets

upgrade: clean 
    @echo "╠ Upgrading dependencies..."
    @flutter pub upgrade

If we run make upgrade we can see in the console that both targets are called:

➜  flutter_makefiles git:(master) ✗ make upgrade       
╠ Cleaning the project...
Cleaning Xcode workspace...                                         3.1s
Cleaning Xcode workspace...                                         3.9s
Deleting .dart_tool...                                               1ms
Deleting .packages...                                                0ms
Deleting Generated.xcconfig...                                       0ms
Deleting flutter_export_environment.sh...                            0ms
Deleting ephemeral...                                                0ms
╠ Upgrading dependencies...
Resolving dependencies...

But what happens if one of the targets take precedence fails? Let’s say that we want to add two targets:

  1. run_unit - will run all the unit tests
  2. build_dev_mobile - after all the tests run and pass, we build the app with the dev flavor.

Let’s first add these targets to the Makefile:

.PHONY: clean format upgrade build_dev_mobile run_unit

# Other targets

run_unit: 
    @echo "╠ Running the tests"
    @flutter test

build_dev_mobile: clean run_unit
    @echo "╠ Running the app"
    @flutter build apk --flavor dev

If we have a failing test, we will be greeted with the following message:

➜  flutter_makefiles git:(master) ✗ make build_dev_mobile
╠ Cleaning the project...
# ...
╠ Running the tests
Running "flutter pub get" in flutter_makefiles...                2,177ms
# ...
00:09 +0 -1: Counter increments smoke test [E]                                                                                       
  Test failed. See exception logs above.
  The test description was: Counter increments smoke test
  
00:09 +0 -1: Some tests failed.                                                                                                      
make: *** [run_unit] Error 1

As we can see, the make command will fail before it reaches the build_dev_mobile target. However the error message is not clear enough: make: *** [run_unit] Error 1.

We can provide a more appropriate error message when a command fails by using the || operator:

run_unit: 
    @echo "╠ Running the tests"
    @flutter test || (echo "▓▓ Error while running tests ▓▓"; exit 1)

If we rerun make build_dev_mobile, we see that it now has our error message:

➜  flutter_makefiles git:(master) ✗ make build_dev_mobile
╠ Cleaning the project...
# ...
╠ Running the tests
# ...  
00:08 +0 -1: Some tests failed.                                                                                                      
▓▓ Error while running tests ▓▓
make: *** [run_unit] Error 1

Making use of all

We have defined plenty of phony targets, but what happens if we only call make with no targets?

By default, it will only run the first target, which may not always be what we need. Sometimes we will need to chain multiple actions such as:

  1. Do a lint analysis on our code - lint
  2. Format all our code - format
  3. Run the dev flavor - run_dev_mobile

However, we don’t want to run all of these actions whenever we decide to use run_dev_mobile, hence why we choose not to add lint and format as precedent targets to it.

First, we’d need to add two new targets for lint and running the app:

run_dev_mobile: 
    @echo "╠ Running the app"
    @flutter run --flavor dev

lint: 
    @echo "╠ Verifying code..."
    @dart analyze . || (echo "▓▓ Lint error ▓▓"; exit 1)

So, now we know we need to call in order lint, format, and run_dev_mobile. To run them every time we use the make command with no target, we define them in the all target, at the top of the file:

.PHONY: all run_dev_mobile run_unit clean upgrade lint format

all: lint format run_dev_mobile

# All other targets

Every time that we use the make command with no targets we will see the following output:

➜  flutter_makefiles git:(master) ✗ make
╠ Verifying code...
Analyzing ....                         1.2s
No issues found!
╠ Formatting the code
Formatted 2 files (0 changed) in 0.33 seconds.
╠ Running the app
# ...

BONUS - Adding a help command

Although there is no out-of-the-box solution for displaying a help message, we can use this very useful Github Gist.

As stipulated there, we can just add a comment after each target declaration so that we can then display a help message for it:

.PHONY: all run_dev_web run_dev_mobile run_unit clean upgrade lint format help

all: lint format run_dev_mobile

# Adding a help file: https://gist.github.com/prwhite/8168133#gistcomment-1313022
help: ## This help dialog.
    @IFS=$$'\n' ; \
    help_lines=(`fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//'`); \
    for help_line in $${help_lines[@]}; do \
        IFS=$$'#' ; \
        help_split=($$help_line) ; \
        help_command=`echo $${help_split[0]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \
        help_info=`echo $${help_split[2]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \
        printf "%-30s %s\n" $$help_command $$help_info ; \
    done

run_dev_mobile: ## Runs the mobile application in dev
    @echo "╠ Running the app"
    @flutter run --flavor dev

run_unit: ## Runs unit tests
    @echo "╠ Running the tests"
    @flutter test || (echo "Error while running tests"; exit 1)

# Remaining targets

Running make help will output:

➜  flutter_makefiles git:(master) ✗ make help
help:                          This help dialog.
run_dev_mobile:                Runs the mobile application in dev
run_unit:                      Runs unit tests
clean:                         Cleans the environment
format:                        Formats the code
lint:                          Lints the code
upgrade: clean                 Upgrades dependencies

Conclusion

When used properly, the Makefile can give us flexibility and speed. The only limit is our creativity.

We could use it to:

What is also good about it is that we can make it universal - which means that we could take it from project to project and have it as our major toolset for app development.

One thing to note is that it is very important that we use Tabs instead of spaces inside each target. This is especially true when copying and pasting content from the web. If we add spaces instead of tabs we will be greeted with the following error message:

Makefile:34: *** missing separator. Stop.

If you want to read more about the Makefile, you can check Sachin Patil ’s article - What is a Makefile and how does it work?

Follow me!

I often share some small insights on Flutter 💙