In our last post we covered using a k3s on a Raspberry Pi4 to serve up build agents for Azure DevOps.  But what about those who use Github?  Github has a robust yaml based build system with actions and runners. Is it possible to leverage our setup to serve build foo for a GH repo?

Create a GH Project

Let's start with the basics. Make a local repo:

$ mkdir helloworld-gh-docker-action
$ cd helloworld-gh-docker-action/
$ git init
Initialized empty Git repository in /Users/johnsi10/Workspaces/helloworld-gh-docker-action/.git/
$ git checkout -b master
Switched to a new branch 'master'

Create Dockerfile and Action.yaml (from this guide)

$ cat Dockerfile 
# Container image that runs your code
FROM alpine:3.10

# Copies your code file from your action repository to the filesystem path `/` of the container
COPY entrypoint.sh /entrypoint.sh

# Code file to execute when the docker container starts up (`entrypoint.sh`)
ENTRYPOINT ["/entrypoint.sh"]
$ cat action.yml 
# action.yml
name: 'Hello World'
description: 'Greet someone and record the time'
inputs:
  who-to-greet:  # id of input
    description: 'Who to greet'
    required: true
    default: 'World'
outputs:
  time: # id of output
    description: 'The time we greeted you'
runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.who-to-greet }}

Create entrypoint.sh and set perms (and test)

$ cat entrypoint.sh 
#!/bin/sh -l

echo "Hello $1"
time=$(date)
echo ::set-output name=time::$time
$ chmod 755 entrypoint.sh 
$ ./entrypoint.sh 
Hello 
::set-output name=time::Wed Feb 19 08:37:29 CST 2020

You can create a readme as well:

$ cat README.md 
# Hello world docker action

This action prints "Hello World" or "Hello" + the name of a person to greet to the log.

## Inputs

### `who-to-greet`

**Required** The name of the person to greet. Default `"World"`.

## Outputs

### `time`

The time we greeted you.

## Example usage

uses: actions/hello-world-docker-action@v1
with:
  who-to-greet: 'Mona the Octocat'

Next, we add an origin and push (assumes you created a repo in your namespace):

$ git remote add origin https://github.com/idjohnson/helloworld-gh-docker-action.git
$ git add -A
$ git commit -m "Initial Repo"
[master (root-commit) d34233c] Initial Repo
 4 files changed, 50 insertions(+)
 create mode 100644 Dockerfile
 create mode 100644 README.md
 create mode 100644 action.yml
 create mode 100755 entrypoint.sh
$ git push -u origin master
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 12 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 1.02 KiB | 1.02 MiB/s, done.
Total 6 (delta 0), reused 0 (delta 0)
To https://github.com/idjohnson/helloworld-gh-docker-action.git
 * [new branch]      master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.

Testing

Let’s test with their public hello world action first

$ mkdir -p .github/workflows
$ vi .github/workflows/main.yaml
$ cat .github/workflows/main.yaml 
on: [push]

jobs:
  hello_world_job:
    runs-on: ubuntu-latest
    name: A job to say hello
    steps:
    - name: Hello world action step
      id: hello
      uses: actions/hello-world-docker-action@v1
      with:
        who-to-greet: 'Mona the Octocat'
    # Use the output from the `hello` step
    - name: Get the output time
      run: echo "The time was ${{ steps.hello.outputs.time }}"

Add, commit and push

$ git add .github/
$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   .github/workflows/main.yaml

$ git commit -m "set github action"
[master 1bc143f] set github action
 1 file changed, 15 insertions(+)
 create mode 100644 .github/workflows/main.yaml
$ git push
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 596 bytes | 596.00 KiB/s, done.
Total 5 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To https://github.com/idjohnson/helloworld-gh-docker-action.git
   d34233c..1bc143f  master -> master

We can see it now reflected:

GH Runner (Provided)

Add a Self-Hosted Runner

click Actions to start

Go to actions (e.g. https://github.com/idjohnson/helloworld-gh-docker-action/settings/actions) and click "Add Runner" to follow the steps (it will show you a token you'll need)

pi@raspberrypi:~ $ mkdir actions-runner && cd actions-runner
pi@raspberrypi:~/actions-runner $ curl -O -L https://github.com/actions/runner/releases/download/v2.165.2/actions-runner-linux-arm-2.165.2.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   630  100   630    0     0   2290      0 --:--:-- --:--:-- --:--:--  2282
100 53.4M  100 53.4M    0     0  23.5M      0  0:00:02  0:00:02 --:--:-- 37.0M
pi@raspberrypi:~/actions-runner $ tar xzf ./actions-runner-linux-arm-2.165.2.tar.gz
pi@raspberrypi:~/actions-runner $ 
pi@raspberrypi:~/actions-runner $ ./config.sh --url https://github.com/idjohnson/helloworld-gh-docker-action --token ABTJDHFJIKJJDJDLKKDJDQ4

Then use run.sh to start it up

pi@raspberrypi:~/actions-runner $ ./run.sh 

√ Connected to GitHub

2020-02-25 11:59:28Z: Listening for Jobs

Testing

Now lets test.. We change our yaml file to:

# Use this yaml in your workflow file for each job
runs-on: self-hosted
changing yaml in browser

On the Pi we immediately see:

pi@raspberrypi:~/actions-runner $ ./run.sh 

√ Connected to GitHub

2020-02-25 11:59:28Z: Listening for Jobs
2020-02-25 12:02:13Z: Running job: A job to say hello
2020-02-25 12:02:27Z: Job A job to say hello completed with result: Succeeded

And we see the same reflected in the GH UI:

I was able to background the run.sh and continue to run pipelines without a connected shell (for a short amount of time)...

However, after disconnecting, eventually you'll go offline.  That is, Unless you did this from a kvm attached to the device, it will time out and go offline:

ideally we would containerize this…

Containerizing the Pi based GH Runner

Using: https://github.com/myoung34/docker-github-actions-runner

apiVersion: apps/v1
kind: Deployment
metadata:
  name: actions-runner
  namespace: runners
spec:
  replicas: 2
  selector:
    matchLabels:
      app: actions-runner
  template:
    metadata:
      labels:
        app: actions-runner
    spec:
      volumes:
      - name: dockersock
        hostPath:
          path: /var/run/docker.sock
      - name: workdir
        hostPath:
          path: /tmp/github-runner
      containers:
      containers:
      - name: runner
        image: myoung34/github-runner:latest
        env:
        - name: RUNNER_TOKEN
          value: ABTHDHDHHDHDHHDHDHDHDDVS
        - name: REPO_URL
          value: https://github.com/idjohnson/helloworld-gh-docker-action
        - name: RUNNER_WORKDIR
          value: /tmp/github-runner
        volumeMounts:
        - name: dockersock
          mountPath: /var/run/docker.sock
        - name: workdir
          mountPath: /tmp/github-runner

Note: that RUNNER_TOKEN does expire.  So you may need to do "Add runner" as at the start of this guide to get a fresh token, lest you get a 401 error as i did below:

Normal   Pulled     37m (x5 over 39m)    kubelet, raspberrypi  Successfully pulled image "myoung34/github-runner:latest"
  Warning  BackOff    87s (x172 over 39m)  kubelet, raspberrypi  Back-off restarting failed container
$ kubectl logs actions-runner-5594bdf89c-j7f4j -n runners
ldd: ./bin/libcoreclr.so: No such file or directory
ldd: ./bin/System.Security.Cryptography.Native.OpenSsl.so: No such file or directory
ldd: ./bin/System.IO.Compression.Native.so: No such file or directory

--------------------------------------------------------------------------------
|        ____ _ _   _   _       _          _        _   _                      |
|       / ___(_) |_| | | |_   _| |__      / \   ___| |_(_) ___  _ __  ___      |
|      | |  _| | __| |_| | | | | '_ \    / _ \ / __| __| |/ _ \| '_ \/ __|     |
|      | |_| | | |_|  _  | |_| | |_) |  / ___ \ (__| |_| | (_) | | | \__ \     |
|       \____|_|\__|_| |_|\__,_|_.__/  /_/   \_\___|\__|_|\___/|_| |_|___/     |
|                                                                              |
|                       Self-hosted runner registration                        |
|                                                                              |
--------------------------------------------------------------------------------

# Authentication

Http response code: Unauthorized from 'POST https://api.github.com/repos/idjohnson/helloworld-gh-docker-action/actions-runners/registration'
{"message":"Token expired.","documentation_url":"https://developer.github.com/v3"}
Response status code does not indicate success: 401 (Unauthorized).
An error occurred: Not configured

After fixing the token and applying:

$ kubectl apply -f ./test.yaml 
deployment.apps/actions-runner created
$ kubectl get pods -n runners
NAME                              READY   STATUS    RESTARTS   AGE
actions-runner-7f866cff78-hjkj9   1/1     Running   0          7s

And updating the RS to add a runner:

$ kubectl get pods -n runners
NAME                              READY   STATUS    RESTARTS   AGE
actions-runner-7f866cff78-hjkj9   1/1     Running   0          3m16s
$ git diff
diff --git a/test.yaml b/test.yaml
index d742dca..6895f66 100644
--- a/test.yaml
+++ b/test.yaml
@@ -4,7 +4,7 @@ metadata:
   name: actions-runner
   namespace: runners
 spec:
-  replicas: 1
+  replicas: 2
   selector:
     matchLabels:
       app: actions-runner
$ kubectl apply -f ./test.yaml 
deployment.apps/actions-runner configured
$ kubectl get pods -n runners
NAME                              READY   STATUS    RESTARTS   AGE
actions-runner-7f866cff78-hjkj9   1/1     Running   0          3m29s
actions-runner-7f866cff78-pvmmp   1/1     Running   0          6s

Did that work? Did we get 2 runners?

Indeed, horizontal scaling works

Summary

Github is clearly the largest most widely adopted source code hosting site for the open-source community (especially after the demise of google code in 2016).  With Microsoft buying Github, many feared it would mean a push into Azure DevOps, but it has not come to pass.  Github is as strong as ever and continues to roll out new features and support (in fact, right after buying, Microsoft made private repos free which is what kept me personally out of Github and in Azure DevOps).  

Github Actions are yaml based. If you can stomach that, then they are great.  I think at this point I'm just old and curmudgeonly on yaml. That's a me thing i need to get over. The fact is, GH Actions are very similar to Azure DevOps yaml pipelines and I would expect convergence in due time.

Using a Pi not only allows for ARM based builds, but allows one to create CI/CD pipelines to build and launch locally.  It also puts the control in one's hands. I think they are free, but in reading the pricing guides and notes on self hosted, it's still not clear if you are constrained to 20 concurrent / 2000 hours. Notes in forum suggest the data-transfer is free but the "minutes" might count against private repos even on self-hosted.

Overall I'm pretty impressed with the offering, but not enough to jump ship from my Azure DevOps.