{"id":2966,"date":"2024-12-09T05:34:44","date_gmt":"2024-12-09T10:34:44","guid":{"rendered":"https:\/\/badecho.com\/?p=2966"},"modified":"2024-12-21T22:33:57","modified_gmt":"2024-12-22T03:33:57","slug":"reusable-github-workflows","status":"publish","type":"post","link":"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/","title":{"rendered":"Reusable CI\/CD GitHub Workflows"},"content":{"rendered":"\n<div class=\"wp-block-image\"><figure class=\"aligncenter size-full is-resized\"><img loading=\"lazy\" src=\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows.png\" alt=\"Reusable CI\/CD GitHub Workflows\" class=\"wp-image-2967\" width=\"856\" height=\"448\" srcset=\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows.png 856w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows-300x157.png 300w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows-768x402.png 768w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows-480x251.png 480w\" sizes=\"(max-width: 856px) 100vw, 856px\" \/><\/figure><\/div>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>I&#8217;ve <a href=\"https:\/\/badecho.com\/index.php\/2023\/07\/17\/automated-versioning-with-github\/\" target=\"_blank\" rel=\"noreferrer noopener\">previously written a bit on the workflow used in Bad Echo software repositories<\/a>, including how things were versioned, built, and deployed. I&#8217;ve made numerous improvements since.<\/p>\n\n\n\n<p>Because I&#8217;ve written about my GitHub automation in the past, I figured I&#8217;d supply an update with some explanations readers may find helpful when implementing their own processes.<\/p>\n\n\n\n<h2>CI\/CD Pipeline Overview<\/h2>\n\n\n\n<p>The new CI\/CD pipeline used in Bad Echo repositories comprises the following components:<\/p>\n\n\n\n<ul><li>CI\/CD workflow (<code>ci.yml<\/code>) 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 <\/li><li>Release workflow (<code>release.yml<\/code>) that, when executed manually, will generate a new stable release of the code<\/li><li>Version file (<code>version.json<\/code>) where versioning information is recorded and sourced from during compilation<\/li><li>Build submodule (<code>\/build\/<\/code>), which houses various scripts used by workflows across numerous repositories<\/li><\/ul>\n\n\n\n<p>This may sound a lot like what was <a href=\"https:\/\/badecho.com\/index.php\/2023\/07\/17\/automated-versioning-with-github\/\" target=\"_blank\" rel=\"noreferrer noopener\">detailed in the previous article<\/a>. However, many significant improvements have been made, making it a much more sound process in the end. <\/p>\n\n\n\n<p>Let&#8217;s start by looking at versioning.<\/p>\n\n\n\n<h2>Versioning: More Hands-Off<\/h2>\n\n\n\n<p>Version information is recorded in a version file, the structure of which has stayed the same since I wrote about it last.<\/p>\n\n\n\n<h6>version.json<\/h6>\n\n\n<pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n{\n  &quot;majorVersion&quot;: 1,\n  &quot;minorVersion&quot;: 0,\n  &quot;patchVersion&quot;: 12,\n  &quot;prereleaseId&quot;: &quot;alpha&quot; \n}\n<\/pre>\n\n\n<p>Bad Echo projects use a versioning scheme that follows the semantic versioning specification, as <a href=\"https:\/\/badecho.com\/index.php\/2023\/07\/17\/automated-versioning-with-github\/#versioning-concepts\" target=\"_blank\" rel=\"noreferrer noopener\">stated in the previous article<\/a>. <\/p>\n\n\n\n<p>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 <code>Directory.Build.props<\/code> file that <a href=\"https:\/\/badecho.com\/index.php\/2023\/07\/17\/automated-versioning-with-github\/#directory-build-props\" target=\"_blank\" rel=\"noreferrer noopener\">was described in the last article<\/a>.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>One aspect of the previously implemented pipeline that I didn&#8217;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.<\/p>\n\n\n\n<p>With the new pipeline, the automated workflow increments the version components for us, making me a happy camper.<\/p>\n\n\n\n<p>Now, on to the star of the show: the CI workflow.<\/p>\n\n\n\n<h2>CI\/CD Workflow<\/h2>\n\n\n\n<p>The CI\/CD workflow (<code>\/.github\/workflows\/ci.yml<\/code>) is integral to the entire process. It&#8217;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.<\/p>\n\n\n\n<p>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&#8217;ll look at now.<\/p>\n\n\n\n<h3>More Jobs: Good for More than Just the Economy<\/h3>\n\n\n\n<p>Previously, the workflow consisted of only a single <a href=\"https:\/\/docs.github.com\/en\/actions\/writing-workflows\/choosing-what-your-workflow-does\/using-jobs-in-a-workflow\" target=\"_blank\" rel=\"noreferrer noopener\">job<\/a>. One change I wanted to make was the segmenting of all major phases of the process into individual jobs.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>Some particular issues commonly arise, however, when dealing with multiple jobs. We&#8217;ll look at what those are as they come up while we review each part of the workflow.<\/p>\n\n\n\n<h3>Overview of the Workflow<\/h3>\n\n\n\n<p>The following is an example of a CI\/CD workflow template applied to a new repository. You can read about <a href=\"https:\/\/badecho.com\/index.php\/2023\/07\/17\/automated-versioning-with-github\/#workflow-templates\" target=\"_blank\" rel=\"noreferrer noopener\">what workflow templates are here<\/a>.<\/p>\n\n\n\n<p>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&#8217;ll provide a link to such an example later in the article and the original template.<\/p>\n\n\n\n<h3>1. You&#8217;re Triggering Me, Man!<\/h3>\n\n\n\n<p>Workflows usually begin with trigger definitions, which specify when the workflow should run. Our CI\/CD workflow has several.<\/p>\n\n\n\n<h6>ci.yml &#8211; Triggers<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nname: CI\/CD\n \non:\n  push:\n    branches:  \n      - master\n    paths-ignore:\n      - '.github\/workflows\/**'\n      - '!.github\/workflows\/ci.yml'\n  pull_request:\n    branches:\n      - master\n  workflow_call:\n    inputs:\n      release-build:\n        required: true\n        type: boolean\n        default: false\n      skip-tests:\n        required: false\n        type: boolean\n        default: false\n    secrets:\n      REPO_TOKEN:\n        required: true\n      NUGET_API_KEY:\n        required: true\n<\/pre>\n\n\n<p>We see a traditional <code>push<\/code> 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.<\/p>\n\n\n\n<p>One of my recent changes to the workflow was the addition of a <code>workflow_call<\/code> trigger, which turns this workflow into a <a href=\"https:\/\/docs.github.com\/en\/actions\/sharing-automations\/reusing-workflows\" target=\"_blank\" rel=\"noreferrer noopener\">reusable one<\/a>. <\/p>\n\n\n\n<p>I didn&#8217;t like the duplication of build\/deploy code across my other workflows, so now we can have them all call into this one.<\/p>\n\n\n\n<p>When the <code>release-build<\/code> 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.<\/p>\n\n\n\n<p>We&#8217;ll see how this workflow gets called by another when we look at the release workflow.<\/p>\n\n\n\n<h3>2. Build Job<\/h3>\n\n\n\n<p>Our first job fetches the source and compiles it.<\/p>\n\n\n\n<h6>ci.yml &#8211; Build<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nbuild:\n  name: Fetch and Build Source\n  runs-on: windows-2022\n  outputs:\n    build-submodule-sha: ${{ steps.submodule-status.outputs.BUILD_SUBMODULE_SHA }}\n  steps:\n  - name: Checkout\n    uses: actions\/checkout@v4\n    with:\n      submodules: recursive\n      fetch-depth: 0\n  - name: Get Build Submodule Hash\n    id: submodule-status\n    run: Write-Output &quot;BUILD_SUBMODULE_SHA=$($(-split $(git submodule status build))[0])&quot; &gt;&gt; $Env:GITHUB_OUTPUT\n  - name: Setup .NET\n    uses: actions\/setup-dotnet@v4\n    with:\n      dotnet-version: 9.0.x\n  - name: Add MSBuild to PATH\n    uses: microsoft\/setup-msbuild@v2    \n  - name: Build\n    run: .\\build\\Invoke-Build.ps1 -ReleaseBuild:$([System.Convert]::ToBoolean($Env:RELEASE_BUILD)) -PackageConfiguration:$Env:PACKAGE_CONFIGURATION\n    env:\n      RELEASE_BUILD: ${{ inputs.release-build != '' &amp;&amp; inputs.release-build || 'false' }}\n      PACKAGE_CONFIGURATION: ${{ env.PACKAGE_CONFIGURATION }}\n    # Store our compiled assets for subsequent jobs.\n  - name: Upload Build Artifacts\n    uses: actions\/upload-artifact@v4\n    with:\n      name: binaries\n      path: bin\/rel\/\n  - name: Upload Package Artifacts\n    uses: actions\/upload-artifact@v4\n    with:\n      name: packages\n      path: artifacts\/\n<\/pre>\n\n\n<p>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.<\/p>\n\n\n\n<p>The <code>setup-dotnet<\/code> action does not add MSBuild to our <code>PATH<\/code>, which some of my projects require due to being incompatible with the <code>dotnet<\/code> tool (i.e. unmanaged code), so we follow up with the <code>setup-msbuild<\/code> action.<\/p>\n\n\n\n<p>The most important bit is the build script invocation, which actually conducts the code compilation. I&#8217;ll include a link to the script later in the article, but let&#8217;s first look at how we&#8217;re invoking it.<\/p>\n\n\n\n<p>The following environment variables are passed to it:<\/p>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nenv:\n  RELEASE_BUILD: ${{ inputs.release-build != '' &amp;&amp; inputs.release-build || 'false' }}\n  PACKAGE_CONFIGURATION: ${{ env.PACKAGE_CONFIGURATION }}\n<\/pre>\n\n\n<p>If the workflow is invoked due to a push, it won&#8217;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&#8217;t want every little commit to result in a stable release.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<h3>3. Test Job<\/h3>\n\n\n\n<p>Now that we got our stuff built, it&#8217;s time to test it!<\/p>\n\n\n\n<h6>ci.yml &#8211; Test<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\ntest:\n  name: Run Unit Tests\n  needs: build\n  if: inputs.skip-tests != true\n  runs-on: windows-2022\n  steps:\n  - name: Setup .NET\n    uses: actions\/setup-dotnet@v4\n    with:\n      dotnet-version: 9.0.x\n  - name: Add VSTest to PATH\n    uses: darenm\/Setup-VSTest@v1.3\n  - name: Download Build Artifacts\n    uses: actions\/download-artifact@v4\n    with:\n      name: binaries\n      path: bin\/rel\/\n  - name: Execute Test Runner\n    run: |\n        Get-ChildItem bin\\rel\\*.Tests.dll | `\n        ForEach-Object {vstest.console \/Logger:trx`;LogFileName=&quot;$($_.BaseName).trx&quot; \/ResultsDirectory:testResults $_.FullName; if ($LASTEXITCODE -eq 1) {$failWhenDone = $true} }; `\n        if ($failWhenDone -eq $true) { exit 1 }\/Logger:trx`;LogFileName=&quot;$($_.BaseName).trx&quot; \/ResultsDirectory:testResults $_.FullName}\n  - name: Upload Test Artifacts\n    uses: actions\/upload-artifact@v4\n    with:\n      name: test-results\n      path: testResults\/\n<\/pre>\n\n\n<p>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.<\/p>\n\n\n\n<p>We don&#8217;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 <code>darenm\/Setup-VSTest<\/code> action in order to add <code>vstest.console<\/code> to our <code>PATH<\/code>, allowing for easy test execution.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<h3>4. Deploy Job<\/h3>\n\n\n\n<p>The last job for our workflow deploys the previously compiled packages to NuGet for all the world to enjoy.<\/p>\n\n\n\n<h6>ci.yml &#8211; Deploy<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\ndeploy:\n  name: Deploy Packages\n  needs: [build, test]\n  if: always()\n  runs-on: windows-2022\n  steps:\n  - name: Check Previous Jobs' Results\n    if: needs.build.result != 'success' || (needs.test.result != 'success' &amp;&amp; inputs.skip-tests != true)\n    run: exit 1\n  - name: Checkout Build Submodule\n    uses: actions\/checkout@v4\n    with:\n      repository: BadEcho\/build\n      ref: ${{ needs.build.outputs.build-submodule-sha }}\n      path: build        \n  - name: Setup .NET\n    uses: actions\/setup-dotnet@v4\n    with:\n      dotnet-version: 9.0.x\n  - name: Download Package Artifacts\n    uses: actions\/download-artifact@v4\n    with:\n      name: packages\n      path: artifacts\/\n  - name: Push to NuGet\n    run: .\\build\\Publish-Packages.ps1 ${{ secrets.NUGET_API_KEY }}\n<\/pre>\n\n\n<p>There are a few bits here that may come off as odd at first, most notably the <code>needs<\/code> and <code>if<\/code> conditional expression.<\/p>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nneeds: [build, test]\nif: always()\nruns-on: windows-2022\nsteps:\n- name: Check Previous Jobs' Results\n  if: needs.build.result != 'success' || (needs.test.result != 'success' &amp;&amp; inputs.skip-tests != true)\n  run: exit 1\n<\/pre>\n\n\n<p>The <code>needs<\/code> key defines the dependencies for this job. Both <code>build<\/code> and <code>test<\/code> must have ran successfully in order for this job to run. This rather reasonable configuration is then followed by an <code>if<\/code> conditional that causes the job to always run, even if <code>build<\/code> and <code>test<\/code> failed or didn&#8217;t run.<\/p>\n\n\n\n<p>The reason for this configuration is so that we can support the <code>skip-tests<\/code> input. When set to true, this input causes the test job to be skipped (which I prefer when creating a stable release, something you&#8217;d only do after an immediately preceding CI workflow run is completed successfully).<\/p>\n\n\n\n<p>So, the above configuration is needed if we have a job with &#8220;optional&#8221; dependencies. However, we still don&#8217;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.<\/p>\n\n\n\n<p>After determining whether or not the job should run, we set up .NET once again (we need to use the <code>dotnet-nuget<\/code> tool), grab our package artifacts and our build scripts, and then execute a package push script.<\/p>\n\n\n\n<p>We could simplify this a bit by using inline PowerShell (which would obviate the need to retrieve our build scripts in this job&#8217;s environment), but I prefer to limit inline scripts to one- or two-liners.<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter size-full is-resized\"><img loading=\"lazy\" src=\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/CIJobs.png\" alt=\"Reusable CI\/CD GitHub Workflows - Example Run\" class=\"wp-image-2974\" width=\"947\" height=\"250\" srcset=\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/CIJobs.png 947w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/CIJobs-300x79.png 300w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/CIJobs-768x203.png 768w, https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/CIJobs-480x127.png 480w\" sizes=\"(max-width: 947px) 100vw, 947px\" \/><figcaption>Now we have a nice looking workflow with segmented phases.<\/figcaption><\/figure><\/div>\n\n\n\n<h2>Release Workflow<\/h2>\n\n\n\n<p>The CI\/CD workflow we discussed will automatically generate pre-release builds in response to changes being pushed to our repo.<\/p>\n\n\n\n<p>There comes a time, now and again, when we might have a winner in terms of stability and functionality. And that&#8217;s when we know it&#8217;s time for a stable release.<\/p>\n\n\n\n<p>To create a stable release, we manually execute a workflow that&#8217;s dedicated to that task.<\/p>\n\n\n\n<h3>Deduplicating Redundancies<\/h3>\n\n\n\n<p>One thing I didn&#8217;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.<\/p>\n\n\n\n<p>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&#8217;s done with that, it can do all the other &#8220;release-y&#8221; stuff it needs to do.<\/p>\n\n\n\n<p>This leaves us with a much more condensed workflow.<\/p>\n\n\n\n<h3>Overview of the Workflow<\/h3>\n\n\n\n<p>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.<\/p>\n\n\n\n<h3>1. You&#8217;re Still Triggering Me!<\/h3>\n\n\n\n<p>Unlike the previous workflow, the release workflow only executes when done so manually by a human (or any other creature of similar intelligence).<\/p>\n\n\n\n<h6>release.yml &#8211; Triggers<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nname: Create Stable Release\n\non:\n  workflow_dispatch:\n    inputs:\n      component-to-increment:\n        description: Version Component to Increment\n        required: true\n        type: choice\n        options:\n        - Major\n        - Minor\n        - Patch\n<\/pre>\n\n\n<p>The <code>workflow_dispatch<\/code> 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. <\/p>\n\n\n\n<p>A stable release is meant to be the definitive release for a particular version, so our version will need a bumpin&#8217; after the release is made.<\/p>\n\n\n\n<h3>2. Build Job<\/h3>\n\n\n\n<p>The build job is where we call into our reusable CI\/CD workflow.<\/p>\n\n\n\n<h6>release.yml &#8211; Build<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nbuild:\n  name: Execute Build Workflow\n  uses: .\/.github\/workflows\/ci.yml\n  with:\n    release-build: true\n    skip-tests: true\n  secrets:\n    REPO_TOKEN: ${{ secrets.REPO_TOKEN }}\n    NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}\n<\/pre>\n\n\n<p>We indicate via the <code>release-build<\/code> 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.<\/p>\n\n\n\n<p>It may be prudent to add a check that ensures that the most recent CI\/CD run actually <em>did<\/em> pass. I have not done that here; maybe I&#8217;ll blurb about it if I do in the future.<\/p>\n\n\n\n<p>Note that we <em>must<\/em> 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).<\/p>\n\n\n\n<h3>3. Create Release Job<\/h3>\n\n\n\n<p>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. <\/p>\n\n\n\n<h6>release.yml &#8211; Create Release<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\ncreate-release:\n  name: Create Release\n  needs: build\n  runs-on: windows-2022\n  steps:\n  - name: Checkout\n    uses: actions\/checkout@v4\n    with:\n      submodules: true\n      fetch-depth: 0      \n  - name: Setup Git User\n    run: git config --global user.email &quot;chamber@badecho.com&quot;; git config --global user.name &quot;Echo Chamber&quot;\n  - name: Create Release Tag\n    run: .\\build\\New-ReleaseTag.ps1 &quot;${{ env.PRODUCT_NAME }}&quot;\n  - name: Bump Version\n    run: .\\build\\Bump-Version.ps1 ${{ github.event.inputs.component-to-increment }}\n<\/pre>\n\n\n<p>Pretty straight forward. Links to the referenced scripts are provided below.<\/p>\n\n\n\n<p>This would also be a great place to generate an actual &#8220;Release&#8221; hosted on GitHub &#8212; something I&#8217;ll get around to doing myself later.<\/p>\n\n\n\n<h2>Links to Complete Examples<\/h2>\n\n\n\n<p>You can find templates for the workflows discussed in this article in my organizational repository:<\/p>\n\n\n\n<ul><li><a href=\"https:\/\/github.com\/BadEcho\/.github\/blob\/master\/workflow-templates\/ci.yml\" target=\"_blank\" rel=\"noreferrer noopener\">ci.yml<\/a><\/li><li><a href=\"https:\/\/github.com\/BadEcho\/.github\/blob\/master\/workflow-templates\/release.yml\" target=\"_blank\" rel=\"noreferrer noopener\">release.yml<\/a><\/li><\/ul>\n\n\n\n<p>The various build scripts referenced by the workflows can be found in the <a href=\"https:\/\/github.com\/BadEcho\/build\" target=\"_blank\" rel=\"noreferrer noopener\">Bad Echo build repository<\/a>.<\/p>\n\n\n\n<p>Have a good one!<\/p>\n\n\n\n<h2>Update: Release Validation<\/h2>\n\n\n\n<p>Since writing this article, I was driven to add validation to the release workflow that ensured the commit you&#8217;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.<\/p>\n\n\n\n<p>Here&#8217;s what that looks like.<\/p>\n\n\n\n<h6>release.yml &#8211; Validation<\/h6>\n\n\n<pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\nvalidate-release:\n  name: Validate Release Candidate\n  runs-on: windows-2022\n  steps:\n  - name: Fail If Not Default Branch\n    if: github.ref_name != github.event.repository.default_branch\n    uses: actions\/github-script@v7.0.1\n    with:\n      script: core.setfailed('Can only create stable releases from the repository\\'s default branch.')\n  - name: Shallow Checkout\n    uses: actions\/checkout@v4\n    with:\n      submodules: true\n  - name: Fetch Last CI\/CD Run Conclusion\n    id: check-last-run\n    run: |\n      Write-Output &quot;LAST_RUN_CONCLUSION=$(.\\build\\Get-LatestWorkflowConclusion.ps1 -Repository:$Env:REPOSITORY -Branch:$Env:BRANCH -WorkflowPath:$Env:WORKFLOW_PATH)&quot; &gt;&gt; $Env:GITHUB_ENV\n    env:\n      REPOSITORY: ${{ github.repository }}\n      BRANCH: ${{ github.ref_name }}\n      WORKFLOW_PATH: .github\/workflows\/ci.yml\n      GH_TOKEN: ${{ secrets.REPO_TOKEN }}\n  - name: Fail If Last Run Unsuccessful\n    if: env.LAST_RUN_CONCLUSION != 'success'\n    uses: actions\/github-script@v7.0.1\n    with:\n      script: core.setFailed('Cannot create a stable release for a commit that resulted in a failed CI\/CD run.')\n<\/pre>\n\n\n<p>The first step ensures that we&#8217;re only creating a stable release from our default main branch (we don&#8217;t want to source releases from feature or unstable branches).<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>Push and pull request event-triggered workflows can be restricted to specific branches, but I&#8217;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.<\/p>\n\n\n\n<p>We then fetch the conclusion of the branch&#8217;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.<\/p>\n\n\n\n<p>The &#8220;check step&#8221; calls the following script:<\/p>\n\n\n\n<h6>Get-LatestWorkflowConclusion.ps1<\/h6>\n\n\n<pre class=\"brush: powershell; title: ; notranslate\" title=\"\">\n# Gets the result of the most recent run for a workflow.\n\nparam (\n    [string]$Repository,\n    [string]$Branch,\n    [string]$WorkflowPath\n)\n\n$allRuns = gh api &quot;\/repos\/$Repository\/actions\/runs&quot; | ConvertFrom-Json\n$workflowRuns = $allRuns.workflow_runs.Where({$_.path -eq $WorkflowPath -and $_.head_branch -eq $Branch})\n\nif ($workflowRuns) {\n    return $workflowRuns[0].conclusion\n}\n<\/pre>\n\n\n<p>It&#8217;s pretty simple; I will probably expand on it. With just this, however, we have some guarantee now that we won&#8217;t be creating &#8220;stable&#8221; releases of unstable code.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>After creating some fancy new Azure DevOps pipelines at work, I decided the automated workflow used for my personal projects [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[10],"tags":[41,42,90,81,80,89],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v14.9 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\r\n<title>Reusable CI\/CD GitHub Workflows - omni&#039;s hackpad<\/title>\r\n<meta name=\"description\" content=\"Demonstrates how to implement CI\/CD and release pipelines with automatic versioning support using reusable Github Actions workflows.\" \/>\r\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\r\n<link rel=\"canonical\" href=\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/\" \/>\r\n<meta property=\"og:locale\" content=\"en_US\" \/>\r\n<meta property=\"og:type\" content=\"article\" \/>\r\n<meta property=\"og:title\" content=\"Reusable CI\/CD GitHub Workflows - omni&#039;s hackpad\" \/>\r\n<meta property=\"og:description\" content=\"Demonstrates how to implement CI\/CD and release pipelines with automatic versioning support using reusable Github Actions workflows.\" \/>\r\n<meta property=\"og:url\" content=\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/\" \/>\r\n<meta property=\"og:site_name\" content=\"omni&#039;s hackpad\" \/>\r\n<meta property=\"article:published_time\" content=\"2024-12-09T10:34:44+00:00\" \/>\r\n<meta property=\"article:modified_time\" content=\"2024-12-22T03:33:57+00:00\" \/>\r\n<meta property=\"og:image\" content=\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows.png\" \/>\r\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\r\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"WebSite\",\"@id\":\"https:\/\/badecho.com\/#website\",\"url\":\"https:\/\/badecho.com\/\",\"name\":\"omni&#039;s hackpad\",\"description\":\"Game Code Disassembly. Omnified Modification. Madness.\",\"publisher\":{\"@id\":\"https:\/\/badecho.com\/#\/schema\/person\/3de67496328be7ae6e1f52faf582e9d2\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":\"https:\/\/badecho.com\/?s={search_term_string}\",\"query-input\":\"required name=search_term_string\"}],\"inLanguage\":\"en-US\"},{\"@type\":\"ImageObject\",\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#primaryimage\",\"inLanguage\":\"en-US\",\"url\":\"https:\/\/badecho.com\/wp-content\/uploads\/2024\/12\/ReusableWorkflows.png\",\"width\":856,\"height\":448,\"caption\":\"Reusable CI\/CD GitHub Workflows\"},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#webpage\",\"url\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/\",\"name\":\"Reusable CI\/CD GitHub Workflows - omni&#039;s hackpad\",\"isPartOf\":{\"@id\":\"https:\/\/badecho.com\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#primaryimage\"},\"datePublished\":\"2024-12-09T10:34:44+00:00\",\"dateModified\":\"2024-12-22T03:33:57+00:00\",\"description\":\"Demonstrates how to implement CI\/CD and release pipelines with automatic versioning support using reusable Github Actions workflows.\",\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/\"]}]},{\"@type\":\"Article\",\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#article\",\"isPartOf\":{\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#webpage\"},\"author\":{\"@id\":\"https:\/\/badecho.com\/#\/schema\/person\/3de67496328be7ae6e1f52faf582e9d2\"},\"headline\":\"Reusable CI\/CD GitHub Workflows\",\"datePublished\":\"2024-12-09T10:34:44+00:00\",\"dateModified\":\"2024-12-22T03:33:57+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#webpage\"},\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/badecho.com\/#\/schema\/person\/3de67496328be7ae6e1f52faf582e9d2\"},\"image\":{\"@id\":\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#primaryimage\"},\"keywords\":\".NET,C#,git,GitHub,PowerShell,YAML\",\"articleSection\":\"General Dev\",\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/badecho.com\/index.php\/2024\/12\/09\/reusable-github-workflows\/#respond\"]}]},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\/\/badecho.com\/#\/schema\/person\/3de67496328be7ae6e1f52faf582e9d2\",\"name\":\"Matt Weber\",\"image\":{\"@type\":\"ImageObject\",\"@id\":\"https:\/\/badecho.com\/#personlogo\",\"inLanguage\":\"en-US\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/7e345ac2708b3a41c7bd70a4a0440d41?s=96&d=mm&r=g\",\"caption\":\"Matt Weber\"},\"logo\":{\"@id\":\"https:\/\/badecho.com\/#personlogo\"}}]}<\/script>\r\n<!-- \/ Yoast SEO plugin. -->","_links":{"self":[{"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/posts\/2966"}],"collection":[{"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/comments?post=2966"}],"version-history":[{"count":12,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/posts\/2966\/revisions"}],"predecessor-version":[{"id":2988,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/posts\/2966\/revisions\/2988"}],"wp:attachment":[{"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/media?parent=2966"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/categories?post=2966"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/badecho.com\/index.php\/wp-json\/wp\/v2\/tags?post=2966"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}