[D365] Create Azure feed from build pipeline

[D365] Create Azure feed from build pipeline

Using the pipelines in Azure DevOps to build D365fO packages is a great way to automate builds and share the resulting artifacts.

A great way to provide the generated (deployable) packages to other systems is using Azure Artifact Feeds.

Creating a feed in DevOps

Switch to the DevOps project you want to create a feed in and select the Artifacts tile on the left side menu:

Azure DevOps Artifacts menu

Create a new feed via the button at the top panel:

Azure DevOps Artifacts panel
Creation dialog for Azure Feed

Enter a name and set the options who can see this feed.
A rule of thumb is to set it either to your Azure AD or invite specific people.

Connect a feed

This section is only informational, we will setup the push of packages to this feed within a pipeline task.

To connect to this feed, click the Connect to feed button and see the options in the following dialog:

Empty Azure Feed page
Azure Feed connection dialog

Setup pipeline to push artifacts to feed

Now we want to have our pipeline to push the artifacts from the build (deployable package in our case) to this feed so we don't have to do this manually every time we successfully ran a build.

You'll have tasks similar to this setup in your pipeline, where Generate Packages and Publish Artifacts: Packages are the ones of interest for us:

Pipeline tasks overview

Task: create nuspec file

Add a new task of type PowerShell and set it up to run a script to generate a NuSpec file:

Task generate nuspec

The script is as following:

# The path of the artifact to extract.
$artifactPath = $Env:AGENT_BUILDDIRECTORY
$stagingDirectory = $Env::BUILD_ARTIFACTSTAGINGDIRECTORY
$sourceType = "SoftwareDeployablePackage"
$binariesPath = "$stagingDirectory\Files"
$metadataPath = $binariesPath
$iconUrl = ""

<# param ( [parameter(mandatory="$false," helpmessage="The path of the artifact to extract." )] [string]$artifactpath, [string]$stagingdirectory, [string]$sourcetype, [string]$binariespath="$($stagingDirectory)\Files" , [string]$metadatapath="$binariesPath," [string]$iconurl ) #>

function Get-NuSpecXML() {
  # xmlns=`"http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd`"
  $xml = New-Object xml
  $xml.PreserveWhitespace = $True
  $xml.LoadXml("    
    
        
            ApplicationPlatform
            7.0.4030.2
            Microsoft
            Microsoft
            
            Application Platform
            Copyright 2016 Microsoft
        
        
            
        
    ")
    

  $xml
}

function Add-File([xml]$xml, [string]$source, [string]$target) {
  $fileNode = $xml.CreateElement("file")
  $attribute = $xml.CreateAttribute("src")
  $attribute.Value = $source
  $attribute = $fileNode.Attributes.Append($attribute)
  $attribute = $xml.CreateAttribute("target")
  $attribute.Value = $target
  $attribute = $fileNode.Attributes.Append($attribute)

  $fileNode = $xml.package.files.AppendChild($fileNode)
}

function Create-NuSpec([string]$PackagePath) {
  $PackageName = (Get-Item $PackagePath).BaseName
    
  $NugetPackageName = $PackageName
    
  $PackageBinPath = Join-Path $PackagePath "bin"

  $xml = Get-NuSpecXML
  $xml.package.metadata.id = "$NugetPackageName"
  $xml.package.metadata.title = $xml.package.metadata.id   
    
  $xml.package.metadata.description = "Dynamics 365 for Finance and Operations package - $NugetPackageName"

  $owner = ""
  [xml]$descriptorXML = "";
  # Try to find main descriptor by looking for the package name as the model name
  $descriptorPath = Join-Path $PackagePath "Descriptor"

        
  #$descriptorPath = $descriptorPath.ToLower().Replace($filesPath.ToLower(), $sourcesPath)
  $packageMainDescriptor = Join-Path $descriptorPath "$PackageName.xml"
  if (!(Test-Path $packageMainDescriptor) -and (Test-Path $descriptorPath)) {
    # Get all descriptors
    $packageDescriptor = Get-ChildItem $descriptorPath -Filter *.xml | Where-Object { $_.BaseName.Replace(" ", "") -eq $PackageName }
    if ($packageDescriptor.Count -gt 0) {
      $packageMainDescriptor = Join-Path $descriptorPath "$($packageDescriptor[0].BaseName).xml"
    }
    else {
      $packageMainDescriptor = Join-Path $descriptorPath "$($packageDescriptor.BaseName).xml"
    }
  }

  if (Test-Path $packageMainDescriptor) {
    [xml]$descriptorXML = Get-Content $packageMainDescriptor -Encoding UTF8
    if ($descriptorXML.AxModelInfo.Publisher) {
      $owner = $descriptorXML.AxModelInfo.Publisher
    }
        
    $xml.package.metadata.copyright = "Copyright $((Get-Date).Year) $owner"
    $xml.package.metadata.authors = $owner
    $xml.package.metadata.owners = $owner
    $xml.package.metadata.title = $descriptorXML.AxModelInfo.DisplayName
    if ([string]::IsNullOrEmpty($descriptorXML.AxModelInfo.Description)) {
      $xml.package.metadata.description = "Dynamics 365 for Finance and Operations package - $NugetPackageName"
    }
  }

  $PackageDLL = Join-Path $PackageBinPath "Dynamics.AX.$PackageName.dll"
  if (Test-Path $PackageDLL) {        
    $PackageVersion = (Get-Item $PackageDLL).VersionInfo.FileVersion
    $xml.package.metadata.version = $PackageVersion
  }
  else {
    $xml.package.metadata.version = "$($descriptorXML.AxModelInfo.VersionMajor).$($descriptorXML.AxModelInfo.VersionMinor).$($descriptorXML.AxModelInfo.VersionBuild).$($descriptorXML.AxModelInfo.VersionRevision)"
        
  }

  switch -Regex ($sourceType) {
    "Binaries|SoftwareDeployablePackage" {
      Add-File -xml $xml -source ".\bin\**" -target ".\bin\"

      if (Test-Path $(Join-Path $PackagePath "Reports")) {
        Add-File -xml $xml -source ".\Reports\**" -target ".\Reports\"
      }

      if (Test-Path $(Join-Path $PackagePath "Resources")) {
        Add-File -xml $xml -source ".\Resources\**" -target ".\Resources\"
      }

      if (Test-Path $(Join-Path $PackagePath "WebContent")) {
        Add-File -xml $xml -source ".\WebContent\**" -target ".\WebContent\"
      }

      if (Test-Path $(Join-Path $PackagePath "FileLocations.xml")) {
        Add-File -xml $xml -source ".\FileLocations.xml" -target ".\"
      }
      #Add-File -xml $xml -source ".\Resources\**" -target ".\Resources\"
      #Add-File -xml $xml -source ".\WebContent\**" -target ".\WebContent\"
      #Add-File -xml $xml -source ".\FileLocations.xml" -target ".\"
    }
    "Model|SourceCode" {
		    #$PackagePath = Join-Path $sourceDirectory -ChildPath $PackageName

      if (Test-Path $(Join-Path $PackagePath "Descriptor")) {

        Add-File -xml $xml -source ".\Descriptor\**" -target ".\Descriptor\"
        #Add-File -xml $xml -source "$(Join-Path $PackagePath "Descriptor")\**" -target ".\Descriptor\"

        $descriptorPath = Join-Path $PackagePath "Descriptor"

        $models = (Get-ChildItem $descriptorPath -Filter *.xml) | ForEach-Object { $_.BaseName }
        foreach ($model in $models) {
          Add-File -xml $xml -source ".\$model\**" -target ".\$model\"

          # Add-File -xml $xml -source "$(Join-Path $PackagePath $model)\**" -target ".\$model\"
        }
      }           
    }
  }

  $nuSpecFile = Join-Path $PackagePath "$NugetPackageName.nuspec"
  $xml.Save($nuSpecFile)
  Write-Host "NuSpec for $PackagePath created."
}

#$PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition

#$solutionPath = "$PSScriptRoot"

$filesPath = Join-Path $stagingDirectory -ChildPath "Files"

New-Item -ItemType Directory -Force -Path $filesPath

switch -regex ($sourceType) {
  "SoftwareDeployablePackage" {
    $extractPath = Join-Path $stagingDirectory -ChildPath "Extracted"
    # $filesPath = Join-Path $stagingDirectory -ChildPath "Files"


    Write-Host $extractPath


    #Remove-Item $filesPath -Recurse -ErrorAction Ignore

    Get-ChildItem -Path $artifactPath -Include AXDeployable*.zip -Recurse | % {

      Write-Host $_.Name
      Remove-Item $extractPath -Recurse -ErrorAction Ignore
      Expand-Archive -LiteralPath $_.FullName -DestinationPath $extractPath

      $nupkgPath = Join-Path $extractPath -ChildPath "AOSService\Packages"

      Get-ChildItem $nupkgPath -Filter *.nupkg | 
        Foreach-Object {

          Write-Host $_.Name

          #Write-Host $($_.BaseName).Replace("dynamicsax-","")
          Rename-Item $_.FullName -NewName $($_.Name).Replace(".nupkg", ".zip");
          $nupkgExtractPath = Join-Path $extractPath -ChildPath "nupkg"
          Remove-Item $nupkgExtractPath -Recurse -ErrorAction Ignore
          Expand-Archive -LiteralPath $_.FullName.Replace(".nupkg", ".zip") -DestinationPath $nupkgExtractPath

          $nuspecPath = Get-ChildItem $nupkgExtractPath -Filter *.nuspec | Select-Object -First 1
          $nuspecContent = [XML](Get-Content $nuspecPath.FullName)
          Write-Host $nuspecContent.package.metadata.summary

          $fileZipPath = Join-Path "$($nupkgPath)\files" -ChildPath $($_.Name).Replace(".nupkg", ".zip")
          $packageExtractPath = Join-Path $filesPath -ChildPath $nuspecContent.package.metadata.summary
          #Remove-Item $packageExtractPath -Recurse -ErrorAction Ignore
          Expand-Archive -LiteralPath $fileZipPath -DestinationPath $packageExtractPath -Force
                
        }

    }
  }
  "Model" {
    $extractPath = Join-Path $stagingDirectory -ChildPath "Extracted"
    # $filesPath = Join-Path $stagingDirectory -ChildPath "Files"
      
    Write-Host $extractPath

    #Remove-Item $filesPath -Recurse -ErrorAction Ignore

    Get-ChildItem -Path $artifactPath -Include AXModelSource*.zip -Recurse | % {

      Write-Host $_.Name
      Remove-Item $extractPath -Recurse -ErrorAction Ignore
      Expand-Archive -LiteralPath $_.FullName -DestinationPath $extractPath
                       

      Get-ChildItem $extractPath -Filter *.axmodel | 
        Foreach-Object {

          Write-Host $_.Name

          #Write-Host $($_.BaseName).Replace("dynamicsax-","")
                
          Rename-Item $_.FullName -NewName $($_.Name).Replace(".axmodel", ".zip");
          $modelExtractPath = Join-Path $extractPath -ChildPath $_.Name
          Remove-Item $modelExtractPath -Recurse -ErrorAction Ignore
          Expand-Archive -LiteralPath $_.FullName.Replace(".axmodel", ".zip") -DestinationPath $modelExtractPath

          $packages = Join-Path $modelExtractPath -ChildPath "Static"               

          Get-ChildItem -Directory $packages | % {
            Write-Host $_.Name

            Copy-Item -Container $_.FullName -Destination $filesPath -Recurse -Force
          }

        }
    }
  }
  "Binaries|SourceCode" {
    if ($metadataPath -ne $filesPath) {
      Get-ChildItem -Directory $metadataPath | % {
        Copy-Item -Path $_.FullName -Destination $(Join-Path $filesPath -ChildPath $_.Name) -Recurse -Force
      }
    }

    if ($binariesPath -ne $filesPath) {
      Get-ChildItem -Directory $binariesPath | % {
        Copy-Item -Container $_.FullName -Destination $filesPath -Recurse -Force
      }
    }
  }
}

if (![String]::IsNullOrEmpty($filesPath) -and $(Test-Path -Path $filesPath)) {
  Get-ChildItem -Directory $filesPath | % { Create-NuSpec $_.FullName }
  Get-ChildItem -Path $filesPath -Include *.nuspec -Recurse | % { Write-Host $_.Name }
}

Task: pack nuget

Add another task right below. Search for nuget pack and add the NuGet task after the publish task:

Configure it to pack the deployable package with the NuSpec file from the previous step:

Task to pack the nuget package

You can find a Reference for this task in the Docs.

Task: push nuget

Add another NuGet task right below for the push command:

Task nuget push

Enter the feed URL from the Connect to feed dialog before as target feed.
You can use the dropdown to select feeds within the same organization.

Run the pipeline

Run the pipeline and wait until it's done.
Review the logs of the tasks, especially the newly added NuGet tasks.

Check your feed

Your feed should now contain a NuGet package with your artifacts from the pipeline (the deployable package) and can be consumed by others.

Resulting feed

Consume a D365 deployable package via feed

In the release or pipeline where you want to add a package from a feed to the resulting deployable package, create a new NuGet task and configure it as

Command: custom
Command and arguments: install MyModule -excludeversion -source https://$(NugetAccount).pkgs.visualstudio.com/_packaging/COMPANY.PROJECT.Packages/nuget/v3/index.json -OutputDirectory "$(NugetOutputDirectory)"
Replace MyModule with your package name and COMPANY.PROJECT.Packages with the name of your feed you want to consume.

Task to consume feed

The two variables are configured as followed:

NugetAccount: Name of your DevOps instance
NugetOutputDirectory: $(Build.SourcesDirectory)\Metadata