Continuous integration with GitHub Actions
Tags: dotnet, devops, github, docker
Published: 2021-10-12 10:20:11

Continuous integration is cheap, but not integrating continuously can be very expensive. Nowdays, it's hard to imagine software development lifecycle without automated solutions that take care of all those repetitive tasks. There is a ton of different technologies that can solve our problems and in this post we will focus on GitHub Actions.

### Understanding GitHub Actions

GitHub Actions are event-driven workflows that come right in your code repository. By saying event-driven, it means that you can execute series of commands when specific event occurs. For example, every time someone creates a pull request for a repository, you can automatically run a command that executes a software testing script. Aside from event trigger, procedure can also be scheduled.

Procedures are defined in YAML file called `workflow`. Workflow consists of one or more `jobs` that can be executed simultaneously or chronologically. Each job then uses `steps` to control the order in which `actions` are run. These actions are the commands that automate your software testing, building etc.

Event is the starting point in this cycle. It's an activity that triggers workflows and it can originate from GitHub when someone creates issue, pull request or merge changes to the specific branch. Also, it's possible to use `repository dispatch webhook` and trigger workflow when an external event occurs.

Job is collection of steps that are executed on the same `runner`. By default all jobs in workflow are running in parallel, but there are some situations where job depends on the result of the previous one so we can configure them to execute sequentially. For some simple tasks we can create workflows with only one job, but for more complex scenarios this is not recommended approach. In that case multi-job workflows are way to go.

Each step in a job represents an individual task that can be shell command or an action. Since every job is executed on the same runner it allows steps to share data with each other. For example first step of a job can build container image and then in the second step that image can be pushed to a container registry. Data can also be shared between jobs by storing workflow data as artifacts. Artifacts allow you to persist data after a job has completed, and share that data with another job in the same workflow. To use the data in another job you just need to download artifacts. Some of the common artifacts are log outputs, test results, binary or compressed files and code coverage results.

Per official documentation, actions are standalone commands that are combined into steps to create a job. Actions are the smallest portable building block of a workflow and you can create your own actions, or use actions created by the GitHub community. To use an action in a workflow, you must include it as a step.

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5sa5msje5z1jvzdovyco.png)

Another important concept we have to mention are `runners`. In essence a runner is a server with GitHub Actions runner application installed on it. A runner listens for available jobs, runs one job at a time, and reports the progress, logs, and results back to GitHub. Hosted runners, provided by GitHub, are based on Ubuntu, Windows, and macOS, and each job in a workflow runs in a fresh virtual environment. Trough workflow you can install additional tools and binaries on a runner. If for some reason you need different operating system or require specific configuration you can use self-hosted runners where you have full control of the entire environment. However, GitHub does not recommend self-hosted runners for public repositories. 
Windows and Ubuntu runners are hosted in Azure and subsequently have the same IP address ranges as the Azure datacenters. macOS runners are hosted in GitHub's own macOS cloud. Each Windows or Ubuntu runner has 2-core CPU, 7GB of RAM memory and 14 GB of SSD disk space. macOS runners have 3-core CPU, 14GB of RAM memory and 14GB of SSD disk space.
In terms of pricing, on a free plan you have 2000 automation minutes per month which is enough for learning and some smaller projects. You can find more about pricing plans on the following [link](https://github.com/pricing?gclid=EAIaIQobChMI_8L5ipTD8wIVC893Ch1o-QfUEAAYASABEgKIK_D_BwE). 

### Demo

In the following demo we have .NET REST API application. Our task is to run unit tests against the solution and if they succeed package the application as the container image and push it to the container registry. Of course, we want to do all of that in an automated way when push or pull request on the `master` branch occurs.

Source code can be found [here](https://github.com/cvitaa11/dotnet-ci). As you can see, workflow is placed inside `.github/workflows` directory as YAML file.

First couple of lines are used to define workflow name and specify events that will trigger the workflow run. 

```
name: CI master

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
```

Next, `workflow_dispatch` section allows us to run the workflow manually using Actions tab on GitHub or trough CLI. 

The `jobs` section is the main part which defines tasks and controls their order of execution. 
```
jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - name: Test the solution 
        run: dotnet test
```
First attribute is the name of the job followed by the `runs-on` parameter which specifies the infrastructure environment, in this case ubuntu-latest. Under the `steps` segment we define actual tasks. The `dotnet test` command is used to execute unit tests in a given solution. The `dotnet test` command builds the solution and runs a test host application for each test project in the solution. The test host executes tests in the given project using a test framework, for example: MSTest, NUnit, or xUnit, and reports the success or failure of each test. If all tests are successful, the test runner returns 0 as an exit code. Otherwise if any test fails, it returns 1 and the workflow will be stopped.

Obviously, if any test fails we don't want to build and package our application so the second job depends on the results of the first one.

```
  build:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - name: Build container image
        run: |
          docker build -f ./dotnet-ci/Dockerfile -t ${{secrets.registry}}/${{github.repository}}:${{ github.run_number }} .
      - name: Container registry login
        uses: docker/login-action@v1.10.0
        with:
          registry: ${{secrets.registry}}
          username: ${{secrets.username}}
          password: ${{secrets.password}}

      - name: Push image to container registry
        run: |
          docker push ${{secrets.registry}}/${{github.repository}}:${{ github.run_number }}
```
Attribute `needs` will make this job to wait for the `test` to finish and just if `test` succeeds, workflow will start the `build` job on a new runner.

Workflow needs access to some sensitive informations like login credentials and we definitely don't want to store it as plain text. That's where secrets come in place, they are definied on a repository level and can only be created by the repository owner. Secret value is not visible, they can only be modified or deleted. For container registry we will use [GitHub Packages](https://github.com/features/packages) and authenticate with username and PAT (Personal Access Token) as password. This way we will have all the assets in one place, our code repository.

Each container image needs to have a tag, and we want that value to be unique. Thankfully, GitHub provides us with number of environment variables and we decided to tag image with `run_number`. That is a unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. Alternative options would be to tag image with commit SHA, `run_id` or use semantic versioning, but for simplicity we chose `run_number`.

Now we can take a look at GitHub web console under Actions tab.
![Workflow summary](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kljuygg603vqyny6tx6i.png)

As you can see, under Summary we have a nice overview of all the jobs in our workflow and and their interrelationship.

Click on the single job will give us list of all the steps of which the job is composed. We can also click on any step and get the entire log output which is really helpful when it comes to debugging. 

![Log output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gq0hdclyeqhnlg8h73v0.png)

### Conclusion

Ever since Microsoft acquired GitHub they have been adding new features and functionalities that made GitHub not only great source control managment tool, but also very mature DevOps platform. GitHub Actions are very practical and easy to use which makes them a great choice for smaller projects and getting started with continuous integration. However, for projects at a large scale they are still not as powerful as Azure Pipelines or some other enterprise solutions from cloud providers.