Simplifying CI/CD with GitHub Actions and Makefiles: A Full-Stack Developer‘s Perspective

Header image

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:

  1. Checks out the code
  2. Sets up Node.js
  3. Installs dependencies with npm ci
  4. Runs make all to test and build the project
  5. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *