Accelerating the software development life cycle while ensuring the quality and performance of applications is a challenging task. GitHub Actions makes it easy to automate all required CI software workflows for your GitHub repository.

GitHub Actions Runner is an application that runs a job from a GitHub Actions workflow. GitHub Actions also provides a self-hosted runner that allows you to run continuous integration (CI) tests that require actual hardware. End-to-end testing (E2E testing) is a popular methodology to test an application's functionality and performance under real-life conditions. Still, it often demands actual hardware, rendering it infeasible to run on the public cloud.

In this article, we'll delve into our experience performing E2E testing for an open source project on-premises using a containerized self-hosted runner. The self-hosted runner container image used in this tutorial is available for download from the registry.

Self-hosted runner container on Red Hat Enterprise Linux

First, we'll create a self-hosted runner container on Red Hat Enterprise Linux (RHEL).

Build and download the containerized runner image

The whole procedure is covered in

To build the containerized runner for a given runner version, enter the following:

podman build --build-arg RUNNER_VERSION=2.301.1 --tag

To see what runner releases are available to use for RUNNER_VERSION, check on

Alternatively, you can download a specific self-hosted runner container image for the 2.301.1 release:

podman pull

The GitHub runner will check if a newer version is available on startup. It will self-update and restart with the latest version. However, it is worth using the latest version for the container image. Note that the runner's self-update takes time and may not always be successful.

GitHub token protection

In order to generate a registration token, the container requires you to enter a GitHub personal access token (PAT) when starting. The PAT has to belong to the target repository owner for the container to register successfully.

From a security perspective, using the PAT directly with the Podman command is not a good idea. Instead, a Podman secret should be created for the PAT:

echo "your github access token" > token && podman secret create github_token token && rm -rf token

In the above step, github_token must be used as the secret name, as this is the default secret filename that the script inside the container will look for. If you want to choose a different secret name, you can use the environment variable GH_TOKEN_PATH to specify the secret file path when running Podman to start the container.

With all the information we have so far, run the self-hosted runner with Podman:

podman run --secret github_token --name runner -it --rm --privileged -e GH_OWNER='<your github id>' -e GH_REPOSITORY='<repo name>'

If using a different Podman secret name, say some_github_token, use the extra environment variable GH_TOKEN_PATH:

podman run --secret some_github_token --name runner -it --rm --privileged -e GH_OWNER='<your github id>' -e GH_REPOSITORY='<repo name>' -e GH_TOKEN_PATH=/run/secrets/some_github_token

Pass in extra information

In reality, E2E CI workflows often require extra information outside of the target GitHub repository.

For illustrative purposes, we use the RHEL SR-IOV test suite as an example throughout this article. Its E2E CI workflow requires testbed information. The testbed information is not checked into the GitHub repository. For the runner container to access this information, a volume mount can be used. You can apply the same technique in Red Hat OpenShift.

To set up the volume mount for this purpose, first create a folder on the host and copy the required files into this folder. For the RHEL SR-IOV E2E CI, the required files are testbed.yaml and config.yaml, so copy these files into the folder and start the container with the volume mount:

sudo mkdir -p /opt/E2E-config
sudo cp testbed.yaml  /opt/E2E-config
sudo cp config.yaml  /opt/E2E-config
sudo chown -R nobody:nobody  /opt/E2E-config
podman run --secret github_token --name runner -it --rm --privileged -e GH_OWNER='redhat-partner-solutions' -e GH_REPOSITORY='rhel-sriov-test' -v /opt/E2E-config:/config

In the above sample step, the volume is mounted to /config inside the container. That means the E2E CI workflow needs to go to this folder to retrieve these YAML files. Interested readers can take a look at the following E2E CI workflow for reference.

Label the runner

A GitHub repo can have multiple containerized runners on the same server using different labels. This is useful if various tests have different hardware requirements; for example, 800-series and 700-series Intel NICs:

podman run --secret github_token --name runner810 -it --rm --privileged -e RUNNER_LABEL='810' -e GH_OWNER='redhat-partner-solutions' -e GH_REPOSITORY='rhel-sriov-test' -v /opt/E2E-config-810:/config
podman run --secret github_token --name runner710 -it --rm --privileged -e RUNNER_LABEL='710' -e GH_OWNER='redhat-partner-solutions' -e GH_REPOSITORY='rhel-sriov-test' -v /opt/E2E-config-710:/config

You can refer to the runners above as runs-on: [self-hosted, 810] or runs-on: [self-hosted, 710]  in a GitHub workflow. 

How to use the runner label will be explained later in the How to trigger the CI section.

Self-hosted runner as a systemd service

Directly using the Podman command line to start the runner container primarily serves the purpose of proof of concept. For production use, you can use a systemd service to manage the self-hosted runner. Here is the systemd unit file that was used by the RHEL SR-IOV E2E CI:

Description=github self runner in container

ExecStart=/usr/bin/podman run --secret github_token --name runner --rm --privileged -e GH_OWNER='redhat-partner-solutions' -e GH_REPOSITORY='rhel-sriov-test' -v /opt/E2E-config:/config
ExecStop=/usr/bin/podman stop runner


After the container starts and successfully registers with the target GitHub repository, the self-hosted runner can be found under the target repository's Actions/Runners, as shown in Figure 1:

Figure 1: The self-hosted runner listed in the repository.

How to trigger the CI

Here is the sample code for using the runner label:

name: sriov-e2e-test
run-name: sriov-e2e-test initiated by ${{ }}
    types: [ labeled ]
        description: 'NIC hardware'
        required: true
        default: '810'
        type: choice
        - 810
        - 710
    runs-on: ubuntu-latest
      label: ${{ steps.step1.outputs.label }}
      - name: Check label
        id: step1
        run: |
          if [ ${{ }} == 'e2e-test' ]; then
            echo "label=810" >> $GITHUB_OUTPUT
          elif [ ${{ }} == 'e2e-test-710' ]; then
            echo "label=710" >> $GITHUB_OUTPUT
          elif [ -n ${{ github.event.inputs.tag }} ]; then
           echo "label=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT

Using a label to trigger an E2E CI action

As illustrated in the above sample code, one option to trigger the E2E test is to use the appropriate label, e2e-test or e2e-test-710 (see Figure 2). This labeling mechanism serves as a way to limit who can trigger the E2E runs due to hardware resource constraints. Only repo users with write permission can set a pull request label and trigger the E2E test execution.  

Figure 2: Triggering a E2E test run with the e2e-test label.

The labels double as a flag showing which PRs have been tested. 

On-demand triggering

In addition to the labeling above, we can also trigger this E2E action on demand. NIC hardware labels (810 or 710) are collected from the user input, in this case, and used to trigger the appropriate runner.

On demand

Self-hosted runner as an OpenShift Workload

If the test environment already has an OpenShift/Kubernetes cluster installed, and the user does not plan to add an extra RHEL server to host the runner systemd service, the runner container can be hosted on the OpenShift/Kubernetes cluster instead. In this situation, the self-hosted runner will be a workload in the pod format.

To use the runner container as an OpenShift workload for controlling an on-premise E2E CI testbed, the OpenShift cluster needs to be on-premise and have connectivity to the E2E CI testbed.

We will need to take steps to protect the user's PAT and pass in extra test configuration, similar to the runner container.

GitHub token protection

In OpenShift, create a secret for the PAT:

kubectl create secret generic gh-token --from-literal=github_token=<your github token>

In the above command, the name github_token is used for the same reason explained earlier in the podman usage.

The secret gh-token will be mounted as a volume later in the runner pod YAML spec.

Pass in extra information

Once again using the RHEL SR-IOV test suite repository for demo purposes, its E2E CI workflow requires testbed.yaml and config.yaml files, which can be passed to the runner pod via a volume map.

First, create a folder and store the required files under this folder:

ls /opt/E2E-config

Create a ConfigMap from this folder:

oc create configmap test-config --from-file=/opt/E2E-config

This ConfigMap, test-config, will be used in the volume map of the runner pod YAML spec.

Self-hosted runner in deployment

We will let OpenShift take care of the runner pod lifecycle management using a deployment. Here is the self-hosted runner deployment YAML spec:

apiVersion: apps/v1
kind: Deployment
  name: runner-deployment
    app: runner
  replicas: 1
      app: runner
        app: runner
      - name: secret-volume
          secretName: gh-token
      - name: config-volume
          name: test-config
      - name: runner
          privileged: true
        - name: GH_OWNER
          value: "redhat-partner-solutions"
        - name: GH_REPOSITORY
          value: "rhel-sriov-test"
        - name: GH_TOKEN_PATH
          value: "/etc/gh_secrets/github_token"
        - name: secret-volume
          readOnly: true
          mountPath: "/etc/gh_secrets"
        - name: config-volume
          mountPath: "/config"

Notice in the above YAML spec the OpenShift secret gh-token is mounted to the path /etc/gh_secrets, so the environment variable GH_TOKEN_PATH is used to tell the container to retrieve the secret from this path.

The extra information for the E2E CI testbed is mounted under /config. As explained earlier, the E2E workflow will look for the extra information in that folder inside the container.


We reviewed E2E CI building blocks which allow real hardware test execution for a GitHub open source project. Lightweight GitHub Actions CI, along with the containerized GitHub Actions runner, allow minimizing system footprint while maintaining CI functionality. Furthermore, this CI implementation fits well into corporate IT security policy for lab access: nothing extra gets exposed to the internet.

Feel free to comment below if you have questions. We welcome your feedback!

Last updated: September 19, 2023