Simplify .NET CI/CD with Psake and MSBuild

Efficient Build Automation for .NET Applications

Posted by Alfus Jaganathan on Tuesday, September 21, 2021

Background

In modern software development practices, CICD ( continious Integration & Continious Delivery/Deployment) plays an important role, especially with modern application development architecture, say microservices architecture with modern DevOps operations. This applies to all types of software development, regardless of technology, framework, programming language, runtime environment, etc.

There are several CICD tools available, which can make the CICD process seemless. Few of my familiar CICD tools are Jenkins, Azure Pipelines CircleCI, GitlabCI, Concourse, Bamboo, Teamcity, Travis, Cloud Build and CruiseControl. There are a lot more in the market to pick and choose according to our needs. Each of them, has it’s own advantages and disadvantages. However, I see a common limitation across all these tools, that will lock us with these tools/platforms, but very hard to move to another one later, if required.

To overcome this, we need to use some agnostic scripting tools like powershell, bash, nant, cmd/batch, etc. to perform most of the low level tasks. These scripts can be used by any of the above mentioned CICD tools, so switching from one to another will be comparatively easier.

But it may become cumbersome at times, when writing raw scripts using these tools. Fortunately, there are few open source projects like psake, invoke-build, nuke, cake, etc. where few of them use powershell tasks and others use programming language like c#.

I have chosen psake here, where we can explore a bit in detail to see how to use it for .NET applications. In fact, I am using psake for most of my personal .NET projects.

We will look into other tools ( invoke-build, nuke and cake), one-by-one, in the upcoming articles.

Why PSAKE?

All the tools mentioned, are platform agnostic. Fundementally, cake and nuke are based on c#, but psake and invoke-build are based on powershell.

In addition to platform agnostic nature, powershell is a common scripting language familiar by most of the developers, being it a java or c# or golang or any other. This made me biased towards powershell which intern made me to choose psake. Interestingly, Invoke-build seems to be the successor of psake, which we will look into, in a later article.

Implementation

Let’s have a detail look at, how to implement psake for automating build of a .NET project using msbuild.exe

Below are the three script files required

  1. build.cmd for windows - this is the entry point script used to be called with some arguments
  2. configure-build.ps1 - this script is called first, which configures the dependencies. e.g. install psake, install vssetup, etc.
  3. default.ps1 - psake based script file which contains all the build tasks to be invoked

Let’s build these files one by one with minimal implementation.

Build default.ps1

Note: Here we are using msbuild.exe to perform underlying tasks like clean, restore, build, etc. We can also use dotnet.exe and its corresponding arguments instead of msbuild.exe. Unlike msbuild.exe, dotnet.exe can be used in windows and non-windows platforms like linux/ macOS. You can find those details in the article Psake build automation for .NET projects using dotnet.exe.

1. Build Reusable Functions - these are some of the basic powershell functions we need to build, so that use them later in the process.

(i) Function that returns the location of vstest.exe

function global:Get-VsTestLocation() 
{
  $vstest_exe = exec { & "c:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"  -latest -products * -requires Microsoft.VisualStudio.PackageGroup.TestTools.Core -property installationPath}
  $vstest_exe = join-path $vstest_exe 'Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe'
  return $vstest_exe
}

(ii) Function that can delete a directory, whether empty or not, we use here in cleanup tasks.

function global:Delete-Directory($directory_name)
{
  rd $directory_name -recurse -force  -ErrorAction SilentlyContinue | out-null
}

2. Setting Up Properties/Variables - these are the variables required by the tasks (restore, build, test, etc.). We can the keep the solution name and project name same for simplicity. Another point to note here is, $solution_name will be injected via command line parameter/argument.

$script:project_config = "Debug" # script level variable to store configuration, that we will be used when building/publishing the project

properties {
  $base_dir = resolve-path . # resolves current working directory
  $publish_dir = "$base_dir\_publish" # the directory where the artifacts are published
  $solution_file = "$base_dir\$solution_name.sln" # solution file path
  $project_file = "$base_dir\src\$solution_name\$solution_name.csproj" # primary project file path
  $release_id = "win-x64" # rid that we will be used when publishing the project
  $platform = "Any CPU" # target platform that we will be used when publishing the project

  $msbuild = Get-LatestMsbuildLocation # powershell cmdlet that gets the location of msbuild.exe ()
  $vstest = Get-VsTestLocation # custom powershell function that gets the location of vstest.exe ()
}

3. Create Tasks - Create basic tasks like clean, restore, build, publish, etc.

(i) Clean the solution using msbuild /t:clean command, in addition to directories like bin, obj, etc.

task Clean 
{
  if (Test-Path $publish_dir) {
      delete_directory $publish_dir
  }
  Get-ChildItem .\ -include obj,bin -Recurse | foreach ($_) { remove-item $_.fullname -Force -Recurse}
  exec { & $msbuild /t:clean /v:m /p:Configuration=$project_config $solution_file }
}

(ii) Restores all solution dependencies using msbuild /t:restore command

task Restore 
{
  exec { & $msbuild /t:restore $solution_file /v:m /p:NuGetInteractive="true" /p:RuntimeIdentifier=$release_id }
}

(iii) Compile the solution using msbuild /t:build command. Additionally, we can use dependency on tasks as below so that it makes sure that dependendency task is executed before the current task

task Compile -depends Restore 
{
  exec { & $msbuild /t:build /v:m /p:Configuration=$project_config /nologo /p:Platform=$platform /nologo $solution_file }
}

(iv) Execute tests on all projects that name matches pattern *.Tests, using vstest.exe.

task RunTests
{
  Push-Location $base_dir
  $test_assemblies = @((Get-ChildItem -Recurse -Filter "*Tests.dll" | Where-Object {$_.Directory -like '*bin*'} | Where-Object {$_.Directory -notlike '*ref*'}).FullName) -join ' '
  Start-Process -FilePath $vstest -ArgumentList $test_assemblies ,"/Parallel" -NoNewWindow -Wait
  Pop-Location
 }

(v) Publish the primary project using msbuild /t:publish command

task Publish
{
  exec { 
      & $msbuild /t:publish /v:m /p:Platform=$platform /p:SelfContained="true" /p:RuntimeIdentifier=$release_id /p:PublishDir=$publish_dir /p:Configuration=$project_config /nologo /nologo $project_file 
  }
}

(vi) Set project configuration as needed (Debug or Release)

task SetDebugBuild {
    $script:project_config = "Debug"
}

task SetReleaseBuild {
    $script:project_config = "Release"
}

4. Create Task Groups - Group these tasks as another single task, so that we can execute them together using a single task.

task DevBuild -depends SetDebugBuild, Restore, Clean, Compile, RunTests
task DevPublish -depends DevBuild, Publish
task CiBuild -depends SetReleaseBuild, Restore, Clean, Compile, RunTests
task CiPublish -depends CiBuild, Publish

5. Create Aliases - Create alises or short names for the grouped build tasks (nice to have)

task default -depends DevBuild #default used if none of the targets/tasks are mentioned with build.cmd
task cb -depends CiBuild
task cp -depends CiPublish
task dp -depends DevPublish

Build configure-build.ps1

This is the script file used to configure dependencies. In this case, we need certain powershell modules psake, vssetup and buildutils to be installed to perform the build operations. Here is how the script file looks like.

$has_psake = Get-Module -ListAvailable | Select-String -Pattern "Psake" -Quiet
if(-Not($has_psake)) {
    Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
    Install-Module Psake -Scope CurrentUser
}

$has_vsSetup = Get-Module -ListAvailable | Select-String -Pattern "VSSetup" -Quiet
if(-Not($has_vsSetup)) {
    Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
    Install-Module VSSetup -Scope CurrentUser -Force
}

$has_buildUtils = Get-Module -ListAvailable | Select-String -Pattern "BuildUtils" -Quiet
if(-Not($has_buildUtils)) {
    Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
    Install-Module BuildUtils -Scope CurrentUser -Force
}

Build build.cmd

build.cmd is the entry point for executing build. Based on the sample we built above, we can execute the build from a command line using build.cmd which uses the default task.

To run a specific task, we can execute by passing a task or alias as a command argument. e.g. build.cmd DevPublish, build.cmd dp, build.cmd cp, etc.

Here is how build.cmd file looks like.

@echo off
powershell.exe -NoProfile -ExecutionPolicy bypass -Command "& {.\configure-build.ps1 }"
powershell.exe -NoProfile -ExecutionPolicy bypass -Command "& {invoke-psake .\default.ps1 %1 -parameters @{"solution_name"="'<solution file name>'"}; exit !($psake.build_success) }"

Note: <solution file name> has to replaced by the actual solution name.

Hope you find this article, an useful one!

Love helping the community!


comments powered by Disqus