Thumbnail: python

Creating GitHub Actions in Python

by on under blog
14 minute read

Note: This post is also available in Go flavour.

GitHub Actions provide a way to automate your software development workflows on GitHub. This includes traditional CI/CD tasks on all three major operating systems such as running test suites, building applications and publishing packages. But it also includes automated greetings for new contributors, labelling pull requests based on the files changed, or even creating cron jobs to perform scheduled tasks.

Also GitHub Actions is free for open source projects 🎉!

In GitHub Actions you create Workflows, these workflows are made up of Jobs, and these Jobs are made up of Steps.

Steps can run commands, run setup tasks, or run an action in your repository, a public repository, or an action published in a Docker registry. Not all steps run actions, but all actions run as a step.

In this case an Action is a piece of code which will be run on the GitHub Actions infrastructure. It will have access to the workspace containing your project and can perform any task you wish. It can take inputs, provide outputs and access environment variables. This allows you to chain Actions together for endless possibilities. You can use prebuilt Actions from the GitHub Marketplace or write your own.

In this post we are going to walk through creating your own Container Action in Python and publishing it to the GitHub Marketplace for others to use.

To give ourselves a head start we are going to use this python-container-action template that I’ve published on GitHub. We will talk through each file we need to create so you don’t strictly need to use it, but it will make our lives a little easier.

Clone the template

To get started head to the template on GitHub and press the big green “Use this template” button.

Use this template

Give your new Action a repo name and optionally a description. In this example we are going to make an Action which lints YAML files, so I’m going to name it gha-lint-yaml.

gha-lint-yaml

This will give us a new repository that is essentially a fork of the template.

Our new Action repo

Also our template has a couple of GitHub Actions configured to test the Action (woo GitHub Action recursion). We can visit the “Actions” tab and hopefully see both tests have passed.

GitHub Actions running on our Action

We will come back to these tests later.

Lastly on the GitHub side for now let’s clone our repository locally.

$ git clone <your repo clone url>

Repo clone dialog

Writing our Action code

If you look in the project you will find a few files. The two we are going to focus on now are the Dockerfile and main.py.

Dockerfile

The Dockerfile here is a pretty minimal Python Docker build.

FROM python:3-slim AS build-env
ADD . /app
WORKDIR /app

# We are installing a dependency here directly into our app source dir
RUN pip install --target=/app requests

# A distroless container image with Python and some basics like SSL certificates
# https://github.com/GoogleContainerTools/distroless
FROM gcr.io/distroless/python3-debian10
COPY --from=build-env /app /app
WORKDIR /app
ENV PYTHONPATH /app
CMD ["main.py"]

We start with a builder container based on python:3-slim. We copy in our code and run pip install --target=/app <deps> to install our dependancies into our app directory.

We then use a multistage build step to move to a very minimal distroless container. This container image has no operating system and just contains a couple of things like SSL certificates to ensure our application can function correctly when speaking to web services securely.

We copy our Python code and dependencies over from the builder container and set it as our runtime command.

The result of this is a fairly small container image. The example we have here should build to around 50MB.

$ docker build -t jacobtomlinson/gha-lint-yaml:latest .
...
$ docker images | grep gha-lint-yaml
jacobtomlinson/gha-lint-yaml                               latest              4eb311726658        9 seconds ago       54.1MB

Of course this will grow a little as we add more code, but it’ll still be small compared to a container with a full linux distro in it. It also is a very secure way to build containers as we have hugely reduced our attack surface if a bad actor were to exploit our application, they wouldn’t even have a shell to get into in the container.

You can make even smaller containers using a statically compiled language like Go. There’s a tutorial and template for that too!

main.py

Let’s also take a minute to explore the example main.py file that we have here before we replace it with our YAML linting code.

import os
import requests  # noqa We are just importing this to prove the dependency installed correctly


def main():
    my_input = os.environ["INPUT_MYINPUT"]

    my_output = f"Hello {my_input}"

    print(f"::set-output name=myOutput::{my_output}")


if __name__ == "__main__":
    main()

This is a little longer than a standard Python hello world example.

We have wrapped our logic in a main() function and are calling it from within an if __name__ == "__main__" block. We are also importing requests a little unnecessarily here just to prove that our dependency installation in our Dockerfile worked, we aren’t actually going to use it.

The first line in our main() function is getting a GitHub Action input from an environment variable. GitHub actions can take arguments using a with statement and these get exposed via environment variables named INPUT_ followed by the argument name in all caps.

name: My Workflow
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: jacobtomlinson/[email protected]
      with:
        myInput: world

In the above configuration the variable INPUT_MYINPUT would be created with the value world. We can then grab this within our code.

The next line of Python code is formatting this variable into an output string, nothing fancy here apart from f-strings (woo f-strings 🎉). This means our output string will now be Hello world in our little example workflow.

Then lastly it is printing to the stdout using some specific GitHub Actions syntax. You can pass information back to the GitHub Actions workflow with certain control phrases. This is an example of setting an Action’s output by printing ::set-output name=<output name>::<output value>.

This means we can access our output value within other GitHub Actions later in the workflow.

Writing our YAML linter

This article isn’t intended to be a Python tutorial, so we won’t go into the detail of the code here. But here is a general outline.

Our application is going to take some inputs:

  • The path to the file we want to lint relative to the root of the project.
  • Whether we should be strict in our linting (error on warning).

We will then use the third-party library yamllint to lint the code, expose errors and warnings using control phrases and exit with a 0 or 1 depending on whether the file linted successfully or not.

Our action will also print an output reporting the number of warnings if we pass linting.

import os
import sys

from yamllint import linter
from yamllint.config import YamlLintConfig


def main():
    yaml_path = os.environ["INPUT_PATH"]
    strict = os.environ["INPUT_STRICT"] == "true"
    conf = YamlLintConfig("extends: default")
    warning_count = 0

    with open(yaml_path) as f:
        problems = linter.run(f, conf, yaml_path)

    for problem in problems:

        if problem.level == "warning" and strict:
            problem.level = "error"

        print(
            f"::{problem.level} file={yaml_path},line={problem.line},"
            f"col={problem.column}::{problem.desc} ({problem.rule})"
        )

        if problem.level == "warning":
            warning_count = warning_count + 1

        if problem.level == "error":
            sys.exit(1)

    print(f"::set-output name=warnings::{warning_count}")

    sys.exit(0)


if __name__ == "__main__":
    main()

Also as we are using the dependency yamllint in our code we need to update the pip install in our Dockerfile.

RUN pip install --target=/app yamllint

Update action.yml

Now that we have some code we need to update our action.yml file to let GitHub Actions how to run our code and what inputs and outputs to expect.

name: "YAML file linter"
description: "Lint a YAML file in your project"
author: "Jacob Tomlinson"
inputs:
  path:
    description: "Path to the YAML file to be linted"
    required: true
  strict:
    description: "Run the linter in strict mode (error on warnings)"
    required: false
    default: false
outputs:
  warnings:
    description: "Number of warnings raised if lint was successful"
runs:
  using: "docker"
  image: "Dockerfile"

We start here by setting some metadata to describe the Action, what it does and who wrote it.

We then list our inputs and outputs, give them a description, set any default values and whether they are optional or not. For our YAML linting Action we have specified a path input so we can set which file to lint, this is not optional. We also have a strict input where we can optionally set our linter into error on warning mode. Then we let GitHub Actions know to expect an output of warnings from our code.

Lastly we tell GitHub how to run our Action. In this case we are using docker and we can either specify an image by name, or set it to Dockerfile which will cause GitHub Actions to build our image itself when running the Action.

A nice future enhancement could be to set up Continuous Deployment to build and push our image to Docker Hub. This would allow us to reference our image by name here and it will already be built, saving us time whenever our Action is used. Currently our image takes around 7 seconds to build which isn’t terrible, but if our Action was more complex this would be longer.

Update README

We should also update our README.md file to reflect this configuration to help folks understand how to use our Action.

I’m not going to include this file here as it is a bit long but you can check it out on GitHub.

I recommend you include the following sections:

  • Badges showing that it is a Marketplace Action
  • An overview of what the Action does
  • A table of inputs and outputs
  • Examples of different ways to use your Action

Write tests

Now that we have the code, configuration and documentation for our Action we should test it out.

Local testing

While writing the code I did some local testing. I created a new file in a directory called tests named valid.yaml.

Then I ran my Action code with my environment variables set inline to test the change.

$ INPUT_PATH=tests/valid.yaml INPUT_STRICT=false python main.py
::warning file=tests/valid.yaml,line=1,col=1::missing document start "---" (document-start)
::set-output name=warnings::1

Running this has reported one warning using the GitHub Actions control phrase format and reported 1 warnings as an output. Great!

I also created an invalid file called tests/invalid.yaml to check that the linting fails.

$ INPUT_PATH=tests/invalid.yaml INPUT_STRICT=false python main.py
::warning file=tests/invalid.yaml,line=1,col=1::missing document start "---" (document-start)
::error file=tests/invalid.yaml,line=4,col=1::syntax error: could not find expected ':' (None)
exit 1

Here we can see that an error was raised and that the command exited with a status of 1 which would fail the GitHub Actions workflow. Super!

Now let’s set up some Continuous Integration to test out code every time we push up changes or someone opens a Pull Request. We can set this up using GitHub Actions!

Testing the build

The simplest thing we can do is to test that our Python code is valid. As we used the python-container-action template we already have a Workflow configured in .github/workflows/python.yml which should work just fine for our new code.

name: Lint
on: [push, pull_request]
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Set up Python 3.7
        uses: actions/[email protected]
        with:
          python-version: "3.7"

      - uses: actions/[email protected]

      - name: Lint
        run: |
          pip install flake8
          flake8 main.py

This Workflow runs three Steps. We get a working Python environment with actions/[email protected] and then we check out our code with actions/[email protected]. These are both official Actions provided by GitHub and you can find the source for both of them on GitHub too.

We then have a run step where we have provided a short shell script to install flake8 and then use it to lint our code.

We don’t need to change anything here as this should be enough for most simple Python applications.

Integration testing

Now that we have tested our code is valid we can also test that our Action runs correctly on the GitHub Actions infrastructure. We can do this by having our Action run itself on push and pull requests.

This Action inception is one of my favorite features of GitHub Actions as we can do full end-to-end integration testing without any complex setup.

There is an example in .github/workflows/integration.yml which we will modify to look like this.

name: Integration Test
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - name: Self test
        id: selftest
        uses: jacobtomlinson/[email protected]
        with:
          path: "tests/valid.yaml"
      - name: Check outputs and modified files
        run: |
          test "${{ steps.selftest.outputs.warnings }}" == "1"

In this workflow we start by checking out our Actions code. Then we run our own Action on itself and set our inputs to be the same as we were doing locally (note we have omitted the strict input as it is optional and the default value is fine).

This Action should lint our valid.yaml file and raise one warning.

Then we have a run Action which checks whether our Action behaved as we would expect by testing that the warnings output correctly shows 1.

Push to GitHub

Next we can push our Action to GitHub and let our checks run to ensure everything works as expected.

$ git add -A

$ git commit -m "Initial commit"

$ git push origin master

We can head to the Actions tab on our repository and watch our Lint and Integration Test workflows run.

Lint test

Our lint step has passed, so we have definitely pushed valid Python code.

Integration test

Hooray our integration test has also passed. We can have a look at the outputs from our Action and the checks to see that our Action raised a warning in orange as expected and then correctly reported 1 warnings. Note that is shows the default strict value was filled in for us too.

Publishing to the Marketplace

Now that we have created our Action and verified that it works as expected we can publish it to the Marketplace so other folks can find it.

Technically anyone could use our Action right now, we have already used it ourselves in our integration test. But the master branch is a moving target and we want to make it a little easier for folks to find it.

To publish it we need to do a GitHub release. We can do this the usual way through the releases page, but GitHub may have already detected that we have written an Action and included a prompt to on our repo. Click the “Draft a release” button on the “Publish this Action to Marketplace” dialog or head to the releases page and click it from there.

If you followed the publish dialog the “Publish this Action to the GitHub Marketplace” checkbox will already be checked. If you went in through the releases page you will need to check this box yourself.

Drafting a release

GitHub has already picked up some information from our action.yml file including the name and description. It is also warning us that we haven’t set a logo or color for our Action. This is how you set the icon you will see next to your Action in the Marketplace.

Actions with their icons

We will leave ours out for now which will just use the defaults but you can go back to your action.yml and set yours however you like.

You also need to choose which categories your Action belongs in. For our YAML file linter Action I’ve gone for “Continuous Integration” and “Utilities”.

You also need to set a version tag. My preference is to use SemVer and start at 0.1.0.

Once you click “Publish Release” you will be taken to the release page. You should notice that on the left hand side it has a little blue “Marketplace” badge which means this release is available on the Marketplace.

Our first release

If you click that link you will be taken to the Marketplace page for your newly published action.

Marketplace listing for YAML file linter

The Marketplace listing will use your README.md from your repository and provide some quick links for people to get started with your Action.

Conclusion

That’s it! We have successfully published our Action on the GitHub Marketplace for others to use. They can specify a tagged version so that they can ensure their workflows are deterministic and use it in any project they like.

So to quickly recap:

  • We cloned the python-container-action template.
  • We wrote a small utility application in Python to lint YAML files.
  • We updated our action.yml file to describe the inputs and outputs of our application.
  • We updated our README.md with documentation to help people get started with our Action.
  • We wrote some tests to make sure our Action works as expected when run in a GitHub Actions Workflow.
  • Finally we published it to the GitHub Marketplace by making a release.

You can have a look at the YAML file linter Action here and even use it in your own workflows.

If you find this tutorial useful or even end up publishing your own GitHub Action on the Marketplace let me know in the comments below or on social media!

Python, GitHub Actions, Tutorial
Spotted a mistake in this article? Why not suggest an edit!
comments powered by Disqus