Upgrading to .NET 8: Part 2 - Automation is our Friend
Making it easier to test .NET previews using GitHub Actions for on-going automation.
In part 1 of this series I recommended that you prepare to upgrade to .NET 8 and suggested that you start off by testing the preview releases. Testing the preview releases is a great way to get a head start on the upgrade process and to identify any issues sooner rather than later, but it does require an investment of your time from preview to preview each month.
Even if you don't want to test new functionality, you still need to download the new .NET SDK, update all the .NET SDK and NuGet package versions in your projects, and then test that everything still works (that's already automated at least, right?). This can be a time-consuming process over the course of a new .NET release, and it starts to become harder to scale if you want to test lots of different codebases with the latest preview of the next .NET release.
What if we could automate some of this process so that we only need to focus on the parts where we as humans really add value compared to the mechanical parts of an upgrade?
In part 2 of this series I'm going to explain how I've gone about automating the boring parts of the process of testing the latest .NET preview releases using GitHub Actions.
The automation is intended to help with moving from preview to preview each month, but the first preview you wish to test compared to the stable release of .NET 6 (or 7) you currently use will need to be done manually.
This process can be relatively painless in most cases, requiring you to:
- Update your .NET SDK version in
global.jsonto the latest preview release (e.g.
- Update your Target Framework(s) to the latest version (e.g.
- Update your NuGet package reference(s) to the latest preview versions (e.g.
- Fixing (or suppressing - no judgement) any new .NET analyser errors flagged by the .NET SDK.
- Fixing any breaking changes.
- Ensuring all your tests still pass.
With these steps in place, you can then commit the changes to a new branch and push it to GitHub. To make things easy to automate by convention, I do things like this:
- The branch is named
dotnet-vnext- this means we can reuse the process with minimal changes for .NET 9 in 2024 (and beyond).
- The pull request is left in a draft state - this means it can't be merged until we're ready to do so (such as when the
- Don't use a fork - it's much easier to manage merge conflicts over the branch's lifetime if you use a branch in the same repository.
dotnet-vnextas a triggering branch (e.g. for pull requests) for any GitHub Actions workflows that already run for
With the one-off preparation to create the branch done, we're now ready to layer on the automation and save ourselves time in the long run.
Updating the .NET SDK and NuGet package versions
In May I did a talk at DDD South West about how you can use GitHub Actions to automate the process of updating your .NET projects to the latest patch version every month using my update-dotnet-sdk GitHub Action and a GitHub Actions reusable workflow.
There's much more detail about how that works in the sample repository, but in a nutshell it uses the .NET release notes JSON in GitHub to determine if there's a new .NET release available for a channel (.NET 6, .NET 7 etc.) and then raises a pull request to update the
global.json file in the repository to use the latest SDK version. It can also optionally update your .NET NuGet packages to the latest patch version in the same pull request too. For one of my repositories that uses SignalR I also extended it include the SignalR npm package.
It occured to me that I could use the same process to update the version of the .NET SDK for the current .NET preview release on a branch in the same way. By manually running the workflow to update the .NET SDK on my
dotnet-vnext branch instead of
main I could then have the workflow raise a pull request to automatically update the .NET SDK version to the latest preview release each month.
To prevent any issues from breaking the long-lived draft pull request for the .NET 8 upgrade itself, I set up a branch protection rule for the
dotnet-vnext branch with the following settings:
- Require a pull request before merging - this helps keep the branch stable from automation breakages
- Require status checks to pass before merging (the same ones I use for
main) - this ensures the CI passes before an update is merged
- Allow force pushes (more on this later)
- Allow bypassing the above settings - this allows a human to bypass the settings that are there to keep the automation in check
Optionally (but recommended), you can then add further automation to handle reviewing and merging these pull requests to the
dotnet-vnext branch automatically if the CI passes. For my own repositories I handle this using my own imaginatively named GitHub automation bot: costellobot (hey, naming is hard). There's another solution demonstrated in the sample repository.
With this all set up, I can now manually run the workflow to update the .NET SDK version on the
dotnet-vnext branch when there's a preview version available. This is typically on Patch Tuesday each month, but not always.
If the upgrade from one preview to the next goes without a hitch, then it automatically merges and there's nothing that needs doing manually. I'm then free to play with any new features in the latest preview release separately.
If the CI fails, then I can investigate and fix/report the issue(s) before merging the pull request. It also gives you a Git commit that's easy to share in a GitHub issue (assuming that your repository is public) to make it easier for the .NET teams to triage and fix any issues you may find.
This approach makes it easy to focus your time on the parts that are important, rather than having to review everything. 😮💨
Rebasing the branches
A downside of a long-lived branch is that development work in the default branch of your repository likely doesn't stop and things continue to move along.
This means that the
dotnet-vnext branch can quickly start to accumulate merge conflicts as code is changed in the default branch. This is particularly true for versions of NuGet packages and the .NET SDK, especially if you use Dependabot.
While we can easily resolve these conflicts manually, it's quite tedious to do so, and it often just requires you to manually pick the highest version of the dependency in question when there's a conflict on a line for a
This sounds like yet another thing we can automate the process for, right?
For this I created a GitHub Actions workflow that runs on the
dotnet-vnext branch and uses the GitHub API and a custom command-line tool I wrote called Rebaser (namesake) to automatically rebase the branch and force-push the changes back to the branch (this is why we allowed force pushes on the branch when we set up the branch protection rules).
This workflow uses the GitHub API to find the pull request associated with the
dotnet-vnext branch and checks the value of the
mergeable_state property of the response. If the value is
dirty, then the branch has conflicts and needs to be rebased. We ignore other merge states as they don't need us to do anything most of the time.
If the merge state of the branch is found to be "dirty", then Rebaser is run against the repository to rebase the branch. If any simple conflicts caused by version numbers are found, then it will attempt to resolve them itself by always chosing the higher version number for the dependency in question. If Rebaser cannot automatically resolve the conflict, then the rebase is aborted and the workflow emits a warning for a human to resolve the conflict manually.
In the case where a manual conflict needs to be resolved, Rebaser can be run locally with the
--interactive flag specified. This opens Visual Studio Code for each file with a confict in turn and lets the user then leverage the built-in merge conflict resolution UI to resolve the conflict(s) and then save the file to continue the rebase. This is really useful in the case where there's lots of easily resolvable conflicts, but a there's just a single change that needs some manual intervention. In these cases, Rebaser can do the heavy lifting for most of the changes (the boring ones), and us humans can just focus on the few that need manual attention.
Stiching things together
At this point we have a workflow that does our version updates for us and another that rebases the branch when it needs it, but both of these workflows need to be run manually. That's not very automated, is it?
What if we could automate the automation (whoa, meta) to run the workflows for us when we need them to? What if we could run the workflows for all of the repositories we're testing .NET previews with? That sounds like something that would really save us some time each month.
Checking for new releases
- A workflow checks the dotnet/core repository for changes to the
- When changes are found, which usually implies a new release is available, the workflow raises a repository dispatch event named
- This event triggers the
update-dotnet-sdksworkflow which runs the
update-dotnet-sdkworkflow in each repository that has opted-in to the automation for the
mainbranch. This isn't run for
dotnet-vnextat the moment as when the release notes change there's likely a new version of .NET 6, 7 and 8 at the same time. Running just for
mainmeans we can get the .NET 6/7 updates applied to it first, and then the .NET 8 updates later as it would just create a merge conflict anyway.
In the future I might extend this to be smarter and determine whether a preview (or not) has been released, and then run the workflow for either
dotnet-vnext as appropriate.
I updated the workflow above to handle this scenario. It now checks the
release-notes/**/releases.json file(s) for the latest version(s) of .NET that have been released. If any version is a preview, then it will run the workflow for
dotnet-vnext; for any others it will run the workflow for
main. The commit that made those changes can be found here.
Checking for conflicts
- Costellobot receives webhook payloads for pushes to the repositories it is installed in.
- If a change has been found to have been made in the default branch to a file that could cause a conflict in the
dotnet-vnextbranch, then the bot will raise a repository dispatch event named
- This event triggers the
rebaseworkflow for just that repository, which will then rebase the branch if needed as described above.
A sequence diagram of how all the workflows and events fit together is shown below.
More detailed information about how the workflows above operate can be found in Testing .NET vNext.
What's the status of the upgrade?
With all this in place, it would be good to know the status of everything in one place. Information that would be good to show includes:
- What version of the .NET SDK is being used?
- Is the
dotnet-vnextbranch using the latest preview version?
- Is the pull request's CI passing?
- Are there any merge conflicts?
GitHub Actions once again comes to the rescue here, as we can use it to create a workflow that uses the GitHub CLI to easily query the state of the pull requests in our branches and then generate a Markdown report using Step Summaries. This shows us a table of all of the repositories we're testing .NET 8 with and uses shields.io to generate a badge that we can use to tell at-a-glance if there's something not right with any repository.
With some inline PowerShell script and the GitHub CLI (
gh), the dotnet-upgrade-report workflow generates a report that provides us with the information we need, using colour coding to draw attention to any rows that might be of interest. An example of the report can be found here.
Here's a snippet from one as an example:
As you can see above, at the time the report was generated all of the repositories' CI were passing and using the latest .NET 8 preview SDK, but the adventofcode repository had a merge conflict that needed resolving.
The report can be generated on-demand by running the workflow manually, but is is also scheduled to run at 10:00 on working days.
This report allows us to easily track the status of our testing over the course of the preview releases as well as providing us with an index that allows us to pivot to our pull requests.
Humans are still needed
With long-lived branches like
dotnet-vnext being around potentally for months accumulating changes, it's possible that over time the amount of change in the pull request will snowball and become a larger proposition to review and merge when the time comes. Developers typically aren't fans of large pull requests with hundreds of changed lines!
Yet not all of these changes need to wait until .NET 8 is released - some of these changes may be safe to merge into your
main branch now.
One example of this is the new CA1859 code analysis rule. This rule suggests that concrete types are used where possible to improve performance. For example, instead of using
IList<T> as the type for a private field in a class you should use
List<T>. This is because the Just-In-Time compiler (JIT) might be able to "devirtualize" the calls to the methods on the concrete type, which can improve performance.
In the case of CA1859, resolving these analysis warnings doesn't rely on the use of the .NET 8 SDK nor any new APIs. This means that you can safely apply these changes to your default branch now with .NET 6 or 7. The "only" thing .NET 8 did was bring the capability to detect these issues to us. Not waiting for these changes to be made until our .NET 8 upgrade is merged means that we can not only benefit from the performance improvements now, but we also reduce the size of the Git diff in our pull request, making it easier to review and merge when the time comes. The only downside is that someone could change the code back in the default branch and regress the behaviour, but I think that's a small issue compared to the benefits of making such changes now.
Using the workflows described above in my github-automation repository, I've been able to automate a lot of the boring parts of upgrading from one .NET preview to the next. These workflows have allowed me to:
- Watch for new .NET versions using the JSON release notes
- Use the update-dotnet-sdk GitHub action to update the .NET SDK and NuGet packages for the repositories we're testing
- Keep changes that break the CI in a separate branch and pull request for manual inspection
ifwhen an issue is found
- Automatically rebase the
dotnet-vnextbranch as and when needed (or make it easier to do so manually)
With these parts automated, we can focus on the more interesting parts of the process that come up, like any issues we might find, or trying out new functionality in the latest preview releases. Nice. 😎
I hope you find the automation I've described above useful and an inspiration for automating .NET version upgrades for your own application repositories.
In part 3 of this series, we'll look at some of the changes that have been introduced in the first five preview releases of .NET 8 and some of the interesting issues testing them uncovered: Part 3 - Previews 1-5.
Upgrading to .NET 8 Series Links
You can find links to the other posts in this series below.
- Part 1 - Why Upgrade?
- Part 2 - Automation is our Friend (this post)
- Part 3 - Previews 1-5
- Part 4 - Preview 6
- Part 5 - Preview 7 and Release Candidates 1 and 2
- Part 6 - The Stable Release