Public/Invoke-CpmfUipsPack.ps1

function Invoke-CpmfUipsPack {
<#
.SYNOPSIS
    Bumps projectVersion, packs a UiPath project with uipcli, and stages the
    .nupkg to a local NuGet feed.
 
.DESCRIPTION
    Execution order:
      1. Install-CpmfUipsPackCommandLineTool (skipped with -SkipInstall; repeated per target)
      2. Update-CpmfUipsPackProjectVersion (skipped with -NoBump; runs once before all targets)
      3. uipcli package pack (once per entry in -Targets)
      4. Copy .nupkg to -FeedPath (once per target)
      5. If -MultiTfm and both net6+net8 targets: merge lib/ TFMs into one nupkg
 
    With -UseWorktree, steps 2-5 run inside a temporary git worktree created at
    HEAD. The working directory is never modified. The worktree is always removed
    on exit, even on failure.
 
    If the pack step fails after the version has been bumped (non-worktree mode),
    the original version is restored in project.json.
 
    Returns the full path(s) of staged .nupkg files as [string[]].
 
.PARAMETER ProjectJson
    Path to the UiPath project.json.
 
    Default: ..\project.json relative to the module root. Always pass this
    parameter explicitly when using the module from any location other than
    a Scripts\ subfolder of the UiPath project:
 
        Invoke-CpmfUipsPack -ProjectJson 'C:\repos\MyProject\project.json'
 
.PARAMETER ShowVersion
    Print the module version and exit without performing any pack work.
 
.PARAMETER FeedPath
    Destination directory for the staged .nupkg. Defaults to C:\Users\Public\nugetfeed.
 
.PARAMETER OutputPath
    Base directory for the native uipcli pack output. The module creates a unique
    subdirectory beneath this root for each pack run. Defaults to
    C:\Users\Public\UiPath.CLI.Windows\pack-output.
 
.PARAMETER UipcliArgs
    Additional arguments passed verbatim to uipcli, e.g.
    -UipcliArgs '--traceLevel','Verbose','--outputType','Tests'
 
.PARAMETER NoBump
    Skip the version bump. Useful when repacking after a rollback.
 
.PARAMETER SkipInstall
    Skip Install-CpmfUipsPackCommandLineTool. Use when .NET and uipcli are already installed.
 
.PARAMETER UseWorktree
    Pack from a clean git worktree instead of the working directory. Requires
    the project to be inside a git repository.
 
.PARAMETER WorktreeBase
    Parent directory for the temporary worktree. Defaults to the system temp
    directory. Ignored unless -UseWorktree is set.
 
.PARAMETER WorktreeSibling
    Place the worktree as a sibling of the git repo root rather than in temp.
    Implies -UseWorktree.
 
.PARAMETER CliVersionNet6
    UiPath CLI version for the net6 target (23.x classic). Default: 23.10.2.6.
 
.PARAMETER CliVersionNet8
    UiPath CLI version for the net8 target (25.x dotnet tool). Default: 25.10.11.
 
.PARAMETER UipcliPathNet6
    Absolute path to the net6 uipcli.exe. Overrides version-based path inference when supplied.
 
.PARAMETER UipcliPathNet8
    Absolute path to the net8 uipcli.exe. Overrides version-based path inference when supplied.
 
.PARAMETER Targets
    Which CLI versions to build with. Valid values: 'net6', 'net8'.
    Defaults to @('net8'). Use @('net6','net8') to build for both, which
    requires -Backend uipcli.
 
.PARAMETER MultiTfm
    For Library projects: after building with both net6 and net8 targets, merge
    the lib/ TFM folders into a single nupkg. Requires -Targets @('net6','net8').
    Ignored for Process/Tests projects (a warning is emitted).
 
.PARAMETER CliVersion
    Deprecated. Use -CliVersionNet6 or -CliVersionNet8 instead.
    Versions matching '^23\.' map to -CliVersionNet6; others map to -CliVersionNet8.
 
.PARAMETER ToolBase
    Tool root directory. Forwarded to Install-CpmfUipsPackCommandLineTool. Defaults to
    %LOCALAPPDATA%\cpmf\tools.
 
.PARAMETER ToolBasePath
    Canonical tool root directory. Same as -ToolBase; kept for the shared path-var naming convention.
 
.PARAMETER ProjectVersion
    Write this exact version string to project.json before packing, instead of
    computing an auto-bump. Useful in CI pipelines that derive the version from a
    git tag (e.g. GITHUB_REF_NAME). Implies -NoBump for subsequent targets.
    On pack failure the original version is restored.
 
.PARAMETER Backend
    Which tool performs the pack. Valid values: 'workflowcompiler' (default),
    'uipcli', 'uipathcli'.
 
    'workflowcompiler' invokes UiPath.WorkflowCompiler directly — the engine the
    other two drive internally. It is the only backend that can control
    --include-sources (uipcli hardcodes it to true, so packages built through
    uipcli ship every .cs file in plaintext under content/) and the only one that
    can override the package id without editing project.json. It is net8-only and
    cannot be combined with -MultiTfm or -Targets net6.
 
    'uipcli' and 'uipathcli' ignore every package identity parameter and both
    source-inclusion settings, because they expose no such options; a warning is
    emitted when they are supplied anyway. Use them for net6 builds and for
    -MultiTfm, which the single net8 compiler cannot serve.
 
.PARAMETER PublisherProps
    Path to a publisher props file (MSBuild-style XML) supplying shared package
    identity and pack policy: PublisherAuthors, PublisherTags, PublisherIconUrl,
    PublisherProjectUrl, PublisherRepositoryType, and the PublisherPack* policy
    group. Explicit parameters always win over the props file, which in turn
    wins over env vars and config files.
 
.PARAMETER PackageId
    Package id written to the nuspec, overriding project.json's "name" without
    editing the file. Workflowcompiler backend only.
 
.PARAMETER PackageAuthor
    Package author. Defaults to PublisherAuthors from the props file.
 
.PARAMETER PackageDescription
    Package description.
 
.PARAMETER PackageTags
    Space-separated package tags. Defaults to PublisherTags.
 
.PARAMETER IconUrl
    Package icon URL. Defaults to PublisherIconUrl.
 
.PARAMETER ProjectUrl
    Package project URL. Defaults to PublisherProjectUrl.
 
.PARAMETER ReleaseNotes
    Package release notes.
 
.PARAMETER RepositoryType
    Repository type stamped into the package. Defaults to PublisherRepositoryType.
 
.PARAMETER RepositoryUrl
    Repository URL. Derived from the project's git origin remote when omitted.
 
.PARAMETER RepositoryBranch
    Repository branch. Derived from the project's git HEAD when omitted.
 
.PARAMETER RepositoryCommit
    Repository commit. Derived from the project's short git HEAD SHA when omitted.
 
.PARAMETER IncludeSources
    Ship project source files inside the package under content/. Defaults to
    false — deliberately the opposite of the compiler's own default. Workflow-
    compiler backend only.
 
.PARAMETER WorkflowCompilerPath
    Path to UiPath.WorkflowCompiler.dll or .exe. Falls back to the props file's
    PublisherPackWorkflowCompilerPath, then $env:UIPATH_WORKFLOWCOMPILER_LOCATION.
 
.PARAMETER DotnetPath
    Dotnet host used to run a .dll compiler. Defaults to the props file's
    PublisherPackDotnetPath, then 'dotnet'.
 
.PARAMETER ConfigFile
    Path to a .psd1 config file that supplies default values for any parameter
    not explicitly passed on the command line. Explicit parameters always win.
 
    Supported keys: FeedPath, OutputPath, UipcliArgs, NoBump, SkipInstall, UseWorktree,
    WorktreeBase, WorktreeSibling, CliVersionNet6, CliVersionNet8, UipcliPathNet6,
    UipcliPathNet8, Targets, MultiTfm, ToolBase, ToolBasePath, Backend,
    PublisherProps, PackageId, PackageAuthor, PackageDescription, PackageTags,
    IconUrl, ProjectUrl, ReleaseNotes, RepositoryType, RepositoryUrl,
    RepositoryBranch, RepositoryCommit, IncludeSources, WorkflowCompilerPath,
    DotnetPath.
 
.OUTPUTS
    [string[]] Full path(s) of the staged .nupkg file(s).
 
.NOTES
    Set $env:UIPATH_DISABLE_TELEMETRY to any non-empty value (e.g. '1' or 'true')
    to suppress uipcli telemetry data transmission. The telemetry banner will still
    be printed — that is expected uipcli 23.x behaviour, not a bug.
#>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string[]])]
    param(
        [string]  $ProjectJson     = (Join-Path $PSScriptRoot '..\project.json'),
        [switch]  $ShowVersion,
        [string]  $FeedPath        = 'C:\Users\Public\nugetfeed',
        [string]  $OutputPath      = '',
        [string[]]$UipcliArgs      = @(),
        [ValidateSet('uipcli', 'uipathcli', 'workflowcompiler')]
        [string]  $Backend         = 'workflowcompiler',
        [switch]  $NoBump,
        [switch]  $SkipInstall,
        [switch]  $UseWorktree,
        [string]  $WorktreeBase    = [System.IO.Path]::GetTempPath(),
        [switch]  $WorktreeSibling,
        [string]  $CliVersionNet6  = '23.10.2.6',
        [string]  $CliVersionNet8  = '25.10.11',
        [string]  $UipcliPathNet6,
        [string]  $UipcliPathNet8,
        [string[]]$Targets         = @('net8'),
        [switch]  $MultiTfm,
        [string]  $CliVersion      = '',   # deprecated
        [string]  $ProjectVersion  = '',
        [Alias('ToolBase')]
        [string]  $ToolBasePath    = (Join-Path $env:LOCALAPPDATA 'cpmf\tools'),
        [string]  $ConfigFile      = '',

        # ── Publisher identity / workflowcompiler backend ────────────────────
        [string]  $PublisherProps       = '',
        [string]  $PackageId            = '',
        [string]  $PackageAuthor        = '',
        [string]  $PackageDescription   = '',
        [string]  $PackageTags          = '',
        [string]  $IconUrl              = '',
        [string]  $ProjectUrl           = '',
        [string]  $ReleaseNotes         = '',
        [string]  $RepositoryType       = '',
        [string]  $RepositoryUrl        = '',
        [string]  $RepositoryBranch     = '',
        [string]  $RepositoryCommit     = '',
        [switch]  $IncludeSources,
        [string]  $WorkflowCompilerPath = '',
        [string]  $DotnetPath           = ''
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($ShowVersion) {
        $moduleVersion = (Get-Module CpmfUipsPack).Version
        Write-Output "CpmfUipsPack $moduleVersion"
        return
    }

    # Deprecated -CliVersion shim
    if ($PSBoundParameters.ContainsKey('CliVersion') -and $CliVersion -ne '') {
        Write-Warning "[CpmfUipsPack] -CliVersion is deprecated. Use -CliVersionNet6 or -CliVersionNet8."
        if ($CliVersion -match '^23\.') { $CliVersionNet6 = $CliVersion }
        else                            { $CliVersionNet8 = $CliVersion }
    }

    # Apply layered config defaults (user config < env vars < project config)
    # Explicit command-line parameters always win over all config sources.
    $cfg = Get-CpmfUipsPackEffectiveConfig -ConfigFile $ConfigFile

    # Publisher props sit between explicit parameters and the config layers:
    # explicit parameter > publisher props > env var > repo/user config.
    # Overlaying onto $cfg gets that ordering for free, because the loops below
    # only apply $cfg values for parameters the caller did not bind.
    $publisherPropsPath = if ($PSBoundParameters.ContainsKey('PublisherProps')) {
        $PublisherProps
    } elseif ($cfg.ContainsKey('PublisherProps')) {
        [string]$cfg['PublisherProps']
    } else {
        ''
    }

    $publisherPropMap = Get-CpmfUipsPublisherProps -Path $publisherPropsPath

    if ($publisherPropMap.Count -gt 0) {
        $propsToParams = [ordered]@{
            'PublisherAuthors'                 = 'PackageAuthor'
            'PublisherTags'                    = 'PackageTags'
            'PublisherIconUrl'                 = 'IconUrl'
            'PublisherProjectUrl'              = 'ProjectUrl'
            'PublisherRepositoryType'          = 'RepositoryType'
            'PublisherPackWorkflowCompilerPath' = 'WorkflowCompilerPath'
            'PublisherPackDotnetPath'          = 'DotnetPath'
        }
        foreach ($propName in $propsToParams.Keys) {
            if ($publisherPropMap.ContainsKey($propName)) {
                $cfg[$propsToParams[$propName]] = $publisherPropMap[$propName]
            }
        }
        # Booleans arrive as the strings 'true'/'false'; both are truthy as
        # strings, so parse rather than pass through.
        if ($publisherPropMap.ContainsKey('PublisherPackIncludeSources')) {
            $parsed = $false
            if ([bool]::TryParse($publisherPropMap['PublisherPackIncludeSources'], [ref]$parsed)) {
                $cfg['IncludeSources'] = $parsed
            } else {
                Write-Warning "[PublisherProps] PublisherPackIncludeSources is not a boolean: '$($publisherPropMap['PublisherPackIncludeSources'])' — ignored."
            }
        }
    }

    foreach ($key in @('FeedPath', 'OutputPath', 'WorktreeBase', 'CliVersionNet6', 'CliVersionNet8', 'UipcliPathNet6', 'UipcliPathNet8', 'ToolBasePath', 'Backend',
                       'PublisherProps', 'PackageId', 'PackageAuthor', 'PackageDescription', 'PackageTags',
                       'IconUrl', 'ProjectUrl', 'ReleaseNotes', 'RepositoryType', 'RepositoryUrl',
                       'RepositoryBranch', 'RepositoryCommit', 'WorkflowCompilerPath', 'DotnetPath')) {
        if (-not $PSBoundParameters.ContainsKey($key) -and $cfg.ContainsKey($key)) {
            Set-Variable -Name $key -Value $cfg[$key]
        }
    }
    foreach ($key in @('UipcliArgs', 'Targets')) {
        if (-not $PSBoundParameters.ContainsKey($key) -and $cfg.ContainsKey($key)) {
            Set-Variable -Name $key -Value ([string[]]$cfg[$key])
        }
    }
    foreach ($key in @('NoBump', 'SkipInstall', 'UseWorktree', 'WorktreeSibling', 'MultiTfm', 'IncludeSources')) {
        if (-not $PSBoundParameters.ContainsKey($key) -and $cfg.ContainsKey($key) -and $cfg[$key]) {
            Set-Variable -Name $key -Value ([switch]$true)
        }
    }
    # Deprecated CliVersion in config
    if (-not $PSBoundParameters.ContainsKey('CliVersionNet6') -and
        -not $PSBoundParameters.ContainsKey('CliVersionNet8') -and
        $cfg.ContainsKey('CliVersion') -and $cfg['CliVersion'] -ne '') {
        Write-Warning "[CpmfUipsPack] Config key 'CliVersion' is deprecated. Use 'CliVersionNet6' or 'CliVersionNet8'."
        $v = $cfg['CliVersion']
        if ($v -match '^23\.') { $CliVersionNet6 = $v } else { $CliVersionNet8 = $v }
    }

    # Validate -Targets
    $validTargets = @('net6', 'net8')
    foreach ($t in $Targets) {
        if ($t -notin $validTargets) {
            throw "-Targets contains invalid value '$t'. Valid values: net6, net8"
        }
    }

    if ([string]::IsNullOrWhiteSpace($OutputPath)) {
        $OutputPath = if ($env:PUBLIC) {
            Join-Path $env:PUBLIC 'UiPath.CLI.Windows\pack-output'
        } else {
            Join-Path ([System.IO.Path]::GetTempPath()) 'UiPath.CLI.Windows\pack-output'
        }
    }

    $resolvedUseWorktree = $UseWorktree -or $WorktreeSibling

    # The workflowcompiler backend is a single net8 build: it has no net6
    # counterpart and cannot feed the multi-TFM merge.
    if ($Backend -eq 'workflowcompiler') {
        if ($MultiTfm) {
            throw "-MultiTfm is not supported with -Backend workflowcompiler (single net8 compiler; nothing to merge). Use -Backend uipcli -Targets net6,net8 -MultiTfm."
        }
        if ($Targets -contains 'net6') {
            throw "-Targets net6 is not supported with -Backend workflowcompiler. Use -Targets net8, or -Backend uipcli for a net6 build."
        }
        if ($Targets.Count -gt 1) {
            throw "-Backend workflowcompiler builds a single target. Use -Targets net8, or -Backend uipcli to build several."
        }
    } else {
        # uipcli and uipathcli expose no package-identity or source-inclusion
        # options at all, so anything supplied here would be silently dropped and
        # read as applied. Sources in particular: uipcli always passes
        # --include-sources True down to the compiler, so the package ships every
        # .cs file whatever -IncludeSources or PublisherPackIncludeSources say.
        $ignoredSettings = [System.Collections.Generic.List[string]]::new()
        foreach ($name in @('PackageId', 'PackageAuthor', 'PackageDescription', 'PackageTags',
                            'IconUrl', 'ProjectUrl', 'ReleaseNotes', 'RepositoryType',
                            'RepositoryUrl', 'RepositoryBranch', 'RepositoryCommit')) {
            if (-not [string]::IsNullOrWhiteSpace((Get-Variable -Name $name -ValueOnly))) {
                $ignoredSettings.Add("-$name")
            }
        }
        if ($publisherPropMap.Count -gt 0) { $ignoredSettings.Add('-PublisherProps') }

        $sourcesRequested = $PSBoundParameters.ContainsKey('IncludeSources') -or
                            $publisherPropMap.ContainsKey('PublisherPackIncludeSources')

        if ($sourcesRequested) {
            Write-Warning "[Publish] The '$Backend' backend cannot control source inclusion — it has no such option, and always packs project sources into content/. -IncludeSources and PublisherPackIncludeSources are ignored here. Use -Backend workflowcompiler to keep sources out of the package."
        }
        if ($ignoredSettings.Count -gt 0) {
            Write-Warning "[Publish] The '$Backend' backend ignores package identity settings: $(($ignoredSettings | Sort-Object -Unique) -join ', '). Use -Backend workflowcompiler for these to take effect."
        }
    }

    Test-CpmfUipsPackPrerequisites `
        -RequireGit:$resolvedUseWorktree `
        -RequireDotnetCli:($Targets -contains 'net8') `
        -ToolBase $ToolBasePath

    $ProjectJson = (Resolve-Path $ProjectJson).Path
    $ProjectRoot = Split-Path $ProjectJson -Parent

    # ── workflowcompiler backend: resolve the binary and assemble metadata ────
    $workflowCompiler = @{}
    $packOptions      = @{}
    $packSettings     = @{}

    if ($Backend -eq 'workflowcompiler') {
        $workflowCompiler = Resolve-CpmfUipsWorkflowCompiler `
            -WorkflowCompilerPath $WorkflowCompilerPath `
            -PublisherProps       $publisherPropMap `
            -DotnetPath           $DotnetPath

        # Repository fields are per-repo, computed fresh from git unless supplied.
        $gitMeta = @{}
        if ([string]::IsNullOrWhiteSpace($RepositoryUrl) -or
            [string]::IsNullOrWhiteSpace($RepositoryBranch) -or
            [string]::IsNullOrWhiteSpace($RepositoryCommit)) {
            $gitMeta = Get-CpmfUipsGitMetadata -Path $ProjectRoot
        }
        foreach ($key in @('RepositoryUrl', 'RepositoryBranch', 'RepositoryCommit')) {
            if ([string]::IsNullOrWhiteSpace((Get-Variable -Name $key -ValueOnly)) -and $gitMeta.ContainsKey($key)) {
                Set-Variable -Name $key -Value $gitMeta[$key]
            }
        }

        # Copyright and licence live in the props file for the MSBuild edition
        # only — the compiler's pack-options model has no field for either.
        $packOptions = @{
            id               = $PackageId
            author           = $PackageAuthor
            description      = $PackageDescription
            releaseNotes     = $ReleaseNotes
            tags             = $PackageTags
            iconUrl          = $IconUrl
            projectUrl       = $ProjectUrl
            repositoryType   = $RepositoryType
            repositoryUrl    = $RepositoryUrl
            repositoryBranch = $RepositoryBranch
            repositoryCommit = $RepositoryCommit
        }

        $packSettings = @{
            IncludeSources = [bool]$IncludeSources
            LogLevel       = 'Warning'
            SkipAnalyze    = $false
            SkipValidate   = $false
            NoOptimizeDeps = $false
            SplitPackages  = $false
            ExcludeConfiguredSources = $false
            PolicyFileType = 'Default'
        }

        # PublisherPack* policy defaults, when the props file supplies them.
        $policyMap = [ordered]@{
            'PublisherPackSkipAnalyze'              = 'SkipAnalyze'
            'PublisherPackSkipValidate'             = 'SkipValidate'
            'PublisherPackNoOptimizeDeps'           = 'NoOptimizeDeps'
            'PublisherPackSplitPackages'            = 'SplitPackages'
            'PublisherPackExcludeConfiguredSources' = 'ExcludeConfiguredSources'
        }
        foreach ($propName in $policyMap.Keys) {
            if ($publisherPropMap.ContainsKey($propName)) {
                $parsed = $false
                if ([bool]::TryParse($publisherPropMap[$propName], [ref]$parsed)) {
                    $packSettings[$policyMap[$propName]] = $parsed
                } else {
                    Write-Warning "[PublisherProps] $propName is not a boolean: '$($publisherPropMap[$propName])' — ignored."
                }
            }
        }
        foreach ($pair in @(@('PublisherPackLogLevel', 'LogLevel'), @('PublisherPackPolicyFileType', 'PolicyFileType'))) {
            if ($publisherPropMap.ContainsKey($pair[0])) { $packSettings[$pair[1]] = $publisherPropMap[$pair[0]] }
        }
    }

    # Pre-install all requested CLI versions before acquiring the lock.
    # The workflowcompiler backend has nothing to install — the binary is
    # supplied by configuration, never downloaded.
    if (-not $SkipInstall -and $Backend -ne 'workflowcompiler') {
        if ($Backend -eq 'uipathcli') {
            Install-UipathcliTool -ToolBasePath $ToolBasePath
        } else {
            foreach ($target in $Targets) {
                $cliVer = if ($target -eq 'net6') { $CliVersionNet6 } else { $CliVersionNet8 }
                $uipcliPath = if ($target -eq 'net6') { $UipcliPathNet6 } else { $UipcliPathNet8 }
                Install-CpmfUipsPackCommandLineTool -CliVersion $cliVer -UipcliPath $uipcliPath -ToolBasePath $ToolBasePath
            }
        }
    }

    $lockFile = Join-Path $ProjectRoot '.uipath-pack.lock'

    Invoke-WithFileLock -LockFile $lockFile -ScriptBlock {

    if ($resolvedUseWorktree) {
        $repoRoot = git -C $ProjectRoot rev-parse --show-toplevel 2>$null
        if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) {
            throw "Cannot use -UseWorktree: $ProjectRoot is not inside a git repository"
        }
        $repoRoot = $repoRoot.Trim() -replace '/', '\'

        if ($WorktreeSibling) {
            $WorktreeBase = Split-Path $repoRoot -Parent
        }

        $relativeProjectJson = $ProjectJson.Substring($repoRoot.Length).TrimStart('\', '/')

        $worktreePath = Get-GitWorktreePath `
            -ProjectJson  $ProjectJson `
            -RepoRoot     $repoRoot `
            -WorktreeBase $WorktreeBase

        Invoke-GitWorktree -RepoRoot $repoRoot -WorktreePath $worktreePath -ScriptBlock {
            param($wt)
            $wtProjectJson = Join-Path $wt $relativeProjectJson
            Invoke-MultiTargetPack `
                -ProjectJson     $wtProjectJson `
                -FeedPath        $FeedPath `
                -UipcliArgs      $UipcliArgs `
                -Backend         $Backend `
                -NoBump:$NoBump `
                -ProjectVersion  $ProjectVersion `
                -Targets         $Targets `
                -CliVersionNet6  $CliVersionNet6 `
                -CliVersionNet8  $CliVersionNet8 `
                -UipcliPathNet6   $UipcliPathNet6 `
                -UipcliPathNet8   $UipcliPathNet8 `
                -MultiTfm:$MultiTfm `
                -ToolBasePath    $ToolBasePath `
                -OutputPath      $OutputPath `
                -WorkflowCompiler $workflowCompiler `
                -PackOptions     $packOptions `
                -PackSettings    $packSettings
        }
    } else {
        Write-Output (Invoke-MultiTargetPack `
            -ProjectJson     $ProjectJson `
            -FeedPath        $FeedPath `
            -UipcliArgs      $UipcliArgs `
            -Backend         $Backend `
            -NoBump:$NoBump `
            -ProjectVersion  $ProjectVersion `
            -Targets         $Targets `
            -CliVersionNet6  $CliVersionNet6 `
            -CliVersionNet8  $CliVersionNet8 `
            -UipcliPathNet6   $UipcliPathNet6 `
            -UipcliPathNet8   $UipcliPathNet8 `
            -MultiTfm:$MultiTfm `
            -ToolBasePath    $ToolBasePath `
            -OutputPath      $OutputPath `
            -WorkflowCompiler $workflowCompiler `
            -PackOptions     $packOptions `
            -PackSettings    $packSettings)
    }

    } # end Invoke-WithFileLock
}

# ---------------------------------------------------------------------------
# Internal helper — orchestrates version bump + one-or-more PackAndStage calls
# + optional MultiTfm merge.
# ---------------------------------------------------------------------------
function Invoke-MultiTargetPack {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string[]])]
    param(
        [string]   $ProjectJson,
        [string]   $FeedPath,
        [string[]] $UipcliArgs,
        [string]   $Backend = 'uipcli',
        [switch]   $NoBump,
        [string]   $ProjectVersion = '',
        [string[]] $Targets,
        [string]   $CliVersionNet6,
        [string]   $CliVersionNet8,
        [string]   $UipcliPathNet6,
        [string]   $UipcliPathNet8,
        [switch]   $MultiTfm,
        [string]   $ToolBasePath,
        [string]   $OutputPath,
        [hashtable]$WorkflowCompiler = @{},
        [hashtable]$PackOptions      = @{},
        [hashtable]$PackSettings     = @{}
    )

    $results        = [System.Collections.Generic.List[string]]::new()
    $isFirstTarget  = $true
    # Computed once — independent of uipcli version
    $uipathcliExe   = Join-Path $ToolBasePath 'uipathcli\uipath.exe'

    foreach ($target in $Targets) {
        $cliVer    = if ($target -eq 'net6') { $CliVersionNet6 } else { $CliVersionNet8 }
        $uipcliPath = if ($target -eq 'net6') { $UipcliPathNet6 } else { $UipcliPathNet8 }
        $p         = Get-CpmfUipsToolPaths -CliVersion $cliVer -UipcliPath $uipcliPath -ToolBase $ToolBasePath
        # Use a target tag in the filename only when building multiple targets
        $targetTag = if ($Targets.Count -gt 1) { $target } else { '' }

        # Version bump runs inside the first Invoke-PackAndStage only
        $thisBump    = if ($isFirstTarget) { $NoBump } else { [switch]$true }
        $thisVersion = if ($isFirstTarget) { $ProjectVersion } else { '' }

        $staged = Invoke-PackAndStage `
            -ProjectJson    $ProjectJson `
            -FeedPath       $FeedPath `
            -UipcliArgs     $UipcliArgs `
            -Backend        $Backend `
            -NoBump:$thisBump `
            -ProjectVersion $thisVersion `
            -UipcliExe      $p.UipcliExe `
            -UipathcliExe   $uipathcliExe `
            -TargetTag      $targetTag `
            -OutputPath     $OutputPath `
            -WorkflowCompiler $WorkflowCompiler `
            -PackOptions    $PackOptions `
            -PackSettings   $PackSettings

        if ($staged) { $results.Add($staged) }
        $isFirstTarget = $false
    }

    # Multi-TFM merge: combine net8 + net6 builds into one nupkg
    if ($MultiTfm -and $Targets.Count -eq 2 -and
        $Targets -contains 'net6' -and $Targets -contains 'net8') {

        $net8Path = $results | Where-Object { $_ -like '*.net8.nupkg' } | Select-Object -First 1
        $net6Path = $results | Where-Object { $_ -like '*.net6.nupkg' } | Select-Object -First 1

        if ($net8Path -and $net6Path) {
            # Output name: strip .net8 infix → <name>.<version>.nupkg
            $mergedName = [System.IO.Path]::GetFileName($net8Path) -replace '\.net8\.nupkg$', '.nupkg'
            $mergedPath = Join-Path $FeedPath $mergedName

            $merged = Invoke-MultiTfmMerge `
                -Net8Nupkg  $net8Path `
                -Net6Nupkg  $net6Path `
                -OutputPath $mergedPath

            # Remove the two per-target nupkgs; return merged path only
            Remove-Item $net8Path, $net6Path -Force -ErrorAction SilentlyContinue
            $results.Clear()
            if ($merged) { $results.Add($merged) }
        } else {
            Write-Warning "[Publish] -MultiTfm specified but could not locate both net6 and net8 nupkgs for merge."
        }
    } elseif ($MultiTfm) {
        Write-Warning "[Publish] -MultiTfm requires -Targets @('net6','net8') — ignored."
    }

    Write-Output ($results.ToArray())
}

# ---------------------------------------------------------------------------
# Internal helper — version bump + pack + stage, with rollback on failure.
# Extracted so both worktree and non-worktree paths share identical logic.
# ---------------------------------------------------------------------------
function Invoke-PackAndStage {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [string]  $ProjectJson,
        [string]  $FeedPath,
        [string[]]$UipcliArgs,
        [string]  $Backend        = 'uipcli',
        [switch]  $NoBump,
        [string]  $ProjectVersion = '',
        [string]  $UipcliExe,
        [string]  $UipathcliExe  = '',
        [string]  $TargetTag     = '',
        [string]  $OutputPath,
        [hashtable]$WorkflowCompiler = @{},
        [hashtable]$PackOptions      = @{},
        [hashtable]$PackSettings     = @{}
    )

    # Version bump (capture current for rollback)
    $versionBumped   = $false
    $previousVersion = Update-CpmfUipsPackProjectVersion -ProjectJson $ProjectJson -NoBump

    if (-not $NoBump) {
        if ($ProjectVersion -ne '') {
            $newVersion = Update-CpmfUipsPackProjectVersion -ProjectJson $ProjectJson -ProjectVersion $ProjectVersion
        } else {
            $newVersion = Update-CpmfUipsPackProjectVersion -ProjectJson $ProjectJson
        }
        Write-Verbose "[Publish] Version: $previousVersion → $newVersion"
        $versionBumped = $true
    } else {
        Write-Verbose "[Publish] Version bump skipped (-NoBump). Current: $previousVersion"
    }

    if ([string]::IsNullOrWhiteSpace($OutputPath)) {
        $OutputPath = if ($env:PUBLIC) {
            Join-Path $env:PUBLIC 'UiPath.CLI.Windows\pack-output'
        } else {
            [System.IO.Path]::GetTempPath()
        }
    }

    $TempOutputDir = Join-Path $OutputPath ([System.Guid]::NewGuid().ToString())
    try {
        $null = New-Item -ItemType Directory -Path $TempOutputDir -Force

        if ($PSCmdlet.ShouldProcess($ProjectJson, "Pack with $Backend and stage to feed")) {
            $label = if ($TargetTag) { " [$TargetTag]" } else { '' }
            $projectName = [System.IO.Path]::GetFileName((Split-Path $ProjectJson -Parent))

            # The package version is only known here, after the bump.
            $effectivePackOptions = @{}
            foreach ($k in $PackOptions.Keys) { $effectivePackOptions[$k] = $PackOptions[$k] }
            $effectivePackOptions['version'] = if ($versionBumped) { $newVersion } else { $previousVersion }

            Write-Progress -Activity "CpmfUipsPack$label" -Status "Packing $projectName …" -PercentComplete 10
            $exitCode = Invoke-CliBackend `
                -Op           pack `
                -Backend      $Backend `
                -UipcliExe    $UipcliExe `
                -UipathcliExe $UipathcliExe `
                -ProjectJson  $ProjectJson `
                -OutputDir    $TempOutputDir `
                -ExtraArgs    $UipcliArgs `
                -WorkflowCompiler $WorkflowCompiler `
                -PackOptions  $effectivePackOptions `
                -PackSettings $PackSettings
            Write-Progress -Activity "CpmfUipsPack$label" -Completed
            if ($exitCode -ne 0) { throw "$Backend pack failed (exit $exitCode)" }

            $nupkg = Get-ChildItem -Path $TempOutputDir -Filter '*.nupkg' |
                Select-Object -First 1
            if (-not $nupkg) { throw "No .nupkg found in $TempOutputDir after pack" }

            $null = New-Item -ItemType Directory -Path $FeedPath -Force
            $destName = if ($TargetTag) {
                $nupkg.Name -replace '\.nupkg$', ".$TargetTag.nupkg"
            } else {
                $nupkg.Name
            }
            $dest = Join-Path $FeedPath $destName

            Write-Progress -Activity "CpmfUipsPack$label" -Status "Staging $destName …" -PercentComplete 90
            Copy-Item -Path $nupkg.FullName -Destination $dest -Force
            Write-Progress -Activity "CpmfUipsPack$label" -Completed

            Write-Verbose "[Publish] Copied: $destName → $FeedPath"
            Write-Output $dest
        }
    } catch {
        if ($versionBumped) {
            Write-Warning "[Publish] Pack failed — restoring version to $previousVersion"
            $raw      = Get-Content $ProjectJson -Raw
            $restored = $raw -replace '("projectVersion"\s*:\s*")[^"]*(")', "`${1}$previousVersion`${2}"
            [System.IO.File]::WriteAllText($ProjectJson, $restored, (New-Object System.Text.UTF8Encoding $false))
        }
        throw
    } finally {
        Remove-Item -Path $TempOutputDir -Recurse -Force -ErrorAction SilentlyContinue
    }
}