Reusable CI/CD GitHub Workflows

After creating some fancy new Azure DevOps pipelines at work, I decided the automated workflow used for my personal projects deserved some much-needed love.

I’ve previously written a bit on the workflow used in Bad Echo software repositories, including how things were versioned, built, and deployed. I’ve made numerous improvements since.

Because I’ve written about my GitHub automation in the past, I figured I’d supply an update with some explanations readers may find helpful when implementing their own processes.

CI/CD Pipeline Overview

The new CI/CD pipeline used in Bad Echo repositories comprises the following components:

  • CI/CD workflow (ci.yml) which, when a push to the repo is made, will build all projects, run unit tests, and then deploy packages (versioned as pre-releases, by default) to NuGet
  • Release workflow (release.yml) that, when executed manually, will generate a new stable release of the code
  • Version file (version.json) where versioning information is recorded and sourced from during compilation
  • Build submodule (/build/), which houses various scripts used by workflows across numerous repositories

This may sound a lot like what was detailed in the previous article. However, many significant improvements have been made, making it a much more sound process in the end.

Let’s start by looking at versioning.

Versioning: More Hands-Off

Version information is recorded in a version file, the structure of which has stayed the same since I wrote about it last.

version.json
{
  "majorVersion": 1,
  "minorVersion": 0,
  "patchVersion": 12,
  "prereleaseId": "alpha" 
}

Bad Echo projects use a versioning scheme that follows the semantic versioning specification, as stated in the previous article.

The version components in this file are piped into the MSBuild process as properties that get applied to other version-related properties, shaping the versioning metadata that gets baked into our compiled assemblies. This relies on the use of a Directory.Build.props file that was described in the last article.

The build number applied to pre-release versions is determined by the number of commits pushed since the version file was last changed. Whenever a version component in the file is bumped, the builder number is reset.

One aspect of the previously implemented pipeline that I didn’t like was having to remember to manually update a version component in this file after doing a stable release. The process felt clunky to me and prone to error.

With the new pipeline, the automated workflow increments the version components for us, making me a happy camper.

Now, on to the star of the show: the CI workflow.

CI/CD Workflow

The CI/CD workflow (/.github/workflows/ci.yml) is integral to the entire process. It’s triggered automatically whenever a push is made to the repo and is also executed by other workflows whenever code needs to be built, tested, and deployed.

The workflow I use in Bad Echo repos has undergone quite a metamorphosis since the last article, with many improvements and changes added that we’ll look at now.

More Jobs: Good for More than Just the Economy

Previously, the workflow consisted of only a single job. One change I wanted to make was the segmenting of all major phases of the process into individual jobs.

There are several organizational and functional benefits to this approach. A big one is the ability to re-run failed jobs independently from the others. This is something that could be useful when dealing with strange, intermittent unit test failures, but I primarily wanted it for the deployment phase.

Sometimes deployments fail, typically due to an expired API key, and I hated having to go through the build and test phases all over again whenever that happened.

Some particular issues commonly arise, however, when dealing with multiple jobs. We’ll look at what those are as they come up while we review each part of the workflow.

Overview of the Workflow

The following is an example of a CI/CD workflow template applied to a new repository. You can read about what workflow templates are here.

Everything is good to go after applying the template for most of my repositories. If specialized logic is required, it can be added after using the template. I’ll provide a link to such an example later in the article and the original template.

1. You’re Triggering Me, Man!

Workflows usually begin with trigger definitions, which specify when the workflow should run. Our CI/CD workflow has several.

ci.yml – Triggers
name: CI/CD
 
on:
  push:
    branches:  
      - master
    paths-ignore:
      - '.github/workflows/**'
      - '!.github/workflows/ci.yml'
  pull_request:
    branches:
      - master
  workflow_call:
    inputs:
      release-build:
        required: true
        type: boolean
        default: false
      skip-tests:
        required: false
        type: boolean
        default: false
    secrets:
      REPO_TOKEN:
        required: true
      NUGET_API_KEY:
        required: true

We see a traditional push trigger, which will cause this workflow to run whenever changes are pushed to the repo. This is a rather essential characteristic of a continuous integration component.

One of my recent changes to the workflow was the addition of a workflow_call trigger, which turns this workflow into a reusable one.

I didn’t like the duplication of build/deploy code across my other workflows, so now we can have them all call into this one.

When the release-build input is true, the assemblies and packages will be versioned as new stable releases rather than pre-releases. The workflow defaults to building pre-release assemblies when triggered by a push.

We’ll see how this workflow gets called by another when we look at the release workflow.

2. Build Job

Our first job fetches the source and compiles it.

ci.yml – Build
build:
  name: Fetch and Build Source
  runs-on: windows-2022
  outputs:
    build-submodule-sha: ${{ steps.submodule-status.outputs.BUILD_SUBMODULE_SHA }}
  steps:
  - name: Checkout
    uses: actions/checkout@v4
    with:
      submodules: recursive
      fetch-depth: 0
  - name: Get Build Submodule Hash
    id: submodule-status
    run: Write-Output "BUILD_SUBMODULE_SHA=$($(-split $(git submodule status build))[0])" >> $Env:GITHUB_OUTPUT
  - name: Setup .NET
    uses: actions/setup-dotnet@v4
    with:
      dotnet-version: 9.0.x
  - name: Add MSBuild to PATH
    uses: microsoft/setup-msbuild@v2    
  - name: Build
    run: .\build\Invoke-Build.ps1 -ReleaseBuild:$([System.Convert]::ToBoolean($Env:RELEASE_BUILD)) -PackageConfiguration:$Env:PACKAGE_CONFIGURATION
    env:
      RELEASE_BUILD: ${{ inputs.release-build != '' && inputs.release-build || 'false' }}
      PACKAGE_CONFIGURATION: ${{ env.PACKAGE_CONFIGURATION }}
    # Store our compiled assets for subsequent jobs.
  - name: Upload Build Artifacts
    uses: actions/upload-artifact@v4
    with:
      name: binaries
      path: bin/rel/
  - name: Upload Package Artifacts
    uses: actions/upload-artifact@v4
    with:
      name: packages
      path: artifacts/

We start by following the standard procedure of cloning the repo and installing .NET. We also record the commit hash for the build submodule as an output, so jobs down the line can grab any needed deployment scripts.

The setup-dotnet action does not add MSBuild to our PATH, which some of my projects require due to being incompatible with the dotnet tool (i.e. unmanaged code), so we follow up with the setup-msbuild action.

The most important bit is the build script invocation, which actually conducts the code compilation. I’ll include a link to the script later in the article, but let’s first look at how we’re invoking it.

The following environment variables are passed to it:

env:
  RELEASE_BUILD: ${{ inputs.release-build != '' && inputs.release-build || 'false' }}
  PACKAGE_CONFIGURATION: ${{ env.PACKAGE_CONFIGURATION }}

If the workflow is invoked due to a push, it won’t have any inputs, so a conditional expression like the one shown above is needed. By default, CI runs produce pre-release assemblies. We don’t want every little commit to result in a stable release.

We wrap up the build job by uploading all of our compiled assets as artifacts. This is required because subsequent jobs will be running in their own hosted virtual environments.

3. Test Job

Now that we got our stuff built, it’s time to test it!

ci.yml – Test
test:
  name: Run Unit Tests
  needs: build
  if: inputs.skip-tests != true
  runs-on: windows-2022
  steps:
  - name: Setup .NET
    uses: actions/setup-dotnet@v4
    with:
      dotnet-version: 9.0.x
  - name: Add VSTest to PATH
    uses: darenm/Setup-VSTest@v1.3
  - name: Download Build Artifacts
    uses: actions/download-artifact@v4
    with:
      name: binaries
      path: bin/rel/
  - name: Execute Test Runner
    run: |
        Get-ChildItem bin\rel\*.Tests.dll | `
        ForEach-Object {vstest.console /Logger:trx`;LogFileName="$($_.BaseName).trx" /ResultsDirectory:testResults $_.FullName; if ($LASTEXITCODE -eq 1) {$failWhenDone = $true} }; `
        if ($failWhenDone -eq $true) { exit 1 }/Logger:trx`;LogFileName="$($_.BaseName).trx" /ResultsDirectory:testResults $_.FullName}
  - name: Upload Test Artifacts
    uses: actions/upload-artifact@v4
    with:
      name: test-results
      path: testResults/

As was just mentioned, each job is hosted by an isolated runner. So, we start with a blank slate here and have to reinitialize the environment as needed.

We don’t need to do a full checkout again; we just need to reinitialize .NET and download our build artifacts so that we can test them. We also use the darenm/Setup-VSTest action in order to add vstest.console to our PATH, allowing for easy test execution.

Once testing is completed, we upload our test results as an artifact. This would also be a great place to add any desired custom actions that generate reports for the test results.

4. Deploy Job

The last job for our workflow deploys the previously compiled packages to NuGet for all the world to enjoy.

ci.yml – Deploy
deploy:
  name: Deploy Packages
  needs: [build, test]
  if: always()
  runs-on: windows-2022
  steps:
  - name: Check Previous Jobs' Results
    if: needs.build.result != 'success' || (needs.test.result != 'success' && inputs.skip-tests != true)
    run: exit 1
  - name: Checkout Build Submodule
    uses: actions/checkout@v4
    with:
      repository: BadEcho/build
      ref: ${{ needs.build.outputs.build-submodule-sha }}
      path: build        
  - name: Setup .NET
    uses: actions/setup-dotnet@v4
    with:
      dotnet-version: 9.0.x
  - name: Download Package Artifacts
    uses: actions/download-artifact@v4
    with:
      name: packages
      path: artifacts/
  - name: Push to NuGet
    run: .\build\Publish-Packages.ps1 ${{ secrets.NUGET_API_KEY }}

There are a few bits here that may come off as odd at first, most notably the needs and if conditional expression.

needs: [build, test]
if: always()
runs-on: windows-2022
steps:
- name: Check Previous Jobs' Results
  if: needs.build.result != 'success' || (needs.test.result != 'success' && inputs.skip-tests != true)
  run: exit 1

The needs key defines the dependencies for this job. Both build and test must have ran successfully in order for this job to run. This rather reasonable configuration is then followed by an if conditional that causes the job to always run, even if build and test failed or didn’t run.

The reason for this configuration is so that we can support the skip-tests input. When set to true, this input causes the test job to be skipped (which I prefer when creating a stable release, something you’d only do after an immediately preceding CI workflow run is completed successfully).

So, the above configuration is needed if we have a job with “optional” dependencies. However, we still don’t want the deploy job to run if either the build or test jobs are canceled or failed, so we have a step that will fail the run in such an event.

After determining whether or not the job should run, we set up .NET once again (we need to use the dotnet-nuget tool), grab our package artifacts and our build scripts, and then execute a package push script.

We could simplify this a bit by using inline PowerShell (which would obviate the need to retrieve our build scripts in this job’s environment), but I prefer to limit inline scripts to one- or two-liners.

Reusable CI/CD GitHub Workflows - Example Run
Now we have a nice looking workflow with segmented phases.

Release Workflow

The CI/CD workflow we discussed will automatically generate pre-release builds in response to changes being pushed to our repo.

There comes a time, now and again, when we might have a winner in terms of stability and functionality. And that’s when we know it’s time for a stable release.

To create a stable release, we manually execute a workflow that’s dedicated to that task.

Deduplicating Redundancies

One thing I didn’t like about the previous iteration of my release workflow was how it was essentially a copy-paste of the CI/CD workflow with some minor modifications.

To address the redundancy issue, the CI/CD workflow was turned into a reusable workflow, which the release workflow can now call to execute the build/deployment logic it needs. After it’s done with that, it can do all the other “release-y” stuff it needs to do.

This leaves us with a much more condensed workflow.

Overview of the Workflow

The following is, once again, an example of a release workflow applied to a new repository. Unlike the CI/CD workflow, the likelihood of needing to add any specialized logic has been low in my experience.

1. You’re Still Triggering Me!

Unlike the previous workflow, the release workflow only executes when done so manually by a human (or any other creature of similar intelligence).

release.yml – Triggers
name: Create Stable Release

on:
  workflow_dispatch:
    inputs:
      component-to-increment:
        description: Version Component to Increment
        required: true
        type: choice
        options:
        - Major
        - Minor
        - Patch

The workflow_dispatch trigger allows this workflow to be ran manually. The input asks the developer to specify the version component they want bumped after the stable release is made.

A stable release is meant to be the definitive release for a particular version, so our version will need a bumpin’ after the release is made.

2. Build Job

The build job is where we call into our reusable CI/CD workflow.

release.yml – Build
build:
  name: Execute Build Workflow
  uses: ./.github/workflows/ci.yml
  with:
    release-build: true
    skip-tests: true
  secrets:
    REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
    NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}

We indicate via the release-build input that we want a stable release, and we skip the tests because we should only be executing this workflow if the most recent CI/CD run passed.

It may be prudent to add a check that ensures that the most recent CI/CD run actually did pass. I have not done that here; maybe I’ll blurb about it if I do in the future.

Note that we must provide the reusable workflow with all the secrets it might require at invocation, even if it lives in the same repository as the caller (and, therefore, would normally have access to those same secrets).

3. Create Release Job

If the build job passed, then a stable release has been published. So, we create a new Git tag for the version, commit that, and then bump our version.

release.yml – Create Release
create-release:
  name: Create Release
  needs: build
  runs-on: windows-2022
  steps:
  - name: Checkout
    uses: actions/checkout@v4
    with:
      submodules: true
      fetch-depth: 0      
  - name: Setup Git User
    run: git config --global user.email "chamber@badecho.com"; git config --global user.name "Echo Chamber"
  - name: Create Release Tag
    run: .\build\New-ReleaseTag.ps1 "${{ env.PRODUCT_NAME }}"
  - name: Bump Version
    run: .\build\Bump-Version.ps1 ${{ github.event.inputs.component-to-increment }}

Pretty straight forward. Links to the referenced scripts are provided below.

This would also be a great place to generate an actual “Release” hosted on GitHub — something I’ll get around to doing myself later.

Links to Complete Examples

You can find templates for the workflows discussed in this article in my organizational repository:

The various build scripts referenced by the workflows can be found in the Bad Echo build repository.

Have a good one!

Update: Release Validation

Since writing this article, I was driven to add validation to the release workflow that ensured the commit you’re creating a stable release for had a successful CI/CD run. I felt that this justifies the skipping of the unit tests by the release workflow.

Here’s what that looks like.

release.yml – Validation
validate-release:
  name: Validate Release Candidate
  runs-on: windows-2022
  steps:
  - name: Fail If Not Default Branch
    if: github.ref_name != github.event.repository.default_branch
    uses: actions/github-script@v7.0.1
    with:
      script: core.setfailed('Can only create stable releases from the repository\'s default branch.')
  - name: Shallow Checkout
    uses: actions/checkout@v4
    with:
      submodules: true
  - name: Fetch Last CI/CD Run Conclusion
    id: check-last-run
    run: |
      Write-Output "LAST_RUN_CONCLUSION=$(.\build\Get-LatestWorkflowConclusion.ps1 -Repository:$Env:REPOSITORY -Branch:$Env:BRANCH -WorkflowPath:$Env:WORKFLOW_PATH)" >> $Env:GITHUB_ENV
    env:
      REPOSITORY: ${{ github.repository }}
      BRANCH: ${{ github.ref_name }}
      WORKFLOW_PATH: .github/workflows/ci.yml
      GH_TOKEN: ${{ secrets.REPO_TOKEN }}
  - name: Fail If Last Run Unsuccessful
    if: env.LAST_RUN_CONCLUSION != 'success'
    uses: actions/github-script@v7.0.1
    with:
      script: core.setFailed('Cannot create a stable release for a commit that resulted in a failed CI/CD run.')

The first step ensures that we’re only creating a stable release from our default main branch (we don’t want to source releases from feature or unstable branches).

Even if I were using release branches, I would probably still want the source for the release to be the current stable branch and then have the release workflow create the release branch as part of the process.

Push and pull request event-triggered workflows can be restricted to specific branches, but I’m not aware of a similar way to do that with manually triggered ones other than with a step to check and another to exit the workflow as we see in the above snippet.

We then fetch the conclusion of the branch’s most recent CI/CD run. If the conclusion returns as successful, we can permit the release to continue. We could verify that the commit SHA of the run matches that of the HEAD, but it goes to reason, given we trigger a CI/CD run on a push, that the latest run is for the current commit.

The “check step” calls the following script:

Get-LatestWorkflowConclusion.ps1
# Gets the result of the most recent run for a workflow.

param (
    [string]$Repository,
    [string]$Branch,
    [string]$WorkflowPath
)

$allRuns = gh api "/repos/$Repository/actions/runs" | ConvertFrom-Json
$workflowRuns = $allRuns.workflow_runs.Where({$_.path -eq $WorkflowPath -and $_.head_branch -eq $Branch})

if ($workflowRuns) {
    return $workflowRuns[0].conclusion
}

It’s pretty simple; I will probably expand on it. With just this, however, we have some guarantee now that we won’t be creating “stable” releases of unstable code.