robiningelbrecht/continuous-integration-example

CI/CD example using GitHub workflows and actions

dev-master 2022-04-02 11:18 UTC

README

CI/CD

CI/CD codecov.io License PHPStan Enabled PHP

This repository aims to build a fairly complete CI/CD example using GitHub workflows and actions.

Keep in mind that the toolset used in this repository is not the only solution to build a solid workflow. I'm sure there are many tools I have never heard of that can get the job done as wel 💅.

If you liked this tutorial, please consider giving it a ⭐

Note: This tutorial won't explain the complete inner workings of GitHub workflows and actions, so some basic knowledge is required.

Note 2: Since I'm a PHP developer, all examples in this tutorial are PHP based. It should be fairly easy to convert the workflows to be used with a "non PHP" code base.

🐣 Setting up the repository

Before we get into the technical stuff, we first need to set up our repository. The main thing we want to do is setting up the default branch and the branch protection rules.

The default branch

The default branch is considered the “base” branch in your repository, against which all pull requests and code commits are automatically made, unless you specify a different branch.

You can configure the default branch by navigating to https://github.com/username/repository/settings/branches. You can set the default branch to whatever you want, but usually "main" or "master" are used.

Branch protection rules

Branch protection rules allow you to disable force pushing, prevent branches from being deleted, and optionally require status checks before merging. These checks are important to ensure code quality and have a solid CI. For now, we will configure the bare minimum, but we will get back to this.

Navigate to https://github.com/username/repository/settings/branches and add a new branch protection rule with following settings:

  • Branch name pattern: the name of your default branch
  • ✅ Require a pull request before merging
  • ✅ Require approvals
  • Required number of approvals before merging: 1
  • ✅ Require status checks to pass before merging
  • ✅ Require branches to be up-to-date before merging

All other options should stay unchecked.

These rules will basically disable the ability to push to your default branch and force you to work with pull requests and code reviews.

Configuring issue & PR templates

With issue and pull request templates, you can customize and standardize the information you'd like contributors to include when they open issues and pull requests in your repository.

As this as not a required step to set up your workflows, it's always a good idea to standardize how users provide you with feedback about new features and bugs. It's up to you (and your team) to decide if you want to use this feature.

💎 Configuring the CI workflow

The next step is configuring the CI workflow. The workflow used in this example contains two jobs that should ensure code quality. It is triggered for all pull requests:

on:
  pull_request:
  workflow_dispatch:

Since we configured that codes changes can only end up on the default branch via pull requests, we are sure that the test suite will run for every new/changed line of code.

Running the test suite

Let's take a closer look at all steps configured in this job.

For the unit tests to be able to run, we need to install PHP (deuh). Later on we'll need Xdebug as well to check and ensure code coverage.

  # https://github.com/marketplace/actions/setup-php-action
  - name: Setup PHP 8.1 with Xdebug 3.x
    uses: shivammathur/setup-php@v2
    with:
      php-version: '8.1'
      coverage: xdebug

🔥 PRO tip 🔥

If you want to run your test suite against multiple PHP versions and/or operating systems you can do this by using a matrix setup:

  name: Test suite PHP ${{ matrix.php-versions }} on ${{ matrix.operating-system }}
  runs-on: ${{ matrix.operating-system }}
  strategy:
    matrix:
      operating-system: ['ubuntu-latest', 'ubuntu-18.04']
      php-versions: [ '7.4', '8.0', '8.1' ]
  steps:
    - name: Setup PHP ${{ matrix.php-versions }} with Xdebug 3.x
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php-versions }}
        coverage: xdebug

This should result in a workflow run for all possible combinations in the matrix:

CI matrix

The next step is to pull in the code and install all dependencies

  # https://github.com/marketplace/actions/checkout
  - name: Checkout code
    uses: actions/checkout@v2

  - name: Install dependencies
    run: composer install --prefer-dist

After which the tests can finally run

  - name: Run test suite
    run: vendor/bin/phpunit --testsuite unit --fail-on-incomplete  --log-junit junit.xml --coverage-clover clover.xml

You probably noticed that the command to run the test contains some options. Each of them have a purpose:

  • --fail-on-incomplete: forces PHPUnit to fail on incomplete tests
  • --log-junit junit.xml: generates an XML file to publish the test results later on
  • --coverage-clover clover.xml: generates an XML file to check the test coverage later on

After running the tests, we can visualize and publish them as a comment on the pull request.

  # https://github.com/marketplace/actions/publish-unit-test-results
  - name: Publish test results
    uses: EnricoMi/publish-unit-test-result-action@v1.31
    if: always()
    with:
      files: "junit.xml"
      check_name: "Unit test results"

Unit test results

We'll also send the generated clover.xml report to codecov.io

Codecov gives companies actionable coverage insights when and where they need them to ensure they are shipping quality code.

Codecov.io basically allows you to check your code coverage and find untested code. It does so by providing fancy graphs and charts.

  # https://github.com/marketplace/actions/codecov
  - name: Send test coverage to codecov.io
    uses: codecov/codecov-action@v2.1.0
    with:
      files: clover.xml
      fail_ci_if_error: true # optional (default = false)
      verbose: true # optional (default = false)

The codecov action also adds a comment on each pull request.

Codecov.io results

Last but not least we ensure a minimum test coverage of 90% across the project. If the minimum coverage isn't reached, the job will fail. This is done using this test coverage checker.

  - name: Check minimum required test coverage
    run: |
      CODE_COVERAGE=$(vendor/bin/coverage-checker clover.xml 90 --processor=clover-coverage)
      echo ${CODE_COVERAGE}
      if [[ ${CODE_COVERAGE} == *"test coverage, got"* ]] ; then
        exit 1;
      fi

Static code analysis & coding standards

Running static code analysis and applying coding standards are configured in a separate job because these don't need Xdebug or other fancy dependencies.

To run these tasks we'll use PHPStan and PHP Coding Standards Fixer

PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code.

The PHP Coding Standards Fixer (PHP CS Fixer) tool fixes your code to follow standards; whether you want to follow PHP coding standards as defined in the PSR-1, PSR-2, etc., or other community driven ones like the Symfony one.

Once again we need to install PHP, checkout the code and install dependencies

  # https://github.com/marketplace/actions/setup-php-action
  - name: Setup PHP 8.1
    uses: shivammathur/setup-php@v2
    with:
      php-version: '8.1'

  # https://github.com/marketplace/actions/checkout
  - name: Checkout code
    uses: actions/checkout@v2
    
  - name: Install dependencies
    run: composer install --prefer-dist

After which we run the static code analyser

  - name: Run PHPStan
    run: vendor/bin/phpstan analyse

And check coding standards

  - name: Run PHPcs fixer dry-run
    run: vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --config=.php-cs-fixer.dist.php

The job will fail if one of both tasks does not succeed.

Now that the CI workflow has been configured, we can go back to the repository branch protection rules and tighten them up by configuring extra required status checks:

Protected branch settings

These settings require both jobs in the CI workflow to succeed before the PR can be merged.

Example pull requests

There are some example pull requests to show the different reasons why a PR can fail and what it takes for one to pass.

🚀 Configuring the build & deploy workflow

At this point new features and bug fixes can be "safely" merged to the main branch, but they still need to be deployed to a remote server. The workflow used in this example contains two jobs that take care of the deploy. It will be triggered manually:

  on:
    workflow_dispatch:

Creating a build

We'll start of with creating a build by using artifacts. Before starting, we first need to check if the selected branch is allowed to be deployed:

  build:
    if: github.ref_name == 'master' || github.ref_name == 'development'
    name: Create build ${{ github.run_number }} for ${{ github.ref_name }}

If any other branch than master or development is selected the workflow will be aborted.

To create the build with the necessary files we first have to pull the dependencies again

  # https://github.com/marketplace/actions/checkout
  - name: Checkout code
    uses: actions/checkout@v2

  # https://github.com/marketplace/actions/setup-php-action
  - name: Setup PHP 8.1
    uses: shivammathur/setup-php@v2
    with:
      php-version: '8.1'

  - name: Install dependencies
    run: composer install --prefer-dist --no-dev

After which we can create an artifact that contains the files needed for a deploy.

  # https://github.com/marketplace/actions/upload-a-build-artifact
  - name: Create artifact
    uses: actions/upload-artifact@v3
    with:
      name: release-${{ github.run_number }}
      path: |
        src/**
        vendor/**

All artifacts created during a workflow can be downloaded from the workflow summary page. This can come in handy to "debug" your artifact and to check which files are actually included.

Artifacts

Deploying to a remote server

The next and final step is to deploy the build we created in the previous step. Before we can do this, we first need to configure an environment.

Navigate to https://github.com/username/repository/settings/environments to do this. In this example we'll have an environment for master and development on which we'll configure the following:

Environment settings

These settings will enforce that only the development branch can be deployed to the development environment. The secrets configured on the environment will be used to connect to the remote server during deploy.

Now we're ready to start configuring the deploy job. We start off by

  • Referencing the build step. We cannot deploy before the build has been finished.
  • Referencing the environment we are deploying. This will
    1. allow us to use the secrets configured on that environment
    2. allow GitHub to validate that the correct branch is deployed to that environment
    3. allow GitHub to indicate if a PR has been deployed (or not):

Branch deploy info

FYI: ${{ github.ref_name }} contains the branch or tag the workflow is initialised with.

  needs: build
  environment:
    name: ${{ github.ref_name }}
    url: https://${{ github.ref_name }}.env

By setting the concurrency we make sure only one deploy (per environment) at a time can be run.

  concurrency: ${{ github.ref_name }}

The first step in this job will download the artifact we created in the previous job. It contains all the files that need to be transferred to the remote server.

  # https://github.com/marketplace/actions/download-a-build-artifact
  - name: Download artifact
    uses: actions/download-artifact@v3
    with:
      name: release-${{ github.run_number }}

Next we'll use rsync to transfer al downloaded file to the server. This step uses the secrets we have configured on our repository's environments to authenticate.

  # https://github.com/marketplace/actions/rsync-deployments-action
  - name: Rsync build to server
    uses: burnett01/rsync-deployments@5.2
    with:
      switches: -avzr --delete
      path: .
      remote_path: /var/www/release-${{ github.run_number }}/
      remote_host: ${{ secrets.SSH_HOST }}
      remote_user: ${{ secrets.SSH_USERNAME }}
      remote_key: ${{ secrets.SSH_KEY }}

Once the files have been transferred, the last thing we need to do is run a deploy script. This script can do a number of things depending on the stack you are using. In this example we'll run some database updates and install a new crontab.

  # https://github.com/marketplace/actions/ssh-remote-commands
  - name: Run remote SSH commands
    uses: appleboy/ssh-action@master
    with:
      host: ${{ secrets.HOST }}
      username: ${{ secrets.USERNAME }}
      key: ${{ secrets.KEY }}
      port: ${{ secrets.PORT }}
      script: |
        RELEASE_DIRECTORY=/var/www/release-${{ github.run_number }}
        CURRENT_DIRECTORY=/var/www/app
        
        # Remove symlink.
        rm -r "${CURRENT_DIRECTORY}"
        
        # Create symlink to new release.
        ls -s "${RELEASE_DIRECTORY}" "${CURRENT_DIRECTORY}"
        
        # Run database migrations
        ${CURRENT_DIRECTORY}/bin/console doctrine:migrations:migrate
        
        # Install updated crontab
        crontab ${RELEASE_DIRECTORY}/crontab
        
        # Clear cache
        ${CURRENT_DIRECTORY}/bin/console cache:clear

At this point new features and/or bug fixes are deployed to your remote server. You should be good to go to repeat this cycle over and over and over again 😌

🍔 Hungry for more?

This example touches only a few aspects of continuous integration and continuous development. There are lots of extra things I could have covered, but I wanted to keep this clean and simple.

Integration tests

Integration testing is the phase in software testing in which individual software modules are combined and tested as a group.

There are multiple frameworks out there that provide a toolset to implement your integration tests, codeception is one of them.

End-to-end tests

End-to-end testing is a technique that tests the entire software product from beginning to end to ensure the application flow behaves as expected.

https://codecept.io/ is one of many tools that provde a e2e testing framework.

Visual regression tests

A visual regression test checks what the user will see after any code changes have been executed by comparing screenshots taken before and after deploys.

BackstopJS is an open-source tool that allows you to implement such checks.

Auto deploy on merging

This example handles deploys as a manual action, but it's possible to automate this. Let's assume you want to deploy every time something is merged, you can configure your workflow to be triggered as following:

  on:
    push:
      branches:
        - master
        - develop

Speed up your test suite

As your application and thus test suite grows, your workflows will take longer and longer to complete. There are several nifty tricks to speed up you test suite:

  • Use Paratest to run test in parallel
  • Cache your vendor dependencies
  • Use an in-memory SQLite database for tests that hit your database
  • Disable Xdebug, if you don't need test coverage

Composite actions

Composite actions can be used to split workflows into smaller, reusable components. I could tell you all about them, but this blogpost does a perfect job at explaining how to define and use them. Big up to the author James Wallis 👌

🌈 Feedback and questions

As I stated in the beginning, this is only one approach on how you could set up your CI/CD and deploy flow. It's just an example to get you going. If you have any feedback or suggestions to improve this tutorial, please let me know. I'm always open to learning new approaches and getting to know new tools.

If you have any questions, feel free to 📭 contact me, I'll be glad to help you out.