Braden++

How to use a specific version of MSVC in GitHub Actions

Alright, I’m not breaking new ground here, but this is a difficulty I’ve had. Maybe it’s a difficulty you’ve had too. It’s not common, but if you want to test your code with a particular version of MSVC, it’s fairly tricky and finicky. Before taking the time to do all this described below, I’ve never had success with running multiple versions of MSVC with GitHub Actions.

GitHub Actions used to have multiple versions of Visual Studio build tools installed on their Windows runners, but this was removed in May 2024. Instead, only the latest build tools are present, so we must find a way to install that specific version of MSVC ourselves.

After figuring out the process and starting to write this article, I found setup-msvc-dev, an Action that does exactly this. I haven’t tested it, so I can’t speak on how well it works. This article only focuses on how I’m implementing this functionality for myself, and how you can too.


Cutting to the chase

Here’s the general method. We need to install the compiler and build tools through the command line alone, and then ensure the environment is setup properly. These are the setups I devised, and I’ll go into more detail on each one.

  1. Download the correct bootstrapper
  2. Execute the bootstrapper on quiet mode
  3. Wait until the installer finishes
  4. Run the batch script to set the correct env variables
  5. Build as normal


1. Download the correct bootstrapper

You can grab the version-specific bootstrappers from the Visual Studio 2022 Release History page. There is a similar page for the other versions of Visual Studio. They have a list of every patch version that had been released, and the associated bootstrapper executables. The “Build Tools” bootstrappers are the ones that don’t require a license.

I’m concerned that someone at Microsoft may decide to remove the publicly available download locations of the bootstrappers. It doesn’t seem too robust to rely on them keeping the bootstrappers up forever, but it’s also more inconvenient to setup some sort of artifact repository with all the bootstrappers I might want. I plan to locally download all the bootstrappers I use to my own machine as an archive, and use Microsoft’s download locations in the script. I’ll deal with it later if the download location is removed.

On my machine locally, I have wget but I don’t have curl. It’s the opposite on GitHub Actions. Note than when using a “composite action” in GitHub Actions, you need to specify curl.exe. Use whichever download tool suits you.

For this demonstration, let’s say I want to use Visual Studio 17.13.3. I’m choosing this because (1) 17.13 was never an LTSC, (2) 17.13 is out of support, and (3) this version isn’t even the last patch of 17.13. If this version works, then anything should work.

wget -O vs_buildtools.exe https://download.visualstudio.microsoft.com/download/pr/9b2a4ec4-2233-4550-bb74-4e7facba2e03/00f873e49619fc73dedb5f577e52c1419d058b9bf2d0d3f6a09d4c05058c3f79/vs_BuildTools.exe
# or
curl.exe -L -o vs_buildtools.exe https://download.visualstudio.microsoft.com/download/pr/9b2a4ec4-2233-4550-bb74-4e7facba2e03/00f873e49619fc73dedb5f577e52c1419d058b9bf2d0d3f6a09d4c05058c3f79/vs_BuildTools.exe


2. Execute the bootstrapper on quiet mode

I found that --quiet --norestart worked well locally, which ensures the no UI elements pop up and the machine doesn’t need to restart. However, the bootstrapper must be run as an administrator, which means there’s a UI pop-up to approve running as an admin. On GitHub Actions, we’re already an admin, so we don’t need to worry about that.

The --installPath can be set to anything. At the time of writing this article, the Windows runners on GHA use default working directory D:\a\<repo-name>\<repo-name>, which is where the repo’s contents go once the code has been checked out. I will use ..\vs-install as the install path.

The VS installer has a zillion “individual components” that you can install, which are grouped together into “workloads”. Giving it a cursory look, we would want to use “Microsoft.VisualStudio.Workload.VCTools”, which is the ID for the workload titled “Desktop development with C++”, no matter which version is being installed. That said, it installs many things that we may not need. Instead, I would rather focus on the individual components required for simply building C++. In this case for Visual Studio 17.13.3, the component for the compiler has the ID “Microsoft.VisualStudio.Component.VC.14.43.17.13.x86.x64”. We also need “Microsoft.VisualStudio.Component.VC.Tools.x86.x64” for the batch script that sets up the environment variables. All in all, this amounts to the arguments --add Microsoft.VisualStudio.Component.VC.14.43.17.13.x86.x64 --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64. These components may have dependencies, so we also use --includeRecommended. In my experience, using the individual components cuts the install size in half, compared to the workload.

Lastly, as far as I’m aware, --noUpdateInstaller ensures that we use the exact version of the bootstrapper and installer that we want, and nothing newer. We may not need this argument, but I’d rather have it just in case.


3. Wait until the installer finishes

Now we need to run the bootstrapper.

.\vs_buildtools.exe `
  --quiet --norestart `
  --installPath ..\vs-install `
  --add Microsoft.VisualStudio.Component.VC.14.43.17.13.x86.x64 `
  --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
  --includeRecommended `
  --noUpdateInstaller

However, this doesn’t work. The bootstrapper process immediately exits successfully, while the installation happens in the background. While the bootstrapper is called \vs_buildtools.exe, the installer is called setup.exe, and multiple of these may be spawned as part of the installation. Locally, I was able to write a portion of the script to loop and wait until there are no more processes called “setup”, but this didn’t work on GHA.

After working on other sorts of “hackier” ideas like the manual looping, I finally settled on using something more robust. PowerShell’s Start-Process command has a -Wait optional parameter, which does exactly what I need.

Indicates that this cmdlet waits for the specified process and its descendants to complete before accepting more input. This parameter suppresses the command prompt or retains the window until the processes finish.

I’m also using the Start-Process argument -NoNewWindow to keep this process running in the current console.

Start the new process in the current console window.

All told, we actually need to run the bootstrapper like this.

Start-Process `
  -FilePath ".\vs_buildtools.exe" `
  -ArgumentList @(
    '--quiet', '--norestart',
    '--installPath', '..\vs-install',
    '--add', 'Microsoft.VisualStudio.Component.VC.14.43.17.13.x86.x64',
    '--add', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
    '--includeRecommended',
    '--noUpdateInstaller'
  ) `
  -NoNewWindow `
  -Wait

It doesn’t look too much different than the directly run command, but now the shell waits until the installer is finished, without doing something hacky like looping on which processes exist on the machine.


4. Run the batch script to set the correct env variables

For me, this is the most finicky part of this whole experience, and the most annoying to deal with.

If you download a version of MSVC, you can’t just add the cmake option -DCMAKE_CXX_COMPILER=path\to\cl.exe, you need to setup the proper environment. Of course the environment variables can be set manually, but these may change across various versions. Instead, we use <install-path>\VC\Auxiliary\Build\vcvarsall.bat. This is a parametrized script that sets up the proper environment. You can either call vcvarsall.bat x64 or use the provided script vcvars64.bat with no arguments, which calls vcvarsall.bat under the hood anyway.

If we simply run ..\vs-install\VC\Auxiliary\Build\vcvars64.bat, the environment variables will only be set for the duration of the script, and will be reset afterwards. So we either need to find a way to make these environment variables escape the confines of the script, or we run our commands from within the context of the script.

First I tried the latter idea, with something like this.

cmd /c "..\vs-install\VC\Auxiliary\Build\vcvars64.bat && powershell"

This starts a new PowerShell session within the session of cmd that’s running the script, which itself it within our original PowerShell session. Shell-ception I guess. This works locally, but it doesn’t work on GitHub Actions, and I’m not exactly sure why. Next I tried figuring out other ways to call the script. Maybe using the & operator in PowerShell? Or using the call operator in cmd? None of those things allowed successfully starting the new PowerShell session with all the relevant environment variables set.

Instead, maybe it would work to call cmake from within the cmd session. Realistically, we don’t actually need everything to be within the proper MSVC environment. We only need the cmake generating step to have the correct environment, and then cmake --build can be run without it.

cmd /c "..\vs-install\VC\Auxiliary\Build\vcvars64.bat && cmake <args...>"

This also worked on my local machine and didn’t work in GHA. Honestly, I would love to know why, but I gave up on this train of thought and switched gears.

What if the environment variables could be captured from the script, and set them in the main PowerShell session? The key here is to silence the script’s own output with >nul, and then use the set command to display all the currently set environment variables.

cmd /c "..\vs-install\VC\Auxiliary\Build\vcvars64.bat >nul && set"

This outputs all the environment variables from the script’s context as pairs of key=value. From here, we can pipe this into ForEach-Object and extract the relevant key-value pairs.

cmd /c "..\vs-install\VC\Auxiliary\Build\vcvars64.bat >nul && set" |
  ForEach-Object {
    # ...
  }

From here, we could do a few different things. We could set the environment variables in the current PowerShell session, but that isn’t as extensible. With GitHub Actions, each step is a new session, so the environment variables will be lost later. Instead, we can use the GITHUB_ENV environment variable to pass the environment variables between steps, and keep these changes for the duration of the job.

In Bash, this would look like echo "MY_ENV_VAR=myValue" >> $GITHUB_ENV, and this is the format of all the examples on the GHA docs. Using PowerShell, it looks like Add-Content $env:GITHUB_ENV "MY_ENV_VAR=myValue".

Therefore, here is the final command to the batch script.

cmd /c "..\vs-install\VC\Auxiliary\Build\vcvars64.bat >nul && set" |
  ForEach-Object {
    Add-Content $env:GITHUB_ENV $_
  }

After this, all the subsequent steps in the job will still have the proper MSVC environment setup, so there is no need for starting inner PowerShell sessions or anything like that. This method is generic enough to work on any MSVC version, as of the time of writing this article.


5. Build as normal

After the previous steps have all been wrapped up into a composite GitHub Action, and they’ve been appropriately parametrized, we can just build as normal. If the previous steps have succeeded, then this will build the project with the desired version of MSVC.

For example, I’ve been testing with a GHA script that looks similar to this.

on:
  push:
jobs:
  install-msvc:
    runs-on: windows-2022
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup MSVC
        uses: ./.github/actions/setup-msvc
        with:
          vs-version: 14.43.17.13
          bootstrapper-url: https://download.visualstudio.microsoft.com/download/pr/9b2a4ec4-2233-4550-bb74-4e7facba2e03/00f873e49619fc73dedb5f577e52c1419d058b9bf2d0d3f6a09d4c05058c3f79/vs_BuildTools.exe
          # install-path: ..\vs-install # Defaults to this value

      - name: Build the code
        run: |
          mkdir build
          cd build
          cmake ..
          cmake --build . --target main
          & .\build\Debug\main.exe

In this case, I created a small executable that just spits out the MSVC version.

#include <iostream>
int main() {
    std::cout << "MSVC version " << _MSC_FULL_VER << '\n';
}

With the parameters given to the composite action in this article, I get the following output.

MSVC version 194334809

MSVC 19.43.34809 is the version shipped with Visual Studio 17.13.3, so it looks like this all works!


A brief discussion on parametrization

I wanted to make this script reusable, which involved factoring it into a composite Action. Realistically, there is only 1 “logical” piece of information needed for this action, the version of Visual Studio. However, at a lower level, we need both the download location for the bootstrapper as well as the specific name of the individual component to install.

While it is possible to map the string “17.13.3” to both “14.43.17.13” and the specific URL used here, this isn’t something I’ve done. For now this composite Action remains without a lookup table. Instead, you need to visit the Release History page and find the link manually.

Maybe in the future I’ll automate this process.


Wait, we’re re-installing these Visual Studio components every single time?

Yeah, unfortunately. I tried to use actions/cache@v4 on the install directory, but it didn’t work. If the cache doesn’t yet exist, then everything works just fine. If the cache exists already, CMake detects the pre-installed MSVC version instead of the one installed in the script. At the time of writing this article, I’m getting MSVC version 194435222 instead.

At this time, I haven’t been able to figure out why that’s happening. I’d rather get this script out into the world sooner, and worry about the caching optimization later.

I’d appreciate any help on this front, if you are reading this and you see an obvious solution.


You can use this Action

So that’s it. That’s how to use any MSVC version with GitHub Actions. It’s nice to have this wrapped up in a pre-packaged composite Action, and then hopefully never worry about it again.

If you want to use this action, you can take a look at k3DW/setup-msvc.

And of course, I’m happy to discuss this more with anyone who might be interested. Thanks for reading!