GitHub released Actions a while ago, but I haven’t had a good chance to try it out until recently.

While still somewhat rough around the edges, having limitations like it being not possible to trigger an action by a click of a button (like other CI servers) - I still find them quite powerful.

By utilizing GitHub Actions, I was able to make full use of Git to make a packaged release with release notes, attached to them and pre-built artifacts uploaded.

There are many ways one could make it work, but this is how I’ve achieved this setup.

Project structure

A typical project for me has the following structure (some irrelevant files/folders ommited):

├── build/          # Ignored from git, contains build artifacts
├── CHANGELOG.md    # Changelog file containing every version's release notes
├── Dockerfile      # Dockerfile used to build the app inside of docker container
├── go.mod
├── go.sum
├── hack/           # Support scripts (we will get to them later)
├── main.go
...
└── Makefile

Versioning

I wanted to make as few operations as possible when making a release. For versioning of my project I’ve decided to go with Git tags, following semantic versioning.

When deciding which version the binary should have, following set of rules are applied:

  • If current commit is same as a latest tag - the tag name is chosen, e.g v1.0.2
  • If current commit does not match latest tag - a tag name with appended commit is used, e.g v1.0.2+a3dc218
  • If no tags have been created - current commit is appended to v0.0.0, e.g v0.0.0+a3dc218

A simple shell script (hack/version.sh) helps with this:

LATEST_TAG_REV=$(git rev-list --tags --max-count=1)
LATEST_COMMIT_REV=$(git rev-list HEAD --max-count=1)

if [ -n "$LATEST_TAG_REV" ]; then
    LATEST_TAG=$(git describe --tags "$(git rev-list --tags --max-count=1)")
else
    LATEST_TAG="v0.0.0"
fi

if [ "$LATEST_TAG_REV" != "$LATEST_COMMIT_REV" ]; then
    echo "$LATEST_TAG+$(git rev-list HEAD --max-count=1 --abbrev-commit)"
else
    echo "$LATEST_TAG"
fi

Building a project

I use make to build my Go projects, and also to start docker build process (make is also used inside of the container). When building the project, I also want to inject the version string into a binary (this way myapp --version can respond with the version info).

This is achieved with a Makefile that can look something like this:

APP_VERSION=$(shell hack/version.sh)
GO_BUILD_CMD= CGO_ENABLED=0 go build -ldflags="-X main.appVersion=$(APP_VERSION)"

BINARY_NAME=my-app
BUILD_DIR=build

.PHONY: all
all: clean lint test build-all package-all

.PHONY: lint
lint:
	@echo "Linting code..."
	@go vet ./...

.PHONY: test
test:
	@echo "Running tests..."
	@go test ./...

.PHONY: pre-build
pre-build:
	@mkdir -p $(BUILD_DIR)

.PHONY: build-linux
build-linux: pre-build
	@echo "Building Linux binary..."
	GOOS=linux GOARCH=amd64 $(GO_BUILD_CMD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64

.PHONY: build-osx
build-osx: pre-build
	@echo "Building OSX binary..."
	GOOS=darwin GOARCH=amd64 $(GO_BUILD_CMD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64

.PHONY: build build-all
build-all: build-linux build-osx

.PHONY: package-linux
package-linux:
	@echo "Packaging Linux binary..."
	tar -C $(BUILD_DIR) -zcf $(BUILD_DIR)/$(BINARY_NAME)-$(APP_VERSION)-linux-amd64.tar.gz $(BINARY_NAME)-linux-amd64

.PHONY: package-osx
package-osx:
	@echo "Packaging OSX binary..."
	tar -C $(BUILD_DIR) -zcf $(BUILD_DIR)/$(BINARY_NAME)-$(APP_VERSION)-darwin-amd64.tar.gz $(BINARY_NAME)-darwin-amd64

.PHONY: package-all
package-all: package-linux package-osx

.PHONY: docker
docker:
	docker build --force-rm -t $(BINARY_NAME) .

.PHONY: build-in-docker
build-in-docker: docker
	docker rm -f $(BINARY_NAME) || true
	docker create --name $(BINARY_NAME) $(BINARY_NAME)
	docker cp '$(BINARY_NAME):/opt/' $(BUILD_DIR)
	docker rm -f $(BINARY_NAME)

.PHONY: clean
clean:
	@echo "Cleaning..."
	@rm -Rf $(BUILD_DIR)

Now it’s simply a matter of running make all to produce binaries for OSX and Linux and package them in .tar.gz files.

One could also run make build-in-docker to run everything in Docker container and then copy results out. This is the command we will be using to build our project with Github Actions, as it allows us to ensure additional required tooling can be installed without poluting the builder system.

Following Dockerfile is enough to make it work:

FROM golang:1.13-alpine

RUN apk --no-cache add alpine-sdk
WORKDIR /src

# Copy over dependency file and download it if files changed
# This allows build caching and faster re-builds
COPY go.mod  .
COPY go.sum  .
RUN go mod download

# Add rest of the source and build
COPY . .
RUN make all

# Copy to /opt/ so we can extract files later
RUN cp build/* /opt/

Action - Build

Now it’s time to setup a build workflow for our project. Workflows for GitHub Actions are placed in .github/workflows folder. I want my builds to run on both push to master as well as pushes to PRs that are targeting master.

My .github/workflows/build.yml would look like this:

name: Build

on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - master

jobs:
  build:
    name: Build on push
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@master

      - name: Build project
        run: |
          make build-in-docker

The workflow we have simply has two steps:

  1. Checkout code
  2. Build with make build-in-docker

Great, now we have validation of all PRs. It’s also a good idea to require status checks to pass before PR can be merged).

Action - Release

For our release, I want to create a new release and upload both a changelog and packaged artifacts to the GitHub release page of my projects.

While GitHub provides official create-release and upload-release-asset steps, I found them very limiting, especially the upload-release-asset action which required exact name to be specified and only one file upload per step. This can quickly become a nightmare to handle if I want to include version name in the filename and support cross-platform builds.

Luckily, there is a community provided softprops/action-gh-release action which cobines both creation and asset upload steps and supports glob matching for files. To make things even better, it can read the contents of Release “body” (release notes) from a file in my workspace.

Putting it to work, my .github/workflows/release.yml workflow looks like this:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    name: Create Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@master

      - name: Build project
        run: |
          make build-in-docker

      - name: Generate Changelog
        run: |
          VERSION=$(hack/version.sh)
          hack/changelog.sh $VERSION > build/$-CHANGELOG.md

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          body_path: build/$-CHANGELOG.md
          files: build/my-app-*.tar.gz
        env:
          GITHUB_TOKEN: $

This workflow will only trigger on tags that start with v, e.g v1.2.3, and will do the following:

  1. Checkout code
  2. Build it with make in docker
  3. Run a script to generate changelog for the version we are building and save it
  4. Create a GitHub release for this tag, use generated changelog for release notes and upload all archive files from build directory.

Note: You do not need to configure the GITHUB_TOKEN secret as it's automatically injected for you by runner agent.

Nice, clean and easy !

Bonus - Changelog

I’ve mentioned I had entire changelog in one file - CHANGELOG.md, and you can also see the generation step calling hack/changelog.sh.

Here is how they look like:

## 1.1.0

### Improvements

* Improved failure handling
* Reduced execution time for all operations by 10%

### Bug fixes

* Panic caused by renewal of credentials (#38)
* Certificate name does not follow standards (#32)

## 1.0.0

This is the initial release.

### Known issues

* Failure handling is far from ideal
* Operations can take long time to complete

#!/bin/sh

MARKER_PREFIX="##"
VERSION=$(echo "$1" | sed 's/^v//g')

IFS=''
found=0

cat CHANGELOG.md | while read "line"; do

    # If not found and matching heading
    if [ $found -eq 0 ] && echo "$line" | grep -q "^$MARKER_PREFIX $VERSION$"; then
        found=1
        continue
    fi

    # If needed version if found, and reaching next delimter - stop
    if [ $found -eq 1 ] && echo "$line" | grep -q -E "^$MARKER_PREFIX [[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+"; then
        found=0
        break
    fi

    # Keep printing out lines as no other version delimiter found
    if [ $found -eq 1 ]; then
        echo "$line"
    fi
done

By running the hack/changelog.sh v1.1.0 we would get only the relevant changelog for that version..

Conclusion

I find GitHub Actions great for simpler projects and they provide a lot of flexibility where traditionally we had to use TravisCI or Jenkins. With Actions being extendable, we can most likely fullfil more complex scenarios too, but this remains to be seen.

GitHub is being very generous by supporting both Public and Private repos for free with Actions (with generous limits on private ones before payment is required, unlike others) and we can also bring our own workers.. (Wait, can I run this on my home server?!).