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.](/content/images/size/w2000/2024/07/stop.webp)
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.](https://jscarle.dev/content/images/2024/07/image.png)
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.](https://jscarle.dev/content/images/2024/07/Screenshot-2024-07-01-151913.png)
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.
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.](https://jscarle.dev/content/images/2024/07/inconsistent.png)
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.](https://jscarle.dev/content/images/2024/07/addstatuscheck.png)
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.](https://jscarle.dev/content/images/2024/07/statuschecks.png)
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:
- actions/checkout@v4
Up to date! 👍 - actions/setup-dotnet@v1
Behind, currently at version 4. 👎 - actions/upload-artifact@v3
Behind, currently at version 4. 👎 - actions/download-artifact@v3
Behind, currently at version 4. 👎 - azure/login@v1
Behind, currently at version 2. 👎 - azure/webapps-deploy@v2
Behind, currently at version 3. 👎
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
dotnet build
on your solution locally and observe the order in which projects are built to figure out which order is best.--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.--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.](https://jscarle.dev/content/images/2024/07/Before.png)
And the improved GitHub Action:
![The summary of the improved GitHub Action.](https://jscarle.dev/content/images/2024/07/After.png)
Have ideas on how this could be improved further? Find me online or leave me a comment and let me know!