The core of Github Actions are workflow files found in a repository. These workflow files are stored in the folder .github/workflows
and are automatically read by Github.
Before we dive into the common workflows in Github Actions, let's first understand the high-level anatomy of Github Actions.
Workflows are configurable automated processes designed to run when an event occurs. These events may include things creating a pull request or when an issue is created. A single workflow may be triggered by different events, and it may have certain restrictions being placed on it (for example, a workflow triggered by a pull request will only run when the target branch is the main
branch).
Each workflow is comprised of one or more jobs. Jobs are essentially units of work within the workflow. Jobs can either run in sequential order or in parallel (if one depends on the other). Every job runs within its own runner which is a virtual machine or a container.
You are able to pick between using a virtual machine to execute a job (default) or a Docker container. This guide will not focus on the nuances behind choosing one over the other.
Each job is comprised of several sequential steps that either execute some script defined or an action, which are reusable extensions.
Because steps execute within the same job (and thus the same runner), they can share data between one another through the shared virtual machine/container filesystem. However, because jobs run in different runners, they do not have direct access to the same virtual machine/container filesystem. There are other ways to share data between jobs that we will discuss in one of the common workflows.
The above content is taken from the official Github Actions documentation on the components of Github Actions. https://docs.github.com/en/actions/about-github-actions/understanding-github-actions
To allow you to get a glimpse of what it is like working with Github Actions and setting up various pipelines in Github Actions, we have prepared a simple example React application for you.
The goal of this guide would be for you to add various Github Actions workflows to this example application and extend off of it.
So, throughout a few hands-on activities, you will get the opportunity to build a common CI/CD pipeline to automatically test, lint, and deploy the application.
It is a simple React app created with Vite, built using Typescript, and styled with Tailwind CSS. It is a very simple calculator application that allows you to add/subtract/divide/multiply two numbers — x
and y
— and display the output:
The calculations are performed using a utility class calculator.ts
and there is a unit test suite calculator.test.ts
that we have provided as well.
Everything else is not that relevant and you are free to gloss over them if you want.
Fork the repository
Clone the repository
(Optional) Run the repository
You do not need to run the project locally since we will be focusing on writing Github Actions workflows, which will not require running the project locally.
As you go through this section, we will be building on top of this existing project, adding Github Actions and exploring the concepts discussed above.
As you work through this section, you may want to test your Github Action workflows locally before pushing them to Github (to conserve the minutes). You may use the act
tool to do so.
The installation instructions for act
can be found here: https://nektosact.com/installation/index.html
There are several limitations to using act
, such as not having direct access to an actual Github environment, and it does not also simulate/work for every event type. So use it just for understanding the basics of Github Actions.
It is very common for many Github workflows to involve:
(Optional) Creating a fork of a repository
Working on a feature/bug fix on a feature branch
Pushing the feature branch to Github
Creating a pull request of the feature branch to the main branch of the repository
Have some set of tests and automated checks start to verify the state of the pull request
We are going to replicate this workflow on the example application.
To avoid being overwhelmed with tasks, let's break down the "expected set of tests and automated checks" to be the following:
Run unit tests (calculator.test.ts
)
Lint
Then, once the pull request is merged in main
, we also want to (3) deploy it to Github Pages.
This section will tackle our very first task: running unit tests.
The most fundamental questions that Github Actions workflows answers are:
When will it run?
What will it do?
"When will it run?" is answered by specifying the events that the workflow responds to. In our scenario, we want this workflow to run when a pull request is created on the repository.
"What will it do?" can be then broken down into several more guiding questions?
Which OS should this run in?
Are there key differences between OSes that should be accounted for?
What programming language is this project written in?
What steps should be run to achieve the given workflow?
Can certain tasks be split into separate jobs and run in parallel?
What are jobs that depend on others?
We will take a look at how we can answer questions (4) and (5) in a following section. Let's first tackle questions (1), (2), and (3) in our scenario.
In the case of calculator.test.ts
, the OS we run it on does/should not matter as there are no OS specific test cases. For simplicity, we will pick Ubuntu.
The project is written in React, so it depends on having Node.js available.
We first need to get the repository, install all necessary dependencies, and run the test
script that is provided (see package.json
)
You might be asking yourself, "What if I have unit tests that are specific to an OS? Or version of Node.js? Or Ubuntu version?" Github Actions supports something it calls matrix strategies, that run a given workflow across a matrix of variables. We will briefly dive into it in the Cookbook!
Now that we have clearly outlined the key details of this workflow, let's get down to writing your very first workflow file. Remember, all Github Actions workflows must reside in .github/workflows
, so create a new file called ci.yml
!
Let's break down each section and explain what we're doing.
We are giving the workflow a name that Github Actions will display. If the name is omitted, GitHub displays the workflow file path relative to the root of the repository.
Then, we specify the times where this workflow runs, aka "when will it run?". They are specified with the on
key and the values correspond to the list of events that trigger a workflow.
In our case, we know that we want to run the workflow during a pull request, so we have included the pull_request
event. We have also included the workflow_dispatch
event, so that we are able to manually trigger this workflow from Github without requiring a pull request (more about it here). This is particularly useful if we want to verify that the workflow works without going through the hassle of creating a pull request.
We then start to specify the jobs that comprise the workflow. We give the job a job ID of unit-tests
.
Recall that we said that every job runs in its own runner, which is a virtual machine by default. Therefore, we need to specify the OS that our unit-tests
job will execute in, which we have decided earlier to be Ubuntu. As we are not particular about the version of Ubuntu we will use, we can use the ubuntu-latest
, which is one of the many available Github-hosted runners.
This means that we can think of every step of this job executing within an Ubuntu virtual machine (because they really do!). So we will be using bash
commands, and we will have access to things like the apt
package manager that is available in Ubuntu.
We declare the steps of a job under the steps
array as a list of dictionaries. As we are running the job in a virtual machine, we will basically have an empty machine at the start of the job, and it is our responsibility to start populating and interacting with this empty machine to create the intended workflow.
The very first step we need to do is fetch the current repository, so that the virtual machine runner has access to the project structure, and more importantly, the unit tests. We give the step a human readable name (that is also displayed on Github) using the name
key. If there is no name provided, Github will display the script or action that is being used.
Here, we use an action — which are reusable extensions that perform some set of operations — called actions/checkout@v4
. You can read more about what the action is comprised of here, but essentially, we are using it to perform a checkout of the current repository, retrieving all of its contents (from the latest commit) onto the virtual machine runner.
Then, we need to setup Node.js on the virtual machine runner. We can use the action actions/setup-node@v4
to setup Node.js automatically for us. You can read more about the action here.
We can specify inputs for the action using the with
key, providing the various inputs as a dictionary. In this case, we want to use Node.js version 20, and we want to use the yarn
package manager — instead of npm
— as that is what we have used for the example application.
Before we can run the unit tests, we need to ensure that all of the necessary project dependencies are retrieved. This is where we can use scripts in steps.
Recall in Defining jobs, we mention that all steps in the job are effectively running on Ubuntu, so we will specify scripts that use Ubuntu's built-in shell: bash
. You might want to specify a different shell for certain use cases, which you can do so here.
So, we declare our script through the run
string. The pipe operator after the run
key indicates that we are specifying a multi-line string in YAML.
The script we will run this time is yarn
, which effectively installs the project's dependencies. You can think of this as running yarn
directly in an Ubuntu terminal, where the current folder ($GITHUB_WORKSPACE
) is the root of the Node.js project.
Finally, we can start to execute the unit tests of the project. Again, we use a script, but this time, the script will be NODE_ENV=production yarn test
which effectively sets the environment variable NODE_ENV
to be of value production
.
test
is a script that we have provided in the example application, and it essentially runs vitest
, executing calculator.test.ts
.
Voilà ! You have successfully written your very first Github Actions workflow! Simple isn't it? Let's recap what we did.
At a glance, this is the high-level overview of the new CI/CD pipeline you have written.
To properly visualize and understand how the filesystem of the virtual machine runner changes throughout the job, we have also created this visualization (bolded text are the changes between steps):
Once you have added the workflow, you need to commit and push it!
Then, we can start to verify that the Github Action works as intended! This is where the workflow_dispatch
event comes in handy, where we are able to manually trigger Github Actions. If you navigate to your fork of the example repository, you can visit the Actions tab. You will see the following:
It lists the workflows available, and what has run/are running. We are interested in our new pipeline CI/CD Pipeline
, so select it and you should see the following:
It looks almost the same, but this time, there is a banner that tells you that "This workflow has a workflow_dispatch
event trigger". Then, there is a dropdown to "Run workflow", select it and stick with main
and click the "Run workflow" button:
Refresh the page, and you will now see a new entry in the table:
Give it a few seconds and then click into the action. You will see that the unit tests have failed:
This is because we have intentionally made one of the unit tests to fail (divide ½ is not 0.4!). Let's try to fix this unit test while exploring the the pull_request
event!
The following steps expects some level of understanding of Git. You can refer to our Git guide for more information!
This guide is not a software engineering exercise, so we will tell you exactly where the error is and we will focus on examplifying the pull_request
event.
We had intentionally set one of the unit test assertions to be incorrect, specifically along these lines:
We have set the expected value to be 0.4
when it should clearly be 0.5
! Let's fix this as a pull request to your own repository to see the pull_request
event in action.
Create a new branch, called fix-unit-test
:
Then, go to the file calculator.test.ts
and fix line 13 to be the following:
Then, add the file and create a commit. The commit message can be anything you want:
Finally, push the branch to your fork:
Then, go to your fork on Github and create a new pull request. Pull request > New pull request. Give the pull request any title and you can leave the description blank.
The base branch should be YOUR main
branch, not the original repository's branch!
Create the pull request and wait a while. You will notice that the component towards the bottom changes to this:
This is how you know that your workflow is running and it was triggered by the pull_request
!
Now, if you select the CI/CD pipeline running, you will be brought back to the same page as earlier, instead, this time, you will notice that the workflow passes!
In fact, you will even see the individual steps of the job unit-tests
that you defined! Wonderful! You have successfully:
Created a new Github Actions workflow
Observed how a failing unit test might look like
Fixed and verified that the pull_request
event is working
Go ahead and merge the pull request into main
and update your local repository to receive the latest changes:
Now that we have implemented the very first step, let's take a look at implementing step 2: linting!
With your new workflow ci.yml
, you are able to run unit tests. But another key operation in most CI/CD workflows is linting the project, ensuring that the code follows a certain standard and set of conventions.
Using Github Actions, we want a pull request to fail if the branch contains poorly linted code.
As described in the previous section, we think of answering two key questions when constructing the workflow:
"When will it run?" — established to be during pull_request
(and additionally workflow_dispatch
for testing)
"What will it do?" — execute the yarn lint
script given in the package.json
However, there is an additional question we will want to answer, given that we already have an existing workflow:
"Is this going to be a separate workflow? A separate job in the same workflow? Or just another step in the existing job?"
There is no right or wrong answer for the above. But it is worth considering the following factors:
Is this a part of the CI workflow? — yes, so we might not want to separate it out
Is the task a part of unit testing? — no, so we might want to split it out to avoid cluttering a single job
So in this case, we choose to create a separate job within the same workflow ci.yml
. By default, jobs will run in parallel, but can be designed to run sequentially. So, we get the added benefit of having both linting and unit tests running in parallel, saving time (arguable since we need to reinstall the project dependencies in each job, but as jobs get more complex, running them in parallel will allow simpler ones to complete first), and preventing the results of one job from affecting the other (one can fail while the others pass).
Before we dive into the code required, maybe take some time to think about and attempt to implement the above job! It is not very different from the previous implementation!
You will notice that every step except the last is the same as the unit-tests
job. That is because the initial setup of the virtual machine runner does not change! We still need to
Fetch the repository
Setup Node.js
Install project dependencies
And this is all because all jobs run in separate virtual machine runners! So linting
does not share these steps with unit-tests
.
The only step that differs between linting
and unit-tests
is the linting step, which we rely on the provided lint
script in package.json
, which calls eslint .
.
Now, the single workflow has evolved to include two parallel jobs!
As mentioned at the start of this section, we will be verifying that the linting works by using the workflow_dispatch
event. So, once again, push the latest changes to ci.yml
to your fork and manually run the workflow:
This time, you will see that there are now two separate jobs running within the same workflow:
Both of them will also complete at around the same time since both linting and unit tests are relatively small at this time:
Try playing around with this new job. Create a branch and purposely commit and PR poorly linted code and see what happens! The linting
job should fail while the unit-tests
job will continue to work.
Amazing! We have not only setup a CI/CD workflow that runs unit tests, but also linting, and both run in parallel and don't affect each other's outcomes!
Let's tackle the the final piece of the puzzle: deploying the application to Github Pages!
The final step of our Target workflow is automatically deploying our application to Github Pages when it is merged into main
. This is the CD of CI/CD!
However, as Github Actions does not express "merging into main
" as an event, we will instead be thinking in terms of deploying when changes are pushed to main
instead.
We first go through the same questions as Linting code:
"When will it run?" — when changes are pushed to main
"What will it do?" — compile and build the React project and publish the generated build files to Github Pages
"Is this going to be a separate workflow? A separate job in the same workflow? Or just another step in the existing job?" — this will be a separate workflow because (a) the events that trigger it are different from ci.yml
, and (b) it is not logically a part of the ci
workflow
If we look at our answer for (2), you may notice that we are essentially describing two separate tasks:
Compiling and building the React project
Publishing the generated build files to Github Pages
While we can represent them as a single job, we would like to explore what it's like to design jobs that are dependent on one another and passing around artifacts in Github Actions.
We also realize that (3) reveals that we are no longer treating ci.yml
as the full CI/CD pipeline, so you are free to rename the workflow!
What a mouthful! That's quite a lot of new steps and concepts. Fret not, we will be explaining each step individually.
Recall in Workflow triggers previously, we mentioned that the events may be dictionaries instead when there may be more properties/conditions. In our scenario, we only want the workflow to deploy to Github Pages when we push to main
. So, we can express this using the push
event, and specify that it should only run when one of the branches
(which includes main
) is pushed to.
Similar to the jobs written in Running unit tests and Linting code, we will declare a job to
Fetch the repository
Setup Node.js
Install project dependencies
Build the production distribution
Doing so, we should now have a dist/
folder in our virtual machine runner for the build
job.
Let's talk more about the final step of the build
job.
Artifacts are files or collections of files produced during a workflow run. These artifacts are stored on Github. You may wish to use artifacts for things like storing build logs, test results, binary or compressed files, etc.
They are also a way to share data between jobs in a workflow. Recall in Anatomy of Github Actions where we mentioned that steps in the same job share the same filesystem as they belong to the same job's virtual machine runner, but steps in different jobs do not share the same filesystem as they have completely separate filesystems, belonging to separate virtual machine runners. Artifacts are the way to bridge this gap.
For our use case, we want to publish the generated dist/
folder from the previous step in build
as an artifact, so that our next job, deploy
has access to the files and can publish them accordingly.
Thankfully, there is an existing action actions/upload-pages-artifact@v3
that handles this process, as we specify the name of the artifact generated github-pages
and the path to the directory containing the static assets, i.e. dist/
.
So, after build
runs, we would have an artifact called github-pages
uploaded to Github and accessible to subsequent jobs. You can read more about artifacts on the Github Actions documentation.
We then declare a new job, deploy
that needs
the build
job. This is how we construct the dependency graph between jobs, requiring one to complete before the other can execute.
In order to ensure that we can successfully publish to Github Pages, we also need to modify the default permissions of GITHUB_TOKEN
.
GITHUB_TOKEN
is a special access token that is automatically created as a secret in all workflows. It has access to the current repository, and it expires after the workflow completes. Secrets are a way of storing sensitive information in an organization, repository, or repository environment.
Essentially, GITHUB_TOKEN
allows steps in the job to have some access to the current repository. So, in order for the job to publish to Github Actions, we want to grant the token write
access to both pages
and id-token
. More information about the various permissions of GITHUB_TOKEN
can be found here.
An environment in Github refers to a general deployment target that can be configured with protection rules and secrets. Essentially, they allow you to handle different stages of your project, like development
, production
, and in our case, github-pages
.
These environments are displayed on the repository page.
For our scenario, we want to set the url
of the environment to point to the output (page_url
) of one of the job steps with step id deployment
.
Finally, we can start deploying the artifact we published earlier. We use the action actions/deploy-pages@v4
, targeting the artifact named github-pages
, which we named earlier.
Notice that we also give an additional id
to the step, deployment
. This allows the step to be accessible via ${{ steps.deployment }}
and allows the outputs of the action to be accessible to the environment (seen above). ${{ ... }}
is a way of declaring expressions in a workflow file. More information about expressions in Github Actions can be found here.
We now have a workflow with sequential jobs, with build
generating the production build as an artifact, and deploy
consuming that artifact and publishing it to Github Pages.
We can also visualize the process of uploading an artifact as such:
As per usual, add cd.yml
, create a commit, and push to main
.
This should already trigger the workflow to run. However, if you navigate to Actions and select the "Deploying the Github Pages" workflow, you will notice that it fails:
If you select the specific workflow run, go to the deploy
job, and select the "Publishing production artifact" step, you will see the following error:
The last message tells us what went wrong:
So, visit the URL provided (it is different from the one above!) and select "Github Actions" instead:
Finally, to re-run the workflow, go back to Actions and return to the failed workflow run. At the top right corner, you should see a button to "Re-run worflows", choose to re-run all jobs:
This time, you should see the following:
You can select the URL in the deploy
job component and you should be greeted with the following UI:
🎊 Congratulations! You have successfully setup a traditional CI/CD pipeline using Github Actions! Play around with the workflows we have created!
Next up: we will be exploring some unique workflows in Github Actions!