Upgrading to .NET 8: Part 2 - Automation is our Friend

Making it easier to test .NET previews using GitHub Actions for on-going automation.

11 July 2023 by Martin Costello |
11 July 2023 by Martin Costello

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.

Getting started

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:

  1. Update your .NET SDK version in global.json to the latest preview release (e.g. 8.0.100-preview.5.23303.2).
  2. Update your Target Framework(s) to the latest version (e.g. net6.0 to net8.0).
  3. Update your NuGet package reference(s) to the latest preview versions (e.g. 8.0.0-preview.5.23280.8).
  4. Fixing (or suppressing - no judgement) any new .NET analyser errors flagged by the .NET SDK.
  5. Fixing any breaking changes.
  6. 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:

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:

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 <PackageReference>.

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?

A drinking bird pressing a button on a keyboard

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

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 main or 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

A sequence diagram of how all the workflows and events fit together is shown below.

A sequence diagram showing the automated workflow

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:

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:

An example upgrade report showing the status of 9 repositories' upgrades

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.

Summary

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:

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.