Steven's Thoughts

Git Hooks and GitHub Actions

We leaned about minikube three weeks ago, then about Terraform two weeks ago. This week we will learn to use GitHub Actions to ensure that our code is tested on each code push.

Round 0: git hooks (optional)

If you have been coding for a bit, you will have pushed failing code to your public repo. This is both frustrating and, at least for me, a bit embarassing.

git hooks are a partial solution to this problem. (Only partial because it doesn't work if you don't define any tests.)

There are several types of hooks, but we will focus on pre-commit and pre-push.

pre-commit hooks are executed locally before you're allowed to write your commit message and are ideal for ensuring you cannot commit failing code. pre-push hooks are great for ensuring your integration tests succeed so you can't push broken code to your repository.

But what if you want to push some failing tests at the end of the day (because you follow TDD) and they will help you know where to start on Monday after a long weekend? Well, git hooks aren't for you.

Just kidding!

git has a --no-verify flag that prevents hooks from running, so git commit -am "Pushing newest tests" --no-verify and git push --no-verify succeeds.

Are these hooks special? They're just shell scripts, so not really.

Let's take a look at .git/hooks/pre-commit:

#!/bin/sh

go test -v ./...

The pre-commit hook alerts you to failing code as soon as possible if you make frequent commits, which is a useful practice.

You could also choose to place the same test code in .git/hooks/pre-push if you'd rather be alerted to failing tests prior to pushing them to your team or public repo.

Or you could use act, described in the following section, to perform tests before pushing your code to your repo.

You could even continue as is with no automated tests prior to commits or pushes and let your centralized test runner on GitHub run them for you with Continuous Integration tests using GitHub Actions.

A final note before getting there, however:

git doesn't commit any changes inside your .git folder, so your hooks won't be pushed to your team's repo. You could create a hooks directory in the root of your repository and copy the files there with cp ./git/hooks/* hooks to share your hooks with your team, but the choice is yours.

Now, back to our regularly scheduled tutorial.

Round 1: GitHub Actions (source)

Every Action is a YAML file in .github/workflows defined by three main sections:

  1. A name: field.
  2. An on: block defining triggers that start the job.
  3. A jobs: block specifying which jobs to run.

The name: field below gives the workflow the name of Hello World. The on: block tells us that the jobs defined will only be run when we push to the main branch or we trigger it from the Actions tab on GitHub. And the jobs: block allows us to name each job, specify the OS it runs on, and define the steps needed to run each job.

Let's take a look:

name: Hello World

on:
  # Triggers this workflow on push to the main branch of this code repository.
  push:
    branches:
      - main

  # Allows deployment to be invoked manually through the GitHub Actions user interface.
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "hello-world"
  hello-world:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs our our "Hello world" shell script.
      - name: Runs the "Hello World" shell script
        run: ./index.sh

The workflow above is triggered on every push to the main branch. It runs the hello-world job, which checks out the repo and executes the following ./index.sh shell script:

echo Hello World

Yes, it's very boring. But boring is what we need when beginning to learn.

It's worth remembering that the whole purpose of this series is to avoid pushing code to GitHub code while iterating on new project ideas, so do we really need a remote repo to run these workflows?

Thankfully, no.

act is a command line tool that runs the workflows locally to simplify your life. act can be very verbose, so I recommend using it only if act -q tells you your test fails.

Here is what success looks like:

INFO[0000] Using docker host 'unix:///var/run/docker.sock', and daemon socket 'unix:///var/run/docker.sock'
[Hello world/hello-world] πŸš€  Start image=catthehacker/ubuntu:act-latest
[Hello world/hello-world]   🐳  docker pull image=catthehacker/ubuntu:act-latest platform= username= forcePull=true
[Hello world/hello-world]   🐳  docker create image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[Hello world/hello-world]   🐳  docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[Hello world/hello-world]   🐳  docker exec cmd=[node --no-warnings -e console.log(process.execPath)] user= workdir=
[Hello world/hello-world] ⭐ Run Main actions/checkout@v3
[Hello world/hello-world]   🐳  docker cp src=/home/steven/github/bootstrapping-microservices-in-go/chapter-08/example-1/. dst=/home/steven/github/bootstrapping-microservices-in-go/chapter-08/example-1
[Hello world/hello-world]   βœ…  Success - Main actions/checkout@v3
[Hello world/hello-world] ⭐ Run Main Runs the "Hello world" shell script
[Hello world/hello-world]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
| Hello world!
[Hello world/hello-world]   βœ…  Success - Main Runs the "Hello world" shell script
[Hello world/hello-world] Cleaning up container for job hello-world
[Hello world/hello-world] 🏁  Job succeeded

…and here is what failure looks like with act -q:

INFO[0000] Using docker host 'unix:///var/run/docker.sock', and daemon socket 'unix:///var/run/docker.sock'
[Hello world/hello-world] πŸš€  Start image=catthehacker/ubuntu:act-latest
[Hello world/hello-world]   🐳  docker pull image=catthehacker/ubuntu:act-latest platform= username= forcePull=true
[Hello world/hello-world]   🐳  docker create image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[Hello world/hello-world]   🐳  docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[Hello world/hello-world]   🐳  docker exec cmd=[node --no-warnings -e console.log(process.execPath)] user= workdir=
[Hello world/hello-world] ⭐ Run Main actions/checkout@v3
[Hello world/hello-world]   🐳  docker cp src=/home/steven/github/bootstrapping-microservices-in-go/chapter-08/example-1/. dst=/home/steven/github/bootstrapping-microservices-in-go/chapter-08/example-1
[Hello world/hello-world]   βœ…  Success - Main actions/checkout@v3
[Hello world/hello-world] ⭐ Run Main Runs the "Hello world" shell script
[Hello world/hello-world]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[Hello world/hello-world]   ❌  Failure - Main Runs the "Hello world" shell script
[Hello world/hello-world] exitcode '1': failure
[Hello world/hello-world] 🏁  Job failed
Error: Job 'hello-world' failed

Failed jobs mean you have to spend some time debugging your code, which is just another day in the life.

Is your workflow passing? Great! Now you can focus on testing with Ginkgo and Gomega.

Round 2: Refactoring with GitHub Actions, CI, and Go (source)

Our Go code from earlier weeks has been a bit atrocious. Not in that it didn't work, but our func main() block violates the Single Responsibility Principle (SRP): functions should only have a single reason to change.

Our earlier main() blocks have initialized our environment variables, created an http.ServeMux, added anonymous http.HandlerFuncs to the http.ServeMux, and then started our server with http.ListenAndServe.

Before we refactor the video-streaming microservice, let's configure the workflow for our GitHub Actions.

Our GitHub Action

Our task is fairly straightforward:

  1. Checkout the repo.
  2. Install Go.
  3. Install Ginkgo for tests. (Discussed next post.)
  4. Download the Go modules.
  5. Run the tests.

In our workflow file, that looks like:

name: CI

on:
  push:
    branches:
      - main

jobs:
  CI:
    runs-on: ubuntu-latest
    steps:
      # Checkout our repo.
      - uses: actions/checkout@v3

      # Add Go
      - uses: actions/setup-go@v5
        with:
          go-version: "1.23"

      - name: Installing Ginkgo CLI
        run: go install github.com/onsi/ginkgo/v2/ginkgo

      - name: Installing modules
        run: go mod download

      - name: Running Tests
        run: ginkgo run ./...

Now the Refactoring

Our cluttered code from chapter-03:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
)

const (
	contentLength = "Content-Length"
	contentType   = "Content-Type"
)

func main() {
	port, found := os.LookupEnv(`PORT`)
	if !found {
		log.Fatal(`Please specify the port number for the HTTP server with the environment variable PORT.`)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		videoPath := "./videos/SampleVideo_1280x720_1mb.mp4"
		videoReader, err := os.Open(videoPath)
		if err != nil {
			log.Print("Not Found")
			w.WriteHeader(http.StatusNotFound)
			return
		}
		defer videoReader.Close()
		videoStats, err := videoReader.Stat()
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		w.Header().Add(contentLength, strconv.FormatInt(videoStats.Size(), 10))
		w.Header().Add(contentType, "video/mp4")
		// use io.Copy for streaming.
		io.Copy(w, videoReader)
	})

	http.ListenAndServe(fmt.Sprint(":", port), mux)
}

This code would look a lot cleaner if it were simpler, so let's create a function to build the http.ServeMux and add the http.HandlerFuncs. Let's also place that function in its own package while we're at it:

package handlers

import (
	"io"
	"net/http"
	"os"
	"strconv"
)

const (
	contentLength = "Content-Length"
	contentType   = "Content-Type"
)

func BuildMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		videoPath := "./videos/SampleVideo_1280x720_1mb.mp4"
		videoReader, err := os.Open(videoPath)
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		defer videoReader.Close()
		videoStats, err := videoReader.Stat()
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		w.Header().Add(contentLength, strconv.FormatInt(videoStats.Size(), 10))
		w.Header().Add(contentType, "video/mp4")
		// use io.Copy for streaming.
		io.Copy(w, videoReader)
	})

	mux.HandleFunc(`GET /live`, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	return mux
}

That nicely encapsulates the http.ServeMux. It uses anonymous functions for the http.HandlerFuncs and therefore doesn't follow the SRP, so let's try a second refactoring:

package handlers

import (
	"io"
	"net/http"
	"os"
	"strconv"
)

const (
	contentLength = "Content-Length"
	contentType   = "Content-Type"
)

func BuildServeMux() *http.ServeMux {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /video", getVideoHandler)
	mux.HandleFunc(`GET /live`, livenessHandler)

	return mux
}

// getVideoHandler streams a video to the browser.
func getVideoHandler(w http.ResponseWriter, r *http.Request) {
	videoPath := "./videos/SampleVideo_1280x720_1mb.mp4"
	videoReader, err := os.Open(videoPath)
	if err != nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}
	defer videoReader.Close()
	videoStats, err := videoReader.Stat()
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.Header().Add(contentLength, strconv.FormatInt(videoStats.Size(), 10))
	w.Header().Add(contentType, "video/mp4")
	// use io.Copy for streaming.
	io.Copy(w, videoReader)
}

// livenessHandler returns http.StatusOK to verify the server is live.
func livenessHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}

BuildServeMux is now much more expressive and easier to read.

But what about our new main.go? Let's have a look:

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/bootstrapping-microservices-in-go/chapter-08/example-2/handlers"
)

func main() {
	port, found := os.LookupEnv(`PORT`)
	if !found {
		log.Fatal(`Please specify the port number for the HTTP server with the environment variable PORT.`)
	}

	mux := handlers.BuildServeMux()

	http.ListenAndServe(fmt.Sprint(":", port), mux)
}

Less than 10 lines of code? That's fantastic!

What about CI?

Writing the actual Continuous Integration test code is the focus of Chapter 9 of Bootstrapping Microservices (due next post), but we can briefly touch on the topic here.

The book's testing framework of choice is Jest. The closest Go equivalent is Ginkgo combined with the Gomega matchers package.

Our test code for the handlers package looks like:

package handlers_test

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/bootstrapping-microservices-in-go/chapter-08/example-2/handlers"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestMux(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Mux Suite")
}

var _ = Describe("VideoStreamingHandlers", func() {
	mux := handlers.BuildServeMux()
	It("testing /live endpoint", func() {
		req, err := http.NewRequest(http.MethodGet, `/live`, nil)
		Expect(err).To(BeNil())

		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, req)

		Expect(rr.Code).To(Expect(http.StatusOK))
	})
})

Next week will be test-centric, so we won't dive any deeper into this for now.

Note on Git Hooks

Try the pre-commit hook now. Break the tests by expecting another status code and try to compile. The inability to commit the failing code may make a believer out of you.

Round 3: Deploying to Kubernetes with GitHub Actions (source)

Now we're almost complete. We will modify our GitHub Action from Round 2 to both test AND deploy our image to Kubernetes.

The book uses the envsubst command for templating, but we're just going to focus on CI/CD in this section for now. We will also add a pre-push hook to ensure our deployments to Kubernetes lack errors before being submitted to GitHub,

The only code change this week is to add a new job to our GitHub Action to deploy our microservice to Kubernetes. (Yes, we could use Terraform again, but we're following the book's structure since the Terraform chapter is optional.)

The main pain with testing CD is that minikube doesn't (currently) run under act, so we will use Kind as our Kubernetes platform instead in the CD section. Kind has some issues, namely that it does not support Services, but we can use NodePort for now.

All that said, here is the new GitHub Action workflow:

name: CI

on:
  push:
    branches:
      - main

jobs:
  CI:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v5
        with:
          go-version: "1.23"

      - name: Installing Ginkgo CLI and modules
        run: go install github.com/onsi/ginkgo/v2/ginkgo

      - name: Installing modules
        run: go mod download

      - name: Running Tests
        run: ginkgo run ./...

  CD:
    runs-on: ubuntu-latest
    env:
      CONTAINER_REGISTRY: "localhost:5000"
      VERSION: 1

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v5
        with:
          go-version: "1.23"

      - name: Install kind
        run: go install sigs.k8s.io/kind@latest

      - name: Start kind
        run: kind create cluster

      - name: Build image
        run: ./scripts/build-image.sh

      - name: Push image
        run: ./scripts/push-image.sh

      - name: Deploy image
        run: ./scripts/deploy.sh

Next we will take a look at the build-image.sh script:

set -u

docker build -t $CONTAINER_REGISTRY/video-streaming:$VERSION .

The set -u command prevents the script from running unless all variables are set; $CONTAINER_REGSTRY and $VERSION in this case.

Next we will load our Docker image into the Kind environment:

set -u

kind load docker-image $CONTAINER_REGISTRY/video-streaming:$VERSION

And finally, we need to download kubectl, create our Deployment, and wait for it to be available:

set -u

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

kubectl apply -f ./scripts/kubernetes/deploy.yml
kubectl wait --for=condition=ready pod -l app=video-streaming

Test act in the example-3 directory.

Conclusion

Success! Congrats!

For now, anyway. I will focus more on CI/CD with act in a future article. The ideal goal is to run minikube inside act omce I submit a few PRs and they are accepted.

In closing, thank you for your your patience for this week's article! PRs for open source sometiems take forever, unfortunately.

The next article includes front-end tests, mocks, and end-to-end testing with Playwright, so it may take two weeks. Should I use Angular instead of React?

Let me know your thoughts!

As always, thanks to Ashley Davis and his amazing Bootstrapping Microservices book!

#microservices #docker #git #github #ci #cd