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: github.com/LondonAppDeveloper/gh-actions-completed-code
What we’ll be doing
We’ll do the following:
- Clone a sample project we’ve provided for testing
- Configure GitHub Actions on the project
- Setup a job that runs when new changes are pushed
- Configure a staging deployment job to run if merging into the main branch
- 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.
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:
- Projects contain both a main (formally master) and production branch
- Changes are implemented on branches prefixed by feature/ or bugfix/
- Pull Requests are used to merge changes into main
- Code merged into main is automatically deployed to a staging environment
- 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.
Once complete, you should see the project in your own GitHub account.
Select Clone and copy the clone URL by clicking on the clipboard icon.
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:
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:
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:
jobs: hello-gh-actions: name: Hello GitHub Actions runs-on: ubuntu-20.04 steps: - 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…
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).
-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.
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:
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:
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.
For our Run Tests job, you can see that our echo command was run successfully:
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.
jobs: block including and everything below it.
Then, replace it with the following:
jobs: test: name: Test runs-on: ubuntu-20.04 steps: - name: Checkout uses: actions/checkout@v2 - name: Test run: docker-compose run --rm app sh -c "python manage.py test" lint: name: Lint runs-on: ubuntu-20.04 steps: - 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 manage.py 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:
on: push: branches: - main - production pull_request: 
This change will make the workflow run in the following cases:
- A push to
productionbranches (this includes pull requests being merged into these branches)
- A new pull request being created
Because most developers will work from a
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
deploy-staging: name: Deploy to Staging runs-on: ubuntu-20.04 needs: [test, lint] if: github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v2 - name: Deploy to Staging run: echo "Deploy to STAGING code here" deploy-prod: name: Deploy to Production runs-on: ubuntu-20.04 needs: [test, lint] if: github.ref == 'refs/heads/production' steps: - 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…
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/views.py 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.
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.
Scroll to the bottom of the log output and you’ll see that the job failed because the unit tests didn’t complete successfully.
Now let’s go back to app/home/views.py 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.
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.
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.
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:
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:
So that’s how you use GitHub Actions to setup a simple CI/CD workflow.