Leveraging the PowerShell Script Version Control Process for Exchange Cumulative Update Distribution

This is the second in a two-part blog post covering the topic of how to maintain version control over PowerShell scripts and Exchange 2016 deployment files in a large environment. The first blog post should be reviewed before continuing here because the information below builds on the PowerShell Version Control Process (PSVCP) established in that post. At a high level, the first blog covers:

  1. Creating a service account to hold all the PowerShell script and Exchange deployment file information in Active Directory (AD).
  2. Formatting the data input into the service account (including a PowerShell based GUI I created to make inputting and formatting the data easier.
  3. Retrieving the relevant file information from the service account via PowerShell code, and potentially taking action based on that information.

This blog post represents just part of how I script up deploying/upgrading Exchange 2016 and should NOT be taken as official Microsoft direction as the only way, or even the best way, to deploy/upgrade Exchange. To reiterate the first post, the PSVCP is not an official Microsoft offering nor is it supported by Microsoft. This is a solution I came up with and am sharing with the community so that it could be adapted into your own scripted process(es) should you decide you want to centrally track/manage/distribute Exchange deployment files.

NOTE: There are code snippets used in these blog posts, but PLEASE do not copy and paste them as that can lead to unexpected results. Instead please download this sample script from the TechNet Gallery where I have provided the same code used in both blog posts.


Older versions of Exchange (specifically Exchange 2007 and 2010) relied on "Roll Up" (RUs) to update a specific Exchange service pack version (for example Exchange 2010 Service Pack 3 RU 17). RUs could be downloaded and installed manually, either through Windows Update or the Microsoft Download Center, as they weren't very large or complicated to install. RUs could also be deployed in an automated fashion using the Windows Update client (optionally leveraging Windows Software Update Services) or System Center Configuration Manager. In any case, obtaining and deploying Exchange RUs was a straight forward process that most organizations have/had a well-established process for.

Starting with Exchange 2013, Exchange RUs are a thing of the past, and in their place each quarter a new Exchange Cumulative Update (CU) is released. Not only do these very large CUs have to be manually download from the Microsoft Download Center, as they are not published through Windows Update, but there is no built-in mechanism in Windows Software Update Services or System Center Configuration Manager to push them out. Additionally, unlike the RUs which were just incremental updates to a service pack, each CU is a full version of Exchange that can take a significant amount of time and effort to deploy.

In a large environment, this quarterly CU release represents a challenge when it comes to making sure the latest CUs are deployed both for new Exchange server installs and upgrades of existing installs. Consider the following scenario:

  • A company is currently deploying Exchange 2016 CU5 to new servers throughout their environment.
  • The files for Exchange CU5 are copied to various file shares for distribution, and locally to the servers where Exchange is being installed.
  • Exchange 2016 CU6 is released, and the company decides to switch to using that version going forward for new installs, and upgrade existing CU5 servers to CU6.

How does the company ensure that all new installations begin to use CU6, even though the files for CU5 have been distributed to various locations (including servers yet to be installed)? Likewise, how does the company programmatically copy CU6 to the existing CU5 servers, so they can be upgraded? Lastly how does the company ensure that none of the files in the CU were corrupted/modified as the CU was copied over the network?


To address the challenge of ensuring the latest approved (by the company) CU is used for new installations, as well as upgrades to existing servers, I augmented parts of the PSVCP to support the Exchange CU information. It's always good to Reduce/Reuse/Recycle right? 😊 Plus, since the scripts I'm using to deploy new and upgrade existing Exchange 2016 servers already use the PSVCP, it just made sense to leverage for this challenge as well.

The same Active Directory service account used to track PowerShell script versions is now also used to track the approved Exchange CU and its information. Likewise, the PowerShell GUI script I created to help update the service account also assists with publishing and maintaining the Exchange CU information on that service account.

Here is an example of the augmented code in action. A sample script (after verifying it's the latest published version of itself) checks to see if the latest published Exchange CU exists locally on the server, and it automatically downloads it when it doesn't find it. Once downloaded it verifies the file hash to ensure no corruption/interference occurred with the copy over the network, and it also verifies the file version:

Here is an example of that same script in action where the latest Exchange CU does already exist on the local server. It still verifies the file hash and file version to ensure everything is correct before proceeding on with the rest of the script:

The information below covers how to package an Exchange CU for this process, how publish/update Exchange CUs on the service account, and the actual PowerShell code to retrieve the published Exchange CU.

Cumulative Update Version Numbering

Just like how the PowerShell script version number is used to determine if a published script is older or newer than another script, a version number needs to be used to help identify if the published Exchange CU is older or newer than the local copy. Luckily, unlike the PowerShell script version where I had to create a new version number methodology, Exchange already has a version number associated with each CU. You can find more about Exchange version numbers here:
Exchange Server Updates: build numbers and release dates

In case you aren't familiar with the Exchange file version methodology, which is a methodology Microsoft uses on just about every file that has a version property field, it uses the following format:


Thankfully PowerShell knows how to handle this complex version methodology, including being able to compare two version numbers, thanks to the [Version] variable type. The PowerShell code part of this process is designed to look at the version number of the SETUP.EXE file within the Exchange CU package. For example, the version number of the SETUP.EXE file found in CU6 is as follows:


You can test this by setting the version number of a variable like this:

[Version]$VersionTest = "15.1.1034.26"

Then return the $VersionTest variable to see how PowerShell displays the data.

NOTE: I also use this method for upgrading existing Exchange servers, where the code also looks at the SETUP.EXE in the "Bin" folder of the installed server as well as what's in the CU package, so it is comparing apples to apples (as sometimes the version of the SETUP.EXE is a little off from the official CU build version). That process is outside the scope of this blog though.

Service Account Configuration

As mentioned in the last post, the service account is designed to store the five following fields together for each file entry, one per custom attribute. The following is how they pertained to a packaged Exchange CU:

  • FileID - Mandatory field used to track the identity of a file because the actual file name (such as Ex2016_CU6.ZIP) will change over time, and a constant name is needed to identify the file entry.
  • FielName - Mandatory field to track the actual file name.
  • FileVersion - Mandatory field to track the version number of the file.
  • FilePath - Mandatory field to track the access path to the file.
  • FileHash - Mandatory field used to track the hash of the file to ensure the file has not been modified/corrupted when copied over a network link.

When Exchange 2016 CU6 has been packaged up and published to the service account, its file entry data should look like this:

  • FileID - Exchange2016SetupBits
  • FileName - Ex2016_CU6.ZIP
  • FileVersion - 15.1.1034.26
  • FilePath - \\SERVER2.contoso.com\Software\Microsoft\Exchange\2016\Install
  • FileHash - 2AACABB7DB9AC691D866BAD947AEBB6DE0A95D54BA48D784DB9F16AA0B3CC274

Publishing a New Cumulative Update

Each Exchange 2016 CU comes from Microsoft as a large ISO file, so the first part of publishing a new CU is to package it up for distribution over the network. Then the Update-FileEntries.ps1 script is used to publish it to the service account, so it can be consumed by the PowerShell code below.

In the steps below, no screen shots of the Update-FileEntries.ps1 script are shown because they were already covered in the last blog post. When in doubt reference the first blog post for screen shot assistance.

NOTE: The steps below include creating, populating, and then zipping a folder called "CurrentVersion". The use of this specific folder name is critical because the script code (including the Update-FileEntries script) expect to find this folder name inside of the ZIP file.

  1. Download the desired Exchange 2016 CU ISO from Microsoft.
    NOTE: This step needs to follow established procedures for downloading, scanning, and approving a file for use on the company network.
  2. Mount the ISO as a temporary drive (this can be done on a Windows Server 2012 R2 server).
  3. Create a folder called CurrentVersion on the local computer where the ISO is mounted.
  4. Drag and drop all files and folders from the root of the mounted ISO into the CurrentVersion folder.
  5. Once the file copy is complete, right mouse click the CurrentVersion folder and select "Send to" -->  "Compressed (zipped) folder".
  6. Once the CurrentVersion.ZIP file creation is finished, rename it to the file name format of your choosing such as Ex2016_CU##.ZIP, where ## is the number of the CU. Using this example CU4 would use the file name of ZIP, CU10 would be Ex2016_CU10.ZIP, and so on.
  7. Copy the updated ZIP file to the appropriate folder in the established file share. In the example above the script is being copied to the Microsoft\Exchange\2016\Install folder on the Software share of SERVER2.
  8. Run the Update-FileEntries.ps1 script - it does not require the PowerShell session be run as an Administrator, but it does require the user account executing it have modify rights on the service account in AD.
  9. Click the "Get File Entries" button to retrieve the contents of all 15 Custom Attribute based file entries on the service account.
  10. Select the file "Entry #" row that needs to be modified and click the "Edit File Entry" button (double clicking a file entry works as well) to bring up the Edit File Entry window.
  11. Make the necessary changes to the file entry as needed:
    1. File ID - Enter the name "Exchange2016SetupBit" which is used to specifically identify the Exchange CU separately from its file name.
    2. File Name & File Path - To change file name or location of the file, click the "Browse" button, navigate to and select the ZIP file, and press the "Open" button.
      NOTE: The Update-FileEntries script is intentionally hard coded to only allow selecting .PS1 PowerShell script files or .ZIP files, changeable by the pull down in the bottom right hand corner.
    3. File Version - To update the version number of the CU, click the "Retrieve" button. Behind the scenes the Update-FileEntries.ps1 script does the following:
      1. Temporarily opens the ZIP file.
      2. Extracts the SETUP.EXE to the system temp folder.
      3. Closes the ZIP file.
      4. Pulls the version number from the EXE file.
      5. Removes the EXE from the temp folder.
    4. File Hash - To generate a new SHA256 file hash for the file, click the "Generate" button.
      NOTE: Even zipped up, the Exchange 2016 CUs are quite large and can take a little while to generate a file hash. The PowerShell window may appear to hang briefly during the process so just be patient.
      Also if the computer running the Update-FileEntries script has FIPS mode enabled, it should have .NET 4.6.2 installed which includes a FIPS compliant version of SHA256. Otherwise older .NET versions try to use a version of SHA256 that is not considered FIPS complaint, and the file hashing process will fail.
  12. Review the Edit File Entry window and confirm all the necessary changes were made.
  13. To save changes, select the "Save Entry" button.
  14. To reverse any changes made prior to saving, select the "Reset Entry" button.
  15. To completely remove the file entry from the service account, select the "Clear Entry" and then the "Save Entry" button.
    NOTE: This will wipe all data from only the selected Entry (Custom Attribute) #.
  16. Once the Save Entry button is used, or the red X is clicked to cancel the changes, the Edit File Entry window will close, and the focus will return to the Service Account File Entry Editor main window.
  17. When done, close the Service Account File Entry Editor main window using the red X.

Cumulative Update Retrieval Script Code

This code is meant to be added to a script already using the PSVCP from the previous blog post. This means the script should have already done the following before getting to checking the Exchange CU:

  • Defined the service account and its domain where all the information is stored.
  • Defined the directories where the Exchange CU should be copied to ($InstallDir).
  • Successfully queried the service account and loaded the data from all 15 custom attributes as 15 different file entries in an array called $FileEntries.

After the $FileEntries array has been loaded into memory, with the Exchange CU being one of the file entries, the following code will help download the CU if it is not already on the local computer.


The sample scripts are not supported under any Microsoft standard support program or service. The sample scripts are provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the sample scripts or documentation, even if Microsoft has been advised of the possibility of such damages.


I break down the actions the code is taking in a bulleted formatted below, but here is the actual code block I use with the Exchange deployment scripts:

#region Verify-ExchangeFiles
# Check to see if the Exchange setup bits was found in the FileEntries array.
If ($SetupBits = $FileEntries | Where-Object {$_.FileID -eq "Exchange2016SetupBits"}) {
	# It was so record the FilePath as a script variable, and then compare this script's version recorded FileVersion.
	$SetupBitsName = $SetupBits.FileName
	[Version]$SetupBitsVersion = $SetupBits.FileVersion
	$SetupBitsPath = $SetupBits.FilePath
	$SetupBitsHash = $SetupBits.FileHash

	# Verify the latest CU is copied locally, and if not then copy it down from the designated file path.
	Write-Host ""
	Write-Host "Verifying the latest approved Exchange 2016 CU files exist locally."
	If (Test-Path "$InstallDir\$SetupBitsName") {
		Write-Host -ForegroundColor Green "The latest Exchange 2016 CU `"$SetupBitsName`" was found locally."
	} Else {
		Write-Host -ForegroundColor Yellow "Copying the latest Exchange 2016 CU `"$SetupBitsName`" from `"$SetupBitsPath`"."
		Write-Host "NOTE: This can take some time over a slow connection."
		Copy-Item "$SetupBitsPath\$SetupBitsName" -Destination "$InstallDir\" -Force
	# Verify the file hash for the local copy of the latest CU.
	Write-Host ""
	Write-Host "Verifying the file hash for `"$SetupBitsName`"."
	$SetupBitsLocalHash = (Get-FileHash "$InstallDir\$SetupBitsName").Hash
	If ($SetupBitsLocalHash -ne $SetupBitsHash) {
		Write-Host -ForegroundColor Red "The file hash for `"$SetupBitsPath\$SetupBitsName`" was `"$SetupBitsLocalHash`" when it should be `"$SetupBitsHash`"."
		Write-Host "Please manually copy down the `"$SetupBitsName`" file and verify the file has with Get-FileHash before re-running this script." -ForegroundColor Red
	} Else {
		Write-Host -ForegroundColor Green "File hash verified."
	# Verify the file version for the local copy of the latest CU.
	Write-Host ""
	Write-Host "Verifying the file version for `"$SetupBitsName`"."
	$ZipFile = "$InstallDir\$SetupBitsName"
	# Set up a temporary path and file name to store extracted setup.exe.
	$TempFolder = [System.IO.Path]::GetTempPath()
	$TempFile = $TempFolder + "Setup.EXE"
	# Try to open the zip file.
	Add-Type -AssemblyName "System.Io.Compression.FileSystem"
	Try {
		$ZipFileContents = [Io.Compression.ZipFile]::OpenRead($ZipFile)
	} Catch {
		Write-Host -ForegroundColor Red "There was a problem opening the zip file `"$ZipFile`"."
		Write-Host "Please manually downloaded a known working version of the zip file." -ForegroundColor Red
	# Try to find and extract the setup.exe, looping through each entry to avoid case sensitivity issues of the file name with GetEntry.
	Try {
		$SetupFile = $ZipFileContents.Entries | Where-Object {$_.FullName -like "CurrentVersion/Setup.EXE"}
	} Catch {
		Write-Host -ForegroundColor Red "There was a problem temporarily extracting the Setup.EXE file from `"$ZipFile`"."
		Write-Host "Please investigate the contents of the zip file and ensure there is a Setup.EXE in a sub-folder named `"CurrentVersion`"." -ForegroundColor Red
	# Cleanly close the zip file out.
	# Retrieve the file version from the extracted setup.exe and verify it matches the recorded version in the zip file.
	[Version]$TempFileVersion = (Get-Item $TempFile).VersionInfo.FileVersion
	If ($SetupBitsVersion -ne $TempFileVersion) {
		Write-Host -ForegroundColor Red "The file version for the `"$SetupBitsName`" is #$($TempFileVersion.ToString()), when it should be #$($SetupBitsVersion.ToString())."
		Remove-Item $TempFile -Force
		Write-Host "Please manually copy down the `"$SetupBitsName`" file with the correct version number, or update the version number on the service account before re-running this script." -ForegroundColor Red
	} Else {
		Write-Host -ForegroundColor Green "The file version of #$($SetupBitsVersion.ToString()) was verified as the correct version."
	Remove-Item $TempFile -Force

} Else {
	# This script wasn't found on the service account so report that and give the option to continue anyway.
	Write-Warning "The file entry for `"Exchange2016SetupBits`" was not found in a custom attribute of the service account `"$SvcAccountName`"."
	Write-Host "It is highly recommended to NOT proceed with this script as it can't verify the latest files that should be used on the server."
	Write-Host ""
	$Proceed = Read-Host "Do you want to proceed with running this script anyway? (Y/N)"
	If ($Proceed -eq "Y") {
		Write-Host "Continuing with script execution..."
	} Else {
		Write-Host "Exiting this script due to being unable to find the designated file tracking service account in AD." -ForegroundColor Red
#endregion Verify-ExchangeFiles

Here are the steps the code is executing:

  1. The file entries array is searched for the specific file identity of "Exchange2016SetupBits". If no match is found, then note that and prompt to continue running the script bypassing the steps below. Otherwise continue with the rest of the steps.
  2. Extract out from the matching file entry the following:
    1. The Exchange CU ZIP file name.
    2. The version number of the CU.
    3. The path to the Exchange CU ZIP.
    4. The SHA256 file hash of the CU ZIP.
  3. If the Exchange CU file name doesn't already exist in the designed $InstallDir folder, it is copied from the path above.
  4. The file hash of the local Exchange CU is verified against the file hash above. If the hash doesn't match the script exits as something unusual has occurred which requires manual intervention.
  5. The file version of the CU is validated with the following steps, and if any of them encounter an error the script exists:
    1. Temporarily opens the ZIP file.
    2. Extracts the SETUP.EXE to the system temp folder.
    3. Closes the ZIP file.
    4. Pulls the version number from the EXE file.
    5. Compares the extracted EXE file against what's listed above.
    6. Removes the EXE from the temp folder.

There you have it, that code will download the published CU if it doesn’t exist locally, and once there is a local copy the code will validate the file hash and version number as well.

Security Controls

This augmented process has the same common-sense security controls as in the previous blog post. Secure the file share where the files are stored so only authorized users can get to them, and secure the service account so only authorized users can modify its entries.

Closing Thoughts

Now that the latest approved Exchange CU ZIP file has been located, downloaded, and the file version and hash verified, it is ready to be extracted and have SETUP.EXE executed. However, performing the actual install/upgrade of Exchange is outside the scope of the "PowerShell Script Version Control Process", so it is not covered here.

If there is sufficient interest in how I use this process for Exchange server upgrades, I will do a follow-on blog post on how to use PowerShell to extract the contents of the CU ZIP file, compare the version of the CU against the local server's current version (no need to try an upgrade an already upgraded server), and then kicking off the upgrade process.

Please feel free to leave me comments here if you wish, I promise I will try to respond to each in kind.


Dan Sheehan

Senior Premier Field Engineer

Comments (0)

Skip to main content