Package management has been around for a very long time in the Unix world, but not so much in the world of Windows. The advent of the "Windows Store" changed a lot of this, but for one reason or another, it hasn't really taken off. This has left a large hole in the Windows ecosystem that Chocolatey has happily been filling over the past several years (although Microsoft has recently started challenging it). While Chocolatey has a hefty handful of issues (documentation being a big one), in my experience, it gets the job done.

On the subject of documentation, I found my first experience of building a package to be incredibly painful. The same information is spread out over several pages and it was never clear to me where to look for the specifics on certain subjects. I've always been a fan of "code as documentation" which essentially means automating a process and having the code speak to how it's done. For this reason I created the Chocolatey Package Creator Powershell module which abstracts away much of the mundane process of package creation. It also introduces several other benefits, the primary of which is providing easy support for building a CI/CD pipeline for continuous delivery of Chocolatey packages. In this post I'm going to cover how you can make your own Chocolatey package using this module and hook it up to a CI tool (Azure DevOps in this case).

Architecture

Before we dive into writing code, it's important to review the architecture behind this module because it's rather opinionated about how you should structure your code.

Configuration File

At the center of everything is a single package file which is nothing more than a PSD1 (Powershell data file) which describes the major components of the Chocolatey package including the files that make it up and the metadata that will be attached to the final product. The sample one looks like this:

@{
    name          = 'mypackage'
    processScript = 'process.ps1'
    shim          = $False
    localFiles    = @(
        @{
            localPath  = 'files/ChocolateyInstall.ps1'
            importPath = 'tools/ChocolateyInstall.ps1'
        }
    )
    remoteFiles   = @(
        @{
            url        = 'https://my.download.com/installer.msi'
            sha1       = ''
            importPath = 'tools/installer.msi'
        }
    )
    manifest      = @{
        metadata = @{
            id                       = 'mypackage'
            title                    = 'My Package'
            version                  = '1.0.0'
            authors                  = 'Author'
            owners                   = 'Owners'
            summary                  = 'Installs My Package'
            description              = 'My package does x y z'
            projectUrl               = 'https://www.mypackage.com'
            packageSourceUrl         = 'https://github.com/me/mypackagesource'
            tags                     = 'my package'
            copyright                = '2021 Authors'
            licenseUrl               = 'https://www.mypackage.com/license'
            requireLicenseAcceptance = 'false'
            dependencies             = @()
        }
        files    = @(
            @{
                src    = 'tools\**'
                target = 'tools'
            }
        )
    }
}

The core components are as follows:

  • name: A unique name for the package, usually matches the metadata ID
  • processScript: The location (relative to the PSD1 configuration file) of a Powershell script file that will be executed before the package is built. We will cover this more later.
  • shim: Whether or not executable files should be shimmed. By default Chocolatey enables this feature and requires you to create a .ignore file next to every executable file you don't want shimmed (a terrible, terrible design decision in my opinion). If you set this value to false the module will automatically generate .ignore files for you before building the package.
  • localFiles: A list of relative local file paths that will be copied to the package. The importPath is the path, relative to the package root, where the files will be copied to.
  • remoteFiles: A list of remote files that will be downloaded to the package. An optional sha1 field can be set to the expected hash of the file - a mismatch will cause the build process to exit. Like the local files, the importPath is the relative package path where the files will be downloaded.
  • manifest: This can be thought of as the NuSpec file in PSD1 format. This file is automatically generated during the package build process using the configuration data contained within this section. For details about what each field represents, see the (slightly useful) Chocolatey documentation.

As you can see, this configuration file represents a bulk of the instructions for how a package should be built. It documents the source of all the files that make up the package, including where they should be placed within the package, as well as the metadata that will decorate the final package file.

Process File

In many cases the information contained within the configuration file will be sufficient for creating a package - especially in the case of simple packages which just need a few files downloaded (like an MSI). In some cases, however, it may be necessary to perform additional logic that cannot be easily defined within the constraints of the configuration file.

This is the purpose behind the process file. This file is executed after all files have been gathered and can be used to perform extra processing on the package structure like moving files around, unzipping archives, deleting unnecessary files, or even generating new files (like installation config files). The file takes two parameters and the module does not expect it to produce any output. An error within the this file should throw an exception to stop the build process.

param($BuildPath, $Package)

$toolsDir = Join-Path $BuildPath 'tools'

# Additional processing

The $BuildPath is the full path to the root of the package folder and $Package is a copy of the ChocolateyPackage object that is built using the configuration file. The structure of this object matches that of the configuration file; for example, to access the package ID, you would reference $Package.Manifest.Metadata.Id.

Automating Package Creation

Now that we have a good understanding of the architecture behind this module, let's build a package which installs Chrome Enterprise on a Windows machine.

The Package

Chrome Enterprise is delivered in a zip archive that contains multiple files often used to deploy Chrome in an enterprise environment. In our case, we're only interested in one, which is the MSI installer that actually installs Google Chrome.

Here is the package file we will use:

@{
    name          = 'chrome-enterprise'
    processScript = 'process.ps1'
    shim          = $True
    localFiles    = @(
        @{
            localPath  = 'files/ChocolateyInstall.ps1'
            importPath = 'tools/ChocolateyInstall.ps1'
        }
    )
    remoteFiles   = @(
        @{
            url        = 'https://dl.google.com/tag/s/appguid%3D%7B8A69D345-D564-463C-AFF1-A69D9E530F96%7D%26iid%3D%7B28B3FC2A-8F28-9145-D051-455305F69948%7D%26lang%3Den%26browser%3D4%26usagestats%3D0%26appname%3DGoogle%2520Chrome%26needsadmin%3Dtrue%26ap%3Dx64-stable-statsdef_0%26brand%3DGCEB/dl/chrome/install/GoogleChromeEnterpriseBundle64.zip'
            sha1       = 'F80DE425387D4107EA7EDD047796DC4B286AAC85'
            importPath = 'tools/chrome.zip'
        }
    )
    manifest      = @{
        metadata = @{
            id                       = 'chrome-enterprise'
            title                    = 'Google Chrome'
            version                  = '90.0.4430.85'
            authors                  = 'Google'
            owners                   = 'Joshua Gilman'
            summary                  = 'Installs Google Chrome'
            description              = "Get more done with the new Google Chrome. A more simple, secure, and faster web browser than ever, with Google's smarts built-in."
            projectUrl               = 'https://www.google.com/chrome/'
            packageSourceUrl         = 'https://github.com/jmgilman/ChocolateyPackageManager'
            tags                     = 'Google Chrome Browser Web'
            copyright                = '2021 Google'
            licenseUrl               = 'https://chromeenterprise.google/terms/chrome-service-license-agreement/'
            requireLicenseAcceptance = 'false'
            dependencies             = @()
        }
        files    = @(
            @{
                src    = 'tools\**'
                target = 'tools'
            }
        )
    }
}

Most of this should be self-explanatory by now. We copy one local file over, the ChocolateyInstall.ps1 file which will tell Chocolatey how to install the package. We also download the zip archive I talked about earlier from Google's download servers. I haven't spent much time investigating the structure of these servers, and I'm not sure if the link included will always point to version 90.0.4430.85, so in addition to it being best practice, I've also included the SHA so I can see if the archive ever changes.

We'll need to use a process script to extract the zip archive and grab the MSI installer we're looking for. It looks like this:

param($BuildPath, $Package)

$toolsDir = Join-Path $BuildPath 'tools'
$chromeDir = Join-Path $toolsDir 'chrome'
$zipFile = Join-Path $BuildPath $Package.RemoteFiles[0].ImportPath 

New-Item -ItemType Directory $chromeDir
Expand-Archive $zipFile $chromeDir

Copy-Item (Join-Path $chromeDir 'installers/GoogleChromeStandaloneEnterprise64.msi') $toolsDir
Remove-Item $chromeDir -Recurse -Force
Remove-Item $zipFile

This will extract the contents of the zip file into the chrome subdirectory. Note that we utilize the $Package parameter to get the exact import path of the file. After extracting it, we copy the Chrome 64-bit MSI installer to the base of the tools directory and then delete everything else, including the zip file. This will result in a structure similar to:

/chrome-enterprise.nuspec
/tools/ChocolateyInstall.ps1
/tools/GoogleChromeStandaloneEnterprise64.msi

Note that it's not required to put everything in a directory named tools, however, it seemed like it was best practice from the Chocolatey documentation, and given how lackluster it is, I honestly don't know what potential side effects are introduced by not adhering to it. YMMV.

The final file of our package is the ChocolateyInstall.ps1 file which looks like this:

$packageName = 'chrome-enterprise'
$fileName = 'GoogleChromeStandaloneEnterprise64.msi'
$fileType = 'msi'
$silentArgs = '/qn'

$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$fileLocation = Join-Path $toolsDir $fileName

Install-ChocolateyInstallPackage `
    -PackageName $packageName `
    -FileType $fileType `
    -File64 $fileLocation `
    -SilentArgs $silentArgs

This is a pretty typical installation file and much of the format has been borrowed from other install files in the community. This basically tells Chocolatey to call the MSI file with the /qn flags which will quietly install Chrome in the background. As an exercise, you can improve this package by also supporting the x86 version of Chrome (you'll have to grab it with the process script).

The Pipeline

Our package structure now looks like this:

chrome-enterprise/package.psd1
chrome-enterprise/process.ps1
chrome-enterprise/files/ChocolateyInstall.ps1

The final step is to put all of this into a build pipeline. In our case, we're going to use Azure DevOps, but the process is very simple and can be easily extended to other platforms. Before we build a pipeline file, we should build some helper scripts to make the process a bit more smooth. These scripts will actually call the module functions to build and deploy the package.

The first is the build script:

[cmdletbinding()]
param(
    [string] $ConfigFile,
    [string] $OutPath
)

$ErrorActionPreference = 'Stop'
Import-Module ChocolateyPackageCreator

if (!(Test-Path $ConfigFile)) {
    throw 'Cannot find config file at {0}' -f $ConfigFile
}

if (!(Test-Path $OutPath)) {
    throw 'The output path must already exist at {0}' -f $OutPath
}

$verbose = $PSCmdlet.MyInvocation.BoundParameters['Verbose']
$hasDefender = Test-Path (Join-Path $env:ProgramFiles 'Windows Defender/MpCmdRun.exe' -ErrorAction SilentlyContinue)


$config = Import-PowerShellDataFile $ConfigFile
$packagePath = New-ChocolateyPackage (Split-Path $ConfigFile) $config | 
    Build-ChocolateyPackage -OutPath $OutPath -ScanFiles:$hasDefender -Verbose:$verbose

Write-Output "##vso[task.setvariable variable=packagePath;]$packagePath"

This script does the following:

  • Imports the Powershell module
  • Performs some basic checks to ensure the config file and output path already exist
  • Checks to see if Windows Defender is present. The Build-ChocolateyPackage function supports a -ScanFiles parameter which will automatically scan downloaded file using Windows Defender.
  • Loads the configuration file
  • Creates a new ChocolateyPackage object using New-ChocolateyPackage and then pipes it to the Build-ChocolateyPackage function.

The output of the Build-ChocolateyPackage function is the full path to the package (.nupkg) file. This is then written to the output in the very special Azure DevOps format which tells the pipeline to set the variable packagePath to the value returned by the function.

Finally we have a simple deploy script:

[cmdletbinding()]
param(
    [string] $Repository,
    [string] $PackageFile,
    [switch] $Force
)

$ErrorActionPreference = 'Stop'
Import-Module ChocolateyPackageCreator

if (!$env:API_KEY) {
    throw 'Please supply the NuGet API key via the `API_KEY` environment variable'
}

$verbose = $PSCmdlet.MyInvocation.BoundParameters['Verbose']
Publish-ChocolateyPackage `
    -Repository $Repository `
    -ApiKey $env:API_KEY `
    -PackageFile $PackageFile `
    -Force:$Force `
    -Verbose:$verbose

This script is just a simple wrapper for the Publish-ChocolateyPackage function which publishes a Chocolatey package to a NuGet repository. It expects the URL path to a valid NuGet repository, the path to the package file to publish, and an API key supplied via an environment variable.

With these two scripts, we can construct a pipeline configuration as such:

trigger:
  branches:
    include:
      - master
  paths:
    include:
      - chrome-enterprise/*

variables:
  package.config: chrome-enterprise/package.psd1
  nuget.repository: http://my.repository.com/nuget
  nuget.apikey: 'myapikey'

jobs:
- job: Default
  steps:
  - task: PowerShell@2
    displayName: 'Build Chrome Enterprise package'
    inputs:
      filePath: build.ps1
      arguments: -ConfigFile $(package.config) -OutPath $(Build.StagingDirectory) -Verbose
      pwsh: true
  - task: PowerShell@2
    displayName: 'Deploy Chrome Enterprise package'
    inputs:
      filePath: publish.ps1
      arguments: -Repository $(nuget.repository) -PackagePath $(packagePath)
      pwsh: true
    env:
      API_KEY: $(nuget.apikey)

This build pipeline will be triggered every time a change is made to any of the package files on the master branch. It consists of two tasks: the first calls the build script to build the package and the second calls the publish script to publish the package to our NuGet repository. Notice that the deploy script uses $(packagePath) which is the variable we created at the end of our build script using the special Azure DevOps syntax.

Additional things to take note of:

  • The build task uses $(Build.StagingDirectory) as the output directory for the package. This is a temporary directory that is deleted after the job finishes.
  • The tasks specify pwsh: true which tells the build agents to use Powershell Core (7) which the module was built against.
  • All of the user configurable variables, like the path to the configuration file, are defined at the top in the variables section. This makes it really easy to copy/paste this pipeline for another package as all you'll need to do is change a single variable.
  • The env section under the second task tells the agent to define an environment variable named API_KEY which contains the contents of our key. Recall that the publish script was expecting the key to be passed as an environment variable. In a production environment this variable would be set using a secret and not exposed in the pipeline configuration.

The final result of all of these files can be found in the examples directory of the ChocolateyPackageCreator repository.

Conclusion

The above example should be sufficient to work from and create several more packages. Each package should be contained within its own directory with its own build pipeline that automatically executes when changes to the package files are made. As an example, I should be able to go back and bump the version (and update the URL) of the Chrome package and the build pipeline will automatically build the package with the updated contents and deploy it to my repository.

That's it for this post, if you're interested in staying up to date with this project, feel free to give it a star on Github or submit an issue/feature request if you have some ideas/bugs.