Stop using the default Azure template for GitHub Actions in your .NET projects

If you've ever deployed your .NET project using Azure's ClickOps portal, chances are it created a GitHub Action that's less than great.

Stop sign in a field.

If you've ever deployed your .NET project to Azure using Azure's ClickOps portal (you can ask Glenn about that), chances are it created a GitHub Action in your repository that looks like this:

# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions

name: Build and deploy ASP.Net Core app to Azure Web App - nahasapeemapetilonmarket

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up .NET Core
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: '8.x'
          include-prerelease: true

      - name: Build with dotnet
        run: dotnet build --configuration Release

      - name: dotnet publish
        run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp

      - name: Upload artifact for deployment job
        uses: actions/upload-artifact@v3
        with:
          name: .net-app
          path: ${{env.DOTNET_ROOT}}/myapp

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: 'Production'
      url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}

    steps:
      - name: Download artifact from build job
        uses: actions/download-artifact@v3
        with:
          name: .net-app

      - name: Deploy to Azure Web App
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v2
        with:
          app-name: 'nahasapeemapetilonmarket'
          slot-name: 'Production'
          publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D84E3D95B0D857727CE771C9840A48DE }}
          package: .

Or this newer variation that now comes with three secrets to maintain:

# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions

name: Build and deploy ASP.Net Core app to Azure Web App - nahasapeemapetilonmarket

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up .NET Core
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: '8.x'
          include-prerelease: true

      - name: Build with dotnet
        run: dotnet build --configuration Release

      - name: dotnet publish
        run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp

      - name: Upload artifact for deployment job
        uses: actions/upload-artifact@v3
        with:
          name: .net-app
          path: ${{env.DOTNET_ROOT}}/myapp

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: 'Production'
      url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
    permissions:
      id-token: write #This is required for requesting the JWT

    steps:
      - name: Download artifact from build job
        uses: actions/download-artifact@v3
        with:
          name: .net-app
      
      - name: Login to Azure
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_1D074363B9E84262BB598576A5992CDE }}
          tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_ECAA5EC61F7C4BA2B326DB8392D9A74E }}
          subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_BC4F68CBF28D4B439875006DF1D41104 }}

      - name: Deploy to Azure Web App
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v2
        with:
          app-name: 'nahasapeemapetilonmarket'
          slot-name: 'Production'
          package: .

Either one of which has several issues.

Unnecessarily long names

The default name given to the action may seem clear and descriptive until you actually start seeing it put to use. Say you have various actions created this way for different environments, how can you tell which is which at a glance?

GitHub Actions showing hard to read workflows.

Woefully out of date

Until the workflow breaks, no one seems to really update it. Which, if you think about it, makes sense. The team working over at Azure are primarily focused on making sure the infrastructure functions as it should, so they're not often in GitHub validating how the workflow looks.

Deprecated warnings.

Not up to date with .NET

Back in .NET 7.0, it was announced that the -o (--output) would no longer be supported with the publish command. In any .NET project with some amount of complexity, this often produces a The process cannot access the file 'X' because it is being used by another process. message which brings people to use the horrible --maxcpucount:1 switch.

⚠️
Seriously, don't do that. Stop castrating your CI/CD workflow!

Considering that MSBuild is designed to build multiple projects in parallel by default, if the --maxcpucount:1 switch solves an issue in your workflow, it's usually proof that you should not be using build or publish on the solution in combination with the --output switch.

Fix the root cause instead of fixing the effect.

Inconsistent results

Due to the reasons above, this also leads to inconsistent results as the use of the --output flags introduces a race condition.

Repetitive failures.

Impossible to distinguish steps

When adding status check restrictions to a Ruleset, the restrictions are against the job names, not against the GitHub Action's workflow name.

Adding build status check.

This means if you have several actions that have the same job name, you won't be able to tell them apart.

A list of status checks with the same name.

Writing a better GitHub Action

That's also easier to maintain and debug

A good CI/CD workflow is one that is easy to follow, easy to monitor, easy to understand, and easy to debug when things don't go as planed. Don't wait for Murphy's law to come knocking at your door before you decide to rewrite your GitHub Action.

Use short names with comments

Long names are problematic in various places in the GitHub UI. Using short names that are relevant make it easy to distinguish between actions and comments can be used to add more context and information to those short names.

# Deploy to the Production environment
name: Deploy (Production)

Use unique names for each job

Using unique names across all of the workflows allows you distinguish between them when adding status checks to rulesets.

  build:
    name: Build for Production
  deploy:
    name: Deploy to Production

Use the current version for each action

You can figure out the current version of each of the actions by looking at the branches or the releases of their respective repositories in GitHub.

At the time of this writing, the template created by Azure used the following actions:

Use descriptive names for each step

Make your workflow easier to read and follow by giving each step a descriptive name. Use comments to give longer descriptions where necessary.

      - name: Checkout source
        uses: actions/checkout@v4
      - name: Setup .NET 8.0
        uses: actions/setup-dotnet@v4
      - name: Upload Api artifact
        uses: actions/upload-artifact@v4
      - name: Download Api artifact
        uses: actions/download-artifact@v4
      - name: Deploy Api
        uses: azure/webapps-deploy@v3

Break the build step into individual commands

Not only do you eliminate any potential concurrency issue with building multiple projects at the same time, you also make sure that you only build what you need. If new projects are added, such as local development tools, they're not built unless you specifically opt-in to doing so. It also makes it easier to debug which project is failing when a build fails.

  - name: Restore packages
    run: dotnet restore

  - name: Build solution
    run: |
      dotnet build tools/SourceGenerators.csproj --configuration Release --no-restore --no-dependencies
      dotnet build src/Api.csproj --configuration Release --no-restore --no-dependencies

  - name: Publish Api
    run: dotnet publish src/Api.csproj --configuration Release --no-restore --no-build --output ${{ env.GITHUB_WORKSPACE }}/publish
🕵️
Run dotnet build on your solution locally and observe the order in which projects are built to figure out which order is best.
ℹ️
During the Build solution step, the --no-restore switch is used to save time since the restore was done in the previous step, while the --no-dependencies switch ignores project-to-project references and only builds the specified project.
⚠️
In the Publish Api step, it is crucial that the publish command only targets the root project that will be deployed. Otherwise concurrency issues will occur with the use of the --output switch. The --no-restore and --no-build switches are also used to prevent repeating work done in the previous steps.

Improve your CI/CD workflow

There are also some additional quality of life improvements that you can make to your GitHub Action that will improve your CI/CD workflow.

Add your tests

Build your test projects individually and run them individually.

      - name: Build solution
        run: |
          dotnet build tests/SourceGenerators.Fixtures.csproj --configuration Release --no-restore --no-dependencies
          dotnet build tests/SourceGenerators.Tests.csproj --configuration Release --no-restore --no-dependencies
          dotnet build tests/Api.UnitTests.csproj --configuration Release --no-restore --no-dependencies
          dotnet build tests/Api.IntegrationTests.csproj --configuration Release --no-restore --no-dependencies

      - name: Run Source Generator tests
        run: dotnet test tests/SourceGenerators.Tests.csproj --configuration Release --no-restore --no-build

      - name: Run Api tests
        run: dotnet test tests/Api.UnitTests.csproj --configuration Release --no-restore --no-build

      - name: Run Integration tests
        run: dotnet test tests/Api.IntegrationTests.csproj --configuration Release --no-restore --no-build

Use a consistent path and artifact

Publish and upload from a consistent path by using a GitHub environment variable such as GITHUB_WORKSPACE or RUNNER_TEMP with a relevant directory name such as publish resulting in --output ${{ env.GITHUB_WORKSPACE }}/publish.

Give the artifact a relevant name as well.

      - name: Upload Api artifact
        uses: actions/upload-artifact@v4
        with:
          name: api
          path: ${{ env.GITHUB_WORKSPACE }}/publish
      - name: Download Api artifact
        uses: actions/download-artifact@v4
        with:
          name: api

Set the build version

There are a lot of different ways to set the build version, but once you have it set as an environment variable (such as BUILD_VERSION), you can then pass it to the dotnet build command using the --property:Version= switch.

Reduce output noise

Most people aren't aware, but the dotnet tool also includes a --nologo switch which mutes the output of the startup banner or the copyright message on most commands. It's especially useful when running multiple dotnet commands in sequence such as the way this improved workflow does.

Putting it all together

A proper GitHub Action for .NET

It might seem verbose, but the end result is a clear, consistent and reliable workflow that is easy to understand, maintain, and diagnose while also being consistent and reliable.

# Deploy to the Production environment
name: Deploy (Production)

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    name: Build for Production
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source
        uses: actions/checkout@v4

      - name: Setup .NET 8.0
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.x

      - name: Set build version
        run: echo "BUILD_VERSION=$(date +'%Y.%m.%d.%H%M')" >> $GITHUB_ENV

      - name: Restore packages
        run: dotnet restore --nologo

      - name: Build solution
        run: |
          dotnet build tools/SourceGenerators.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION
          dotnet build tests/SourceGenerators.Fixtures.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION
          dotnet build tests/SourceGenerators.Tests.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION
          dotnet build src/Api.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION
          dotnet build tests/Api.UnitTests.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION
          dotnet build tests/Api.IntegrationTests.csproj --configuration Release --nologo --no-restore --no-dependencies --property:Version=$BUILD_VERSION

      - name: Run Source Generator tests
        run: dotnet test tests/SourceGenerators.Tests.csproj --configuration Release --nologo --no-restore --no-build

      - name: Run Api tests
        run: dotnet test tests/Api.UnitTests.csproj --configuration Release --nologo --no-restore --no-build

      - name: Run Integration tests
        run: dotnet test tests/Api.IntegrationTests.csproj --configuration Release --nologo --no-restore --no-build

      - name: Publish Api
        run: dotnet publish src/Api.csproj --configuration Release --nologo --no-restore --no-build --output ${{ env.GITHUB_WORKSPACE }}/publish

      - name: Upload Api artifact
        uses: actions/upload-artifact@v4
        with:
          name: api
          path: ${{ env.GITHUB_WORKSPACE }}/publish

  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: 'Production'
      url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}

    steps:
      - name: Download Api artifact
        uses: actions/download-artifact@v4
        with:
          name: api

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_1D074363B9E84262BB598576A5992CDE }}
          tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_ECAA5EC61F7C4BA2B326DB8392D9A74E }}
          subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_BC4F68CBF28D4B439875006DF1D41104 }}

      - name: Deploy to Azure Web App
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'nahasapeemapetilonmarket'
          slot-name: 'Production'
          package: .

The original Azure templated GitHub Action:

The summary of the original Azure templated GitHub Action.

And the improved GitHub Action:

The summary of the improved GitHub Action.

Have ideas on how this could be improved further? Find me online or leave me a comment and let me know!