[Estimated Reading Time: 9 minutes]

Last time we established that Azure DevOps is able to build Delphi code on my self-hosted build agent, but the script we used was pretty basic. Time to look at a more sophisticated script and use pipeline templates to re-use that script where-ever I need to build a Delphi project. We’ll also look at running tests and capturing results for presentation by Azure DevOps.

Before jumping into the template itself, let’s first take a look at the way we use a template in a consuming pipeline:

trigger:
- develop
- master

pool:
  name: 'The Den'
  demands: 'Delphi'

- powershell: |
    $GitVersion = GitVersion | ConvertFrom-Json
    Write-Host "##vso[task.setvariable variable=version]$($GitVersion.SemVer)"
    Write-Host "##vso[build.updatebuildnumber]$($GitVersion.SemVer)"
  displayName: Determine version for build

- script: |
    SET USES=.\fastmm-4.99.1
    SET USES=%USES%;..\..\src
    SET USES=%USES%;.\deltics.rtl
    SET USES=%USES%;.\deltics.inc
    ECHO ##vso[task.setvariable variable=uses]%USES%
  displayName: 'Initial setup for building and running tests'

- template: templates/delphi-build.yml
  parameters:
    delphiVersion: 7
    project: tests\projects\selftestD7
    searchPath: $(uses)
    postBuild:
      - script: tests\projects\.bin\selftestD7.exe -f=tests\projects\.results\delphi.7.xml
        displayName: 'Execute tests'

- template: templates/delphi-build.yml
  parameters:
    delphiVersion: xe4
    project: tests\projects\selftest.XE4
    searchPath: $(uses)
    postBuild:
      - script: tests\projects\.bin\selftest.xe4.exe -f=tests\projects\.results\delphi.xe4.xml
        displayName: 'Execute tests'
        
- task: PublishTestResults@2
  inputs:
    testResultsFormat: 'XUnit'
    testResultsFiles: '*.xml'
    searchFolder: 'tests/projects/.results'
    testRunTitle: '$(version) Test Results'

This is a simplified version of the actual pipeline I’m using. In the full pipeline I build for every version of Delphi on my agent. I’ve reduced this to just Delphi 7 and Delphi XE4 for the purposes of this example (to show old and new-ish).

We’ve also now progressed beyond the Hello Delphi project. This pipeline builds and runs the self-tests for my Smoketest unit testing framework so some of the pre-amble in this script relates to that, as we’ll see.

Let’s walk through it…

The first powershell step runs GitVersion which will determine an appropriate version with which to label any build based on the git commit history to the point at which this build runs. The results of this are output in json format which I capture into a variable, $GitVersion.

I then execute two build commands.

These take the form of output echoed to the build agent which will be picked up by the build agent and interpreted as commands to be performed by the build agent on our behalf. In a Powershell script we use Write-Host to output these commands. In a cmd.exe script we would use ECHO.

It is also possible to create entirely customised pipeline tasks using a provided Api, with even greater access to controlling the build agent and interacting with the build pipeline. But these console commands are a neat way to expose some of that functionality to any pipeline script, without requiring you to get your hands dirty with custom task development.

The first build command sets a variable which can be picked up in later steps in this build. This variable simply captures the SemVer representation of the build version, as determined by GitVersion:

Write-Host "##vso[task.setvariable variable=version]$($GitVersion.SemVer)"

Why not just keep using the $GitVersion variable itself ?

Well, I can do that in this script, but later steps will run in separate processes and so won’t be aware of this variable. By setting a variable on the agent itself, via the build agent command, the variable can be referenced in later steps and is actually substituted into those steps by the build agent, so this works if those steps are not script-based and cannot directly make use of environment or other script variables.

The next build command is passed back by the build agent to the Azure DevOps build process itself and labels the currently running build with the specified version:

Write-Host "##vso[build.updatebuildnumber]$($GitVersion.SemVer)"
  displayName: Determine version for build

The initial ##vso in the output is what marks these out as one of these special commands. There are many different commands available.

The next step is a simple cmd.exe script which uses an environment variable to build a search path that is needed by my selftest project. Using the same ##vso command, I stash this in a build variable which I call uses, though this time ECHO is used since this is a cmd.exe script:

ECHO ##vso[task.setvariable variable=uses]%USES%

The next two steps build the self-test project for each of Delphi 7 and XE4. These steps use a build template (which we’ll look at next). First let’s look at how I use the template to run tests.

Build templates support parameters to configure them for a particular use, and here I’m using a postBuild parameter to specify an additional script to run after the build (only if it is successful). Here’s that postBuild script as specified for the XE4 build:

- template: templates/delphi-build.yml
  parameters:
    delphiVersion: xe4
    project: tests\projects\selftest.XE4
    searchPath: $(uses)
    postBuild:
      - script: tests\projects\.bin\selftest.xe4.exe -f=tests\projects\.results\delphi.xe4.xml
        displayName: 'Execute tests'

I am relying here on the knowledge that my build template places any resulting .exe in a .bin folder which it will create under the project path. It also creates a .results folder to hold any test results.

Since the self-test project is itself a Smoketest suite, the results of the tests can be output in a file specified on the command line that runs the tests. In this case I put the results from each run into that .results folder and since for this project I am running tests for each version of Delphi that I build with, I give those results a name that identifies the version of Delphi to which the test results relate.

The final step runs a standard task from the Azure DevOps task library to scan for test results in a specified location (this can be very generic and find any test results produced during a run, but in this case I know where the results are so can direct the scan to that specific location).

- task: PublishTestResults@2
  inputs:
    testResultsFormat: 'XUnit'
    testResultsFiles: '*.xml'
    searchFolder: 'tests/projects/.results'
    testRunTitle: '$(version) Test Results'

Any results found are sent back by this task to the Azure DevOps host where they will be ingested into the results for this build. So now, as well as metrics for the build:

I can now also view and dig into test results:

This works because I have recently re-jiggered the output from Smoketest to output results in xUnit 2 compatible format (though this is not yet in the publicly available version of Smoketest).


Now let’s take a look at the build template itself:

parameters:
  delphiVersion: ''
  project: ''
  platform: ''
  searchPath: ''
  unitScopes: 'System;System.Win;Vcl;WinApi'
  fixPack: 'true'
  verbose: 'false'
  runIfSuccessful: 'true'
  postBuild: []

steps:
- powershell: |
    $delphi='${{ parameters.delphiVersion }}'
    $project='${{ parameters.project }}'
    $searchPath='${{ parameters.searchPath }}'
    $unitScopes='${{ parameters.unitScopes }}'
    $noFixPack='${{ parameters.noFixPack }}'
    $verbose='${{ parameters.verbose }}'
    $runIfSuccessful='${{ parameters.runIfSuccesful }}'

    $bits = if($platform -eq 'x64') {'64'} else {'32'}

    $dpr=$(Split-Path -Leaf $project) + '.dpr'
    $cfg='dcc' + $bits + '.cfg'
    $exe='.bin\' + $(Split-Path -Leaf $project) + '.exe'
    $delphiRoot='c:\dcc\' + $delphi
    $delphiBin=$delphiRoot + '\bin'
    $delphiLib=$delphiRoot + '\lib'
        
    # Assume that we will use the standard Delphi compiler but then
    #  check for the presence of an appropriate IDE FixPack compiler
    #  (unless the fixPack parameter is anything other than 'true').
    #
    # If an IDE FixPack compiler is found, use that instead.

    $dcc = 'dcc' + $bits
    if($fixPack -eq 'true') {
        Write-Host 'Checking for IDE FixPack compilers...'

        if(Test-Path -Path $($delphiBin + '\dcc' + $bits + 'speed.exe')) {
            $dcc = 'dcc32'
        } elseif(Test-Path -Path $($delphiBin + '\fastdcc' + $bits + '.exe')) {
            $dcc = 'fastdcc' + $bits
        }
    }
    $dcc=$delphiBin + '\' + $dcc + '.exe'
    Write-Host $('Compiling with ' + $dcc)

    # Modify the Delphi Lib path according to the specified Delphi version
    #  and insert into the searchPath

    if($delphi -eq 'xe') {
        $delphiLib=$delphiLib + '\win32\release'
    } elseif($delphi -gt 'xe') {
        $delphiLib=$delphiLib + '\win' + $bits + '\release'
    }
    $searchPath=$delphiLib + ';' + $searchPath
    Write-Host $('Delphi library path ' + $delphiLib)
    Write-Host $('Using search path ' + $searchPath)
        
    # Construct compiler options for the dccNN.cfg file then create that file

    Set-Location (Split-Path -Path $project)

    if(-Not(Test-Path -Path .bin)) { New-Item .bin -ItemType directory | Out-Null }
    if(-Not(Test-Path -Path .results)) { New-Item .results -ItemType directory | Out-Null }

    $optD='-DCONSOLE'
    $optE='-E.bin'
    $optI='-I' + $searchPath
    $optN='-N.bin'
    $optR='-R' + $searchPath
    $optU='-U' + $searchPath

    if($delphi -gt 'xe') {
        $optNS='-NS' + $unitScopes
    }
        
    Write-Host $('Creating compiler configuration ' + $cfg)

    if(Test-Path -Path $cfg) { Remove-Item $cfg | Out-Null }
    New-Item $cfg -ItemType file | Out-Null

    Add-Content $cfg $optD
    Add-Content $cfg $optE
    Add-Content $cfg $optI
    Add-Content $cfg $optN
    Add-Content $cfg $optR
    Add-Content $cfg $optU
    if($delphi -gt 'xe') {
        Add-Content $cfg $optNS
    }

    # Remove any previous build and invoke the command-line compiler
        
    if(Test-Path -Path $exe) { Remove-Item $exe | Out-Null }

    $cmd=$dcc + ' ' + $dpr + ' -CC'
    Write-Host $('Compiling with ' + $cmd)
    if($verbose -eq 'true') {
        Invoke-Expression $cmd
    } else {
        Invoke-Expression $cmd | Select-String -Pattern 'Fatal: ','Hint: ','Warning: '
    }

    if(Test-Path -Path $exe) {
        Write-Host $('Build succeeded! :)  [' + $exe + ']')
    } else {
        Write-Host "##vso[task.logissue type=error]Build failed.  :("
        exit 1
    }
  displayName: 'Delphi ${{ parameters.delphiVersion }} Build of ${{ parameters.project }} '
- ${{ parameters.postBuild }}

Most of this is the powershell build script that does the actual build, which is much more sophisticated than the simple test we saw last time.

Starting at the beginning, the initial parameters section identifies the parameters which may be passed in to this template, as we saw in the build itself. The most interesting of these is the postBuild parameter which as we saw accepted an entire script to be run by this template. But we can see here that this is not difficult to achieve – far from it in fact.

The first thing that the build script does is place most of these parameters into script variables. This isn’t strictly necessary, but has some benefits.

  1. It does allow us to manipulate the values if we wish. Parameter values are directly substituted into the script before it runs – i.e. you cannot treat parameters themselves as variables.
  2. (and mainly) I’m doing this just to make it slightly less cumbersome to reference them later in the script. 🙂

What then follows is some further setup of script variables, driven by the version of Delphi identified for the build. The aim here is to setup variables that identify the compiler executable (in the relevant bin folder) and the path to the VCL and RTL units that will be needed for the build (in the corresponding lib folder).

As I mentioned, my build agent has the bin and lib folders for many different versions of Delphi on it. By following some conventions I’m able to construct paths to those folders by dropping in the Delphi version as required, also taking account of the target platform in later Delphi versions (to distinguish between x86 and x64 builds).

It should be fairly obvious how this is achieved by looking at a fragment of the file system as it is found on the build agent:

c:\dcc\7
         \bin
            \dcc32.exe
         \lib
 c:\dcc\xe4
          \bin
             \dcc32.exe
             \dcc64.exe
          \lib
             \win32
                  \debug
                  \release
             \win64
                  \debug
                  \release

The next step is a little bit of finesse.

Alongside the stock compilers, in each bin folder I have also put copies of the “speed” and “fast” compilers from the amazing Andreas Hausladen. The build template checks for these, and if found will invoke those in preference to the stock compiler, to eek a little extra efficiency for my builds.

There is then a final tweak to the libPath for later Delphi versions before the .bin and .results folders are created in the project location.

The script then constructs a compiler configuration file. I anticipate this is a section that will either be replaced in the future with something driven by project configuration or will grow to a point where reliance on those project files is avoided entirely.

There are arguments both ways as to which is the more desirable.

  • On the one hand, having builds use project configurations ensures that CI/CD builds are consistent with the builds produced by developers on their machines.
  • On the other hand, using project configurations is more difficult for older Delphi versions that don’t directly support configuration sets (like it or not, some people are still using those, and I still include tests for those in my framework libraries). It also means that a CI/CD build could inadvertently be switched to alternate settings by a developer pushing changes to their project file after making some change to compiler settings that they did not intend.

Either way, for my purposes right now, building and using a configuration file is the way I’ve decided to go for now.

Once the configuration file is prepared, the compiler is then invoked. If the verbose template parameter has not been set true the compiler output is filtered to only show Hints, Warnings and Errors.

 if($verbose -eq 'true') {
        Invoke-Expression $cmd
    } else {
        Invoke-Expression $cmd | Select-String -Pattern 'Fatal: ','Hint: ','Warning: '
    }

The Delphi compiler is inordinately “chatty” otherwise, spewing out a host of largely useless information. This keeps the information in the job output to a more digestible level.

The only thing left to do is check that the build was successful (an EXE was created) and just to make sure that the job status is accurately reflected (i.e. regardless of any exit code set – or not – by the compiler), if we don’t find an EXE then we use another of the built-in build commands to report to the build agent that the job has failed. Otherwise we report our success to the console.

The very last step then simply embeds whatever was passed in the postBuild parameter, having the effect of invoking that post-build script. If no postBuild step is passed, this is an empty step and so simply does nothing.

    if(Test-Path -Path $exe) {
        Write-Host $('Build succeeded! :)  [' + $exe + ']')
    } else {
        Write-Host "##vso[task.logissue type=error]Build failed.  :("
        exit 1
    }
  displayName: 'Delphi ${{ parameters.delphiVersion }} Build of ${{ parameters.project }} '
- ${{ parameters.postBuild }}

Notice that there is no explicit check for a successful build that makes the postBuild script conditional. This is taken care of the fact that if the build itself fails then the default behaviour of the build agent is to skip any subsequent steps.

This also means that if we do want any postBuild step to be run even if the build fails, then we can do that by simply including the appropriate condition property on the postBuild script we pass in. e.g:

- template: templates/delphi-build.yml
  parameters:
    delphiVersion: xe4
    project: tests\projects\selftest.XE4
    searchPath: $(uses)
    postBuild:
      - script: tests\projects\.bin\selftest.xe4.exe -f=tests\projects\.results\delphi.xe4.xml
        condition: always()
        displayName: 'Execute tests'

This obviously doesn’t make sense in this case and is just an example.


Whew! That’s been something of a whilst-stop tour of Azure DevOps and a glimpse inside the CI/CD infrastructure I’m putting in place around my projects.

The best part of this is that everything I’ve shown so far is available for FREE, as in beer, as long as you put in the hours to configure it all.

The only thing that cost any $’s for me in this exercise (aside from Delphi, which I already had) was the Intel NUC PC that I’m using as the build machine in this case, but even that was relatively small beer, just barely over NZ$500 (~US$320) including RAM and SSD etc.

What I’ve shown is not actually complete though. There are some key steps that I’ve deliberately left out as I’m not yet quite ready to share. But I’m getting close.

As a little tease, I’ll just ask… have you heard of nuget ? 😉