In our advanced course on building REST APIs, we teach how to use Travis-CI to run unit tests and linting for your project.

Since launching the course, GitHub has launched their own CI/CD workflow tool called GitHub Actions and many students have asked how to use that instead.

In this post I’m going to show you how to setup a basic workflow using GitHub Actions.

You can find the full completed source code for this tutorial here:

What we’ll be doing

We’ll do the following:

  1. Clone a sample project we’ve provided for testing
  2. Configure GitHub Actions on the project
  3. Setup a job that runs when new changes are pushed
  4. Configure a staging deployment job to run if merging into the main branch
  5. Configure a production deployment job to run if merging into the production branch

Note: For the last two steps, we won’t be performing an actual deployment, but setting up a sample job to simulate it.

Deployment workflow

Even though we are using GitHub for this tutorial, we’ll be implementing a workflow process that aligns with GitLab Flow, which works as follows:

  1. Projects contain both a main (formally master) and production branch
  2. Changes are implemented on branches prefixed by feature/ or bugfix/
  3. Pull Requests are used to merge changes into main
  4. Code merged into main is automatically deployed to a staging environment
  5. Deployments to a production environment are triggered by merging main into a branch called production

If you want to learn more, you can find the full detailed explanation on the Introduction to GitLab Flow page.

If you don’t want to use this workflow, you should be able to modify what’s taught in this guide to create your own.

Cloning the project

The project we’ll be using is a Django application that is setup to run within a Docker.

Note: We’re not going to cover the project setup in this guide, but if you want to learn about it we have a separate tutorial for this on YouTube: Prepare a Django app for Deployment using Docker.

Head over to the gh-actions-starter-code project, and select Fork to make a copy on your own GitHub account.

Screenshot of GitHub project with Fork option highlighted.
GitHub project fork option.

Once complete, you should see the project in your own GitHub account.

Screenshot of project forked to GitHub account.
Forked project in GitHub.

Select Clone and copy the clone URL by clicking on the clipboard icon.

Screenshot of the Clone and clipboard icons on GitHub

Open up the Terminal (macOS) or Command Prompt/PowerShell (Windows), and enter the following command:

git clone <paste your clone URL>

Once done, it should look like this:

Screenshot of Terminal cloning project.
Screenshot of cloning project.

Note: It may look different depending on your Operating System setup. Also, the clone URL should be the one from your account.

Now you’re ready to configure the project to use GitHub Actions.

Configure GitHub Actions

Next we’re going to make the necessary changes to the project code to configure GitHub Actions.

In order to do this, we must setup something called a Workflow.

Workflows need to be defined as YAML files stored inside a directory called .github/workflows in the root of our project.

Since we’re creating a workflow for continuous deployment, we’ll create our configuration in cd.yml, and store it in the full path: .github/workflows/cd.yml

Go ahead and create that file now:

Gif of VSCode with project open
Create a new cd.yml file

Running a simple job

Once you’ve created the file, add the following contents:

name: Continuous Deployment

on: push

On the first line we have --- which is used in YAML to indicate the start of the file.

Then we have name: Continuous Deployment. This is the name of our workflow and appears inside GitHub Actions.

After that we define an on: push line, which is used to tell GitHub Actions when we want to run the job. In this case, we’re setting it up to run every time changes are pushed to GitHub.

Next, let’s add the following to our .github/workflows/cd.yml file:

    name: Hello GitHub Actions
    runs-on: ubuntu-20.04
      - name: Checkout
        uses: actions/checkout@v2
      - name: Run Tests
        run: echo "RUNNING TESTS!"

The above block of code defines a job for our workflow.

I’ll break this down line to explain what’s going on…

The jobs: line is the start of the block. Everything indented below it will be a job or part of the job specification.

Then we have hello-gh-actions:, this defines a new job within our jobs block. This can be anything you want, as long as it’s a unique name (you can’t have two jobs with the same name) and starts with an alphanumeric value (for more details, see the official docs for jobs.<job_id>).

After that, we have name: Hello GitHub Actions, which sets the name that will be displayed in the GitHub Actions page which we’ll see later.

Next we specify runs-on: ubuntu-20.04 which sets the type of machine you want to run your job on. GitHub Actions provided various hosted runners which you can use for free (up to a limit). In our example, we’re using Ubuntu (20.04). You can find the full list here.

Next we define the steps: block. This contains each step that you want to run for your job. Each step corresponds to a command you want to run for your job. The steps are executed in the order they are defined.

Our sample job above has two steps, the first is:

      - name: Checkout
        uses: actions/checkout@v2

This step is used to check out project out in Git. If you’ve used other tools such as GitLab CI/CD or Travis-CI then you may be expecting this to happen automatically. However, with GitHub Actions, this step needs to be done manually for each job. I assume the reason is to save time if you want to run a job that doesn’t need to use the code in your project (for example, to notify a Slack channel for a deployment).

The -name: Checkout part is the name of the step, and uses: actions/checkout@v2 will use GitHub’s pre-built “action” to run this task. You can see the docs for this action here.

Finally, we define the follow step:

      - name: Run Tests
        run: echo "RUNNING TESTS!"

As you may expect, this is a dummy step we added simply for testing GitHub Actions.

Let’s go ahead and test this job. We can do this by committing and pushing the change to GitHub, then heading over to our GitHub Project and selecting the Actions tab.

Gif showing the git push process and the action starting.

Once you’re on the actions page, you should see an entry with the last commit message you pushed. Click the message to open the workflow:

Screenshot of GitHub Actions workflow list page

On the workflow page, you should see an entry for our Hello GitHub Actions job which we defined above. Click on that job to see the details:

Screenshot of job within running workflow

This will take you to the job page which contains a list of all steps of the job. Keep in mind that GitHub Actions automatically adds some steps like Set up job, Post Checkout and Complete job as part of the process. You should see our Checkout and Run Tests jobs sandwiched in-between.

You can select any one of these jobs to expand the details.

Screenshot of steps within Workflow job

For our Run Tests job, you can see that our echo command was run successfully:

Screenshot of expanded steps within workflow job

This is particularly useful if a job fails, because you would see any errors from a failed command here.

Running real jobs

Next we’re going to create a real job that actually runs some unit tests, and we’ll throw in some linting for good measure.

As mentioned earlier, this tutorial uses a sample project that has been setup to run unit tests through Docker, and we’ll be doing that in the job we create.

Delete whole jobs: block including and everything below it.

Then, replace it with the following:

    name: Test
    runs-on: ubuntu-20.04
      - name: Checkout
        uses: actions/checkout@v2
      - name: Test
        run: docker-compose run --rm app sh -c "python test"

    name: Lint
    runs-on: ubuntu-20.04
      - name: Checkout
        uses: actions/checkout@v2
      - name: Lint
        run: docker-compose run --rm app sh -c "flake8"

Important: Pay attention to the indentation, as this is important for YAML files.

This will add two separate jobs to our project.

The Test job (defined first) runs the unit tests for our project.

The command for running tests on a Django project is usually python test.

However, since we configured the project to use Docker, we’re running this using docker-compose, which is available as part of the ubuntu-20.04 runner we’re using.

The second job we define is Lint. This is almost identical to the Test job, except the command is modified to run flake8 instead of the test command.

Choosing when jobs run

As mentioned previously, we use the on: block to determine when workflows are run.

This is useful because the resources used to run jobs cost money. Although GitHub provides a generous 2,000 free minutes per month (usually more than enough for small projects), you’ll need to part with some cash if you have projects that require more job minutes.

Therefore, it’s useful to be configure jobs to only run in certain cases.

For example, you may only need unit testing and linting to run when you make a new pull request to merge changes.

We can do this by modifying the on: block as follows:

      - main
      - production
  pull_request: []

This change will make the workflow run in the following cases:

  • A push to main or production branches (this includes pull requests being merged into these branches)
  • A new pull request being created

Because most developers will work from a feature or bugfix branch (or at least they should), making this change will drastically reduce the job minute budget, because jobs will only run in the above scenarios, instead of every time a push is made.

Running deployment jobs

When including deployment in your pipeline, it’s useful to be able to run jobs conditionally, based on the branch being affected.

This can be acheived by using the if syntax for our job.

Let’s add two new jobs to our project, ensuring the indentation of the new jobs is level with the existing test: and lint: lines.

    name: Deploy to Staging
    runs-on: ubuntu-20.04
    needs: [test, lint]
    if: github.ref == 'refs/heads/main'
      - name: Checkout
        uses: actions/checkout@v2
      - name: Deploy to Staging
        run: echo "Deploy to STAGING code here"

    name: Deploy to Production
    runs-on: ubuntu-20.04
    needs: [test, lint]
    if: github.ref == 'refs/heads/production'
      - name: Checkout
        uses: actions/checkout@v2
      - name: Deploy to Prod
        run: echo "Deploy to PRODUCTION code here"

Note: We are not actually doing any deployment in the above example, but just adding placeholder code where the deployment code would be in a real project.

In the code above, we are adding a deploy-staging and deploy-prod job.

These new jobs are very similar to our test and lint jobs, except for the follow…

We’ve added needs: [test, lint]. This ensures the job will only run after the test and lint jobs have completed successfully (our job needs the specified job to complete before it runs).

The next addition is the the if: syntax which will ensure the job only runs if the specified condition is met. So, with the following code, we run the deployment jobs as follows:

  • deploy-staging will run if the branch is main — meaning code pushed to the main branch will be deployed to staging environment
  • deploy-production will run if the branch is production — meaning code pushed to the production branch will be deployed to the production environment

Now that’s all setup, let’s test some changes.

Testing our workflow

Now we’ve configured GitHub Actions, let’s do some tests to ensure it works.

Start by checking out a new branch locally, for example:

git checkout -b feature/cool-new-feature

Then, let’s break our code by opening up app/home/ and changing our the def index(request): line to the following:

def index(request):
    """Render index page."""
    return None

This should make our unit tests fail.

Commit the change and push it to GitHub.

git commit -am "Oops, broke the code."
git push --set-upstream origin feature/cool-new-feature

Head over to the Actions tab on your GitHub project, and notice that the job does not run. This is because we’ve configured jobs to only run when changes are made to the main and production branches, or a pull request is made.

Let’s create a pull request from our feature/cool-new-feature branch into main.

Git of creating a new Pull Request

After a few seconds, you should see the Test and Lint jobs start.

Then, after about a minute, the jobs should fail.

You can click Details to view the details of the failed jobs.

Screenshot of jobs running within pull request

Scroll to the bottom of the log output and you’ll see that the job failed because the unit tests didn’t complete successfully.

Screenshot of failed job within pull request

Now let’s go back to app/home/ and put it back to what it was before:

def index(request):
    """Render index page."""
    return render(request, 'home/index.html')

Then, commit the changes, and push them to GitHub.

git commit -am "Fixed broken tests."
git push origin

Head back to your Pull Request and you’ll see the jobs automatically restart.

Screenshot of checks being run

After a few minutes they should complete successfully…

Typically this is when a co-worker would do a peer review. But since we’re going lone wolf, as soon as you see two green ticks, select Merge pull request and Confirm merge.

Screenshot of successful jobs within pull request

Once done, you can delete the feature branch (optional, but recommended) and head over to the Actions tab where you’ll find a workflow running for the latest merge.

Screenshot of merge job running

You can click on the Merge pull request #1 text in to open the workflow.

After a few minutes, you’ll see something like this:

Screenshot showing workflow jobs for Test, Lint and Deploy to Staging.

Notice that the Test and Lint jobs ran again (just to ensure they still pass before deploying) and then the Deploy to Staging job runs.

This is where you (or ideally a QA team) would do tests on the staging environment to validate any new features.

Once you’re happy with the changes, you can create and push a new branch called production.

git checkout -b production
git push --set-upstream origin production

Note: After you’ve created the branch the first time, you would merge changes from production < main using a pull request instead.

Then check your Actions page again, and you’ll see the production deployment process running.

A few moments later, you should see this:

Screenshot of workflow jobs for Test, Lint and Deploy to Production

So that’s how you use GitHub Actions to setup a simple CI/CD workflow.

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published.