If you use GitHub Actions for deployments in a “push to master, deploy to prod” sort of flow, you’ve likely wanted to avoid deploying conflicting refs.

By default, GitHub Actions will want to run a deployment for every commit as soon as you push it:

name: Deploy on Push

on:
  push:
    branches: [ master ]
  workflow_dispatch:

jobs:
  deploy-public:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
            fetch-depth: 0  
            
      - name: Run Deployment
        uses: knyzorg/deploy

The above workflow will often be sufficient. Case in point, it’s pasted from the workflow file of this blog.

The flaws only begin to surface when you push code faster than it can be deployed. What do you expect to happen if you push a new commit to master before the Run Deployment step has completed?

It will typically break in some way depending on how sophisticated your deployment target is. In the case of Dokku, the second deployment will fail because it was locked by the first. In the case of lockless system, there is a chance that the later commit will be overridden by the first.

Enter: Concurrency Groups

GitHub Actions have a feature called Concurrency Groups. It lets you declare a string template which groups multiple workflow runs together, and guarantees that only one of them will run at a time.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

GitHub enforces this guarantee in one of two ways:

  • Queuing: Every workflow requested only begins execution if no other workflow is active in the concurrency group. If one is, it will wait its turn and remain in a pending state.
  • Canceling: When a workflow is requested while another workflow within the same concurrency group is already running, the older workflow is aborted.

Both of these approaches are flawed.

Canceling an in-progress job is always dangerous given the possibility of leaving your deployment target in an unknown state. It can work fine if you’re using a cloud provider which has figured out every failure mode, but going around killing processes is not a practice I recommend.

Queuing, on the other hand is very technically sound but not always convenient. Imagine if your deployment takes 10 minutes, and you have commits every 5 minutes: How long will take to deploy your 10th commit?

Bridging the Gap

It’s possible to capture the best of both by refining what exactly we are looking for: How do ensure that every deployment only deploys the latest code?

One idea, is to ensure we checkout the latest code on every run instead of the default $GITHUB_SHA ref. This would make many of the runs redundant, wasting GitHub Actions minutes and will break workflows where you wish to deploy a specific ref via a workflow-dispatch.

A better approach is to add a check validating if the requested ref’s SHA matches the latest SHA of that ref and failing the step if it isn’t.

I have it implemented as such:

name: Deploy on Push

on:
  push:
    branches: [ master ]
  workflow_dispatch:

# `GH_TOKEN` is needed to use the `gh` cli
env:
  GH_TOKEN: ${{ github.token }}

  # Don't run multiple deployments at once
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}


jobs:
  uptodate:
    name: Fail if out of date
    runs-on: ubuntu-latest
    steps:
      - name: Verify if ref is up-to-date
        run: |
          CURRENT=$GITHUB_SHA
          # Get SHA of latest commit
          LATEST=$(gh browse -c -n -R ${{ github.repository }} | cut -d '/' -f7)
          echo "Current: $CURRENT"
          echo "Latest: $LATEST"
          # Exit code 1 if mismatches
          [ "$CURRENT" = "$LATEST" ]          

  deploy-public:
    runs-on: ubuntu-latest
    # Wait for uptodate check
    # Will not run if it fails
    needs: [ uptodate ]
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
            fetch-depth: 0  
            
      - name: Run Deployment
        uses: knyzorg/deploy

The idea is that all the workflows will be queued on a per-ref basis, but the members of the queue will not be processed if it is not the latest. At any given moment, it will be deploying the latest code.

The only exception to this will be if the commits are frequent to the point that by the time the uptodate check runs, it will already be out-of-date.

If that is case, the goals change: you’re no longer looking for a push-to-deploy workflow but rather a truly continuous deployment:

while (true) {
    deploy();
}

I haven’t explored what GitHub Actions offer in this domain, but a frequently recurring workflow could suit your needs.