Leveraging Psake for .NET CI/CD Automation with dotnet.exe

Streamlining .NET Builds with PowerShell and Psake

Posted by Alfus Jaganathan on Wednesday, September 22, 2021

Background

To learn more about the background and why psake, please refer to the article Psake build automation for .NET projects using msbuild.exe

Implementation

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

Below are the three script files required

  1. build.cmd for windows or build.sh for linux/ macOS - 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 use dotnet.exe to perform underlying tasks like clean, restore, build, etc. We can also use msbuild.exe and its corresponding arguments instead of dotnet.exe. Unlike dotnet.exe, msbuild.exe, can be used only in windows. You can find those details in this article Psake build automation for .NET projects using msbuild.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 dotnet.exe

function global:Get-Dotnet()
{
	return (Get-Command dotnet).Path
}

(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 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_dir = "$base_dir\src\$solution_name" # primary project file path
  $project_file = "$project_dir\$solution_name.csproj" # primary project file path
  $release_id = "win-x64" # rid that we will be used when publishing the project

  $dotnet_exe = Get-Dotnet
}

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

(i) Clean the solution using dotnet 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 { & $dotnet_exe clean -c $project_config $solution_file }
}

(ii) Restores all solution dependencies using dotnet restore command

task Restore 
{
  exec { & $dotnet_exe restore $solution_file }
}

(iii) Compile the solution using dotnet 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 { & $dotnet_exe build -c $project_config $solution_file }
}

(iv) Execute tests on project that name matches pattern *.Tests, using dotnet test

task RunTests
{
  exec { & $dotnet_exe test -c $project_config "$project_dir.Tests" -- xunit.parallelizeTestCollections=true }
 }

(v) Publish the primary project using dotnet publish command

task Publish
{
  exec { & $dotnet_exe publish -c $project_config $project_file -o $publish_dir -r $release_id}
}

(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 powershell module psake 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
}

Build build.cmd or build.sh

Note: Use cmd for windows platform and bash for linux/ macOS, to run these build scripts.

build.cmd or build.sh 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 or ./build.sh 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, ./build.sh DevPublish, ./build.sh dp, ./build.sh 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) }"

Here is how build.sh file looks like.

#!/bin/bash
pwsh -NoProfile -ExecutionPolicy bypass -Command "& {.\configure-build.ps1 }"
pwsh -NoProfile -ExecutionPolicy bypass -Command "& {Invoke-psake .\default.ps1 $1 -parameters @{'solution_name'='<solution file name>'}; exit !('$psake.build_success') }"

Notes:

  1. <solution file name> has to replaced by the actual solution name.
  2. Make sure to convert build.sh file an executable using chmod +x ./build.sh

Hope you find this article, an useful one!

Love helping the community!


comments powered by Disqus