GitHub Actions is a great tool for creating custom workflows for building, testing, and deploying your code. They’re flexible and pretty easy to get started. According to the documentation on creating custom actions, there are three supported ways to create custom actions:
- Using JavaScript
- Using a Dockerfile (or Docker image)
- Composite Actions (multi-step builds that can also include shell scripts)
Typically if you want to write custom actions in Go, you have to use a Docker-based approach, but I was getting ready to write some actions to re-use elsewhere and started to wonder, “Could I use GopherJS to create the actions in Go instead?”
You can find all of the code mentioned in this post in this repository: steveyackey/hello-gopherjs
What is GopherJS?
GopherJS is a compiler that takes Go code and turns it into JavaScript that can be used in the browser. If you’d like to try it out, there are instructions in their GitHub repository: https://github.com/gopherjs/gopherjs
Why not use the Dockerfile?
Why not just use the Dockerfile approach? Why even convert to JavaScript with GopherJS when there’s already a supported method?
First, GitHub states in their documentation that Docker-based actions are slower than JavaScript actions. How much slower? It probably depends on the base image you’re using. Docker’s free plan also changed their limits to 100 requests per hour and 200 requests per six hours, so if you’re going to be building frequently (or if others using it a lot), the free plan may cause you some issues (though you could host elsewhere). Also, if you aren’t super familiar with Docker, using GopherJS will allow you to get started without having to learn Docker first.
Lastly, have you ever had a random question pop into your head that you just can’t let go of until it’s answered? Well, that’s what happened to me, and here we are.
Let’s jump in!
My goal was to create an action based on the JavaScript example found here: Creating a JavaScript action - GitHub Docs. I tweaked things a bit, because I wanted to try using both plain Go code to get the environment variables and set outputs, as well as Seth Vargo’s go-githubactions package.
For an action to be valid, it first requires an action.yml
file. Here’s the one we’ll be working with:
name: "Hello GopherJS"
description: "Greet two people and record the time"
inputs:
first:
description: "Who to greet first via the environment variable"
required: true
default: "World"
second:
description: "Who to greet second via go-githubactions"
required: true
default: "World"
outputs:
one:
description: "The time we greeted you first"
two:
description: "The time we greeted you second"
runs:
using: "node12"
main: "index.js"
The action.yml
file defines the parameters for the action. In our hello-gopherjs
action, we’ve got two inputs: first
and second
, which are people to greet. The outputs are one
and two
, representing the times and order people were greeted.
Let’s see some Go!
The Go code behind the action uses the inputs, greets people (which will be seen in the action logs), and outputs a time for other steps to use. We’ll print that out at the end to make sure it worked.
package main
import (
"fmt"
"os"
"time"
githubactions "github.com/sethvargo/go-githubactions"
)
func main() {
fmt.Printf("Hello, %s! I learned your name by directly accessing the environment variable. \n", os.Getenv("INPUT_FIRST"))
fmt.Printf("::set-output name=one::%s \n", time.Now())
fmt.Printf("Hello, %s! I learned your name from go-githubactions. \n", githubactions.GetInput("second"))
githubactions.SetOutput("two", time.Now().String())
}
Lines 12-13 are using the standard method for accessing inputs and setting outputs outside of JavaScript. Inputs are set to environment variables (which become uppercase and prefixed with INPUT_). Outputs are set by printing with the following syntax:
::set-output name=<nameOfOutput>::<value>
Managing that manually isn’t bad, but wouldn’t if we didn’t have to remember that syntax? I recently found the go-githubactions
package and thought it would be fun to try it in the action as well. Using inputs and outputs from actions are much easier using it.
// Getting Inputs doesn't require changing to uppercase.
// Instead it uses the input name as-is:
githubactions.GetInput("myVariableFromActions"))
// Setting an output also is simpler.
githubactions.SetOutput("myOutputName", "valueToOutput")
Running into Challenges
I’ve not used GopherJS a lot in the past, but was excited to dive in. When I ran gopherjs build
prior to adding go-githubactions
, everything worked fine. But, I quickly learned that GopherJS as of 1.16.2 doesn’t natively support using Go modules (and I had already run go mod init
).
$ gopherjs build ./main.go -o index.js
cannot find package "github.com/sethvargo/go-githubactions" in any of:
/usr/local/go/src/github.com/sethvargo/go-githubactions (from $GOROOT)
/home/steve/go/src/github.com/sethvargo/go-githubactions (from $GOPATH)
Module support for GopherJS is on the horizon, but in the meantime, Jonathan Hall wrote a great blog post about how to handle the packages while maintaining a using modules.
Overcoming the Problem
When writing my workflow to build, test, and commit the action, I used what I had learned from Hall’s post and ran go mod vendor
from inside my GOPATH to allow me to keep running Go 1.16 and use modules. Here’s the resulting workflow I created to build, test, and commit the action code:
name: Hello GopherJS
on:
push:
jobs:
hello-gopherjs:
name: Hello GopherJS
runs-on: ubuntu-20.04
env:
workdir: ./go/src/hello-gopherjs
basefile: index
steps:
# Get the branch name to use later in the auto-commit action
- name: Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
id: extract_branch
# Checkout the repository
- uses: actions/checkout@v2
with:
path: ${{ env.workdir }} # we checkout to a directory that will end up in our GOPATH
# Setup Go 1.16
- uses: actions/setup-go@v2
with:
go-version: "^1.16"
# Install GopherJS, set the GOPATH, vendor the dependencies, and build/minify the action
- name: Build
working-directory: ${{ env.workdir }}
run: |
GO111MODULE=off go get -u github.com/gopherjs/gopherjs
export GOPATH=${{ github.workspace }}/go
go mod vendor
GOPHERJS_GOROOT="$(go env GOROOT)" gopherjs build -o ${{ env.basefile }}.js -m
# Test running the action
- name: Hello GopherJS
uses: ./go/src/hello-gopherjs # can't use the env variable here or we get an error
id: hello
with:
first: Steve
second: Lina
# Print out the outputs from the previous step
- name: Get time
run: |
echo "First greeting was at ${{ steps.hello.outputs.one }}"
echo "Second greeting was at ${{ steps.hello.outputs.two }}"
# Auto-commit the resulting .js files
- uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Updated hello-gopherjs GitHub Action src
branch: ${{ steps.extract_branch.outputs.branch }}
file_pattern: ${{ env.basefile }}.js*
repository: ${{ env.workdir }}
The resulting index.js and index.map.js files together are a little under 1MB. When running go build main.go
, the resulting binary is about 2MB.
Final Thoughts
Overall, using GopherJS to help create custom GitHub Actions has been a good experience. I think it will be an even better experience as full module support comes to GopherJS. It was great not having to build the Dockerfile before running the action or decide where to host the Docker image. Try it out, and see what you can make!