Public/Update-AzLocalPipelineExample.ps1
|
function Update-AzLocalPipelineExample { <# .SYNOPSIS Refreshes the bundled CI/CD pipeline YAMLs in a customer repo while preserving operator customisations bracketed by AZLOCAL-CUSTOMIZE markers. .DESCRIPTION Companion to Copy-AzLocalPipelineExample. Where Copy is a CLEAN OVERWRITE tool (intended for the initial drop, or for forcing a hard reset to the bundled samples), Update is a MARKER-AWARE MERGE tool intended for module-upgrade refreshes after the customer has customised the pipelines. Layer 1 customisation marker convention (introduced in v0.7.68): # BEGIN-AZLOCAL-CUSTOMIZE:<section> <... customer-editable content ...> # END-AZLOCAL-CUSTOMIZE:<section> Both markers are plain YAML comments (the leading '#' character) and therefore have zero runtime effect on either GitHub Actions or Azure DevOps. The <section> name is an identifier consisting of letters, digits, hyphens and underscores. It is unique per file. Examples currently shipped: schedule-triggers (every main pipeline) itsm-secrets (Step.7_apply-updates.yml only) Per source YAML the cmdlet: 1. Locates the matching destination file under -Destination BY STABLE LOGICAL ID (v0.8.7), not by filename. Each bundled YAML carries a '# AZLOCAL-PIPELINE-ID: <id>' header comment; the cmdlet matches a destination file by (a) the canonical filename, else (b) a destination YAML whose embedded ID equals the source ID, else (c) a legacy 'Step.N_<base>.yml' alias filename recorded in the bundled pipeline manifest. A (b)/(c) match whose filename differs from the canonical name is RENAMED on disk to the canonical name as part of the merge, carrying the customer's marker bodies (e.g. schedule CRONs) forward. This means a module release that renames or renumbers a pipeline is no longer a breaking change - the customer's CRONs follow the pipeline. - Net-new files in the source set are CREATED (full copy). - Files present at -Destination but not in the source set (and not matched by ID/alias) are left untouched (orphaned customer-only files survive). 2. Parses both files for marker pairs. For every marker name found in BOTH files the destination body (the lines between BEGIN and END) is grafted into the source text in place of the source's body, while the BEGIN and END lines themselves are taken from the source - so any improvements the module author makes to the marker COMMENT itself (the guidance text inside the marker lines, e.g. an updated example cron) reach the customer. 3. Reports per file: Action (Created / Updated / Unchanged / Skipped / Overwritten), PreservedMarkers (names whose body was carried over from destination), NewMarkers (names introduced in this module version), RemovedMarkers (names present at the destination but no longer in the source - their bodies are discarded; the customer must hand-migrate any content). Safety: - The destination is required to already exist - this is an UPDATE tool. Use Copy-AzLocalPipelineExample for the initial drop. - If the destination YAML has NO markers and the source DOES (the common state when refreshing from a pre-v0.7.68 copy), the cmdlet REFUSES to write unless -Force is supplied, because we cannot infer what the customer customised. With -Force the file is overwritten and the customer is expected to re-apply any edits manually. - If both files have no markers at all and they differ, the cmdlet refuses to write unless -Force is supplied - EXCEPT when the only line-level difference is the GENERATED_AGAINST_MODULE_VERSION pin (added in v0.7.95). That field is mechanically bumped on every module release and is not an operator customisation surface, so a pin-only diff is auto-refreshed and reported as 'Updated-PinOnly' without requiring -Force. Handles both the single-line GitHub Actions shape ('GENERATED_AGAINST_MODULE_VERSION: \'X\'') and the two-line Azure DevOps name/value shape. - File encoding on write is UTF-8 WITHOUT BOM (the GitHub Actions / Azure DevOps YAML convention). - The cmdlet uses Get-Content -Raw and preserves whatever line endings the source ships (LF on the bundled samples). The cmdlet is read-only relative to the module install (it never modifies anything under (Get-Module).ModuleBase). Supports -WhatIf and -Confirm. .PARAMETER Destination Folder containing the customer's pipeline YAMLs. Must exist. For GitHub Actions the canonical layout is the repo's .\.github\workflows directory; for Azure DevOps it is whatever folder you imported the YAMLs from. Defaults to the current working directory ($PWD). .PARAMETER Platform Which platform's bundled sample set to compare against. .PARAMETER Force Allow first-migration overwrites (destination has no markers, source does) and forced overwrites of files that diverged outside the marker regions. Without -Force these cases produce a 'Skipped-NeedsForce' result row and no write. .PARAMETER PassThru Emit the per-file result objects to the pipeline. By default the cmdlet only writes summary log messages. .OUTPUTS PSCustomObject[] (with -PassThru) - one row per source file with: File - destination path (always the CANONICAL, de-numbered filename) Action - 'Created' | 'Updated' | 'Updated-PinOnly' | 'Renamed' | 'Unchanged' | 'Overwritten' | 'Skipped-NeedsForce' | 'Skipped-NoChange' RenamedFrom - legacy filename the destination file was matched under and renamed FROM (e.g. 'Step.7_apply-updates.yml'), or $null when no rename occurred. A non-null value means the file was physically renamed to the canonical name; if Action is also 'Updated'/'Overwritten' the content was refreshed in the same pass. PreservedMarkers - [string[]] marker names whose body was preserved from the destination NewMarkers - [string[]] marker names introduced in this module version RemovedMarkers - [string[]] marker names that existed at the destination but are no longer in the source .EXAMPLE Update-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub Marker-aware refresh of the GitHub Actions workflow YAMLs. Any BEGIN/END-AZLOCAL-CUSTOMIZE block content already in your repo survives the upgrade; everything else is brought up to date. .EXAMPLE Update-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -PassThru | Where-Object Action -ne 'Unchanged' | Format-Table File, Action, PreservedMarkers, NewMarkers Show only the files that actually changed in this upgrade, with the marker names that were preserved or newly introduced. .EXAMPLE Update-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -WhatIf Preview which files would be created / updated / skipped without writing anything. .EXAMPLE Update-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Force First-time migration from a pre-v0.7.68 copy: overwrite YAMLs that do not yet contain BEGIN/END-AZLOCAL-CUSTOMIZE markers. Re-apply any operator customisations manually after the run. .NOTES Author : Neil Bird, Microsoft Module : AzLocal.UpdateManagement Added in : v0.7.68 See also : Copy-AzLocalPipelineExample (clean-overwrite tool) #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] [OutputType([PSCustomObject[]])] param( [Parameter(Position = 0)] [ValidateNotNullOrEmpty()] [string]$Destination = $PWD.Path, [Parameter(Mandatory = $true)] [ValidateSet('GitHub', 'AzureDevOps')] [string]$Platform, [switch]$Force, [switch]$PassThru ) # ------------------------------------------------------------------ # 1. Locate the module install (sourceRoot). Match the resolution # pattern used by Copy-AzLocalPipelineExample so a side-by-side # development checkout works the same way the installed module # does. # ------------------------------------------------------------------ $module = Get-Module -Name 'AzLocal.UpdateManagement' | Sort-Object Version -Descending | Select-Object -First 1 if (-not $module) { $moduleRoot = Split-Path -Parent $PSScriptRoot } else { $moduleRoot = $module.ModuleBase } $platformSubfolder = if ($Platform -eq 'GitHub') { 'github-actions' } else { 'azure-devops' } $sourceRoot = Join-Path -Path $moduleRoot -ChildPath 'Automation-Pipeline-Examples' $platformSrc = Join-Path -Path $sourceRoot -ChildPath $platformSubfolder if (-not (Test-Path -LiteralPath $platformSrc -PathType Container)) { throw "Update-AzLocalPipelineExample: bundled $Platform pipeline source folder not found at '$platformSrc'. The module install may be corrupt or this is a development checkout without the sample folder." } # ------------------------------------------------------------------ # 2. Verify destination exists. Update is not a creation tool. # ------------------------------------------------------------------ if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { throw "Update-AzLocalPipelineExample: -Destination folder '$Destination' does not exist. Use Copy-AzLocalPipelineExample for the initial drop, then re-run Update from the same folder." } $destResolved = (Resolve-Path -LiteralPath $Destination -ErrorAction Stop).ProviderPath Write-Log -Message "Update-AzLocalPipelineExample: comparing bundled $Platform samples in '$platformSrc' against destination '$destResolved'." -Level Info # ------------------------------------------------------------------ # 3. Build the list of source YAMLs and the rename-aware matching maps. # # v0.8.7: pipelines are matched to their destination counterpart by # STABLE LOGICAL ID (the '# AZLOCAL-PIPELINE-ID:' header comment), not # by filename. This lets the cmdlet follow a pipeline across a filename # rename or a Step.N renumber WITHOUT stranding the customer's # BEGIN/END-AZLOCAL-CUSTOMIZE marker bodies (e.g. their schedule CRONs). # # Resolution order for each source file's destination match: # (a) canonical filename present at -Destination, else # (b) a destination *.yml whose embedded AZLOCAL-PIPELINE-ID equals # the source ID (the customer renamed it, or it is a newer copy), # else # (c) a legacy 'Step.N_<base>.yml' alias filename listed in the # manifest (a pre-v0.8.7 copy that predates the ID comment). # A (b)/(c) match whose filename differs from the canonical name is # RENAMED on disk to the canonical name after the marker-aware merge. # ------------------------------------------------------------------ $srcFiles = @(Get-ChildItem -LiteralPath $platformSrc -Filter '*.yml' -File -ErrorAction Stop) if ($srcFiles.Count -eq 0) { Write-Log -Message "Update-AzLocalPipelineExample: no source *.yml files found under '$platformSrc'." -Level Warning return } # Manifest: logical ID -> canonical filename + legacy alias filenames. $manifest = @(Get-AzLocalPipelineManifest) # Pre-scan the destination folder once, indexing any *.yml that carries an # AZLOCAL-PIPELINE-ID comment by that ID (first occurrence wins). Used for # resolution path (b). $destIdMap = @{} foreach ($destYml in @(Get-ChildItem -LiteralPath $destResolved -Filter '*.yml' -File -ErrorAction SilentlyContinue)) { try { $destYmlText = [System.IO.File]::ReadAllText($destYml.FullName, [System.Text.UTF8Encoding]::new($false)) $destYmlId = Get-AzLocalPipelineId -Text $destYmlText if ($destYmlId -and -not $destIdMap.ContainsKey($destYmlId)) { $destIdMap[$destYmlId] = $destYml.FullName } } catch { Write-Verbose "Update-AzLocalPipelineExample: could not read '$($destYml.FullName)' during ID pre-scan: $($_.Exception.Message)" } } $results = New-Object System.Collections.Generic.List[pscustomobject] foreach ($srcFile in $srcFiles) { # Canonical destination path = the bundled (already de-numbered) # filename dropped into -Destination. This is always the WRITE target. $destFile = Join-Path -Path $destResolved -ChildPath $srcFile.Name # Resolve the source pipeline's logical ID. Prefer the embedded # AZLOCAL-PIPELINE-ID comment; fall back to the de-numbered base name. $srcText = [System.IO.File]::ReadAllText($srcFile.FullName, [System.Text.UTF8Encoding]::new($false)) $srcId = Get-AzLocalPipelineId -Text $srcText if (-not $srcId) { $srcId = [System.IO.Path]::GetFileNameWithoutExtension($srcFile.Name) } $row = [PSCustomObject]@{ File = $destFile Action = '' RenamedFrom = $null PreservedMarkers = @() NewMarkers = @() RemovedMarkers = @() } # Resolve the EXISTING destination representation to merge FROM. # (a) canonical filename, (b) ID match, (c) alias filename. # $existingDestFile is the READ source; $destFile stays the WRITE # target. When they differ, this is a rename. $existingDestFile = $null $renamedFrom = $null if (Test-Path -LiteralPath $destFile) { $existingDestFile = $destFile # If a canonical file exists AND a legacy alias also lingers, the # alias is a stale orphan from a prior layout. Flag it (do not # touch it - the canonical file is authoritative). foreach ($mEntry in @($manifest | Where-Object { $_.Id -eq $srcId })) { foreach ($aliasName in $mEntry.Aliases) { $aliasPath = Join-Path -Path $destResolved -ChildPath $aliasName if ((Test-Path -LiteralPath $aliasPath) -and ($aliasPath -ne $destFile)) { Write-Log -Message " Note : '$aliasName' is a stale orphan (canonical '$($srcFile.Name)' already present). Safe to delete." -Level Warning } } } } elseif ($destIdMap.ContainsKey($srcId)) { $existingDestFile = $destIdMap[$srcId] $renamedFrom = Split-Path -Leaf $existingDestFile } else { foreach ($mEntry in @($manifest | Where-Object { $_.Id -eq $srcId })) { foreach ($aliasName in $mEntry.Aliases) { $aliasPath = Join-Path -Path $destResolved -ChildPath $aliasName if (Test-Path -LiteralPath $aliasPath) { $existingDestFile = $aliasPath $renamedFrom = $aliasName break } } if ($existingDestFile) { break } } } if ($renamedFrom) { $row.RenamedFrom = $renamedFrom Write-Log -Message " Rename : '$renamedFrom' -> '$($srcFile.Name)' (matched by pipeline ID '$srcId'). Marker bodies (e.g. schedule CRONs) will be carried over." -Level Warning Write-Log -Message " Platform note: renaming a workflow/pipeline file resets its run history grouping and can break branch-protection required status checks (GitHub) or orphan the pipeline definition until you re-point it at the new YAML path (Azure DevOps). Re-point/re-register after this run." -Level Warning } # 3a. Net-new file: simple copy. ------------------------------- if (-not $existingDestFile) { if ($PSCmdlet.ShouldProcess($destFile, "Create new file from bundled sample")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText $row.Action = 'Created' $srcMarkers = Get-AzLocalPipelineCustomiseMarkers -Text $srcText $row.NewMarkers = @($srcMarkers.Keys) Write-Log -Message " Created : $($srcFile.Name)" -Level Success } else { $row.Action = 'Created' # what WHATIF would do } [void]$results.Add($row) continue } # 3b. File exists. Read the existing (possibly aliased) destination # representation. $srcText was already read above. $destText = [System.IO.File]::ReadAllText($existingDestFile, [System.Text.UTF8Encoding]::new($false)) $srcMarkers = Get-AzLocalPipelineCustomiseMarkers -Text $srcText $destMarkers = Get-AzLocalPipelineCustomiseMarkers -Text $destText $hasSrcMarkers = $srcMarkers.Count -gt 0 $hasDestMarkers = $destMarkers.Count -gt 0 # 3c. Both files marker-free -> straight diff/overwrite path. - if (-not $hasSrcMarkers -and -not $hasDestMarkers) { if ($srcText -eq $destText) { if ($renamedFrom) { # Content identical but the file is at a legacy name -> rename to canonical. if ($PSCmdlet.ShouldProcess($destFile, "Rename '$renamedFrom' -> '$($srcFile.Name)' (content identical)")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText if (($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Renamed : '$renamedFrom' -> '$($srcFile.Name)' (content identical)" -Level Success } $row.Action = 'Renamed' } else { $row.Action = 'Unchanged' } [void]$results.Add($row) continue } # 3c.i. Pin-only short-circuit (v0.7.95): if the ONLY content # difference is the GENERATED_AGAINST_MODULE_VERSION pin # (mechanically bumped on every release, not an operator # customisation surface), refresh it in place without -Force. # Normalise the pin to a placeholder in BOTH texts and compare; # equal-after-normalisation means the pin is the only diff. # Pattern matches both the single-line GitHub Actions shape # ('GENERATED_AGAINST_MODULE_VERSION: ''X''') and the two-line # Azure DevOps name/value shape ('- name: GENERATED_..., value: ''X'''). $pinPattern = "(?m)(?:^\s*GENERATED_AGAINST_MODULE_VERSION\s*:\s*'[^']+'|^\s*-?\s*name\s*:\s*GENERATED_AGAINST_MODULE_VERSION\s*\r?\n\s*value\s*:\s*'[^']+')" $pinPlaceholder = "__AZLOCAL_PIN_PLACEHOLDER__" $srcNorm = [regex]::Replace($srcText, $pinPattern, { param($m) [regex]::Replace($m.Value, "'[^']+'", "'$pinPlaceholder'") }) $destNorm = [regex]::Replace($destText, $pinPattern, { param($m) [regex]::Replace($m.Value, "'[^']+'", "'$pinPlaceholder'") }) if ($srcNorm -eq $destNorm) { # Pin is the only line that changed. Extract both values # for the success log line, then write src verbatim. $srcPinMatch = [regex]::Match($srcText, $pinPattern) $destPinMatch = [regex]::Match($destText, $pinPattern) $srcPinValue = if ($srcPinMatch.Success) { ([regex]::Match($srcPinMatch.Value, "'([^']+)'")).Groups[1].Value } else { '?' } $destPinValue = if ($destPinMatch.Success) { ([regex]::Match($destPinMatch.Value, "'([^']+)'")).Groups[1].Value } else { '?' } if ($PSCmdlet.ShouldProcess($destFile, "Bump GENERATED_AGAINST_MODULE_VERSION from '$destPinValue' to '$srcPinValue' (pin-only diff)")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText if ($renamedFrom -and ($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Updated : $($srcFile.Name), pin-only ('$destPinValue' -> '$srcPinValue')" -Level Success } $row.Action = 'Updated-PinOnly' [void]$results.Add($row) continue } if (-not $Force) { $row.Action = 'Skipped-NeedsForce' Write-Log -Message " Skipped : $($srcFile.Name) - diverged from bundled sample, no markers to merge on. Pass -Force to overwrite, or hand-merge the diff." -Level Warning [void]$results.Add($row) continue } if ($PSCmdlet.ShouldProcess($destFile, "Overwrite (no markers, -Force supplied)")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText if ($renamedFrom -and ($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Overwritten (forced): $($srcFile.Name)" -Level Warning } $row.Action = 'Overwritten' [void]$results.Add($row) continue } # 3d. Source has markers, destination doesn't -> first-migration # from a pre-v0.7.68 copy. Cannot infer what to preserve. if ($hasSrcMarkers -and -not $hasDestMarkers) { if (-not $Force) { $row.Action = 'Skipped-NeedsForce' $row.NewMarkers = @($srcMarkers.Keys) Write-Log -Message " Skipped : $($srcFile.Name) - destination has no AZLOCAL-CUSTOMIZE markers (pre-v0.7.68 copy). Pass -Force to migrate (re-apply customisations afterwards), or add BEGIN/END markers around your edits first." -Level Warning [void]$results.Add($row) continue } if ($PSCmdlet.ShouldProcess($destFile, "First-migration overwrite (destination has no markers, -Force supplied)")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText if ($renamedFrom -and ($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Overwritten (first migration): $($srcFile.Name) - re-apply any customisations now." -Level Warning } $row.Action = 'Overwritten' $row.NewMarkers = @($srcMarkers.Keys) [void]$results.Add($row) continue } # 3e. Destination has markers, source doesn't -> reverse case. # We have nowhere to graft the destination body into, so the # destination bodies would be lost. Refuse without -Force. if (-not $hasSrcMarkers -and $hasDestMarkers) { if (-not $Force) { $row.Action = 'Skipped-NeedsForce' $row.RemovedMarkers = @($destMarkers.Keys) Write-Log -Message " Skipped : $($srcFile.Name) - destination has AZLOCAL-CUSTOMIZE markers but the new bundled sample does not. Bodies would be discarded. Pass -Force to overwrite anyway." -Level Warning [void]$results.Add($row) continue } if ($PSCmdlet.ShouldProcess($destFile, "Overwrite (markers removed by upgrade, -Force supplied)")) { Write-Utf8NoBomFile -Path $destFile -Content $srcText if ($renamedFrom -and ($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Overwritten (markers removed): $($srcFile.Name) - destination marker bodies discarded." -Level Warning } $row.Action = 'Overwritten' $row.RemovedMarkers = @($destMarkers.Keys) [void]$results.Add($row) continue } # 3f. Both have markers -> marker-aware merge. # # Walk the source string and, for every BEGIN/END pair in the source # whose <section> name also exists at the destination, splice the # destination's body in. We process matches RIGHT-TO-LEFT so the # captured indices stay valid as we mutate the working copy. $merged = $srcText $preserved = New-Object System.Collections.Generic.List[string] $srcMarkerOrder = $srcMarkers.GetEnumerator() | Sort-Object { $_.Value.Index } -Descending foreach ($entry in $srcMarkerOrder) { $name = $entry.Key if ($destMarkers.ContainsKey($name)) { $srcBlock = $entry.Value $destBlock = $destMarkers[$name] # Keep src's BeginLine + EndLine (canonical comment text) # and inject dest's preserved body. $newBlockText = $srcBlock.BeginLine + $destBlock.Body + $srcBlock.EndLine $merged = $merged.Substring(0, $srcBlock.Index) + $newBlockText + $merged.Substring($srcBlock.Index + $srcBlock.Length) [void]$preserved.Add($name) } } $row.PreservedMarkers = @($preserved) $row.NewMarkers = @($srcMarkers.Keys | Where-Object { -not $destMarkers.ContainsKey($_) }) $row.RemovedMarkers = @($destMarkers.Keys | Where-Object { -not $srcMarkers.ContainsKey($_) }) if ($merged -eq $destText) { if ($renamedFrom) { # Merged content identical to the destination but the file is # at a legacy name -> rename to canonical (carries markers). if ($PSCmdlet.ShouldProcess($destFile, "Rename '$renamedFrom' -> '$($srcFile.Name)' (merged content identical)")) { Write-Utf8NoBomFile -Path $destFile -Content $merged if (($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } Write-Log -Message " Renamed : '$renamedFrom' -> '$($srcFile.Name)' (merged content identical, markers preserved)" -Level Success } $row.Action = 'Renamed' } else { $row.Action = 'Unchanged' } [void]$results.Add($row) continue } if ($PSCmdlet.ShouldProcess($destFile, "Update YAML (preserve $($preserved.Count) marker block(s))")) { Write-Utf8NoBomFile -Path $destFile -Content $merged if ($renamedFrom -and ($existingDestFile -ne $destFile) -and (Test-Path -LiteralPath $existingDestFile)) { Remove-Item -LiteralPath $existingDestFile -Force } $kept = if ($preserved.Count -gt 0) { ", preserved=$([string]::Join(',', $preserved))" } else { '' } $added = if ($row.NewMarkers.Count -gt 0) { ", new=$([string]::Join(',', $row.NewMarkers))" } else { '' } $removed = if ($row.RemovedMarkers.Count -gt 0) { ", removed=$([string]::Join(',', $row.RemovedMarkers))" } else { '' } $renamed = if ($renamedFrom) { ", renamedFrom=$renamedFrom" } else { '' } Write-Log -Message " Updated : $($srcFile.Name)${kept}${added}${removed}${renamed}" -Level Success } $row.Action = 'Updated' [void]$results.Add($row) } # ------------------------------------------------------------------ # 4. Summary log line + optional PassThru emission. # ------------------------------------------------------------------ $byAction = $results | Group-Object Action | Sort-Object Name $summary = ($byAction | ForEach-Object { "$($_.Name)=$($_.Count)" }) -join ' ' Write-Log -Message "Update-AzLocalPipelineExample: $($results.Count) source file(s) processed - $summary" -Level Info if ($PassThru) { return $results.ToArray() } } |