This blog shows how to create a private build process using psake (pronounce SAH-kee, like the rice wine) and assumes you’ll be using some type of continuous integration (CI) build such as Azure DevOps pipelines. 

psake is a PowerShell-based domain specific language (DSL) for building and packaging software. It is structured around tasks that are chained together through dependency relationships.

The script will usually have multiple, high-level tasks that allow the developer to run quick builds, clean builds for before committing, and ones for the CI system

The basic steps for creating a psake build

The following list are the basic steps to get building with psake.  Each one is discussed below.

  1. Ensure the Solution builds in Visual Studio
  2. Add psake through NuGet to one project (doesn’t matter which) that will install it locally
  3. Create build.bat file at the solution file level that loads psake.psm1 and invokes psake
  4. Create default.ps1 at the solution file level with the following tasks:
    a. Init task for cleanup of artifacts
    b. Compile task to build the code
    c. Test task to run the unit tests
    d. Database task deploy the database if applicable

Ensure it Builds

Obviously you must be able to cleanly build your code.  The important word here is cleanly since the build pipeline will be building in a clean environment.  Forgetting to add a file to VCS is a common mistake that will let you successfully build locally, but will lose a cycle on the build.

Add psake to a csproj

You can install psake by adding the psake NuGet package to a project in your solution.  Otherwise the psake GitHub site has directions for installing it.  Note that to run unsigned PowerShell scripts locally, you should issue the following command from an Administrative PowerShell prompt.

Set-ExecutionPolicy RemoteSigned

Create the Scripts

Usually the psake script and its supporting files are in the same folder as the solution to make finding and running them easy.  These files should be committed to VCS so they can be run by anyone, including Azure DevOps.  PowerShell folks can run the PowerShell scripts directly.  If you have devs that may have trouble with that, you can create a .bat file that launches PowerShell.exe like this, which they can double click from explorer.

powershell.exe -NoProfile -ExecutionPolicy remotesigned -Command "& './run-psake.ps1' %*; if ($lastexitcode -ne 0) {write-host "ERROR: $lastexitcode" -fore RED; exit $lastexitcode}"

Wrapper Script, Run-psake.ps1

To make sure you have all dependencies installed (including psake), you can wrap psake with a simple script to install such modules.  This is helpful when running in an Azure DevOps pipeline that’s on a hosted machine since it may not have what you need to run the build.  The script below is a sample that installs psake, SQL Change Automation, and VsSetup then call Invoke-psake with parameters.  

[CmdletBinding()]
param(
[string[]] $taskList = @('Default'),
[string] $version = "1.0.0.0",
[switch] $useSqlChangeAutomation,
[switch] $runOctopack,
[string] $sqlServer = "."
)

Set-StrictMode -Version Latest

$ErrorActionPreference = "Stop"

$psakeMaximumVersion = "4.7.3"
$vssetupMaximumVersion = "2.2.5"

if (-not (Find-PackageProvider -Name "Nuget")) {
    Write-Host "Installing Nuget provider"
    Install-PackageProvider -Name "Nuget" -Force
}
else {
    Write-Host "Nuget provider already installed"
}

if ( $useSqlChangeAutomation )
{
    if (-not (Get-Module -ListAvailable -Name SqlChangeAutomation) )
    {
        $powershellGet = Get-Module powershellget
        if ( -not $powershellGet -or $powershellGet.Version -lt "1.6" )
        {
            if ( $powershellGet )
            {
                Write-Host "Updating PowerShellGet since currently at $($powershellGet.Version)"
            }
            else
            {
                Write-Host "Installing PowerShellGet"
            }
            Install-Module PowerShellGet -MinimumVersion 1.6 -Force -Scope CurrentUser -AllowClobber
        }

        Write-Host "Installing SqlChangeAutomation"
        Install-Module SqlChangeAutomation -Scope CurrentUser -Force -AcceptLicense
    }
    else {
        Write-Host "SqlChangeAutomation already installed"
    }
}

if (-not (Get-Module -ListAvailable -Name VSSetup)) {
    Write-Host "Installing module VSSetup"
    Find-Module -Name vssetup -MaximumVersion $vssetupMaximumVersion | Install-Module -Scope CurrentUser -Force
}
else {
    Write-Host "VSSetup already installed"
}

if (-not (Get-Module -ListAvailable -Name psake)) {
    Write-Host "Installing module psake"
    Find-Module -Name psake -MaximumVersion $psakeMaximumVersion | Install-Module -Scope CurrentUser -Force
}
else {
    Write-Host "psake already installed"
}

if(-not $version) { $version = "1.0.0"}

Invoke-Psake -buildFile ".\default.ps1" -taskList $taskList -properties @{ 
                                                    version = $version
                                                    useSqlChangeAutomation = [bool]$useSqlChangeAutomation
                                                    runOctopack = [bool]$runOctopack
                                                    sqlServer = $sqlServer } `
                                        -Verbose:$VerbosePreference


if ($psake.build_success) { exit 0 } else { exit 1 }

Properties and parameters to psake can be a bit confusing.  The psake documentation explains it pretty well, and this gist has examples of the various combinations.

The task file, default.ps1

This is the file that psake runs.  It is comprised of multiple task function calls that are passed ScriptBlocks of code you want to execute for each step of the build process.  To avoid making this file long and noisy, you can create functions in separate files and dot-source them into this one.

Prolog

At the top of the file, set strict mode and framework then dot-source any other files.

Set-StrictMode -Version latest

Framework "4.6"

. (Join-Path $PSScriptRoot CmPsakeHelpers.ps1)

Properties Section

This section defines variables that you can reference in any task in the script, like a global.  They can be overridden on the command line.  Below is an abbreviated sample.

properties {

$projectName="ClearMeasure.Playbook"

$copyright="Copyright (c) 2017"

$company="Clear Measure"

$baseDir=resolve-path .\

$sourceDir="$baseDir\src"

$acceptanceTestProject="$sourceDir\AcceptanceTests\AcceptanceTests.csproj"

$projectConfig = $env:Configuration

if ([string]::IsNullOrEmpty($databaseServer)) { $databaseServer="localhost"}

$testresultsDir="$baseDir\TestResults"

if ([string]::IsNullOrEmpty($version)) { $version="1.0.0"}

if ([string]::IsNullOrEmpty($projectConfig)) {$projectConfig="Release"}

if ([string]::IsNullOrEmpty($runOctoPack)) {$runOctoPack="true"}

Write-Host("db password is $databasePassword")

}

Tasks

If no task is specified for Invoke-psake, one named default will be executed.  Each task can have multiple dependent tasks that will run before it. Here are high-level tasks that just have dependencies and don’t have a ScriptBlock themselves.

task default -depends Init, ConnectionString, Compile, RebuildDatabase, Test, LoadData

task ci -depends Init, CommonAssemblyInfo, ConnectionString, Compile, RebuildDatabase, CodeCoverage

task ci-assume-db -depends Init, CommonAssemblyInfo, InjectConnectionString, Compile, UpdateDatabaseAzure, CodeCoverage

The Init task does cleanup and setup needed to run the build

task Init {
Write-Host("##[section]Starting: Build task 'Init'")
deleteFile $packageFile
deleteDirectory $buildDir
deleteDirectory $testresultsDir

createDirectory $testDir
createDirectory $buildDir
...

Other tasks do the compile, setup test database, run tests etc.  Below are a couple more examples.  Since any task can be executed when invoking psake, make sure that each one has appropriate dependencies.  For example Test depends on Compile that in turn depends on Init.

task Compile -depends Init {

Write-Host("##[section]Starting: Build task 'Compile'")

& 'C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\msbuild.exe'/t:Clean`;Rebuild /v:m /maxcpucount:1/nologo /p:RunCodeAnalysis=true /p:ActiveRulesets=MinimumRecommendedRules.ruleset /p:Configuration=$projectConfig/p:OctoPackPackageVersion=$version/p:RunOctoPack=$runOctoPack/p:OctoPackEnforceAddingFiles=true $sourceDir\$projectName.sln


copyAndFlatten $sourceDir *.nupkg $buildDir

Write-Host("##[section]Finishing: Build task 'Compile'")

}

task Test -depends Compile {

Write-Host("##[section]Starting: Build task 'Test' in $testDir")

copyAllAssembliesForTest $sourceDir $testDir

& $nunitPath\nunit3-console.exe$testDir\$unitTestAssembly$testDir\$integrationTestAssembly--workers=1--noheader --result="$buildDir\TestResult.xml"`;format=nunit2

Write-Host("##[section]Finishing: Build task 'Test'")

}

Running the Build

Now every developer can use these scripts to run a build and local tests to make sure that when they commit, it will compile and run in Azure DevOps.   Invoke-psake is the command that actually does the work. You have probably wrapped that in Run-psake.ps1 or a bat file, so all a dev has to do is run one of those with default parameters and the build will take off. Passing in other task name(s) will run those.  In our example above, at the high level we have default and ci and ci-assume-db, each of which has slightly different dependent tasks.  You will usually have a high-level task for devs to run for a quick build, and one for a build with tests that they should run before committing to VCS.  Then you may have others that are specific to an environment, with the idea of having as much common code run in the private build as runs in the build pipeline.

Debugging

When an error occurs in one of your tasks, psake will give rather terse output that is not  very helpful.  For example:

Executing azDoCheckMembership
Error: 11/27/2018 3:04:13 PM:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [<<==>>] Exception: The remote server returned an error: (400) Bad Request.

You can turn on verbose output by issuing the following command in the prompt that you’re running psake and re-running the same command.

$psake.config_default.verboseError=$true

Now you get a detailed error message, call stack and much more.  Here the truncated output from the same error with enough information to fix the problem.

11/27/2018 3:07:49 PM: An Error Occurred. See Error Details Below:
----------------------------------------------------------------------
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [<<==>>] Exception: The remote server returned an error: (400) Bad Request.
----------------------------------------------------------------------

ErrorRecord:

PSMessageDetails :
Exception : System.Net.WebException: The remote server returned an error: (400) Bad Request.
at Microsoft.PowerShell.Commands.WebRequestPSCmdlet.GetResponse(WebRequest request)
at Microsoft.PowerShell.Commands.WebRequestPSCmdlet.ProcessRecord()
TargetObject : System.Net.HttpWebRequest
CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
ErrorDetails : {"$id":"1","innerException":null,"message":"The requested version \"4.1\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"4.1-preview\"","typeName":"Microsoft.VisualStudio.Services.WebApi.VssInvalidPreviewVersionException, Microsoft.VisualStudio.Services.WebApi","typeKey":"VssInvalidPreviewVersionException","errorCode":0,"eventId":3000}
InvocationInfo : System.Management.Automation.InvocationInfo
ScriptStackTrace : at Invoke-BcAzureDevOpsMethod, C:\code\ClearMeasure\Internal-Playbook\scripts\Bootcamp\Public\Invoke-BcAzureDevOpsMethod.ps1: line 70
at Add-VSTeamGraphMembership, C:\code\ClearMeasure\Internal-Playbook\scripts\Bootcamp\Public\Add-VSTeamGraphMembership.ps1: line 21
...

Also, you can use your favorite PowerShell debugger to debug psake.  

Summary

It is virtually impossible to have the build environment in Azure DevOps match that of the developer, but using a shared build script can help avoid red builds and a developer scrambling to find that missing file or fix a bug they introduced to the unit test suite.  psake is one way to solve this problem.  In another blog post we looked at Cake, which is the C# cousin of psake. 

References