Github Actions Continuous Integration for a django and Postgresql Web App

 GitHub Actions in Action - Setting up Django and Postgres

tl;dr – Here’s a working example of a Django project.

We use GitHub extensively – for client projects, for internal projects & for open source.

It was a matter of time for GitHub to roll their own CI & catch up with Bitbucket Pipelines & GitLab CI.

Having a CI is integral part of our software development process – build & lint on every commit, deploy to staging & production from specific branches.

We use either CircleCI or CodeShip, depending on the project & the needs.

With GitHub Actions now being generally available for everyone, I was itching to give it a go.

The final aim of this article is to provide you with a working Django + Postgres example, share my struggles during the setup.

I’ll go step by step and include some of the errors that you might encounter, while trying to set things up.

Terminology

As with every other CI, I made the mistake to jump right in, start pasting yml configuration around & hoping for the best.

Only after reading some more about GitHub Actions core concepts, I started making progress.

The most helpful page that I found was this – https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions – I read it from start to end to finally understand the core concepts behind GitHub Actions.

What we need to know:

Workflows

  • GitHub runs “workflows”, which are yml files, located in .github/workflows directory in your repository. The name of the workflows can be arbitrary. For example, GitHub generates .github/workflows/pythonapp.yml for Python apps.
  • We can have more than 1 workflow.
  • Workflows have triggers. You can trigger workflows on “push”, for example. Read more about that here.
  • 1 workflow can have many jobs.

Jobs

  • One job = one machine of some kind, which runs “steps” for you.
  • One job groups a bunch of steps together.
  • Within one workflow, we can have multiple jobs. They run in parallel! That’s very important.
  • We can create a dependency graph between jobs. For example – “For every successful build on master, deploy to staging on Heroku”. Read more about that here.

Steps

Quick summary

  • Workflows group jobs together.
  • Jobs group steps together.
  • Steps are where we put our commands.
  • Jobs in a workflow run in parallel.
  • Steps in jobs run sequentially.

The plan

The plan for this article is:

  1. Start with a simple django-admin startproject project.
  2. Create a workflow for it to run tests & migrations via manage.py
  3. Configure Postgres for the app & the CI.
  4. Configure & run tests with py.test

Lets get started!

A simple project

The first thing we do is to create a simple Django project: django-admin startproject github_actions and push that to GitHub.

The only addition that we are going to make is to add requirements.txt next to manage.py and include Django.

By the time of writing, my requirements.txt file looks like that:

  1. Django==3.0.2

We push that & it’s time to setup our first GitHub Actions workflow.

GitHub Actions Workflow for Python

I don’t like yml for configuration. That’s why I’m going to make GitHub generate an initial structure for me.

Go to your GirHub repo & in the Actions tab, select the one for “Python Application”.


Commit the workflow & pull. Now open .github/workflows/pythonapp.yml.

Lets examine the workflow, so we can learn more about it.

The first lines are related to the workflow itself:

  1. name: Python application
  2. on: [push]

That’s the name of the workflow (what you are going to see in GitHub Action’s tab) & the trigger – on every push to every branch.

Next, we see the single job for our workflow:

  1. jobs:
  2. build:
  3. runs-on: ubuntu-latest

The name of the job is build – that’s what you are going to see in GitHub Action’s tab.

And since 1 job = 1 machine, we are going to run on latest Ubuntu.

Finally, we have the steps – what we are actually going to execute for this specific job.

  1. steps:
  2. - uses: actions/checkout@v1
  3. - name: Set up Python 3.7
  4. uses: actions/setup-python@v1
  5. with:
  6. python-version: 3.7
  7. - name: Install dependencies
  8. run: |
  9. python -m pip install --upgrade pip
  10. pip install -r requirements.txt
  11. - name: Lint with flake8
  12. run: |
  13. pip install flake8
  14. # stop the build if there are Python syntax errors or undefined names
  15. flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
  16. # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
  17. flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
  18. - name: Test with pytest
  19. run: |
  20. pip install pytest
  21. pytest

There’s a bunch of things going on here, so lets first reduce it:

  • We don’t need flake8 for now, so just remove everything there.
  • pytest will come after a few steps, so remove that too.

Your workflow should look like that:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. steps:
  7. - uses: actions/checkout@v1
  8. - name: Set up Python 3.7
  9. uses: actions/setup-python@v1
  10. with:
  11. python-version: 3.7
  12. - name: Install dependencies
  13. run: |
  14. python -m pip install --upgrade pip
  15. pip install -r requirements.txt

Commit it, go to the Actions tab & watch how it passes. Try to spot where GitHub displays the name of the workflow & the name of the job.

Now, lets drill down into our steps.

Step by step, action by action

The first step we have is - uses: actions/checkout@v1. This means – we are “including” another step, that will get our repository checked out on the machine running the job. This is called an “action” – a reusable unit of code.

As I mentioned previously, actions are quite central to GitHub actions.

This means we can create reusable actions & not paste huge snippets in our yml, which makes it quite hard to maintain.

Again, it’s a good idea to familiarize yourself with the concept of “actions”:

Okay, lets continue with our next step:

  1. - name: Set up Python 3.7
  2. uses: actions/setup-python@v1
  3. with:
  4. python-version: 3.7

This is another GitHub action that we are reusing. The only difference this time is that we have the name key – where we can give a human-readable name to this step, that’s going to be displayed in the GitHub Actions output.

The second one is the with key under uses. This is how we can give “input” or “arguments” to the actions that we are reusing.

We are basically saying – set us up with Python version 3.7. To read more about the setup-python action, you can visit the repository here – https://github.com/actions/setup-python

Okay, lets see our final step:

  1. - name: Install dependencies
  2. run: |
  3. python -m pip install --upgrade pip
  4. pip install -r requirements.txt

No actions are being used here. We have the human-readable name and then we have a new key – run, followed by a |. That’s why I don’t like yml for configuration. Things tend to get cryptic.

This says:

“Run each of the commands, separated by newline, that are at one indent away from the run key”.

Meaning, we will execute the two commands, 1 by 1, installing the needed requirements. Read more about this here.

Migrations & Tests

Now, lets add 2 more steps to run our migrations & tests. After all, we have a Django project!

Here’s how the yml file looks like after adding those 2 steps:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. steps:
  7. - uses: actions/checkout@v1
  8. - name: Set up Python 3.7
  9. uses: actions/setup-python@v1
  10. with:
  11. python-version: 3.7
  12. - name: Install dependencies
  13. run: |
  14. python -m pip install --upgrade pip
  15. pip install -r requirements.txt
  16. - name: Run migrations
  17. run: python manage.py migrate
  18. - name: Run tests
  19. run: python manage.py test

Commit & push that, go to Actions tab & observe. Try to understand what’s happening & watch your build succeeds.

Adding Postgres

Now, lets add Postgres to our Django & to GitHub Actions.

Django & Postgres

First, we’ll add Postgres to our Django app. If you haven’t done that before, I suggest reading this wonderful tutorial from DigitalOcean.

  1. We need psycopg2, so we install it – pip install psycopg2 & add it to requirements.txt
  2. Second, we need to update our settings to set the default database to be postgres. Taking this straight from the documentation:
  1. # Database
  2. # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
  3. DATABASES = {
  4. 'default': {
  5. 'ENGINE': 'django.db.backends.postgresql',
  6. 'NAME': 'github_actions',
  7. 'USER': 'radorado',
  8. 'PASSWORD': 'radorado',
  9. 'HOST': '127.0.0.1',
  10. 'PORT': '5432',
  11. }
  12. }

We have a lot of things missing, but lets commit that & see what’ll happen in Actions.

Sadly, the build fails during the Install dependencies step with something like that: Error: pg_config executable not found. – this is an issue coming from psycopg2

psycopg2 dependencies

This library requires some dependencies installed, in order to compile successfully, and to save you several hours of googling around, those are python-dev and libpq-dev. We need to install them not with pip, but with apt-get – Ubuntu’s package manager.

Since every job is running on a machine and we’ve said runs-on: ubuntu-latest, we need to add a step before we install our requirements, to install those dependencies:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. steps:
  7. - uses: actions/checkout@v1
  8. - name: Set up Python 3.7
  9. uses: actions/setup-python@v1
  10. with:
  11. python-version: 3.7
  12. - name: psycopg2 prerequisites
  13. run: sudo apt-get install python-dev libpq-dev
  14. - name: Install dependencies
  15. run: |
  16. python -m pip install --upgrade pip
  17. pip install -r requirements.txt
  18. - name: Run migrations
  19. run: python manage.py migrate
  20. - name: Run tests
  21. run: python manage.py test

Commit & push & lets see if we can install psycopg2 this time.

Okay – Install dependencies succeeded, but Run migrations failed:

  1. ...
  2. django.db.utils.OperationalError: could not connect to server: Connection refused
  3. Is the server running on host "127.0.0.1" and accepting
  4. TCP/IP connections on port 5432?

Seems like Postgres is not running on the machine that’s running our job. That’s the next thing we need to fix.

GitHub Actions & Postgres

This is where I spent quite some time. I was looking for an approach like “actions” – plug something in & get it to run.

If you do some googling around & you eventually find this – https://github.com/Harmon758/postgresql-action – a Postgres action! Lets add it & see what’s going to happen:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. steps:
  7. - uses: actions/checkout@v1
  8. - name: Set up Python 3.7
  9. uses: actions/setup-python@v1
  10. with:
  11. python-version: 3.7
  12. - name: psycopg2 prerequisites
  13. run: sudo apt-get install python-dev libpq-dev
  14. - name: Install dependencies
  15. run: |
  16. python -m pip install --upgrade pip
  17. pip install -r requirements.txt
  18. - uses: harmon758/postgresql-action@v1
  19. with:
  20. postgresql version: '11'
  21. - name: Run migrations
  22. run: python manage.py migrate
  23. - name: Run tests
  24. run: python manage.py test

Sadly, GitHub Actions is getting confused, the step with Postgres is finished, before the database is up and running and we get the following error:

django.db.utils.OperationalError: FATAL: the database system is starting up
Why is this failing? A high level overview of the reason is:
  1. The step runs a Postgres docker image.
  2. Postgres itself needs time to start up.
  3. The next step runs before Postgres has started.
  4. We need some kind of “wait for it” mechanism here.
We can spend more time fighting this (I did), but eventually, you’ll find out about services. The documentation says the following:

Additional containers to host services for a job in a workflow. These are useful for creating databases or cache services like redis. The runner will automatically create a network and manage the life cycle of the service containers.

Sounds exactly what we need.

Again, if you got the google result from above, you probably got that result too – https://github.com/actions/example-services/blob/master/.github/workflows/postgres-service.yml

Now, this is a big yml file and more than one thing is going on there. You can waste a lot of time trying to copy-paste the right thing, especially if you are in a hurry.

Lets declare a service for our job, using the example from above:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. services:
  7. postgres:
  8. image: postgres:10.8
  9. env:
  10. POSTGRES_USER: postgres
  11. POSTGRES_PASSWORD: postgres
  12. POSTGRES_DB: github_actions
  13. ports:
  14. - 5432:5432
  15. # needed because the postgres container does not provide a healthcheck
  16. options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
  17. steps:
  18. - uses: actions/checkout@v1
  19. - name: Set up Python 3.7
  20. uses: actions/setup-python@v1
  21. with:
  22. python-version: 3.7
  23. - name: psycopg2 prerequisites
  24. run: sudo apt-get install python-dev libpq-dev
  25. - name: Install dependencies
  26. run: |
  27. python -m pip install --upgrade pip
  28. pip install -r requirements.txt
  29. - name: Run migrations
  30. run: python manage.py migrate
  31. - name: Run tests
  32. run: python manage.py test

If we run this, python manage.py migrate will fail again. But this time – the error message has changed!

psycopg2.OperationalError: FATAL: password authentication failed for user “radorado”
We managed to connect to postgres inside the build machine & we are now failing to authenticate.
Why? Our database configuration is trying with user radorado and password radorado, while we have provided postgres and postgres as credentials in the service definition.

One important thing to notice is the env key where we pass the user, password & database name for Postgres. This is similar to the with key where we pass input arguments to actions.

Configuring Django for Postgres in GitHub Actions

Now, we can solve the problem from above in many different ways.

The way I’m going to solve it is:
  1. Check if we are running in a workflow inside our settings.py
  2. Change the database settings, if that’s the case

Usually, all CIs export a bunch of environment variables that we can use in our code. This is a nice explanation of environment variables in GitHub Actions.

We are going to check against GITHUB_WORKFLOW:

  1. # Database
  2. # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
  3. DATABASES = {
  4. 'default': {
  5. 'ENGINE': 'django.db.backends.postgresql',
  6. 'NAME': 'github_actions',
  7. 'USER': 'radorado',
  8. 'PASSWORD': 'radorado',
  9. 'HOST': '127.0.0.1',
  10. 'PORT': '5432',
  11. }
  12. }
  13. if os.environ.get('GITHUB_WORKFLOW'):
  14. DATABASES = {
  15. 'default': {
  16. 'ENGINE': 'django.db.backends.postgresql',
  17. 'NAME': 'github_actions',
  18. 'USER': 'postgres',
  19. 'PASSWORD': 'postgres',
  20. 'HOST': '127.0.0.1',
  21. 'PORT': '5432',
  22. }
  23. }

Everything passes ✔️

Adding pytest

First, lets create a new app called website with a model called Page:

  1. from django.db import models
  2. class Page(models.Model):
  3. name = models.CharField(max_length=255, unique=True)
  4. slug = models.SlugField(unique=True)

Then, lets create a dummy test:

  1. from django.test import TestCase
  2. from website.models import Page
  3. class WebsiteTests(TestCase):
  4. def test_page_is_created_successfully(self):
  5. page = Page(
  6. name='Home',
  7. slug='home'
  8. )
  9. page.save()

If we just commit that & observe the workflow, the test is going to pass ✔️ (we have python manage.py test)

Now, for pytestwe simply follow the official guide.

If you want, you can:

  • Separate your requirements file.
  • Add pytest-django straight to requirements.txt
  • Just install pytest as a step in the job.

That’s up to you. For the example, I’ll go with the last option.

Here’s our final pythonapp.yml file:

  1. name: Python application
  2. on: [push]
  3. jobs:
  4. build:
  5. runs-on: ubuntu-latest
  6. services:
  7. postgres:
  8. image: postgres:10.8
  9. env:
  10. POSTGRES_USER: postgres
  11. POSTGRES_PASSWORD: postgres
  12. POSTGRES_DB: github_actions
  13. ports:
  14. - 5432:5432
  15. # needed because the postgres container does not provide a healthcheck
  16. options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
  17. steps:
  18. - uses: actions/checkout@v1
  19. - name: Set up Python 3.7
  20. uses: actions/setup-python@v1
  21. with:
  22. python-version: 3.7
  23. - name: psycopg2 prerequisites
  24. run: sudo apt-get install python-dev libpq-dev
  25. - name: Install dependencies
  26. run: |
  27. python -m pip install --upgrade pip
  28. pip install -r requirements.txt
  29. pip install pytest-django
  30. - name: Run migrations
  31. run: python manage.py migrate
  32. - name: Run tests
  33. run: py.test

Does it spark joy? Yes.

Resources

I hope you learned something.

It’s worthwhile to read some of the documentation of GitHub Actions & understand the underlying mechanism. Blind copy-pasting, as I usually do, leads to a lot of frustration & slow downs.

Here’s a summary of all resources used in that article:

Comments

Popular posts from this blog

How to use Django Bootstrap Modal Forms

Everything you need to know when developing an on demand service app

Documentation is Very vital before you develop any system or app