Skip to content

CICD with GitHub Actions

Posted on:April 11, 2019

In a previous post, I created a Chat Bot Auto Responder. That little project was written in Go. It was deployed directly to GCP as a Cloud Function via the gcloud CLI.

I used two Cloud Functions, one for testing (responding to a sandbox channel), and one for the actual channel (production). After the initial launch, I pushed the code up to GitHub and wanted to use GitHub Actions as the CI/CD mechanism for future development.

This post describes the CI/CD workflow I wanted and what I settled on.

The Problem

It is very likely that I will be the lone developer on this project for the foreseeable future. I want each new commit to cause tests to run and then deploy the code to the sandbox (DEV) cloud function for further manual testing. When the new code is stable-enough, then I’d like the code to be pushed to the production (PROD) cloud function.

What I Wanted

I chose GitHub Actions so I didn’t have to integrate with any other third parties1.

GitHub Actions allows us a way to create workflow(s) of actions that react to certain events that occur on a given repository.

I wanted the following to occur whenever a commit is pushed to the repo:

The GitHub workflow I wanted
The GitHub workflow I wanted

When a commit is pushed to the repo, then:

  1. The unit tests are run (via a golang docker container).
  2. If the commit is pushed onto the master branch (i.e. pull-request merged), then deploy to the PROD cloud function via the GCP CLI Action (“Dummy 1” in the image).
  3. Else if the commit is pushed to a non-master branch (i.e. a commit on a feature branch), then deploy to the DEV cloud function via the GCP CLI Action (“Dummy 2” in the image).

The underlying DSL for the workflow looks like this:

# main.workflow
workflow "Test & Deploy" {
  resolves = [
    "Test",
    "Master Only",
    "Non-Master Only",
    "Dummy 1",
    "Dummy 2"
  ]
  on = "push"
}

action "Master Only" {
  uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740"
  args = "branch master"
  needs = ["Test"]
}

action "Test" {
  uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108"
  args = "build ."
}

action "Dummy 1" {
  uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108"
  args = "build ."
  needs = ["Master Only"]
}

action "Non-Master Only" {
  uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740"
  args = "not branch master"
  needs = ["Test"]
}

action "Dummy 2" {
  uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108"
  args = "build ."
  needs = ["Non-Master Only"]
}

The problem is that workflows require every action to be successful. It does not support either/or branching. This means that the “Non-Master Only” and “Master Only” filter actions have to succeed. If either of them fail, then all dependent actions are cancelled (the deploy dummy actions are cancelled).

What I Settled On

I wanted to use one workflow to cover both master and non-master branches so that I could reuse as many actions as possible. I also wanted less noise in the Actions tab on GitHub; I didn’t want a “master” workflow to run when, most of time, commits would be pushed onto non-master branches.

In order to get my desired CI/CD flow, I needed to create two workflows (that are required to live in the same main.workflow file).

The GitHub workflow I settled on
The GitHub workflow I settled on

The image shows the workflow for the master branch.

When a push is made onto master, the non-master workflow looks like:

The workflow for a non-master branch push
The workflow for a non-master branch push

Both workflows run on every single commit.

The workflow DSL then becomes:

# main.workflow
workflow "Master: Test & Deploy" {
  resolves = [
    "Deploy PROD",
  ]
  on = "push"
}

workflow "Branches: Test & Deploy" {
  resolves = [
    "Deploy DEV",
  ]
  on = "push"
}

action "Master Only" {
  uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740"
  args = "branch master"
}

action "Non-Master Only" {
  uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740"
  args = "not branch master"
}

action "Test" {
  uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108"
  args = "build ."
}

action "Auth with GCloud" {
  uses = "actions/gcloud/auth@ba93088eb19c4a04638102a838312bb32de0b052"
  secrets = ["GCLOUD_AUTH"]
}

action "Deploy DEV" {
  uses = "actions/gcloud/cli@ba93088eb19c4a04638102a838312bb32de0b052"
  needs = ["Test", "Non-Master Only", "Auth with GCloud"]
  args = "functions deploy <CLOUD_FUNC_NAME> --project <PROJECT_NAME> --runtime go111 --trigger-http"
}

action "Deploy PROD" {
  uses = "actions/gcloud/cli@ba93088eb19c4a04638102a838312bb32de0b052"
  needs = ["Test", "Master Only", "Auth with GCloud"]
  args = "functions deploy <CLOUD_FUNC_NAME> --entry-point <FUNC_ENTRY> --project <PROJECT_NAME> --runtime go111 --trigger-http"
}

Note that the GCP secret key (GCLOUD_AUTH) is stored directly on GitHub (not in source control) and belong to a GCP service account that can only manipulate these two cloud functions.

I was able to reuse the GCP authentication and test actions, though visualizing the two workflows by reading just the DSL is a bit difficult.

This workflow runs tests, authenticates to GCP, and filters the git branch all in parallel. When either of these three steps fail, all the other steps are cancelled or fail also.

Overall, not a bad setup. Deploying to DEV on every commit could trample over the work of others, but it’s okay since I’m the only developer :sweat_smile:.

Footnotes

  1. Granted, I do like CircleCI’s product offering.