Modules/businessdev.ALbuild.RuntimePackages/Public/Build-BcProductRuntimePackages.ps1

function Build-BcProductRuntimePackages {
    <#
    .SYNOPSIS
        Builds every outstanding runtime package of ONE product, from AL source, without a container.
 
    .DESCRIPTION
        The per-product entry point for the two ways runtime packages are produced: as the last stages of
        a product's release run, and as one stage of the weekend sweep that fills in the platform versions
        Microsoft has released since.
 
        It runs SERIALLY and in the caller's own runspace, deliberately. The container route needed a
        worker pool because 78-94 % of a platform version was provisioning; emitting from source costs
        ~9 s (measured: 5.8-14.5 s across BC 17-29), so there is nothing left to hide behind parallelism.
        What that buys is worth more than the seconds: the Azure context stays usable (it is not
        runspace-safe), the log is in order without tailing anything, and a failure is attributable
        without replaying a worker file.
 
        Dependencies arrive as SYMBOLS, taken from the NuGet feed and then blob storage - never
        recompiled here. A runtime package is the correct shape for that and it is measured, not assumed:
        a probe app that declares AND uses Extension License compiled to the same bytes whether its
        symbol came from the dependency's runtime package or its normal .app. It is also correct by
        construction, which a normal .app is not - see Save-BcRuntimeDependencyPackage.
 
        A platform version whose chain cannot be assembled is SKIPPED, not failed, and says exactly which
        dependency was missing and where it was looked for. That is the honest outcome: on a BC version
        Microsoft released yesterday the dependency simply has no package yet, and the sweep - where the
        dependency's own stage runs first - fills it in.
 
    .PARAMETER Product
        The product: Name, Publisher, AppId, AppVersion, Projects[], and DependencyChain[] (deepest
        first, each with Name, Publisher, AppId, AppVersion).
 
    .PARAMETER PlatformArtifact
        The platform versions to build, each { PlatformVersion; ArtifactUrl }.
 
    .PARAMETER WorkRoot
        Working folder for stamped project copies and chain symbols.
 
    .PARAMETER OutputFolder
        Root of the '<appId>/<platformVersion>/' output layout the blob and the feed both mirror.
 
    .PARAMETER Country
        Localisation of the artifacts. Part of the output identity, not decoration.
 
    .PARAMETER Feed
        Runtime feed from Register-BcFeed (-Kind runtime): where chain symbols are looked for first, and
        what -SkipPublished asks about what already exists.
 
    .PARAMETER BlobContext
        Azure storage context for the legacy chain-symbol fallback.
 
    .PARAMETER BlobContainerName
        Blob container holding '<appId>/<platformVersion>/<file>'.
 
    .PARAMETER Signing
        Splat for Invoke-BcAppSigning applied to each produced package. Omitted = unsigned.
 
    .PARAMETER SkipPublished
        Ask the feed which platform versions this app version already has, and skip those. This is the
        idempotency the sweep needs: it re-plans from what exists rather than from a queue, so a missed
        run heals on the next one.
 
    .PARAMETER OnVersionComplete
        Called with the result of each finished platform version, before the next one starts. This is
        where the caller uploads to blob storage and pushes to the feed - so an interrupted run keeps
        everything it had already produced.
 
    .PARAMETER JUnitPath
        Where to write the JUnit results (one test case per platform version).
 
    .PARAMETER SummaryPath
        Where to write the Markdown summary.
 
    .PARAMETER IssuePath
        Where to write the by-cause JUnit report.
 
    .PARAMETER NoAzureDevOpsLogging
        Suppress '##vso[...]' commands. Set by tests, which otherwise annotate the job running them.
 
    .OUTPUTS
        PSCustomObject with Produced, Failed, Skipped and Results[] - Results in the same shape the
        factory returned, so the report sections, the JUnit and the by-cause XML all read unchanged.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Builds many packages in one invocation; the plural is the point.')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNull()] [object] $Product,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $PlatformArtifact,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkRoot,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder,
        [string] $Country = '',
        [object] $Feed,
        [object] $BlobContext,
        [string] $BlobContainerName,
        [hashtable] $Signing,
        [switch] $SkipPublished,
        [scriptblock] $OnVersionComplete,
        [string] $JUnitPath,
        [string] $SummaryPath,
        [string] $IssuePath,
        [switch] $NoAzureDevOpsLogging
    )

    Assert-ALbuildLicensed -Feature 'RuntimePackages'

    $name = "$($Product.Name)"
    $appVersion = "$($Product.AppVersion)"
    $chain = @(Get-BcRuntimeProperty -InputObject $Product -Name 'DependencyChain' -Default @())
    $targets = @($PlatformArtifact)

    foreach ($folder in @($WorkRoot, $OutputFolder)) {
        if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Force -Path $folder | Out-Null }
    }

    Write-Host "##[section]$name $appVersion - runtime packages from source"
    Write-ALbuildLog "Product: $name $appVersion (app id $($Product.AppId))."
    Write-ALbuildLog "Platform versions requested: $($targets.Count)$(if ($Country) { " for country '$Country'" })."
    Write-ALbuildLog "Dependency chain: $(if ($chain.Count) { ($chain | ForEach-Object { "$($_.Name) $($_.AppVersion)" }) -join ' -> ' } else { 'none' })."
    Write-ALbuildLog "Output: $OutputFolder"

    # ---------------------------------------------------------------- what already exists
    $published = @()
    if ($SkipPublished -and $Feed) {
        $ownId = Get-BcRuntimeFeedPackageId -Publisher "$($Product.Publisher)" -Name $name -AppVersion $appVersion
        try {
            $published = @(Get-BcPackageVersion -Feed $Feed -PackageId $ownId | ForEach-Object { @($_.Versions) } | Where-Object { $_ })
            Write-ALbuildLog "Feed '$ownId' already carries $($published.Count) platform version(s); those are skipped."
        }
        catch {
            # Deliberately not fatal, and deliberately loud: building a package that already exists is
            # wasteful but harmless, while refusing to build because the feed was unreachable is not.
            Write-ALbuildLog -Level Warning ("Could not ask the feed what already exists ($($_.Exception.Message)); " +
                'every requested platform version will be built.')
        }
    }
    elseif ($SkipPublished) {
        Write-ALbuildLog -Level Warning '-SkipPublished was given without a feed; nothing can be skipped.'
    }

    $results = [System.Collections.Generic.List[object]]::new()
    $chainRoot = Join-Path $WorkRoot 'chainsym'

    if (-not $PSCmdlet.ShouldProcess("$name ($($targets.Count) platform version(s))", 'Build runtime packages')) {
        return [PSCustomObject]@{ Produced = 0; Failed = 0; Skipped = 0; Results = @() }
    }

    Write-Host '##[section]Build progress'
    foreach ($target in $targets) {
        $pv = "$($target.PlatformVersion)"
        $started = Get-Date
        $row = [PSCustomObject]@{ Name = $name; Status = 'Succeeded'; Seconds = 0; Error = $null; File = $null }

        if ($published -contains $pv) {
            $row.Status = 'Skipped'
            $row.Error = 'Already published for this platform version.'
            Write-Host (" {0,-34} skipped already published" -f "BC $pv")
            $results.Add((New-BcRuntimeVersionResult -PlatformVersion $pv -Country $Country -Row $row -Started $started))
            continue
        }

        try {
            # The artifact carries the compiler that must emit this package: a BC 26 runtime package is
            # emitted by BC 26's compiler, so this is not interchangeable.
            $artifact = Get-BcArtifact -ArtifactUrl "$($target.ArtifactUrl)"
            $symbolFolders = @(Get-BcArtifactSymbolFolder -Artifact $artifact)

            # The chain gets a folder per platform version - never the shared one, or a product would
            # find symbols built for a different platform and compile against them.
            $chainFolder = Join-Path $chainRoot "$pv$(if ($Country) { "-$($Country.ToLowerInvariant())" })"
            $missing = @()
            foreach ($dependency in $chain) {
                $got = Save-BcRuntimeDependencyPackage -Publisher "$($dependency.Publisher)" -Name "$($dependency.Name)" `
                    -AppId "$($dependency.AppId)" -AppVersion "$($dependency.AppVersion)" -PlatformVersion $pv `
                    -OutputFolder $chainFolder -Feed $Feed -BlobContext $BlobContext -BlobContainerName $BlobContainerName
                if (-not $got.Found) { $missing += $got.Reason }
            }

            if ($missing.Count -gt 0) {
                # SKIPPED, not failed. On a platform version Microsoft released yesterday the dependency
                # genuinely has no package yet; the sweep builds the chain in order and fills this in.
                $row.Status = 'Skipped'
                $row.Error = "The dependency chain is not available for this platform version. $($missing -join ' ')"
                Write-Host (" {0,-34} skipped dependency missing" -f "BC $pv")
                foreach ($m in $missing) { Write-Host " $m" }
                if (-not $NoAzureDevOpsLogging) {
                    Write-Host "##vso[task.logissue type=warning]BC $($pv): $name skipped - $($missing[0])"
                }
                $results.Add((New-BcRuntimeVersionResult -PlatformVersion $pv -Country $Country -Row $row -Started $started))
                continue
            }

            $projectFolder = Get-BcRuntimeProjectFolder -Product $Product -WorkRoot $WorkRoot -PlatformVersion $pv -Country $Country

            $targetFolder = Join-Path $OutputFolder "$($Product.AppId)" | Join-Path -ChildPath $pv
            if (-not (Test-Path -LiteralPath $targetFolder)) { New-Item -ItemType Directory -Force -Path $targetFolder | Out-Null }

            $package = New-BcRuntimePackageFromSource -ProjectFolder $projectFolder -OutputFolder $targetFolder `
                -SymbolFolder ($symbolFolders + @($chainFolder)) -PlatformPath $artifact.PlatformPath -Kind Runtime
            if ($Signing) { Invoke-BcAppSigning -Path $package @Signing }

            # BC's own file name, which the blob layout and the downloads page both expect.
            $wanted = Join-Path $targetFolder (Get-BcRuntimePackageFileName -Publisher "$($Product.Publisher)" -Name $name -Version $appVersion)
            if ("$package" -ne "$wanted") { Move-Item -LiteralPath $package -Destination $wanted -Force }

            $row.File = $wanted
            $row.Seconds = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1)
            Write-Host (" {0,-34} built {1,6} s {2} KB" -f "BC $pv", $row.Seconds, [Math]::Round((Get-Item $wanted).Length / 1KB, 0))
        }
        catch {
            $row.Status = 'Failed'
            $row.Error = ("$($_.Exception.Message)" -replace '\s+', ' ').Trim()
            $row.Seconds = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1)
            Write-Host (" {0,-34} FAILED {1,6} s" -f "BC $pv", $row.Seconds)
            Write-Host " $($row.Error)"
            if (-not $NoAzureDevOpsLogging) {
                Write-Host "##vso[task.logissue type=error]BC $($pv): $name failed - $($row.Error)"
            }
        }

        $result = New-BcRuntimeVersionResult -PlatformVersion $pv -Country $Country -Row $row -Started $started
        $results.Add($result)

        # Published per version, not at the end: a run that dies keeps every package it had already
        # uploaded. The old design uploaded after the loop and threw 10.5 agent-hours away when a run
        # was cancelled.
        if ($OnVersionComplete -and $row.Status -eq 'Succeeded') {
            try { & $OnVersionComplete $result }
            catch {
                $row.Status = 'Failed'
                $row.Error = "The package was built but publishing it failed: $(("$($_.Exception.Message)" -replace '\s+', ' ').Trim())"
                Write-Host " $($row.Error)"
                if (-not $NoAzureDevOpsLogging) {
                    Write-Host "##vso[task.logissue type=error]BC $($pv): $name - $($row.Error)"
                }
            }
        }
    }

    $rows = @($results | ForEach-Object { $_.Products })
    $produced = @($rows | Where-Object { $_.Status -eq 'Succeeded' }).Count
    $failed = @($rows | Where-Object { $_.Status -eq 'Failed' }).Count
    $skipped = @($rows | Where-Object { $_.Status -eq 'Skipped' }).Count

    foreach ($line in @(Get-BcRuntimeLogSection -Result $results.ToArray())) { Write-Host $line }

    if ($JUnitPath) { Set-Content -LiteralPath $JUnitPath -Value (ConvertTo-BcRuntimeJUnit -Result $results.ToArray()) -Encoding UTF8 }
    if ($SummaryPath) { Set-Content -LiteralPath $SummaryPath -Value (ConvertTo-BcRuntimeSummaryMarkdown -Result $results.ToArray()) -Encoding UTF8 }
    if ($IssuePath) { Set-Content -LiteralPath $IssuePath -Value (ConvertTo-BcRuntimeIssueJUnit -Result $results.ToArray()) -Encoding UTF8 }

    $level = if ($failed -gt 0) { 'Warning' } else { 'Success' }
    Write-ALbuildLog -Level $level ("$($name): $produced package(s) built, $failed failed, $skipped skipped " +
        "across $($results.Count) platform version(s).")

    # No '##vso[task.complete ...]' here: a cmdlet must not decide the result of the task running it.
    # The caller owns that and does it from these counts.
    return [PSCustomObject]@{ Produced = $produced; Failed = $failed; Skipped = $skipped; Results = $results.ToArray() }
}