Modules/businessdev.ALbuild.Marketplace/Public/New-BcMarketplaceSubmission.ps1

function New-BcMarketplaceSubmission {
    <#
    .SYNOPSIS
        Uploads app package(s) and creates a Marketplace (AppSource) submission via Partner Center.
 
    .DESCRIPTION
        Faithful port of the proven Publish-AppSourceApp / New-AppSourceSubmission flow (Partner Center
        ingestion API v1.0), so ALbuild submits exactly the way that works:
          1. read the product's Package branch + package configuration;
          2. upload the main app (Dynamics365BusinessCentralAddOnExtensionPackage) and the library apps
             (Dynamics365BusinessCentralAddOnLibraryExtensionPackage - zipped into one archive when there
             is more than one) to the pre-signed URI the API returns, marking each Processed, and adding a
             package reference;
          3. bump the product's appVersion property from the app manifest (rejecting a downgrade);
          4. update the package configuration and create a 'preview' submission;
          5. optionally wait for validation and auto-promote to live.
        The upload goes through Send-BcIngestionBlob, which uses the issued URI verbatim - see that
        function for why reconstructing the storage endpoint (the old Az.Storage path) broke on 2026-09-01.
        Requires the auth context's PublisherId (x-ms-publisherId); no Az module is needed.
 
    .PARAMETER AuthContext
        Auth context from New-BcMarketplaceAuthContext (Partner Center scope; carries PublisherId).
 
    .PARAMETER ProductId
        The product id (with or without the 'product/' prefix).
 
    .PARAMETER AppFile
        The main .app file to submit.
 
    .PARAMETER LibraryAppFile
        Optional dependent/library .app files.
 
    .PARAMETER AutoPromote
        Promote to live after successful preview validation.
 
    .PARAMETER DoNotWait
        Return after creating the submission without waiting for validation.
 
    .PARAMETER TimeoutMinutes
        Validation wait timeout. Default 30.
 
    .PARAMETER PackageProcessingTimeoutSeconds
        How long to wait for Partner Center to move an uploaded package to 'Processed'. Default 120.
 
    .OUTPUTS
        PSCustomObject: ProductId, SubmissionId, AppFile, Libraries, State, Substate, Promoted.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [PSCustomObject] $AuthContext,
        [Parameter(Mandatory)] [string] $ProductId,
        [Parameter(Mandatory)] [string] $AppFile,
        [string[]] $LibraryAppFile = @(),
        [switch] $AutoPromote,
        [switch] $DoNotWait,
        [int] $TimeoutMinutes = 30,
        [int] $PackageProcessingTimeoutSeconds = 120
    )

    # Partner Center ingestion responses are dynamic JSON whose OPTIONAL properties (value, @odata.etag,
    # @nextLink, appVersion, packageReferences, ...) are absent in many valid responses. The module runs
    # under Set-StrictMode -Version Latest, which throws on a missing property; relax it here (as the
    # proven Publish-AppSourceApp / New-AppSourceSubmission runs) so absent properties read as $null.
    Set-StrictMode -Off

    if (-not (Test-Path -LiteralPath $AppFile)) { throw "App file not found: '$AppFile'." }
    foreach ($lib in $LibraryAppFile) { if (-not (Test-Path -LiteralPath $lib)) { throw "Library file not found: '$lib'." } }

    $ctx = Update-BcMarketplaceAuthContext -AuthContext $AuthContext
    $cleanProductId = $ProductId -replace '^product/', ''
    $baseUrl = "https://api.partner.microsoft.com/v1.0/ingestion/products/$cleanProductId"
    $headers = @{ Authorization = "Bearer $($ctx.AccessToken)"; 'Content-Type' = 'application/json' }
    if ($ctx.PublisherId) { $headers['x-ms-publisherId'] = $ctx.PublisherId }   # required by the ingestion API

    if (-not $PSCmdlet.ShouldProcess($cleanProductId, "Create Marketplace submission for $(Split-Path $AppFile -Leaf)")) { return }

    # Every ingestion call below is keyed on these three; naming them once makes a 401/403/404 in the log
    # attributable without having to guess which offer and which identity the release ran as.
    Write-ALbuildLog "Submission context: product $cleanProductId, publisher $(if ($ctx.PublisherId) { $ctx.PublisherId } else { '<none>' }), api $baseUrl"
    Write-ALbuildLog " main app : $(Split-Path -Leaf $AppFile)"
    foreach ($lib in $LibraryAppFile) { Write-ALbuildLog " library app : $(Split-Path -Leaf $lib)" }

    # --- 1. Package branch + configuration ------------------------------------------------------
    Write-ALbuildLog 'Reading package configuration...'
    # Branches and package configurations are partitioned BY VARIANT: an offer with an AppSource Test
    # Drive (or plan variants) has one of each per variant. Taking [0] therefore picked a variant-scoped
    # branch on the Address Validation offer, and step 4's PUT came back "VariantNotFound - Variant
    # testdrive not found for product ..." - while every variant-less offer (Banking, ERiC, E-Invoice,
    # Sanction Screen) kept working because they return exactly one. Select the base variant explicitly.
    try {
        $branchesPackage = Invoke-RestMethod -Uri "$baseUrl/branches/getByModule(module=Package)" -Method Get -Headers $headers -ErrorAction Stop
        $packageBranch = Select-BcIngestionBaseResource -Resource @(ConvertTo-BcResourceList $branchesPackage) -Description 'package branch'
        $packageInstanceId = $packageBranch.currentDraftInstanceID
    }
    catch { $d = Get-BcMarketplaceErrorDetail -ErrorRecord $_; throw "Failed to get package branches: $($_.Exception.Message)$(if ($d) { " | $d" })" }

    $packageConfigResponse = Invoke-RestMethod -Uri "$baseUrl/packageConfigurations/getByInstanceID(instanceID=$packageInstanceId)" -Method Get -Headers $headers -ErrorAction Stop
    # The configurations come from the base branch's instance, so a variant-scoped one here would be a
    # stale leftover; -AllowVariant keeps a single variant-tagged configuration usable rather than
    # blocking a release that Partner Center would still accept.
    $packageConfiguration = Select-BcIngestionBaseResource -Resource @(ConvertTo-BcResourceList $packageConfigResponse) -Description 'package configuration' -AllowVariant
    Write-ALbuildLog " package branch: draft instance $packageInstanceId, configuration $($packageConfiguration.id)"

    # --- 2. Library files: one archive when more than one ---------------------------------------
    $libraryArchive = ''
    $tempFolder = ''
    if ($LibraryAppFile.Count -eq 1) { $libraryArchive = $LibraryAppFile[0] }
    elseif ($LibraryAppFile.Count -gt 1) {
        $tempFolder = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
        New-Item -ItemType Directory -Path $tempFolder -Force | Out-Null
        $libraryArchive = Join-Path $tempFolder "$([System.IO.Path]::GetFileNameWithoutExtension($AppFile)).libraries.zip"
        Compress-Archive -Path $LibraryAppFile -DestinationPath $libraryArchive -CompressionLevel Fastest
    }

    try {
        $filesToProcess = @(
            [PSCustomObject]@{ File = $AppFile; ResourceType = 'Dynamics365BusinessCentralAddOnExtensionPackage' }
            [PSCustomObject]@{ File = $libraryArchive; ResourceType = 'Dynamics365BusinessCentralAddOnLibraryExtensionPackage' }
        )
        foreach ($fileInfo in $filesToProcess) {
            if (-not $fileInfo.File) { continue }
            $file = $fileInfo.File; $resourceType = $fileInfo.ResourceType
            Write-ALbuildLog "Uploading $(Split-Path -Leaf $file) ($resourceType)..."

            # Drop existing references of this type from the configuration, then (re)attach the
            # collection. A fresh Partner Center package configuration often OMITS packageReferences
            # entirely, and under PowerShell 7 (the release agent) a PSCustomObject - as
            # Invoke-RestMethod/ConvertFrom-Json returns - does NOT let you add a property by bare
            # assignment: `$obj.packageReferences = ...` throws "The property 'packageReferences'
            # cannot be found on this object" (it silently added the property on Windows PowerShell 5.1,
            # where this was authored). Add-Member -Force adds-or-updates the property portably.
            # `$_ -and` drops nulls: when packageReferences is absent/$null, piping it yields a single
            # $null item (PowerShell pipes $null as one element), which would otherwise leave a stray null
            # reference in the array sent to Partner Center.
            $keptReferences = @($packageConfiguration.packageReferences | Where-Object { $_ -and $_.type -ne $resourceType })
            $packageConfiguration | Add-Member -NotePropertyName packageReferences -NotePropertyValue $keptReferences -Force

            $uploadBody = @{ resourceType = $resourceType; fileName = [System.IO.Path]::GetFileName($file) } | ConvertTo-Json
            try { $packageUpload = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Post -Headers $headers -Body $uploadBody -ErrorAction Stop }
            catch {
                $status = 0
                $respProp = $_.Exception.PSObject.Properties['Response']
                if ($respProp -and $respProp.Value) { try { $status = [int] $respProp.Value.StatusCode } catch { $status = 0 } }
                if (-not $status -and "$($_.Exception.Message)" -match '\((\d{3})\)') { $status = [int] $Matches[1] }
                $detail = Get-BcMarketplaceErrorDetail -ErrorRecord $_
                # A 409 used to trigger a "list the pending packages and delete them" recovery. That recovery
                # could never work: the ingestion API answers BOTH `GET /packages` and `DELETE /packages/{id}`
                # with 405 Method Not Allowed (verified against the live API), so the recovery replaced the
                # conflict with a confusing 405. Report the conflict instead, with the manual way out.
                if ($status -eq 409) {
                    throw ("Partner Center already holds an unprocessed '$resourceType' package for this offer (HTTP 409)." +
                        " The ingestion API offers no way to list or delete packages, so remove the pending package in Partner Center" +
                        " (offer > Technical configuration) and re-run the release.$(if ($detail) { " | Partner Center response: $detail" })")
                }
                throw "Failed to create the package upload for '$(Split-Path -Leaf $file)'$(if ($status) { " (HTTP $status)" }) ($($_.Exception.Message))$(if ($detail) { " | Partner Center response: $detail" })."
            }
            Write-ALbuildLog " package id : $($packageUpload.id) (state $($packageUpload.state))"
            if (-not $packageUpload.fileSasUri) { throw "Partner Center returned no fileSasUri for package $($packageUpload.id); nothing to upload to." }

            # Upload to the URI exactly as issued - see Send-BcIngestionBlob for why it must not be rebuilt.
            Send-BcIngestionBlob -SasUri $packageUpload.fileSasUri -Path $file | Out-Null
            Start-Sleep -Seconds 2

            # Mark the package Uploaded (unless already Processed) and confirm it processes.
            $packageState = Invoke-RestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Get -Headers $headers -ErrorAction Stop
            if ($packageState.state -eq 'Processed') { $processed = $packageState }
            else {
                $packageState.state = 'Uploaded'
                $processed = Invoke-BcIngestionRestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Put -Headers $headers `
                    -Body ($packageState | ConvertTo-BcHashTable) -ETag "$($packageState.'@odata.etag')" `
                    -Operation "mark package '$(Split-Path -Leaf $file)' as uploaded"
            }
            # Processing is asynchronous, so a freshly uploaded package can still report 'Uploaded' here.
            # Poll briefly rather than failing a release that would have been ready seconds later.
            $waited = 0
            while ($processed.state -ne 'Processed' -and $waited -lt $PackageProcessingTimeoutSeconds) {
                Write-ALbuildLog -Level Verbose " package state $($processed.state); waiting for processing ($waited s)..."
                Start-Sleep -Seconds 5; $waited += 5
                $processed = Invoke-RestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Get -Headers $headers -ErrorAction Stop
            }
            if ($processed.state -ne 'Processed') {
                throw "Package '$(Split-Path -Leaf $file)' was not processed within $PackageProcessingTimeoutSeconds s (state: $($processed.state))."
            }
            $packageConfiguration.packageReferences += @([PSCustomObject]@{ type = $resourceType; value = $processed.id })
            Write-ALbuildLog -Level Success "Uploaded and processed $(Split-Path -Leaf $file)."
        }
    }
    finally { if ($tempFolder -and (Test-Path -LiteralPath $tempFolder)) { Remove-Item -LiteralPath $tempFolder -Recurse -Force -ErrorAction SilentlyContinue } }

    # --- 3. Bump the product appVersion property from the app manifest ---------------------------
    $propertyInstanceId = $null
    $appVersion = (Expand-BcAppFile -Path $AppFile).Version
    Write-ALbuildLog "Setting product version to $appVersion..."
    $branchesProperty = Invoke-RestMethod -Uri "$baseUrl/branches/getByModule(module=Property)" -Method Get -Headers $headers -ErrorAction Stop
    # Same variant partitioning as the package branch above - take the base variant, not element [0].
    $propertyBranch = Select-BcIngestionBaseResource -Resource @(ConvertTo-BcResourceList $branchesProperty) -Description 'property branch'
    $propertyInstanceId = $propertyBranch.currentDraftInstanceID
    $propertiesResponse = Invoke-RestMethod -Uri "$baseUrl/properties/getByInstanceID(instanceID=$propertyInstanceId)" -Method Get -Headers $headers -ErrorAction Stop
    # Normalise via ConvertTo-BcResourceList (two-statement @() assignment): on Windows PowerShell 5.1 an
    # inline `$x = if (...) { @(...) } else { @(...) }` unwraps a single-element array to a scalar, which
    # made `.Count` blank and always threw "Unable to locate the product property resource (found )".
    # A variant-carrying product can return more than one property resource here, so select the base one
    # rather than insisting on exactly one (which threw "found 2" on such an offer).
    $property = Select-BcIngestionBaseResource -Resource @(ConvertTo-BcResourceList $propertiesResponse) -Description 'product property resource' -AllowVariant
    $prev = [version]'0.0.0.0'
    if ($property.appVersion -and [version]::TryParse("$($property.appVersion)", [ref] $prev) -and $prev -gt $appVersion) {
        throw "The new version ($appVersion) is lower than the version already in Partner Center ($prev)."
    }
    # Same PowerShell 7 caveat as packageReferences above: a product property resource that has never
    # carried an appVersion (a first-ever submission) omits the property, so a bare assignment would
    # throw "The property 'appVersion' cannot be found on this object". Add-Member -Force adds-or-updates.
    $property | Add-Member -NotePropertyName appVersion -NotePropertyValue $appVersion.ToString() -Force
    Invoke-BcIngestionRestMethod -Uri "$baseUrl/properties/$($property.id)" -Method Put -Headers $headers `
        -Body ($property | ConvertTo-BcHashTable -Recurse) -ETag "$($property.'@odata.etag')" `
        -Operation "update the product version property to $appVersion" | Out-Null

    # --- 4. Update package configuration, then create the submission ----------------------------
    Invoke-BcIngestionRestMethod -Uri "$baseUrl/packageConfigurations/$($packageConfiguration.id)" -Method Put -Headers $headers `
        -Body ($packageConfiguration | ConvertTo-BcHashTable -Recurse) -ETag "$($packageConfiguration.'@odata.etag')" `
        -Operation 'update the package configuration' | Out-Null

    # Delete an existing in-progress submission so a fresh one can be created.
    try {
        $inProgress = @((Invoke-RestMethod -Uri "$baseUrl/submissions" -Method Get -Headers $headers -ErrorAction Stop).value | Where-Object { $_.state -eq 'InProgress' })[0]
        if ($inProgress) { Invoke-RestMethod -Uri "$baseUrl/submissions/$($inProgress.id)" -Method Delete -Headers $headers -ErrorAction Stop | Out-Null; Start-Sleep -Seconds 2 }
    }
    catch { Write-ALbuildLog -Level Verbose "Could not check/delete an in-progress submission: $($_.Exception.Message)" }

    $resources = @(@{ type = 'Package'; value = $packageInstanceId })
    if ($propertyInstanceId) { $resources += @{ type = 'Property'; value = $propertyInstanceId } }
    $submissionBody = @{ resourceType = 'SubmissionCreationRequest'; targets = @(@{ type = 'Scope'; value = 'preview' }); resources = $resources } | ConvertTo-Json -Depth 10
    try { $submission = Invoke-RestMethod -Uri "$baseUrl/submissions" -Method Post -Headers $headers -Body $submissionBody -ErrorAction Stop }
    catch { $d = Get-BcMarketplaceErrorDetail -ErrorRecord $_; throw "Failed to create submission: $($_.Exception.Message)$(if ($d) { " | $d" })" }
    $submissionId = $submission.id
    Write-ALbuildLog -Level Success "Created submission $submissionId."

    # --- 5. Wait for validation, optionally promote ---------------------------------------------
    $promoted = $false
    if (-not $DoNotWait) {
        Write-ALbuildLog "Waiting up to $TimeoutMinutes minute(s) for validation..."
        # The wait used to log nothing at Information level, so a release showed a 30-minute gap between
        # "Waiting..." and the outcome - indistinguishable from a hung agent. Report every state change and
        # a heartbeat in between, and make repeated poll failures visible instead of hiding them in Verbose.
        $start = Get-Date; $complete = $false
        $lastState = ''; $lastBeat = Get-Date; $pollErrors = 0
        while (-not $complete -and ((Get-Date) - $start).TotalMinutes -lt $TimeoutMinutes) {
            Start-Sleep -Seconds 30
            $elapsed = [int]((Get-Date) - $start).TotalMinutes
            $ctx = Update-BcMarketplaceAuthContext -AuthContext $ctx
            $headers['Authorization'] = "Bearer $($ctx.AccessToken)"
            try {
                $s = Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId" -Method Get -Headers $headers -ErrorAction Stop
                $pollErrors = 0
                $state = "$($s.state)/$($s.substate)"
                if ($state -ne $lastState) {
                    Write-ALbuildLog " [$elapsed min] submission state: $state"
                    $lastState = $state; $lastBeat = Get-Date
                }
                elseif (((Get-Date) - $lastBeat).TotalMinutes -ge 5) {
                    Write-ALbuildLog " [$elapsed min] still $state (of $TimeoutMinutes min budget)"
                    $lastBeat = Get-Date
                }
                if ($s.state -eq 'Published' -and $s.substate -eq 'ReadyToPublish') { $complete = $true; Write-ALbuildLog -Level Success "Validation complete after $elapsed minute(s)." }
                elseif ($s.state -eq 'Failed') { throw "Submission validation failed: $($s.substate)." }
            }
            catch {
                if ("$($_.Exception.Message)" -like '*validation failed*') { throw }
                $pollErrors++
                $text = " [$elapsed min] status check failed ($pollErrors in a row): $(Format-BcErrorMessage -Text $_.Exception.Message)"
                # One blip is noise; a run of them means we are no longer really watching the submission.
                if ($pollErrors -ge 3) { Write-ALbuildLog -Level Warning $text } else { Write-ALbuildLog -Level Verbose $text }
            }
        }
        if (-not $complete) { Write-ALbuildLog -Level Warning "Validation did not complete within $TimeoutMinutes minute(s); last state: $(if ($lastState) { $lastState } else { '<never observed>' })." }

        if ($AutoPromote -and $complete) {
            Write-ALbuildLog 'Promoting submission to live...'
            # A failed promotion used to be a warning while the result still claimed Promoted = $true, so the
            # release went green with nothing live. If promotion was asked for and did not happen, say so.
            try {
                Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId/promote" -Method Post -Headers $headers -Body '{}' -ErrorAction Stop | Out-Null
                $promoted = $true
                Write-ALbuildLog -Level Success 'Promoted to live.'
            }
            catch {
                $d = Get-BcMarketplaceErrorDetail -ErrorRecord $_
                throw "Submission $submissionId validated but could not be promoted to live ($($_.Exception.Message))$(if ($d) { " | Partner Center response: $d" }). Promote it manually in Partner Center."
            }
        }
    }

    $final = Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId" -Method Get -Headers $headers -ErrorAction SilentlyContinue
    return [PSCustomObject]@{
        ProductId    = $cleanProductId
        SubmissionId = $submissionId
        AppFile      = $AppFile
        Libraries    = $LibraryAppFile
        State        = $final.state
        Substate     = $final.substate
        Promoted     = $promoted
    }
}