By: abhijoshi
Date: Feb. 28, 2025, 11:50 a.m.
Tags: Actions Github
A couple months ago, while doing some admin on my website server, I accidentally deleted an essential folder in my filesystem (never use rm
without checking what you are doing first!) which meant that I had to restore my webserver from scratch all of a sudden. Thanks to the design of my webserver and my efforts to automate the deployment using ansible, terraform and other infrastructure tools, recovering the web server was generally easy, however, I did not have a straightforward way of recovering my written articles, as they had not been packed up anywhere outside my webserver. At the time I only had a small number of articles posted, and so I was able to recover and re-post what was written manually, however, this spelled a major need to find some way of backing up and posting articles in such situations where the web server is out of commission. Though one possibility is to set up some sort of disk backup, that can allow us to restore the articles (and the full state of the server) after a failure, that fact that I wanted to actually find a more programmatic way of writing and posting articles in the first place. This is why I decided to think of creating an automation tool that would automatically take some sort of article file from a central location and post this programatically to the server. I plan to write a separate article on how I came to the setup I have, the TLDR is that I would commit articles in the form of .md files into git, and I would make use of some Github Actions based automation to detect when a new article was added, and upload this article. The action would be written in Go (as initially I thought it would be useful to be able to trigger a script from Github to upload articless). Unfortunately however, on further research, Github did not actually play particularly well with Go to create actions (or any language other than JavaScript for that matter). The rest of this article goes into a bit more detail on how I actually managed to create this action.
Over the past few weeks, I have written both a test github action and a private "Article Uploader" github action, which at the moment is private, but is the action that enables the article upload behaviour described above. Jumping back to the hello-world-go-action, this repository was creates as a proof of concept for writing Github Actions in Go. The structure of the repository is simple to enable its core functionality, and borrows a lot of ideas from the following articles to achieve the result we would like: How We Write GitHub Actions in Go and go-githubactions. I further integrated GitHub Actions workflows to automatically build and commit binaries to the repository, so this process need not be done manually. This can be seen in the buildgo.yaml
action. Our simple action call can be seen in greet.yaml
, where we can see that we do infact call our repository to say hello!
It is possible to write custom GitHub Actions which can then be used from your workflows with the uses
directive. We want to write this Action in Go, which according to GitHub, is possible, however, if you write an Action in anything but JavaScript, then it cannot run natively, meaning it needs to be packaged as some sort of containerized piece of code to be run (where, when using a container, you can either be built and run within an action, or can be pulled from a container registry and run) or needs to have its programming environment set up by previous action steps before the non-JavaScript code can be run. These approaches both come with their downsides however. Outlined in more detail in "How We Write GitHub Actions in Go" above, but in summary, both running non-natively and as a container introduce significant slowdowns in the running time of any Action that uses it. There is another approach however, because Go can be built and packaged essentially as a single binary, we can then introduce a small JavaScript shim to call our binary. In our case, our small JS shim is index.js
and we compile all our main.go
binaries into the bins
folder. All of this work is directly based off of the above resources (the article and Go github actions module). Furthermore, because JavaScript runs natively in Github, our "Go" action also runs natively, and we do not need to mess with containers or setting up an execution environment.
Let's now look at how some of these files are actually structured.
This file essentially discovers what os and architecture we are working with, then runs the relevant binary as a subprocess. We inherit the stdIO from our parent process so that log messages and any variables set within our Go environment are readable by JavaScript, but other than this, there is nothing to special here.
Outlining the two functions specifically, here we discover our host os and set the binary we want to run.
function chooseBinary() {
const platform = os.platform()
const arch = os.arch()
if (platform === 'linux' && arch === 'x64') {
return `main-linux-amd64-${VERSION}`
}
if (platform === 'linux' && arch === 'arm64') {
return `main-linux-arm64-${VERSION}`
}
console.error(`Unsupported platform (${platform}) and architecture (${arch})`)
process.exit(1)
}
And here, we run our binary:
function main() {
const binary = chooseBinary()
const mainScript = `${__dirname}/bins/${binary}/${binary}`
console.log(`Using file ${binary}`);
const spawnSyncReturns = childProcess.spawnSync(mainScript, { stdio: 'inherit' });
const status = spawnSyncReturns.status
if (status != 0) {
console.log(`Failed exit status of ${status}`);
console.log(spawnSyncReturns.error)
process.exit(status);
}
process.exit(0);
}
Our main.go
file essentially just recreates the GitHub JavaScript Hello World Action within Go. We get an input variable, and then log some messages and send back an output, which is read by our workflow.
func main() {
action := githubactions.New()
val := action.GetInput("who-to-greet")
time := time.Now().String()
//---
defer func() {
if err := recover(); err != nil {
log.Println("Running locally")
}
}()
action.SetOutput("time", time)
log.Println("Running on Github")
}
The main things to not here, even though we use our GetInput function to get the GitHub actions input, we can essentially just use the built in os
module from go and read the env variable INPUT_<CAPS REPRESENTATION OF VAR NAME
so if your Action input was who-to-greet
as above, you would essentially get the same result by grabbing the input variable INPUT_WHO-TO-GREET
. Similary the SetOutput
function essentially just writes the key value pair of your output to GITHUB_OUTPUT
. So in essence, it is equivalent to the Bash:
echo "time=${time}" >> $GITHUB_OUTPUT
One thing I did, just for testing, is to recover from the panic that the SetOutput
function triggers when it is not in a Github runner environment, to
This workflow essentially builds our Go binaries and commits them back into our repository in the bins
folder.
We first find out if anything actually needs to be built at all in this step here:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if Go files changed
id: check_changes
run: |
if git diff --name-only HEAD~1 HEAD | grep -qE '*.go$'; then
echo "compile_required=true" >> $GITHUB_OUTPUT
else
echo "compile_required=false" >> $GITHUB_OUTPUT
fi
We then compile our binaries for each architecture we are building for and use GitHub artifacts to pass the compiled binaries between our isolated matrix run environments, and a consolidated commit environment from where we can commit all our builds together. This step actually took me some time to figure out how to do, as matrix runs are concurrent (by default) and spin up individual isolated runners to execute. The rest of the workflow is standard for building Go binaries. Eventually, testing will be added here to make this process more complete and error resistant.
This is actually how the action would be used under normal operation. It is essentially GitHub's Hello World example for actions, we call our custom GitHub action to greet ourselves. We can specify a name to greet, and we are returned that greeting as well as the time of execution. See creating a JavaScript action for more details on how standard actions work.
This is essentially the configuration for our action. We define any input variables required, output variables, as well as our entrypoint, which is our JavaScript shim.
Putting everything together, we now have our own github action which you can call and run as you would one from the GitHub marketplace! An example of this action (in action) can be seen below:
Another feature of this repository, even though it is not part of running the action, is the automatic builds of our Go binaries when Go code is committed, though this is not essential to the action itself, it is a little useful CI/CD style automation and allows me to only have to focus on writing and updating my main.go file, rather than worrying about making sure the binaires are compiled, uploaded to my repo and put in the right place. Furthermore, when there are no changes, this job is nicely skipped (ofcourse, when running the action from an external this build component would not really come into play).
Well there you have it, after all of that, you should ideally be able to write Github Action