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:
- Checkout code
- 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:
- Checkout code
- Build it with make in docker
- Run a script to generate changelog for the version we are building and save it
- 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?!).