Featured image of post Automate your .NET SDK updates for consistent and reproducible builds with global.json and Renovate

Automate your .NET SDK updates for consistent and reproducible builds with global.json and Renovate

Get reproducible builds locally, in your CI, and Docker builds with .NET SDK maintenance via global.json and automatic dependency updates.

This is the story of Tom, a .NET developer who has just finished implementing a new feature in an ASP.NET Core application. Everything works perfectly on his machine, and he submits his code via a pull request. However, the CI pipeline fails to compile due to an IDE0100 error. Not understanding the cause of the problem, Tom asks for help from his colleague Brian, who is able to reproduce it.

Under pressure due to tight deadlines, Tom and Brian do not take the time to investigate why the error does not appear on Tom’s machine. They decide to disable the error with a #pragma directive, and the pull request is quickly completed. The application is then successfully deployed to production a few minutes later.

Tom doesn’t understand where this IDE0100 error is coming from, but is he really a .NET dev? This PHP sticker is suspicious! (photo by Tim Gouw on pexels.com)

Everything seems to end well, but that’s not the case. If Tom and Brian had taken the time to examine the situation more closely, they would have discovered several irregularities in their application’s compilation process. Here’s what really happened.

Tom uses version 8.0.200 of the .NET SDK, while Brian has version 8.0.204. Curious about the new features of .NET 9, Brian has also installed the preview version 9.0.100-preview.4. The CI pipeline, hosted on Azure DevOps, uses the UseDotNet@2 task to install the 8.x version of the SDK, including preview versions.

The day the IDE0100 error appeared was the same day Microsoft released SDK version 8.0.300. This new version introduces a fix that changes the behavior of the Roslyn analyzer IDE0100. Tom, using an earlier and vulnerable version of the .NET 8 SDK, was not affected. However, Brian compiled the application with the .NET 9 SDK preview 4, which included this new behavior.

The irony is that the application is deployed as a container, and the base image used is mcr.microsoft.com/dotnet/sdk:8.0.100, the first version of the .NET 8 SDK, released in November 2023. This version contains several known vulnerabilities and is no longer visible on Docker Hub.

Here are the conclusions that can be drawn from this story inspired by real events:

  • Tom and Brian’s team did not implement a mechanism to ensure that the .NET SDK version used to compile the application is the same on all machines.
  • Their CI behavior can change without notice as Microsoft releases new preview versions of the SDK.
  • SDK versions containing vulnerabilities that have already been fixed are still being used on both developer machines and in production.

This situation can be avoided by explicitly specifying the required .NET SDK version to compile the application. In this article, we will see how to do this using a global.json file, as well as other techniques to ensure regular SDK updates controlled by automatic dependency management.

# Understanding .NET SDK versioning

Before explaining what a global.json file is, it is important to understand the .NET SDK versioning and its release cycle.

Every year, a major version of .NET is released: .NET 5.0, 6.0, 7.0, 8.0, and soon 9.0 in November 2024. This is generally the version that developers reference in projects with <TargetFramework>netX.0</TargetFramework>.

The .NET SDK, however, usually receives monthly updates that may include security patches, new features, or component updates such as NuGet or MSBuild. Therefore, the SDK versioning differs slightly from the runtime versioning. For example, the first SDK for .NET 8 was 8.0.100. This version corresponds to the feature band 8.0.1nn. An increment of the hundredths digit in the third section of the version number may indicate the addition of new features and possibly a breaking change.

Thus, 8.0.101 and 8.0.201 belong to different feature bands, while 8.0.101 and 8.0.199 are in the same feature band. The change in the nn digits in 8.0.1nn indicates that there are no new features, and most often this corresponds to vulnerability or bug fixes.

The SDK 8.0.301 is the latest version available as of June 3, 2024

In summary, the .NET SDK version format is as follows: x.y.znn, where:

  • x is the major version, usually incremented with each annual major release.
  • y is the minor version, which has remained at 0 since .NET 5.
  • z is the feature band, which can change monthly, indicating the addition of new features.
  • nn is the patch version, which can change monthly, indicating bug or vulnerability fixes.

# Setting up a global.json file

A global.json file allows you to define which version of the .NET SDK is used to run .NET CLI commands such as dotnet build, dotnet run, or dotnet test. In the absence of this file, the latest SDK installed on the machine is used. Most of the time, it is created at the root of the solution directory.

{
  "sdk": {
    "version": "8.0.300",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  }
}

sdk.version specifies the minimum SDK version required according to the roll-forward strategy defined below.

sdk.rollForward determines whether a version higher than sdk.version can be used. Some possible values are:

  • latestPatch is the default value and allows any higher patch version of the same major, minor, and feature band version. In the above example, 8.0.300 allows 8.0.301, 8.0.399, etc.
  • latestFeature allows any higher feature band and patch version of the same major and minor version. In the above example, 8.0.300 allows 8.0.400, 8.0.500, etc.
  • latestMinor same principle applied to minor versions. In our example above, 8.0.300 allows 8.1.100, 8.2.199, etc.
  • latestMajor allows any higher major version, regardless of the minor, feature band, or patch version.
  • disable indicates that an exact version is required.

My recommendations are as follows:

  • Use the latest available SDK version that you are using. You can get it on the .NET SDK download page.
  • Allow only patches to avoid breaking changes.
  • Disallow preview versions.

The goal is to ensure reproducible builds and identical behavior from the developer’s machine to production.

# Configure CI tasks to consume the global.json file

Azure DevOps and GitHub Actions allow you to install .NET SDK in a CI pipeline. It is possible to configure these tasks to use the global.json file and ensure that the SDK version is the same as the developer’s machine.

Here is the Azure DevOps task to install the .NET SDK from the global.json file:

- task: UseDotNet@2
  displayName: "Install .NET SDK from global.json"
  inputs:
    packageType: "sdk"
    useGlobalJson: true

And here is the corresponding action for GitHub. When no version is specified, the action tries to find a global.json file in the current directory:

# Detect the global.json file in the current directory
- uses: actions/setup-dotnet@v4

# It is also possible to specify the path of the global.json file
- uses: actions/setup-dotnet@v4
  with:
    global-json-file: "./something/global.json"

# Updating Docker .NET images versions

Let’s look at these few Docker image tags for the .NET SDK, .NET runtime, and ASP.NET Core:

  • mcr.microsoft.com/dotnet/sdk:8.0
  • mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim
  • mcr.microsoft.com/dotnet/sdk:8.0-alpine
  • mcr.microsoft.com/dotnet/runtime:8.0
  • mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseled
  • mcr.microsoft.com/dotnet/aspnet:8.0-jammy

What they have in common is that they do not specify a specific version, but rather “the latest version of the 8.0 branch”. Using these tags can have several consequences:

  • Forgetting to pull the latest image can result in using an old version.
  • A new feature band may be released, possibly with breaking changes.

It is preferable, for clarity and consistency with the global.json file, to specify a precise version of the .NET SDK. For example, mcr.microsoft.com/dotnet/sdk:8.0.301:

# Build the app
FROM mcr.microsoft.com/dotnet/sdk:8.0.301 AS build
# [...] Omitted for brevity

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0.3
# [...] Omitted for brevity

In the next section, we will also see how to automate version updates in your Dockerfiles.

# Automating .NET SDK version updates with Renovate

At this point, you are assured that your developers, your CI, and your containers are using the same version of the .NET SDK. The build result you will get will be the same everywhere.

However, it is important to keep this version up to date to benefit from the latest features and security fixes.

To do this, you can use dependency management tools such as Renovate or Dependabot, so that each new version of the .NET SDK automatically creates a pull request to update the global.json file and Dockerfiles.

In the event that a breaking change is introduced, you will only need to fix the open pull request by these tools so that everyone benefits from the new version.

Here is an example of a Renovate configuration file renovate.json to update the .NET SDK:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:best-practices"
  ],
  "enabledManagers": [
    "nuget",
    "dockerfile"
  ],
  "packageRules": [
    {
      "groupName": "dotnet-sdk",
      "description": "Enable non-major updates for .NET SDK and runtime (global.json and Docker images)",
      "matchPackageNames": [
        "dotnet-sdk",
        "mcr.microsoft.com/dotnet/sdk",
        "mcr.microsoft.com/dotnet/aspnet",
        "mcr.microsoft.com/dotnet/runtime",
        "mcr.microsoft.com/dotnet/runtime-deps"
      ],
      "extends": [
        ":disableMajorUpdates",
        ":pinDigestsDisabled"
      ]
    }
  ]
}

Read my article Locally test and validate your Renovate configuration files to test your configuration against your repository.

This example produced this result on a demo project:

Pull request opened by Renovate on GitHub to update .NET

Want to go a step further? Remove the :pinDigestsDisabled rule to pin the digest of the Docker images. The image tag will look like this: mcr.microsoft.com/dotnet/sdk:8.0.301@sha256:1e0c55b0ae732f333818f13c284a01c0e3a2ec431491e23c0a525f6803895c50.

It has been observed that new .NET Docker images are not immediately available following the release of a new SDK version, which usually occurs during “Microsoft Patch Tuesday”, the second Tuesday of each month. You can use the Renovate schedule option to delay the update by a few days to ensure that the Docker images are available.

# Conclusion

Don’t be like Tom and Brian. Take the time to properly configure your development environment and CI pipelines to ensure consistency and security of your builds. By specifying the .NET SDK version with a global.json file and using tools like Renovate to manage updates, you will avoid unpleasant surprises due to unexpected or vulnerable SDK versions. Also, fix the versions of Docker images in your Dockerfiles to ensure the stability of your deployments. Adopt these best practices for robust and reliable development and deployment.

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee