Simplifying CI/CD with GitHub Actions and Makefiles: A Full-Stack Developer‘s Perspective
In my career as a full-stack developer, I‘ve seen the CI/CD landscape evolve rapidly. From humble beginnings of hand-crafted build scripts, to the rise of powerful but often complex platforms like Jenkins and TeamCity, to the new wave of streamlined, yaml-based tools like CircleCI and GitLab CI.
Through it all, one thing has remained constant: the need for fast, reliable feedback on the quality and deployability of our code. In the age of DevOps and trunk-based development, shipping to prod early and often is the name of the game. And that requires a CI/CD pipeline that is not only rock-solid, but also flexible enough to adapt to changing needs.
The problem with platform-specific CI/CD
Many teams end up locking themselves into a particular vendor‘s ecosystem, relying heavily on platform-specific features and configuration formats. While this can provide a quick onramp and a feeling of increased productivity at first, it often leads to problems down the road.
Overreliance on proprietary tooling creates a barrier to portability. If your builds are deeply entangled with Jenkins‘ Groovy-based Jenkinsfile format, for example, it can be challenging to move to another system if the need arises. You‘re essentially betting the farm on a particular vendor, and that‘s a risky proposition in the fast-moving world of software tooling.
There‘s also the issue of complexity and maintenance burden. Many CI/CD platforms offer a dizzying array of plugins, integrations, and configuration options. While this provides a lot of power and flexibility, it can also lead to unwieldy, hard-to-understand pipelines that are prone to failure and difficult to debug.
Enter GitHub Actions
Announced in late 2018 and launched in November 2019, GitHub Actions is a relative newcomer in the CI/CD space. But it has quickly garnered attention thanks to its slick integration with the world‘s largest code hosting platform.
One of the things that sets Actions apart is its simplicity. At its core, an Actions workflow is just a YAML file specifying a set of jobs to run in response to events. Each job is composed of steps, which can be either shell commands or references to predefined Actions.
Here‘s an example workflow that runs on every push to the main branch:
name: CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build site
run: npm run build
This simplicity lowers the barrier to entry and makes it easy to understand at a glance what a workflow does. But don‘t let the simplicity fool you – Actions is capable of handling even the most complex workflows. By leveraging the power of arbitrary shell commands, Actions can be adapted to almost any use case.
Keeping it simple with Makefiles
So how do we build flexible, tool-agnostic pipelines with GitHub Actions? One approach that I‘ve found effective is to use a good old-fashioned Makefile to encapsulate the actual build and deployment logic.
Make is a venerable tool that has been a staple of Unix-based development for decades. Originally created to manage dependencies between source files and their compiled outputs, Make has evolved into a general-purpose task runner that can orchestrate any shell-based workflow.
A Makefile consists of a set of rules, each defining a target and the commands needed to build it. For example, here‘s a simple Makefile for a Node.js project:
.PHONY: all test build
all: test build
test:
npm test
build:
npm run build
This defines three targets – all
, test
, and build
– with all
depending on the other two. To run the tests and build the project, all we have to do is run make
or make all
.
Makefiles can be adapted to projects in any language or framework. Here‘s an example for a static site built with Hugo:
.PHONY: all test build deploy
all: test build
test:
htmlproofer ./public --check-html
build:
hugo --minify
deploy:
aws s3 sync ./public s3://my-bucket
The beauty of using a Makefile is that it provides a consistent, portable interface to your project‘s build and deployment tasks. Whether you‘re running locally or in a CI environment, the commands are the same. And if you ever need to switch CI providers, your Makefile can come with you unchanged.
Putting it all together
So what does a complete tool-agnostic CI/CD flow with GitHub Actions look like? Let‘s walk through an example.
First, we define our build and deployment targets in a Makefile:
.PHONY: all test build deploy
all: test build
test:
npm test
build:
npm run build
deploy: build
aws s3 sync ./dist s3://my-bucket
Next, we create a workflow file to run this Makefile on every push to main:
name: CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: ‘14.x‘
- name: Install dependencies
run: npm ci
- name: Run Makefile
run: make all
- name: Deploy
if: success()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: make deploy
This workflow does the following:
- Checks out the code
- Sets up Node.js
- Installs dependencies with
npm ci
- Runs
make all
to test and build the project - If the previous steps succeeded, runs
make deploy
to deploy to S3
Note the use of environment variables to provide the AWS credentials needed for deployment. These are stored as encrypted secrets in the repository settings, ensuring they are never exposed in logs or source control.
With this setup, every push to main will trigger a full CI/CD run, with the actual build and deployment logic defined in the Makefile. The Actions workflow serves as a lightweight adapter to run the Makefile in GitHub‘s environment.
The benefits of tool-agnostic CI/CD
So why go to the trouble of using a Makefile instead of relying on GitHub Actions‘ native features? There are several compelling reasons:
-
Portability: By encapsulating your build logic in a Makefile, you can easily run it in any environment that has Make installed, not just in GitHub Actions. This could be a local development machine, a staging server, or even a legacy Jenkins box.
-
Flexibility: Makefiles can run any shell command, so they can be adapted to almost any build or deployment scenario. You‘re not limited to the predefined actions and integrations provided by your CI/CD vendor.
-
Simplicity: A well-structured Makefile provides a clear, concise overview of your build and deployment process. Each target and its dependencies are explicitly defined, making it easy to understand the flow at a glance.
-
Maintainability: By keeping your core build logic in a Makefile, you minimize the amount of vendor-specific configuration needed in your CI/CD system. This reduces complexity and makes your pipelines easier to maintain over time.
Of course, there are tradeoffs to this approach. Makefiles can become unwieldy for very complex projects, and they don‘t provide the same level of built-in functionality and UI niceties as some CI/CD platforms.
But for many projects, a tool-agnostic approach strikes a nice balance between simplicity, flexibility, and maintainability. And when combined with the power and convenience of GitHub Actions, it enables a highly effective CI/CD flow.
The bigger picture
Tool-agnostic CI/CD is part of a broader trend in software development towards avoiding vendor lock-in and maintaining flexibility. As the pace of change accelerates and new tools and platforms emerge all the time, being able to quickly adapt is becoming increasingly important.
By building our pipelines around standard, widely-supported technologies like shell scripts and Makefiles, we give ourselves the freedom to evolve our tooling as needs change. We‘re not beholden to any one vendor‘s roadmap or ecosystem.
This doesn‘t mean we should avoid all platform-specific features. GitHub Actions‘ tight integration with pull requests and issues, for example, can provide a lot of value. But by keeping our core build and deployment logic in a neutral format, we ensure that we can always take it with us if we need to move.
Conclusion
As a full-stack developer, I‘ve learned the value of keeping things simple and maintainable. In the world of CI/CD, that means avoiding overreliance on complex, vendor-specific tooling and instead building pipelines around portable, easy-to-understand technologies.
GitHub Actions and Makefiles are a great combination for achieving this. By using a Makefile to define your build and deployment steps, and Actions to run it on every push, you can create a powerful, flexible CI/CD flow that is easy to reason about and adapt to changing needs.
Of course, this is just one approach among many. The key is to find what works for your team and project, and to continually reevaluate as circumstances change. But by striving for simplicity and portability, you‘ll be well-positioned to handle whatever the future may bring.