Date: 2023-03-11
Developing a custom GitHub Action that uses a Docker image is slow. Documentation for how to build an Action on top of closed-source code is hard to find. This post aims to fill the documentation gap and provide a solution to the slow development problem.
I'll lay out a simple "private" Go project to use as a basis for a custom Action, explain how to build a public Action for the project, and finally construct a fast, local toolchain for development.
Let's say we have a Go project in a private repository that looks like this:
custom-action-demo-code
├── cmd
│ ├── myproject
│ │ └── main.go
├── go.mod
└── pkg
└── mylib
└── mylib.go
// cmd/myproject/main.go
package main
import (
"fmt"
"github.com/michaelmdresser/custom-action-demo-code/pkg/mylib"
)
func main() {
fmt.Println("Hello! This is the main project.")
mylib.PrintSomething()
}
// pkg/mylib/mylib.go
package mylib
import ( "fmt" )
func PrintSomething() {
fmt.Println("something!")
}
We normally release this project by building cmd/myproject/main.go
and distributing the binary (possibly with Docker).
We now want to build a variant of this project to be distributed as a publicly-available GitHub Action.
First, let's update the project a bit for this use-case.
custom-action-demo-code
├── cmd
│ ├── myproject
│ │ └── main.go
│ └── myprojectvariant
│ ├── Dockerfile
│ └── main.go
├── go.mod
└── pkg
└── mylib
└── mylib.go
// cmd/myprojectvariant/main.go
package main
import (
"fmt"
"github.com/michaelmdresser/custom-action-demo-code/pkg/mylib"
)
func main() {
fmt.Println("Hello! This is the variant.")
mylib.PrintSomething()
}
# cmd/myprojectvariant/Dockerfile
FROM golang:latest as builder
WORKDIR /project
COPY . .
RUN ["go", "build", "-o", "/project/out",
"cmd/myprojectvariant/main.go"]
FROM alpine:latest
COPY --from=builder /project/out /project/out
CMD ["/project/out"]
The program we want to run as our custom Action is cmd/myprojectvariant/main.go
, packaged via the Dockerfile
. All we need to do is follow GitHub's guide, right? Not quite.
For our Action to be available to the public, its definition file "action.yaml
" must be in a public GitHub repository. However, our project is closed-source and we don't want to open source it just for the sake of making our Action. This means the following type of Docker Action isn't available:
Fortunately, GitHub has an answer. Docker Actions can use a pre-built container:
This means we can build a Docker image from our closed-source code and reference that image in the open-source action.yaml
.
Let's make a new public repo for our action.yaml
and add a testing workflow.
custom-action-demo
├── .github
│ └── workflows
│ └── test.yaml
├── README.md
└── action.yaml
# action.yaml
name: 'Run my project variant'
description: 'Runs an external Docker container'
runs:
using: 'docker'
image: 'docker://myprojectvariant:12345'
# .github/workflows/test.yaml
name: Test
on: [workflow_dispatch]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the the Action repository
uses: actions/checkout@v3
- name: Run the current version of the custom Action
# This is an interesting way to call a locally-defined
# action.yaml. The path is expected to contain a file
# called action.yaml or action.yml which will be run
# as the Action.
#
# If action.yaml is in a subfolder of the respository
# then this "uses:" statemement should be the path of
# the folder containing the action.
#
# E.g. if the action is in ./actions/foo/action.yaml,
# the statement should be "uses: ./actions/foo"
uses: ./
This addresses the closed-source problem. Now we can push a public image, update our action.yaml
with that image, and have a functional Action. However, there are problems with the testing experience.
Imagine a full test cycle involving a code change in our closed-source repository:
mylib.go
action.yaml
with the new image tagaction.yaml
update to the public GitHub repositoryaction.yaml
on the public GitHub repositoryThis is slow:
And there are other problems beyond speed:
act
There is a wonderful project called act which is designed to run Actions workflows locally. Our test is a workflow, so we can use act
to run it.
In custom-action-demo
:
$ act -l
Stage Job ID Job name Workflow name Workflow file Events
0 test test Test test.yaml workflow_dispatch
We can run our test with act -j test
, eliminating most problems with steps 4, 5, and 6.
We can fix the rest of our problems by taking advantage of the local Docker registry, which act
can use to "pull" the image for our custom Action. Instead of a remote image, we can set the image:
field of action.yaml
to an image in our local registry.
Putting all of these ideas together, here's a new testing workflow:
mylib.go
action.yaml
with the new (local) image tagact -j test
Finally, we can package this flow into a single command. I'm going to wrap steps 2, 3, and 4 up using just; feel free to use your favorite tool instead, like make
or a Bash script.
custom-action-demo
├── .github
│ └── workflows
│ └── test.yaml
├── README.md
├── action.yaml
└── justfile
# justfile
tag := `date -u +%s`
image := "myprojectvariant:" + tag
build:
cd ../custom-action-demo-code && \
docker build \
-f ./cmd/myprojectvariant/Dockerfile \
. \
-t "{{image}}"
updateaction:
sed -i \
's|^ image:.*$| image: "docker://{{image}}"|' \
action.yaml
test: build updateaction
./bin/act -j test --pull=false
Now a simple just test
will build the image, update the Action, and run our test job locally! No network overhead, no Actions runner overhead, no losing focus.
If you want to save even more time in local development, the go build
step can be done outside of Docker to take advantage of the Go toolchain's caching. The resulting binary is then copied into a Docker container. Here's the new build
definition in justfile
and new Dockerfile
:
# justfile excerpt
# Build the binary for the alpine container
buildenv := "GOOS=linux GARCH=amd64 CGO_ENABLED=0"
build:
cd ../custom-action-demo-code && \
{{buildenv}} go build \
-o myprojectvariant \
cmd/myprojectvariant/main.go
cd ../custom-action-demo-code && \
docker build \
-f ./cmd/myprojectvariant/Dockerfile \
. \
-t "{{image}}"
# cmd/myprojectvariant/Dockerfile
FROM alpine:latest
COPY myprojectvariant /project/myprojectvariant
CMD ["/project/myprojectvariant"]
Source code for this blog post can be found on GitHub: