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 } |