Portable CI/CD with pure Go GitHub Actions
I recently converted the govim
project to use GitHub
Actions. The move away from TravisCI was largely
motivated by more generous concurrency limits (GitHub’s
20
jobs vs TravisCI’s 5), faster job startup times, and solid cross-platform support. But there was also the promise of
making it easy to extend workflows with composable third-party actions. This post demonstrates how to write
cross-platform, pure Go GitHub actions that you can use in your workflows and share with others. But first we start by
motivating the real problem we are trying to solve.
Wait, there’s a problem with GitHub Actions?
Julien Renaux wrote a blog post that does a good job of laying out one of the core problems with GitHub Actions. The story goes roughly like this:
- someone writes and open-sources an action that requires secret credentials, e.g. DockerHub access token
- lots of people start using the action via directives like
uses: good/action@v1
because it’s well written and useful - original author welcomes a new maintainer on board
- somehow existing action version tags get moved, pointing to malicious code that steals secrets (any maintainer can update a branch or a tag)
Hence the specific advice is to use a commit hash to partially mitigate this risk:
That’s why you need to specify the exact version (action@sha1) you want to use, so further changes won’t impact you.
— Alain Hélaïli (@AlainHelaili) December 12, 2019
It is somewhat unfortunate at best that this isn’t the default advice in the official documentation; worth noting it doesn’t defend against the commit disappearing.
The problems don’t stop there, because there is also the risk that transitive dependencies can do malicious things too:
There is also a risk that the transitive dependencies can do malicious things.https://t.co/juffjxwxIr
— DrSensor👹 (@dr\_sensor) December 20, 2019
On top of this, it’s not made particularly clear to users that every action they use in their workflow is given implicit access to an access token that has fairly wide-ranging read-write access to the host repository.
So we clearly have a software dependency problem here.
Why Go?
Russ Cox has repeatedly
written about “Our Software Dependency Problem.” The basic
premise of those articles is that “software dependencies carry with them serious risks that are too often overlooked.”
Whilst Russ’ articles raise awareness of the risks and encourage more investigation of solutions (and I strongly
encourage you to read the article in full), the bottom line is that Go has a comprehensive solution to the major
problems outlined, via the Go Module Mirror, Index, and
Checksum Database, that ultimately results in the go
command referencing an auditable
checksum database to authenticate modules. Coupled with the minimum version
selection property of Go modules, we have ourselves a verifiable way to run exactly
the (third party) action code we previously audited (you all audit your dependencies, right?)
The slight wrinkle
At the time of writing (2020/02/04), GitHub does not natively support writing actions in Go:
Hi @github - please can you provide a native way to write/use actions written in Go, that would allow me to do something like:
— Paul Jolly (@\_myitcv) February 3, 2020
uses: $package@$version
which would then use https://t.co/fGOHqwoWSA for resolution, and https://t.co/hqG8e8gGf6 for verification. Thanks #golang
Instead, you have the choice of writing either:
- Docker container-based actions (Linux only; Docker also works on Windows but the official GitHub Actions docs don’t yet list that as “supported”)
- JavaScript-based actions (Linux, macOS, Windows)
With the goal of being fully cross-platform in mind, Docker actions are therefore ruled out.
I fell out of love with JavaScript a long time ago, a process that was accelerated by my working on GopherJS (a compiler from Go to JavaScript). Having to return to its “unique” approach didn’t exactly fill me with glee, but given the current state of affairs there was, seemingly, no other option. Indeed, the first couple of iterations of writing pure Go GitHub actions used GopherJS and the Go’s WebAssembly port. However, both fell a long way short because neither support fork/exec syscalls.
The solution
With half a mind to GitHub eventually shipping native support for Go actions, I instead landed on a solution that uses a
light JavaScript wrapper around the go
command. Let’s explore that approach by writing an action.
But first, let’s start by defining what our toy action will do. Incorporated into a workflow, this toy action will take a single input, the user’s name, and will output a line like:
Hello, Helena! We are running on linux; Hooray!
(obviously adapted to the name of the user and the platform on which our workflow is running).
Creating a module for our action
The documentation for cmd/go
says of modules:
A module is a collection of related Go packages. Modules are the unit of source code interchange and versioning.
The is precisely the definition we are after when it comes to GitHub Actions: we want users of the action to express their dependency on semver versions of our action.
We start therefore by creating a module:
$ go mod init github.com/myitcvblog/myfirstgoaction
go: creating new go.mod: module github.com/myitcvblog/myfirstgoaction
Before we define the action itself, we briefly discuss a key building block: the GitHub Actions API.
GitHub Actions API
GitHub Actions has an API for action authors which is published as an official GitHub Actions SDK for Node.js. Seth Vargo has put together an unofficial GitHub Actions SDK for Go that “provides a Go-like interface for working with GitHub Actions.” Thank you, Seth!
Briefly skimming the SDK documentation, it’s clear to see how we will be getting our input, the name of the user:
// GetInput gets the input by the given name.
func GetInput(i string) string
We now have the relevant pieces in place to define our action.
The Go code
The Go code is now, therefore, the simplest part of this action’s definition.
$ cat main.go
package main
import (
"fmt"
"github.com/sethvargo/go-githubactions"
)
func main() {
name := githubactions.GetInput("name")
fmt.Printf("Hello, %v! We are running on %v; Hooray!\n", name, platform())
}
The platform-specific bit we will put behind build constrained files to demonstrate that aspects works too:
$ cat platform_linux.go
package main
func platform() string {
return "linux"
}
Hopefully the contents for platform_darwin.go
and platform_windows.go
are obvious.
Creating an action metadata file
The next step is to create an action metadata file:
$ cat action.yml
name: 'Greeter'
description: 'Print a platform-aware greeting to the user'
inputs:
name:
description: 'The name of the user'
required: true
runs:
using: 'node12'
main: 'index.js'
Notice how we are running using NodeJS with an entry point of index.js
; we talk about that next.
The index.js
entry point
Whilst we await native support for pure Go GitHub Actions, the simplest solution to running Go actions is a thin NodeJS
wrapper around cmd/go
. For now this should be copy-pasted for each action you create:
$ cat index.js
"use strict";
const spawn = require("child_process").spawn;
async function run() {
var args = Array.prototype.slice.call(arguments);
const cmd = spawn(args[0], args.slice(1), {
stdio: "inherit",
cwd: __dirname
});
const exitCode = await new Promise((resolve, reject) => {
cmd.on("close", resolve);
});
if (exitCode != 0) {
process.exit(exitCode);
}
}
(async function() {
const path = require("path");
await run("go", "run", ".");
})();
Clearly copy-pasting this boilerplate, even in the short term, is not ideal. I am looking at ways to simplify and automate this step using a Go tool (ideas also welcomed).
Using our action
Now let’s switch to creating a project that uses the Greeter action in one of its workflows:
$ go mod init github.com/myitcvblog/usingmyfirstgoaction
go: creating new go.mod: module github.com/myitcvblog/usingmyfirstgoaction
$ cat .github/workflows/test.yml
on: [push, pull_request]
name: Test
jobs:
test:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/setup-go@9fbc767707c286e568c92927bbf57d76b73e0892
with:
go-version: '1.14.x'
- name: Display a greeting
uses: myitcvblog/myfirstgoaction@7096ad461380fef1c02fb53935bd26153465906b
with:
name: Helena
We specify a matrix of all platforms to demonstrate our action truly is cross-platform.
Given GitHub Actions don’t natively support Go actions, and as we demonstrated in our index.js
wrapper, we have to use
the go
command. We therefore must have actions/setup-go
as our first step in
any workflow that uses a Go action of this sort (until native actions come along).
Finally, both uses: actions/setup-go
and uses: myitcv/myfirstgoaction
specify specific commits, per advice earlier
in this post.
That’s it! Let’s commit, push and watch the build succeed!
So what would native actions look like?
There are a few problems with the approach outlined above:
- we need to explicitly install Go
- we need to copy-paste our
index.js
wrapper for each Go action we create - we are not relying on the Go module proxy when using the action and hence have to specify a commit rather than a semver version
Points 1 and 2 clearly disappear when native support is added.
Point 3 is particularly brittle because commits themselves can disappear from GitHub (force pushing to master
, commit
no longer referenced by any tags or branches, gets cleaned up).
Therefore, given point 3 we ideally would use our action in a workflow in the following way:
- name: Display a greeting
uses: github.com/myitcv/myfirstgoaction@v1.0.0
with:
name: Helena
such that when running the action, GitHub’s infrastructure:
- creates a temporary module
- resolves the Go package
github.com/myitcv/myfirstgoaction
at versionv1.0.0
via proxy.golang.org - runs the action via
go run github.com/myitcv/myfirstgoaction
Notice, the package path and module path being equal is just a coincidence of this example
Conclusion
Go provides some novel solutions to the problems of software dependencies. In this article I have demonstrated one way in which pure Go actions can be written today (whilst we await native support from GitHub), leveraging the benefits and protections of the Go Module Mirror, Index, and Checksum Database. Ultimately we all need to review our software dependencies, but at least Go makes it easier to know that the world hasn’t changed under our feet from build-to-build.
Appendix
All of the source code used in this blog post is available on GitHub:
With thanks to Daniel Martí for reviewing this post.
Edit 2020-02-13: move to using github.com/myitcvblog
as the home for these examples
Edit 2020-02-13: I’ve now raised an issue to request native support for pure Go actions
Edit 2020-03-04: Updated link to Julien Renaux’s blog post now that original tweet is no longer availabe