GitHub Actions provide one of the easiest ways to add CI to a git repository with minimal effort. Drop a YAML file in the right place and you’re good to go. How do you create one, though? It’s easier than you might think!

What’s in a GitHub Action?

If you’ve used any CI or CD before you probably have a good idea what you would use it for. GitHub Actions are an implementation of Continuous Integration as well as Continuous Deployment in their current form. You want to automate unit tests, integration tests or deployment of your project somehow. The individual steps are configured in YAML, comparable to what GitLab CI or CircleCI offer. But GHA takes it a step further by using multiple workflows per repository, meaning you can split out different parts to make them easier to maintain or re-use elsewhere. A .github/worfklows/*.yaml can generally be copied to another repo and adjusted as needed.

A typical example adding unit tests to a python project might look like so:

---
name: Unit Tests
# yamllint disable-line rule:truthy
on: [push, pull_request]
jobs:
  schedule:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: 3.8
    - name: Install Python dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt        
    - run: pytest -vv

What you see here will do the following:

  • Run on pushes and pull requests
  • Execute on the latest stable Ubuntu
  • Checkout the code of the git repo
  • Setup Python 3.8
  • Grab dependencies as specified in the requirements.txt
  • Run unit tests

If you drop this as .github/workflows/tests.yaml in a repo with a reasonably typical Python setup you will see Unit Tests show up on PR’s and every push to your git repository.

How are actions implemented anyway?

Most steps in an action are either shell code or actions. actions/checkout is an action that clones a git repository, by default the repo containing the workflow but also other repositories. actions/setup-python is an action that installs a Python environment. These are ones provided by GitHub. The @ allows vendoring i.e. specifying a particular version to use. The prefix is actually just a username, meaning any repository containing an action.yaml can be used as an action.

Actions within actions within actions

Say we want to avoid copying around our Python test workflow completely and instead turn it into a re-usable action. This can be achieved with a composite action which contains several steps like a workflow:

---
name: Python Test Runner
description: Run unit tests in a Python environment
branding:
  icon: 'activity'
  color: 'blue'

inputs:
  manifest:
    description: The manifest to install dependencies from
    required: false
    default: 'manifest.txt'

runs:
  using: composite
  steps:
    - uses: actions/setup-python@v2
      with:
        python-version: 3.8
    - name: Install Python dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r ${{ inputs.manifest }}        
      shell: bash
    - name: Run unit tests
      run: pytest -vv
      shell: bash

The name, description and branding will be relevant for actions published in the marketplace. This isn’t mandatory but makes it easier to discover.

The inputs are configuration variables that can be used with with. Each input becomes available as ${{ inputs.VARIABLE }} where VARIABLE is manifest in this example. Our action would be used like so if the repo was called username/python-test-runner:

- uses: username/python-test-runner
  with:
    manifest: requirements-dev.txt

Note: shell: bash needs to be specified explicitly unlike in workflows.

Containerized actions

GitHub also has built-in support for containerized actions, namely Docker actions. This has the advantage that the setup to build a container is already taken care of, although in some sense it restricts the action to running whatever the container provides and other actions can’t be used here.

runs:
  using: docker
  image: "docker://registry.opensuse.org/devel/openqa/containers-tw/isotovideo:qemu-x86"
  args:
    - QEMU_NO_KVM=1
    - CASEDIR=.
    - SCHEDULE=${{ inputs.schedule }}

Going with a simple example I’ve used in production, we can specify an existing image URL along with the arguments you would pass via the docker command-line (or podman as it were).

Alternatively the image can just as well be a Dockerfile which is then built and run without having to have been published before. The entry point script can then run whatever you need to run. Something like this:

FROM opensuse/tumbleweed
COPY entrypoint.sh /entrypoint.sh
RUN zypper -n in hello
ENTRYPOINT ["entrypoint.sh"]

The script is taken from your repository. RUN calls can take care of any preparation while the container is being build.

Environment variables

Naturally you may need access to the environment and for example use $GITHUB_REPOSITORY to find out the full name of the repo your action is running in, or $CI which really just tells you that you’re in a CI environment. An extensive list is documented albeit not easy to find.

How do I add CI to my action?

This little detail seems to have something in common with a character from the Cinderella fairy tale. There’s no mention of it in the official docs and other tutorials I’ve come across, yet this might be one of the most important considerations. How do I test the action itself? Keep reading if like me you’re not keen to release something without CI. Fortunately it’s quite easy if you realize that ./ works as a relative path to an action:

- name: Run the action implemented in this repo
  uses: ./
  with:
    manifest: requirements-dev.txt

This could be a trivial step in a workflow within the Python Test Runner action which exercises what the action does.

From here on you can publish your action, which usually involves picking a release tag which users of the action can use via @v1 or similar depending on the tag. Vendoring is as trivial as it is crucial.

One final word of advice, keep in mind that your action will benefit from being a support structure for your code, script or container as the case may be. Too much logic shouldn’t be done exclusively in shell commands within the action or it’ll be difficult to test or re-use on other platform when the need arises. On the other hand it’s easy to turn any repo into an action!