How GRRR uses GitHub Actions

Over the last 6 months, GRRR migrated from Travis CI to GitHub Actions, and we happily shed light on how we arrange the workflows.


It took me a while to realize what the difference is between GitHub Actions and Travis CI. The latter focuses on doing Continuous Integration (CI). GitHub Actions is a system to respond to events from the GitHub ecosystem. And yes, one of them is CI, by responding to the push or pull_request events. But much more events are available. GitHub Actions is a powerful tool with a lot of possibilities.

This article explains how GRRR uses GitHub Actions to test and deploy applications. We’ve published other GitHub Actions related articles about using OpenVPN and using AWS assume roles.

The workflows

For an application with unit tests and a production and staging environment we create four workflows:

  • ci.yml: runs unit tests on every push
  • deploy-staging.yml: deploys the main branch to staging
  • deploy-production.yml: deploys tags to production
  • deploy.yml: a re-usable workflow, to prevent duplicate code in the deploy-... workflows.

ci.yml

In the CI workflow, several tools are used to ensure our code is doing what it should, and code quality is secure. Below are the most common tools we use:

  • phpunit docs: Run the PHP unit tests.
  • jest docs: Run the Javascript unit tests.
  • composer validate docs: to ensure composer.json and composer.lock are valid.
  • php artisan migrate:rollback: to ensure the rollback scripts don’t throw exceptions. It’s easy to forget it or make mistakes.
  • phpstan docs: static analysis tool for PHP
  • prettier docs: we love this opinionated code style because it supports every language we use. Just a one-stop-shop to solve the ongoing code style debate.
  • And maybe more by the time you’re reading this.

Below is an example workflow file copied from an active application. It contains some tools from the list above. Almost every tool gets a separate job. This makes it easy to get a list of failed checks in the GitHub UI.

I’ve removed caching for Composer and Yarn to keep the example short.

The trigger to run the workflow is on:push. That means a push to GitHub on any branch or tag will trigger a workflow run. To speed up releasing to production we ignore the release tags. More about that below: deploy-production.yml.

# ci.yml
name: CI
on:
    push:
        branches:
            - "**"
        tags-ignore:
            - "*-release"

jobs:
    php-tests:
        name: PHP tests
        runs-on: ubuntu-20.04
        steps:
            - uses: actions/checkout@v3
            - uses: shivammathur/setup-php@v2
            - run: composer install --prefer-dist --no-interaction --ansi

            - name: Run PHPUnit
              run: vendor/bin/phpunit tests/

    static-analysis:
        name: Static analysis
        runs-on: ubuntu-20.04
        steps:
            - uses: actions/checkout@v3
            - uses: shivammathur/setup-php@v2
            - run: composer install --prefer-dist --no-interaction --ansi

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

    prettier:
        name: Prettier
        runs-on: ubuntu-20.04
        steps:
            - uses: actions/checkout@v3
            - uses: actions/setup-node@v3
            - run: yarn install

            - name: Run Prettier
              run: npx prettier --check .

deploy-staging.yml

We want the staging environment to run the latest code from main. To accomplish that we run this workflow after a completed run of the CI workflow on the main branch. In the example below you see that the workflow starts after a workflow_run event. It says: after a completed run of the CI workflow on the main branch, start this workflow.

Be aware of the difference between a completed and a successful run. A completed run is not always a successful one, but a successful one is always completed. In jobs:deploy:if a check for completeness is added. The workflow context contains an event object with necessary information.

This could be enough for you, but the most appreciated feature by devs at GRRR is workflow_dispatch. This adds a button to the GitHub UI to run this workflow manually, on a branch the dev chooses.

A screenshot of a dispatchable workflow on github.com.

That makes it possible to temporarily deploy a non-main branch to the staging environment. It’s used to show experimental code to other devs or demo prototypes to clients.

A disadvantage of this approach is the fact that a push to main overwrites the staging environment. We fix that by sending a message to the other devs working on the project: “I’m testing branch x on staging, please don’t merge until tomorrow.”. Another option is disabling the workflow, don’t forget to enable it after finishing the feature.

Recently, in Netlify-hosted projects, we have used the great feature of branch subdomains, which is the best solution to this problem.

name: Deploy staging

on:
  workflow_run:
    workflows: ['CI']
      branches: [main]
      types:
         - completed
  workflow_dispatch:

jobs:
  deploy:
    name: Deploy
    uses: organization/repo/.github/workflows/deploy.yml@main
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    with:
      environment: staging
      version: ${{ github.ref_name }}
    secrets:
      SSH_KEY: ${{ secrets.SSH_KEY }}
      KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}

deploy-production.yml

We release by creating a tag: 1.2.3-release. It’s a semver number with the suffix -release. Every tag with that suffix will be deployed to production. A reusable workflow contains the steps to deploy the application.

Unlike deploy-staging.yml this workflow doesn’t depend on the CI workflow. Because the commit, which the tag refers to, is already checked by the CI workflow. A second time is not necessary and will slow the release process down.

name: Deploy production

on:
    push:
        tags:
            - "*-release"

jobs:
    deploy:
        name: Deploy
        uses: organization/repo/.github/workflows/deploy.yml@main
        with:
            environment: production
            version: ${{ github.ref_name }}
        secrets:
            SSH_KEY: ${{ secrets.SSH_KEY }}
            KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}

deploy.yml

A re-usable workflow is a workflow with on.workflow_call in it. It supports the properties inputs and secrets. The two inputs and two secrets used in the example below are used to make the workflow dynamic.

The secrets look a bit verbose because it’s just repeating the names. It’s necessary because a re-usable workflow does not have access to secrets. You have to provide them explicitly. It prevents secret leaking and makes clear which secrets are being used.

name: "Deploy app"

on:
    workflow_call:
        inputs:
            environment:
                description: "GitHub and app environment"
                required: true
                type: string
            version:
                description: "Tag or branch to deploy"
                required: true
                type: string
        secrets:
            SSH_KEY:
                description: "SSH public key"
                required: true
            KNOWN_HOSTS:
                description: "SSH known hosts"
                required: true

jobs:
    deploy-app:
        name: Deploy app
        environment: ${{ inputs.environment }}
        runs-on: ubuntu-20.04

        steps:
            - uses: actions/checkout@v3
              with:
                  ref: ${{ inputs.version }}

            - name: Install SSH key
              uses: shimataro/ssh-key-action@v2
              with:
                  key: ${{ secrets.SSH_KEY }}
                  known_hosts: ${{ secrets.KNOWN_HOSTS }}

            - uses: shivammathur/setup-php@v2

            - name: Install PHP dependencies
              run: composer install --prefer-dist --no-interaction --ansi

            - name: Deployer
              run: vendor/bin/dep deploy "${{ inputs.environment }}" --tag="${{ inputs.version }}" --log="deployer.log"

            - name: Upload logs
              uses: actions/upload-artifact@v3
              if: always()
              with:
                  name: logs
                  path: deployer.log

Conclusion

And that’s it. I’ve shown you the most common test and deploy workflows in our application repositories. Devs are happy with it, especially the possibility to deploy branches to the staging environment.