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 Azure Blob Storage via the package SAS URI, marking each Processed (with a 409-conflict cleanup+retry), 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. Requires the Az.Storage module (blob upload) and the auth context's PublisherId (x-ms-publisherId). .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. .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 ) # 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'." } } if (-not (Get-Command New-AzStorageContext -ErrorAction SilentlyContinue)) { throw 'The Az.Storage module is required to upload the app package. Install it on the agent: Install-Module Az.Storage -Scope AllUsers -Force.' } $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 } # --- 1. Package branch + configuration ------------------------------------------------------ Write-ALbuildLog 'Reading package configuration...' try { $branchesPackage = Invoke-RestMethod -Uri "$baseUrl/branches/getByModule(module=Package)" -Method Get -Headers $headers -ErrorAction Stop if ($branchesPackage.value) { $branchesPackage = $branchesPackage.value } if (-not $branchesPackage) { throw 'No package branches found for the product.' } $packageInstanceId = @($branchesPackage)[0].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 $packageConfiguration = if ($packageConfigResponse.value -and @($packageConfigResponse.value).Count -gt 0) { @($packageConfigResponse.value)[0] } else { $packageConfigResponse } # --- 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. if ($packageConfiguration.packageReferences) { $packageConfiguration.packageReferences = @($packageConfiguration.packageReferences | Where-Object { $_.type -ne $resourceType }) } else { $packageConfiguration.packageReferences = @() } $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 { # 409: an unprocessed package of this type already exists - delete pending ones and retry. $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } elseif ($_.Exception.Message -match '(\d{3})') { [int]$Matches[1] } else { 0 } if ($status -ne 409) { throw } Write-ALbuildLog -Level Warning 'Package upload conflict; removing pending package(s) and retrying...' $existing = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Get -Headers $headers -ErrorAction Stop $pending = @($existing.value | Where-Object { $_.resourceType -eq $resourceType -and $_.state -ne 'Processed' }) if ($pending.Count -eq 0) { throw } foreach ($pkg in $pending) { try { Invoke-RestMethod -Uri "$baseUrl/packages/$($pkg.id)" -Method Delete -Headers $headers -ErrorAction Stop | Out-Null } catch { Write-ALbuildLog -Level Warning "Could not delete package $($pkg.id): $($_.Exception.Message)" } } Start-Sleep -Seconds 2 $packageUpload = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Post -Headers $headers -Body $uploadBody -ErrorAction Stop } # Upload the file to the returned SAS blob URI via Az.Storage. $uri = [System.Uri] $packageUpload.fileSasUri $storageAccount = $uri.DnsSafeHost.Split('.')[0] $container = $uri.LocalPath.TrimStart('/').Split('/')[0] $blobName = $uri.LocalPath.TrimStart('/').Split('/')[1] $storageContext = New-AzStorageContext -StorageAccountName $storageAccount -SasToken $uri.Query Set-AzStorageBlobContent -File $file -Container $container -Blob $blobName -Context $storageContext -Force | 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' $putHeaders = $headers.Clone() if ($packageState.'@odata.etag') { $putHeaders['If-Match'] = $packageState.'@odata.etag' } $processed = Invoke-RestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Put -Headers $putHeaders -Body (($packageState | ConvertTo-BcHashTable) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop } if ($processed.state -ne 'Processed') { throw "Package '$(Split-Path -Leaf $file)' was not processed (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 if ($branchesProperty.value) { $branchesProperty = $branchesProperty.value } if (-not $branchesProperty) { throw 'No property branches found for the product.' } $propertyInstanceId = @($branchesProperty)[0].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 )". $properties = @(ConvertTo-BcResourceList $propertiesResponse) if ($properties.Count -ne 1) { throw "Unable to locate the product property resource (found $($properties.Count))." } $property = $properties[0] $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)." } $property.appVersion = $appVersion.ToString() $propHeaders = $headers.Clone() if ($property.'@odata.etag') { $propHeaders['If-Match'] = $property.'@odata.etag' } Invoke-RestMethod -Uri "$baseUrl/properties/$($property.id)" -Method Put -Headers $propHeaders -Body (($property | ConvertTo-BcHashTable -Recurse) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | Out-Null # --- 4. Update package configuration, then create the submission ---------------------------- $cfgHeaders = $headers.Clone() if ($packageConfiguration.'@odata.etag') { $cfgHeaders['If-Match'] = $packageConfiguration.'@odata.etag' } Invoke-RestMethod -Uri "$baseUrl/packageConfigurations/$($packageConfiguration.id)" -Method Put -Headers $cfgHeaders -Body (($packageConfiguration | ConvertTo-BcHashTable -Recurse) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | 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 --------------------------------------------- if (-not $DoNotWait) { Write-ALbuildLog "Waiting up to $TimeoutMinutes minute(s) for validation..." $start = Get-Date; $complete = $false while (-not $complete -and ((Get-Date) - $start).TotalMinutes -lt $TimeoutMinutes) { Start-Sleep -Seconds 30 $ctx = Update-BcMarketplaceAuthContext -AuthContext $ctx $headers['Authorization'] = "Bearer $($ctx.AccessToken)" try { $s = Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId" -Method Get -Headers $headers -ErrorAction Stop Write-ALbuildLog -Level Verbose " submission state: $($s.state)/$($s.substate)" if ($s.state -eq 'Published' -and $s.substate -eq 'ReadyToPublish') { $complete = $true; Write-ALbuildLog -Level Success 'Validation complete.' } elseif ($s.state -eq 'Failed') { throw "Submission validation failed: $($s.substate)." } } catch { if ("$($_.Exception.Message)" -like '*validation failed*') { throw }; Write-ALbuildLog -Level Verbose " (status check retry: $($_.Exception.Message))" } } if (-not $complete) { Write-ALbuildLog -Level Warning "Validation did not complete within $TimeoutMinutes minute(s)." } if ($AutoPromote -and $complete) { Write-ALbuildLog 'Promoting submission to live...' try { Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId/promote" -Method Post -Headers $headers -Body '{}' -ErrorAction Stop | Out-Null; Write-ALbuildLog -Level Success 'Promoted to live.' } catch { Write-ALbuildLog -Level Warning "Failed to promote: $($_.Exception.Message)" } } } $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 = ($AutoPromote.IsPresent -and -not $DoNotWait) } } |