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?”

Image of the hello-gopherjs GitHub Action

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!