Using a Jenkins Pipeline to build and publish Nuget packages

I've mentioned elsewhere on this blog that our core products are built using standard batch files, which are part of the products source so they can be either build manually or from Jenkins. Over the last year I've been gradually converting our internal libraries onto Nuget packages, hosted on private servers. These packages are also built with a simple batch file, although they currently aren't part of the CI processes and also usually need editing before they can be ran again.

After recently discovering that my StartSSL code signing certificate was utterly useless, I spent the better part of a day rebuilding and publishing all the different packages with a new non-crippled certificate. After that work was done, I decided it was high time I built the packages using the CI server.

Rather than continue with the semi-manual batch files, I decided to make use of the pipeline functionality that was added to Jenkins, which to date I hadn't looked at.

What we are replacing

I suppose to start with it would be helpful to see an existing build file for one of our libraries and then show how I created a pipeline to replace this file. The library in question is named Cyotek.Core and has nothing to do with .NET Core, but has been the backbone of our common functionality since 2009.

@ECHO OFF

SETLOCAL

CALL ..\..\..\build\initbuild.bat

REM Build and sign the file
%msbuildexe% Cyotek.Core.sln /p:Configuration=Release /verbosity:minimal /nologo /t:Clean,Build
CALL signcmd src\bin\Release\Cyotek.Core.dll

REM Create the package
PUSHD %CD%
IF NOT EXIST nuget MKDIR nuget
CD nuget
%nugetexe% pack ..\src\Cyotek.Core.csproj -Prop Configuration=Release
POPD

REM Publish
%nugetexe% push nuget\Cyotek.Core.1.3.0.nupkg -s <YOURPACKAGEURI> <YOURAPIKEY>

ENDLOCAL

These are the steps involved for building one of our Nuget packages

  • Get the source out of SVN (manual)
  • Edit the AssemblyInfo.cs file with a new version (manual)
  • Edit the batch file to mirror the version change (manual)
  • Restore Nuget packages (manual, if required)
  • Build the project in release mode
  • Run the associated testing library if present (manual)
  • Apply a digital signature to the release binary
  • Create a new Nuget package
  • Publish the package

A few inconvenient manual steps there, lets see how Jenkins will help.

About Cyotek.Core's Project Structure

As it turns out, due to the way my environment is set up and how projects are built, my scenario is a little bit more complicated that it might otherwise be.

Our SVN repository is laid out as follows

  • / - Contains a nuget.config file so that all projects share a single package folder, and also contains the strong name key used by internal libraries
  • /build - Numerous batch scripts for performing build actions and InnoSetup includes for product deployment
  • /lib - Native libraries for which a Nuget package isn't (or wasn't) available
  • /resources - Graphics and other media that can be linked by individual projects without having multiple copies of common images scattered everywhere
  • /source - Source code
  • /tools - Binaries for tools such as NUnit and internal deployment tools so build agents have the resources they need to work correctly

Our full products check out a full copy of the entire repository and while that means there is generally no issues about missing files, it also means that new workspaces take a very long time to checkout a large amount of data.

All of our public libraries (such as ImageBox) are self contained. For the most part the internal ones are too, except for the build processes and/or media resources. There are the odd exceptions however with one being Cyotek.Core - we use a number of Win32 API calls in our applications, normally defined in a single interop library. However, there's a couple of key libraries which I want dependency free and Cyotek.Core is one of them. That doesn't mean I want to duplicate the interop declarations though. Our interop library groups calls by type (GDI, Resources, Find etc) and has separate partial code files for each one. The libraries I want dependency free can then just link the necessary files, meaning no dependencies, no publicly exposed interop API, and no code duplication.

What is a pipeline?

At the simplest level, a pipeline breaks your build down into a series of discrete tasks, which are then executed sequentially. If you've used Gulp or Grunt then the pattern should be familiar.

A pipeline is normally comprised of one or more nodes. Each node represents a build agent, and you can customise which agents are used (for example to limit some actions to being only performed on a Windows machine).

Nodes then contain one or more stages. A stage is a collection of actions to perform. If all actions in the stage complete successfully, the next stage in the current node is then executed. The Jenkins dashboard will show how long each stage took to execute and if the execution of the stage was successful. Jenkins will also break the log down into sections based on the stages, so when you click a stage in the dashboard, you can view only the log entries related to that stage, which can make it easier to diagnose some build failures (the full output log is of course still available).

The screenshot below shows a pipeline comprised of 3 stages.

Pipelines are written in custom DSL based on a language named Groovy, which should be familiar to anyone used to C-family programming languages. The following snippet shows a sample job that does nothing but print out a message into the log.

node {
  stage('Message') {
    echo 'Hello World'
  }
}

Jenkins offers a number of built in commands but the real power of the pipeline (as with freestyle jobs) is the ability to call any installed plugin, even if they haven't been explicitly designed with pipelines in mind.

Creating a pipeline

To create a new pipeline, choose New Item from Jenkins, enter a name then select the Pipeline option. Click OK to create the pipeline ready for editing.

Compared to traditional freestyle jobs, there's very few configuration options as you will be writing script to do most of the work.

Ignore all the options for now and scroll to the bottom of the page where you'll find the pipeline editor.

Defining our pipeline

As the screenshot above shows, I divided the pipeline into 3 stages, each of which will perform some tasks

  • Build
    • Get the source and required resources from SVN
    • Setup the workspace (creating required directories, cleaning up old artefacts)
    • Update AssemblyInfo.cs
    • Restore Nuget packages
    • Build the project
  • Test
    • Run the tests for the library using NUnit 2
    • Publish the test results
  • Deploy
    • Digitally sign the release binary
    • Create a Nuget package
    • Publish the package
    • Archive artefacts

Quite a list! Lets get started.

Jenkins recommends you create the pipeline script in a separate Jenkinsfile and check this into version control. This might be a good idea once you have finalised your script, but while developing it is probably a better idea to save it in-line.

With that said, I still recommend developing the script in a separate editor and then copying and pasting it into Jenkins. I don't know if it is the custom theme I use or something else, but the editor is really buggy and the cursor doesn't appear in the right place, making deleting or updating characters an interesting game of chance.

I want all the actions to occur in the same workspace / agent, so I'll define a single node containing my three stages. As a lot of my packages will be compiled the same way, I'm going to try and make it easier to copy and paste the script and adjust things in one place at the top of the file, so I'll declare some variables with these values.

node 
{
  def libName     = 'Cyotek.Core'
  def testLibName = 'Cyotek.Core.Tests'

  def slnPath     = "${WORKSPACE}\\source\\Libraries\\${libName}\\"
  def slnName     = "${slnPath}${libName}.sln"
  def projPath    = "${slnPath}src\\"
  def projName    = "${projPath}${libName}.csproj"
  def testsPath   = "${slnPath}tests\\"

  def svnRoot     = '<YOURSVNTRUNKURI>'
  def nugetApiKey = '<YOURNUGETAPIKEY>'
  def nugetServer = '<YOURNUGETSERVERURI>'

  def config      = 'Release'
  
  def nunitRunner = "\"${WORKSPACE}\\tools\\nunit2\\bin\\nunit-console-x86.exe\""
  def nuget       = "\"${WORKSPACE}\\tools\\nuget\\nuget.exe\""

  stage('Build') 
  {
    // todo
  }
  
  stage('Test') 
  {
    // todo
  }
  
  stage('Deploy') 
  {
    // todo
  }
}

In the above snippet, you may note I used a combination of single and double quoting for strings. Similar to PowerShell, Groovy does different things with strings depending on if they are single or double quoted. Single quoted strings are treated as-is, whereas double quoted strings will be interpolated - the ${TOKEN} patterns will be automatically replaced with appropriate value. In the example above, I'm interpolating both variables I've defined in the script and also standard Jenkins environment variables.

You'll also note the use of escape characters as if you're using backslashes you need to escape them. You also need to escape single/double quotes if they match the quote the string itself is using.

Checking out the repository

I hadn't noticed this previously given that I was always checking out the entire repository, but the checkout command lets you specify multiple locations, customising both the remote source and the local destination. This is perfect, as it means I can now grab the bits I need. I add a checkout command to the Build stage as follows

checkout(
  [
    $class: 'SubversionSCM', 
    additionalCredentials: [], 
    excludedCommitMessages: '', 
    excludedRegions: '', 
    excludedRevprop: '', 
    excludedUsers: '', 
    filterChangelog: false, 
    ignoreDirPropChanges: true, 
    includedRegions: '', 
    locations: 
      [
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'files'   , ignoreExternalsOption: true, local: '.'                              , remote: "${svnRoot}"],
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './build'                        , remote: "${svnRoot}/build"], 
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './tools'                        , remote: "${svnRoot}/tools"], 
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './source/Libraries/Cyotek.Win32', remote: "${svnRoot}/source/Libraries/Cyotek.Win32"]
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: "./source/Libraries/${libName}"  , remote: "${svnRoot}/source/Libraries/${libName}"]
      ], 

    workspaceUpdater: [$class: 'UpdateUpdater']
  ]
)

I didn't write the bulk of the checkout commands by hand, instead I used Jenkins built in Snippet Generator to set all the parameters using the familiar GUI and generate the required script from that, at which point I could start adding extra locations, tinkering formatting etc.

As you can see, I can have configured different local and remote attributes for each location to mimic the full repo. I've also set the root location to only get the files at the root level using the depthOption - otherwise it would check out the entire repository anyway!

If I now run the build, everything is swiftly checked out to the correct locations. Excellent start!

Preventing polling for triggering builds for satellite folders

Well actually, it wasn't. While I was testing this pipeline, I was also checking in files elsewhere to the repository. And as I'd enabled polling for the pipeline, it kept triggering builds without need due to the fact I'd included the repository root for the strong name key. (After this blog post is complete I think I'll do a little spring cleaning on the repository!)

In freestyle projects, I configure patterns so that builds are only triggered when the changes made to the folders that actually contain the application files. However, I could not get the checkout command to honour either the includedRegions or excludedRegions properties. Fortunately, when I took another look at the built-in Snippet Generator, I noticed the command supported two new properties - changelog and poll, the latter of which controls if polling is enabled. So the solution seemed simple - break the checkout command into two different commands, one to do the main project checkout and another (with poll set to false) to checkout supporting files.

The Build stage now looks as follows. Note that I had to put the "support" checkout first, otherwise it would delete the results of the previous checkout (again, probably due to the root level location... sigh). You can always check the Subversion Polling Log for your job to see what SVN URI's its looking for.

checkout(changelog: false, poll: false, scm: 
  [
    $class: 'SubversionSCM', 
    additionalCredentials: [], 
    excludedCommitMessages: '', 
    excludedRegions: '', 
    excludedRevprop: '', 
    excludedUsers: '', 
    filterChangelog: false, 
    ignoreDirPropChanges: true, 
    includedRegions: '', 
    locations: 
      [
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'files'   , ignoreExternalsOption: true, local: '.'                              , remote: "${svnRoot}"],
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './build'                        , remote: "${svnRoot}/build"], 
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './tools'                        , remote: "${svnRoot}/tools"], 
        [credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: './source/Libraries/Cyotek.Win32', remote: "${svnRoot}/source/Libraries/Cyotek.Win32"]
      ], 
      workspaceUpdater: [$class: 'UpdateUpdater']
  ]
)

checkout(
  [
    $class: 'SubversionSCM', 
    additionalCredentials: [], 
    excludedCommitMessages: '', 
    excludedRegions: '', 
    excludedRevprop: '', 
    excludedUsers: '', 
    filterChangelog: false, 
    ignoreDirPropChanges: true, 
    includedRegions: '', 
    locations: [[credentialsId: '<SVNCREDENTIALSID>', depthOption: 'infinity', ignoreExternalsOption: true, local: "./source/Libraries/${libName}", remote: "${svnRoot}/source/Libraries/${libName}"]], 
    workspaceUpdater: [$class: 'UpdateUpdater']
  ]
)

A few minutes later I checked something else in... and wham, the pipeline built itself again (it behaved fine after that though). I had a theory that it was because Jenkins stored the repository poll data separately and only parsed it from the DSL when the pipeline was actually ran rather than saved, but on checking the raw XML for the job there wasn't anything extra. So that will have to remain a mystery for now.

Deleting and creating directories

As I'm going to be generating Nuget packages and running tests, I'll need some folders to put the output into. I already know that NUnit won't run if the specified test results folder doesn't exist, and I don't want to clutter the root of the workspace with artefacts even if it is a temporary location.

For all its apparent power, the pipeline DSL also seems quite limiting at times. It provides a (semi useless) remove directory command, but doesn't have a command for actually creating directories. Not to worry though as it does have bat and sh commands for invoking either Windows batch or Unix shell files. As I'm writing this blog post from a Windows perspective, I'll be using ye-olde DOS commands.

But, before I create the directories, I'd better delete any existing ones to make sure any previous artefacts are removed. There's a built-in deleteDir command which recursively deletes a directory. The current directory, which I why I referred to it as semi-useless above - I would prefer to delete a directory by name.

Another built-in command is dir. Not synonymous with the DOS command, this helpful command changes directory, performs whatever actions you define, then restores the original directory - the equivalent of the PUSHD, CD and POPD commands in my batch file at the top of this post.

The following snippets will delete the nuget and testresults directories if they exist. If they don't then nothing will happen. I found this a bit surprising - I would have expected it to crash given I told it to delete a directory that doesn't exist.

dir('nuget') 
{
  deleteDir()
}
dir('testresults') 
{
  deleteDir()
}

We can then issue commands to create the directories. Normally I'd use IF NOT EXIST <NAME> MKDIR <NAME>, but as we have already deleted the folders we can just issue create commands.

bat('MKDIR testresults')
bat('MKDIR nuget')

And now our environment is ready - time to build.

Building a project

First thing to do is to restore packages by calling nuget restore along with the filename of our solution

bat("${nuget} restore \"${slnName}\"")

Earlier I mentioned that I usually had to edit the projects before building a Nuget package - this is due to needing to update the version of the package as by default Nuget servers don't allow you to overwrite packages with the same version number. Our .nuspec files are mostly set up to use the $version$ token, which then pulls the true version from the AssemblyInformationVersion attribute in the source project. The core products run a batch command called updateversioninfo3 will will replace part of that version with the contents of the Jenkins BUILD_NUMBER environment variable, so I'm going to call that here.

I don't want to get sidetracked as this post is already quite long, so I'll probably cover this command in a different blog post.

bat("""
CALL .\\build\\initbuild
CALL updateversioninfo3 \"${projPath}Properties\\AssemblyInfo.cs\"
""")

If you're paying attention, you'll see the string above looks different from previous commands. To make it easy to specify tool locations and other useful values our command scripts may need, we have a file named initbuild.bat that sets up these values in a single place.

However, each Jenkins bat call is a separate environment. Therefore if I call initbuild from one bat, the values will be lost in the second. Fortunately Groovy supports multi-line strings, denoted by wrapping them in triple quotes (single or double). As I'm using interpolation in the string as well, I need to use double.

All preparation is completed and it's now time to build the project. Although my initbuild script sets up a msbuildexe variable, I wanted to test Jenkins tool commands and so I defined a MSBuild tool named MSBuild14. The tool command returns that value, so I can then use it to execute a release build

def msbHome = tool name: 'MSBuild14', type: 'hudson.plugins.msbuild.MsBuildInstallation'
bat("\"${msbHome}\" \"${slnName}\" /p:Configuration=${config} /verbosity:minimal /nologo /t:Clean,Build")

Running tests

With our Build stage complete, we can now move onto the Test stage - which is a lot shorter and simpler.

I use NUnit to perform all of the testing of our library code. By combining that with the NUnit Plugin it means the rest results are directly visible in the Jenkins dashboard, and I can see new tests, failed tests, or if the number of tests suddenly drops.

Note that the NUnit plugin hasn't been updated to support reports generated by NUnit version 3, so I am currently restricted to using NUnit 2

bat("${nunitRunner} \"${testsPath}bin/${config}/${testLibName}.dll\" /xml=\"./testresults/${testLibName}.xml\" /nologo /nodots /framework:net-4.5")

After that's ran, I call the publish. Note that this plugin doesn't participate with the Jenkins pipeline API and so it doesn't have a dedicated command. Instead, you can use the step command to execute the plugin.

step([$class: 'NUnitPublisher', testResultsPattern: 'testresults/*.xml', debug: false, keepJUnitReports: true, skipJUnitArchiver: false, failIfNoResults: true])

Rather unfortunately the Snippet Editor wouldn't work correctly for me when trying to generating the above step. It would always generate the code <object of type hudson.plugins.nunit.NUnitPublisher>. Fortunately Ola Eldøy had the answer.

However, there's actually a flaw with this sequence - if the bat command that executes NUnit returns a non-zero exit code (for example if the test run fails), the rest of the pipeline is skipped and you won't actually see the failed tests appear in the dashboard.

The solution is to wrap the bat call in try ... finally block. If you aren't familiar with the try...catch pattern, basically you try an operation, catch any problems, and finally perform an action even if the initial operation failed. In our case, we don't care if any problems occur, but we do want to publish any available results.

try
{
  bat("${nunitRunner} \"${testsPath}bin/${config}/${testLibName}.dll\" /xml=\"./testresults/${testLibName}.xml\" /nologo /nodots /framework:net-4.5")
}
finally
{
  step([$class: 'NUnitPublisher', testResultsPattern: 'testresults/*.xml', debug: false, keepJUnitReports: true, skipJUnitArchiver: false, failIfNoResults: true])
}

Now even if tests fail, the publish step will still attempt to execute.

Building the package

With building and testing out of the way, it's time to create the Nuget package. As all our libraries that are destined for packages have .nuspec files, then we just call nuget pack with the C# project filename.

Optionally, if you have an authenticode code signing certificate, now would be a good time to apply it.

I create a Deploy stage containing the appropriate commands for signing and packaging, as follows

bat("""
CALL .\\build\\initbuild
CALL .\\build\\signcmd ${projPath}bin\\${config}\\${libName}.dll
""")

dir('nuget') 
{
  bat("${nuget} pack \"${projName}\" -Prop Configuration=${config}")
}

Publishing the package

Once the package has been built, then we can publish it. In my original batch files, I have to manually update the file to change the version. However, NUGET.EXE actually supports wildcards - and given that the first stage in our pipeline deletes previous artefacts from the build folder, then there can't be any existing packages. Therefore, assuming our updateversioninfo3 did its job properly, and our .nuspec files use $version$, we shouldn't be creating packages with duplicate names and have no need to hard-code filenames.

stage('Deploy') 
{
  dir('nuget') 
  {
    bat("${nuget} push *.nupkg -s ${nugetServer} ${nugetApiKey}")
  }
}

All Done?

And that seems to be it. With the above script in place, I can now build and publish Nuget packages for our common libraries automatically. Which should serve as a good incentive to get as much of our library code into packages as possible!

During the course of writing this post, I have tinkered and adapted the original build script multiple times. After finalising both the script and this blog post, I used the source script to create a further 3 pipelines. In each case all I had to do was change the libName and testsName variables, remove the unnecessary Cyotek.Win32 checkout location, and in one case add a new checkout location for the libs folder. There are now four pipelines happily building packages, so I'm going to class this as a success and continue migrating my Nuget builds into Jenkins.

My freestyle jobs have a step to email individuals when the builds are broken, but I haven't added this to the pipeline jobs yet. As subsequent stages don't execute if the previous stage has failed, that implies I'd need to add a mail command to each stage in another try ... finally block - something to investigate another day.

The complete script can be downloaded from a link at the end of this post.

Downloads

Filename Description Version Release Date
jenkins-nuget-pipeline-example-v1.groovy
  • sha256: 4d1e58fc516e4c82542a4cc19925f993fb0418627cf4ff5df386b44fd954f58f

Sample script for the using a Jenkins Pipeline to build and publish Nuget packages blog post.

14/01/2017 Download

About The Author

Gravatar

The founder of Cyotek, Richard enjoys creating new blog content for the site. Much more though, he likes to develop programs, and can often found writing reams of code. A long term gamer, he has aspirations in one day creating an epic video game. Until that time, he is mostly content with adding new bugs to WebCopy and the other Cyotek products.

Leave a Comment

While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?

Styling with Markdown is supported