Modules/businessdev.ALbuild.Pipeline/Public/Invoke-ALbuildPipeline.ps1

function Invoke-ALbuildPipeline {
    <#
    .SYNOPSIS
        Runs an Azure DevOps pipeline YAML template locally, step by step.
 
    .DESCRIPTION
        Parses an ALbuild pipeline template and executes its steps on the local machine the way the
        Azure DevOps agent would - so a local run reflects the real pipeline from a single source of
        truth. For each "task: Name@n" step it resolves the matching extension task.ps1, sets the
        task's INPUT_* environment variables and runs it, capturing "##vso[task.setvariable]" outputs
        so later steps can read them via $(var). Inline "pwsh"/"powershell" steps are executed and
        "publish" steps are logged (artifacts are not uploaded locally).
 
        Provide the variables the agent normally injects (Build.*, System.*, signing/feed secrets,
        container settings) as environment variables before calling this - typically from a small
        setup script (see templates/local/Run-ALbuildPipeline.sample.ps1).
 
        Only the template subset used by the ALbuild templates is supported: parameters, stages ->
        jobs -> steps (and a deployment job's runOnce.deploy.steps), "${{ if }}" / "${{ each }}",
        "${{ parameters.* }}" and "$(var)" expansion, and the always()/succeededOrFailed() runtime
        conditions. The build-version step is forced to dry-run (no branch claim/push) for local runs.
 
    .PARAMETER Path
        The pipeline YAML template to run.
 
    .PARAMETER Parameters
        Template parameter overrides (name -> value); unset parameters use the template default.
 
    .PARAMETER RepositoryRoot
        The AL repository the steps operate on. Defaults to BUILD_REPOSITORY_LOCALPATH or the current
        location.
 
    .PARAMETER TasksRoot
        The extension Tasks folder. Defaults to the ALbuild repo's 'extension/Tasks' next to the module.
 
    .PARAMETER PowerShellExe
        PowerShell executable used to run each task. Defaults to the current host.
 
    .EXAMPLE
        Invoke-ALbuildPipeline -Path templates/yaml/ci-pipeline.yml -Parameters @{ country = 'de' }
 
    .OUTPUTS
        PSCustomObject: Steps, Failed.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'stepFailed',
        Justification = 'Assigned inside a dot-sourced block and read in the same scope; the analyzer does not track this.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path,
        [hashtable] $Parameters = @{},
        [string] $RepositoryRoot,
        [string] $TasksRoot,
        [string] $PowerShellExe
    )

    # Locate the ALbuild repo roots relative to this module (src/businessdev.ALbuild/Modules/<area>).
    $srcPath = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $script:ModuleRoot))
    $repoRootForTasks = Split-Path -Parent $srcPath
    if (-not $TasksRoot) { $TasksRoot = Join-Path $repoRootForTasks 'extension/Tasks' }
    if (-not (Test-Path -LiteralPath $TasksRoot)) {
        throw "Extension tasks folder not found at '$TasksRoot'. Pass -TasksRoot pointing at the ALbuild 'extension/Tasks'."
    }

    if (-not $RepositoryRoot) {
        $RepositoryRoot = if ($env:BUILD_REPOSITORY_LOCALPATH) { $env:BUILD_REPOSITORY_LOCALPATH } else { (Get-Location).Path }
    }
    if (-not $PowerShellExe) {
        $PowerShellExe = try { (Get-Process -Id $PID).Path } catch { $null }
        if (-not $PowerShellExe) { $PowerShellExe = 'pwsh' }
    }

    # Make the in-repo module importable by the child task processes (Import-ALbuild resolves it by name).
    if (($env:PSModulePath -split [System.IO.Path]::PathSeparator) -notcontains $srcPath) {
        $env:PSModulePath = $srcPath + [System.IO.Path]::PathSeparator + $env:PSModulePath
    }

    $document = Read-AdoPipelineYaml -Path $Path

    # Resolve template parameters: defaults from the template, overridden by -Parameters.
    $resolved = @{}
    if ($document.Contains('parameters')) {
        foreach ($p in $document['parameters']) {
            if ($p.Contains('name')) { $resolved[$p['name']] = if ($p.Contains('default')) { $p['default'] } else { '' } }
        }
    }
    foreach ($k in $Parameters.Keys) { $resolved[$k] = $Parameters[$k] }

    # Runtime variables (case-insensitive): seed from the environment plus the standard dotted macros.
    $variables = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($e in [System.Environment]::GetEnvironmentVariables().GetEnumerator()) { $variables["$($e.Key)"] = "$($e.Value)" }
    $macroAliases = @{
        'Build.BuildId'                     = 'BUILD_BUILDID'
        'Build.ArtifactStagingDirectory'    = 'BUILD_ARTIFACTSTAGINGDIRECTORY'
        'Build.SourcesDirectory'            = 'BUILD_SOURCESDIRECTORY'
        'Build.Repository.LocalPath'        = 'BUILD_REPOSITORY_LOCALPATH'
        'System.AccessToken'                = 'SYSTEM_ACCESSTOKEN'
        'System.TeamFoundationCollectionUri' = 'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI'
        'System.DefaultWorkingDirectory'    = 'SYSTEM_DEFAULTWORKINGDIRECTORY'
        'Agent.TempDirectory'               = 'AGENT_TEMPDIRECTORY'
    }
    foreach ($alias in $macroAliases.Keys) {
        $envName = $macroAliases[$alias]
        if ($variables.ContainsKey($envName)) { $variables[$alias] = $variables[$envName] }
    }

    $steps = @(Get-AdoPipelineStepList -Document $document -Parameters $resolved)
    Write-ALbuildLog -Level Information "Running pipeline '$([System.IO.Path]::GetFileName($Path))' ($($steps.Count) step(s))."

    # Built-in Azure DevOps agent tasks have no ALbuild extension task.ps1. The cloud agent provides
    # them; a local run logs and skips them (e.g. test results / artifacts are not published locally).
    $builtInAdoTasks = @('PublishTestResults', 'PublishBuildArtifacts', 'PublishPipelineArtifact',
        'DownloadBuildArtifacts', 'DownloadPipelineArtifact', 'Checkout')

    $executed = [System.Collections.Generic.List[object]]::new()
    $failed = $false

    foreach ($entry in $steps) {
        $step = $entry.Step
        $params = $entry.Parameters

        $condition = if ($step.Contains('condition')) { "$(Expand-AdoExpression -InputObject $step['condition'] -Parameters $params -Variables $variables)" } else { '' }
        $runEvenIfFailed = ($condition -match 'always\(\)') -or ($condition -match 'succeededOrFailed\(\)')

        $label = if ($step.Contains('displayName')) { "$(Expand-AdoExpression -InputObject $step['displayName'] -Parameters $params -Variables $variables)" }
        elseif ($step.Contains('task')) { "$($step['task'])" }
        elseif ($step.Contains('pwsh') -or $step.Contains('powershell')) { 'pwsh script' }
        elseif ($step.Contains('publish')) { 'publish' }
        else { 'step' }

        if ($failed -and -not $runEvenIfFailed) {
            Write-ALbuildLog -Level Warning "Skipping '$label' (a previous step failed)."
            continue
        }

        Write-ALbuildLog -Level Information ''
        Write-ALbuildLog -Level Information "==> $label"
        $stepFailed = $false
        $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        # Dot-sourced so $stepFailed assignments below run in this scope (not a child scope).
        . {
            if ($step.Contains('task')) {
                $taskName = ("$($step['task'])" -split '@', 2)[0].Trim()
                if ($builtInAdoTasks -contains $taskName) {
                    Write-ALbuildLog -Level Information "Skipping built-in Azure DevOps task '$taskName' (provided by the agent in the cloud; no local equivalent)."
                    return
                }
                $scriptPath = Resolve-AdoTaskScript -TaskReference "$($step['task'])" -TasksRoot $TasksRoot
                $inputs = @{}
                if ($step.Contains('inputs') -and $step['inputs']) {
                    foreach ($key in $step['inputs'].Keys) {
                        $inputs["$key"] = Expand-AdoExpression -InputObject $step['inputs'][$key] -Parameters $params -Variables $variables
                    }
                }
                # Local safety: never claim/push a build branch from a local run.
                if (("$($step['task'])") -like 'StampBuildVersion*' -and -not $inputs.ContainsKey('noBuildBranch')) {
                    $inputs['noBuildBranch'] = 'true'
                }
                $stepEnv = @{}
                if ($step.Contains('env') -and $step['env']) {
                    foreach ($key in $step['env'].Keys) {
                        $stepEnv["$key"] = "$(Expand-AdoExpression -InputObject $step['env'][$key] -Parameters $params -Variables $variables)"
                    }
                }
                $result = Invoke-AdoTaskStep -TaskScript $scriptPath -Inputs $inputs -StepEnv $stepEnv -Variables $variables -PowerShellExe $PowerShellExe
                if (-not $result.Success) { $stepFailed = $true; Write-ALbuildLog -Level Error "Task '$label' failed (exit $($result.ExitCode))." }
            }
            elseif ($step.Contains('pwsh') -or $step.Contains('powershell')) {
                $scriptText = "$(Expand-AdoExpression -InputObject $(if ($step.Contains('pwsh')) { $step['pwsh'] } else { $step['powershell'] }) -Parameters $params -Variables $variables)"
                $result = Invoke-ALbuildProcess -FilePath $PowerShellExe -Arguments @('-NoProfile', '-NonInteractive', '-Command', $scriptText) -PassThru -RetryCount 0 -StreamOutput
                # Output was already echoed live by -StreamOutput; here we only harvest set-variables.
                if ($result.StdOut) {
                    foreach ($line in ($result.StdOut -split "`r?`n")) {
                        $command = ConvertFrom-VsoCommand -Line $line
                        if ($command) { $variables[$command.Name] = $command.Value }
                    }
                }
                if (-not $result.Success) { $stepFailed = $true; Write-ALbuildLog -Level Error "Script step '$label' failed (exit $($result.ExitCode))." }
            }
            elseif ($step.Contains('publish')) {
                $artifactPath = "$(Expand-AdoExpression -InputObject $step['publish'] -Parameters $params -Variables $variables)"
                $artifactName = if ($step.Contains('artifact')) { "$($step['artifact'])" } else { 'artifact' }
                Write-ALbuildLog -Level Information "Publish artifact '$artifactName' from '$artifactPath' (local run: not uploaded)."
            }
            else {
                Write-ALbuildLog -Level Information "Skipping unsupported step '$label' (checkout/download/etc.)."
            }
        }
        $stopwatch.Stop()

        Write-ALbuildLog -Level Information " took $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s."
        $executed.Add([PSCustomObject]@{ Label = $label; Succeeded = (-not $stepFailed) })
        if ($stepFailed) { $failed = $true }
    }

    $summary = [PSCustomObject]@{ Steps = $executed.ToArray(); Failed = $failed }
    if ($failed) { throw "Pipeline run failed. $(@($executed | Where-Object { -not $_.Succeeded }).Count) step(s) failed." }
    Write-ALbuildLog -Level Success "Pipeline run completed: $($executed.Count) step(s)."
    return $summary
}