# Upload APPX to Microsoft Store after a new release is published. # Downloads the .appx artifact from the GitHub release and submits # a new Store submission via the Partner Center Ingestion API. name: ms-store-upload on: release: types: [released] push: branches: [ ms ] jobs: upload-to-store: runs-on: windows-2022 environment: build steps: - uses: actions/checkout@v4 - name: Use Node.js 24.x uses: actions/setup-node@v4 with: node-version: 24.x - name: Install R2 dependencies if store upload may run if: ${{ contains(github.event.head_commit.message, '[r2]') }} run: npm i -D @aws-sdk/client-s3 - name: Determine release tag id: tag env: GH_TOKEN: ${{ github.token }} shell: pwsh run: | $tag = "${{ github.event.release.tag_name }}" if (-not $tag) { Write-Host "No release tag from event, fetching latest release..." $tag = (gh release view --repo "${{ github.repository }}" --json tagName -q '.tagName') if (-not $tag) { throw 'Could not determine release tag.' } } Write-Host "Release tag: $tag" "tag_name=$tag" >> $env:GITHUB_OUTPUT - name: Download APPX from release env: GH_TOKEN: ${{ github.token }} shell: pwsh run: | $tag = "${{ steps.tag.outputs.tag_name }}" New-Item -ItemType Directory -Force -Path dist | Out-Null gh release download "$tag" --repo "${{ github.repository }}" --pattern '*.appx' -D dist Write-Host 'Downloaded APPX files:' Get-ChildItem -Path dist -Filter '*.appx' -Recurse | Format-Table FullName, Length - name: Setup Microsoft Store Developer CLI uses: microsoft/microsoft-store-apppublisher@v1.1 - name: Configure Microsoft Store Developer CLI env: MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} MS_SELLER_ID: ${{ secrets.MS_SELLER_ID }} MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} run: msstore reconfigure --tenantId "$env:MS_TENANT_ID" --sellerId "$env:MS_SELLER_ID" --clientId "$env:MS_CLIENT_ID" --clientSecret "$env:MS_CLIENT_SECRET" shell: pwsh - name: Debug Microsoft Store app access continue-on-error: true env: MS_PRODUCT_ID: ${{ secrets.MS_PRODUCT_ID }} MS_APP_ID: ${{ secrets.MS_APP_ID }} MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log run: | function Get-VisibleProductIds([string] $appsListOutput) { $cleanOutput = [regex]::Replace($appsListOutput, '\x1B\[[0-9;?]*[ -/]*[@-~]', '') return @( $cleanOutput -split "`r?`n" | Where-Object { $_.Trim().StartsWith('│') } | ForEach-Object { $columns = $_.Trim().Split('│') | ForEach-Object { $_.Trim() } if ($columns.Count -ge 5) { $productId = $columns[2] $packageId = $columns[4] if ($productId -and $productId -ne 'ProductId' -and $packageId -and $packageId -ne 'PackageId') { $productId } } } | Select-Object -Unique ) } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:MSSTORE_LOG_FILE) | Out-Null Set-Content -Path $env:MSSTORE_LOG_FILE -Value "=== Microsoft Store debug log $(Get-Date -Format o) ===" function Write-StoreLog([string] $message) { Add-Content -Path $env:MSSTORE_LOG_FILE -Value $message } $msstoreVersion = & msstore --version 2>&1 | Out-String Write-Host "msstore CLI version: $msstoreVersion" Write-StoreLog "msstore CLI version: $msstoreVersion" $configuredProductId = "$env:MS_PRODUCT_ID".Trim() if (-not $configuredProductId) { $configuredProductId = "$env:MS_APP_ID".Trim() } if ($configuredProductId) { Write-Host "Configured Store Product ID: $configuredProductId" } else { Write-Host 'Configured Store Product ID: ' } Write-Host 'Package identifiers in this repo are not valid Store Product IDs:' Write-Host ' Electron appId: org.electerm.electerm' Write-Host ' APPX applicationId: electerm' Write-Host ' APPX identityName: 64159ZHAOXudong.electerm' Write-Host 'Accessible Microsoft Store packaged apps for this account:' Write-StoreLog "Configured Store Product ID: $configuredProductId" Write-StoreLog 'Package identifiers in this repo are not valid Store Product IDs:' Write-StoreLog ' Electron appId: org.electerm.electerm' Write-StoreLog ' APPX applicationId: electerm' Write-StoreLog ' APPX identityName: 64159ZHAOXudong.electerm' Write-StoreLog 'Accessible Microsoft Store packaged apps for this account:' Write-StoreLog '' Write-StoreLog "=== msstore apps list $(Get-Date -Format o) ===" $appsListOutput = (& msstore apps list 2>&1 | Tee-Object -FilePath $env:MSSTORE_LOG_FILE -Append | Out-String) $appsListExitCode = $LASTEXITCODE $global:LASTEXITCODE = 0 Write-Host $appsListOutput Write-StoreLog "msstore apps list exit code: $appsListExitCode" $productIds = Get-VisibleProductIds $appsListOutput if ($appsListExitCode -ne 0) { Write-Host "msstore apps list exited with code $appsListExitCode. This usually means the configured Entra app can authenticate but does not have Partner Center access to packaged apps for this seller account." } elseif ($productIds.Count -eq 0) { Write-Host 'msstore apps list succeeded but no packaged Product IDs were detected. This usually means no packaged Store apps are visible to this account.' } elseif (-not $configuredProductId) { if ($productIds.Count -eq 1) { Write-Host "Exactly one packaged Product ID is visible: $($productIds[0])" } else { Write-Host "Multiple packaged Product IDs are visible: $($productIds -join ', ')" } } elseif ($configuredProductId -notin $productIds) { Write-Host "Configured Store Product ID '$configuredProductId' was not found in the accessible app list." Write-Host "Visible Product IDs: $($productIds -join ', ')" } else { Write-Host "Configured Store Product ID '$configuredProductId' is visible to this account." } shell: pwsh - name: Upload APPX to Microsoft Store and submit for certification env: MS_PRODUCT_ID: ${{ secrets.MS_PRODUCT_ID }} MS_APP_ID: ${{ secrets.MS_APP_ID }} MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log run: | # ── Initialise log file ───────────────────────────────────────────────── New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:MSSTORE_LOG_FILE) | Out-Null Set-Content -Path $env:MSSTORE_LOG_FILE -Value "=== ms-store-upload log $(Get-Date -Format o) ===" function Write-StoreLog([string] $message) { Add-Content -Path $env:MSSTORE_LOG_FILE -Value $message } # Retry wrapper for Invoke-RestMethod with exponential backoff. # The Partner Center Ingestion API is behind Azure Front Door and can # return transient 504 / 502 / 503 responses under load. function Invoke-WithRetry { param( [Parameter(Mandatory=$true)] [scriptblock] $Action, [string] $Description = 'API call', [int] $MaxRetries = 5, [int] $BaseDelaySec = 10 ) for ($i = 0; $i -lt $MaxRetries; $i++) { try { return & $Action } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } $isTransient = $false if ($statusCode -in 408, 429, 500, 502, 503, 504) { $isTransient = $true } if ($_.Exception.Message -match 'timed?\s*out|gateway|service unavailable|connection was closed|unable to connect') { $isTransient = $true } $attempt = $i + 1 if ($isTransient -and $attempt -lt $MaxRetries) { $delay = $BaseDelaySec * [Math]::Pow(2, $i) Write-Host " ⚠ ${Description}: transient error (HTTP $statusCode) on attempt $attempt/$MaxRetries — retrying in ${delay}s..." Write-StoreLog "${Description}: transient error (HTTP $statusCode) attempt $attempt/$MaxRetries, retry in ${delay}s. $($_.Exception.Message)" Start-Sleep -Seconds $delay } else { Write-StoreLog "${Description}: FAILED on attempt $attempt (HTTP $statusCode). $($_.Exception.Message)" throw } } } } function Get-VisibleProductIds([string] $appsListOutput) { $cleanOutput = [regex]::Replace($appsListOutput, '\x1B\[[0-9;?]*[ -/]*[@-~]', '') return @( $cleanOutput -split "`r?`n" | Where-Object { $_.Trim().StartsWith('│') } | ForEach-Object { $columns = $_.Trim().Split('│') | ForEach-Object { $_.Trim() } if ($columns.Count -ge 5) { $productId = $columns[2] $packageId = $columns[4] if ($productId -and $productId -ne 'ProductId' -and $packageId -and $packageId -ne 'PackageId') { $productId } } } | Select-Object -Unique ) } try { $appx = Get-ChildItem -Path dist -Filter '*.appx' -Recurse | Select-Object -First 1 if (-not $appx) { throw 'No .appx file found under dist/' } Write-StoreLog '' Write-StoreLog "=== publish preflight $(Get-Date -Format o) ===" Write-StoreLog "APPX file: $($appx.FullName)" # Soft preflight: enumerate accessible packaged apps. # 'msstore apps list' can fail on its own (e.g. a CLI regression pulled in by # 'version: latest') even when the Entra app and the configured Product ID are # perfectly valid. The actual submission below calls the Dev Center Ingestion API # directly and does not depend on this command, so when MS_PRODUCT_ID is explicitly # configured we trust it and proceed instead of aborting the whole build. $appsListOutput = (& msstore apps list 2>&1 | Tee-Object -FilePath $env:MSSTORE_LOG_FILE -Append | Out-String) $appsListExitCode = $LASTEXITCODE $global:LASTEXITCODE = 0 Write-StoreLog "msstore apps list exit code: $appsListExitCode" $productIds = Get-VisibleProductIds $appsListOutput $productId = "$env:MS_PRODUCT_ID".Trim() if (-not $productId) { $productId = "$env:MS_APP_ID".Trim() } if (-not $productId) { # No explicitly configured id: we must rely on the app list. if ($appsListExitCode -eq 0 -and $productIds.Count -eq 1) { $productId = $productIds[0] Write-Host "Resolved MS Product ID from account app list: $productId" } else { $visibleProductIds = if ($productIds.Count) { $productIds -join ', ' } else { 'none detected' } throw "Missing MS_PRODUCT_ID or MS_APP_ID secret and could not resolve a single Product ID from 'msstore apps list' (exit $appsListExitCode). Visible Product IDs for this account: $visibleProductIds" } } elseif ($appsListExitCode -ne 0 -or $productIds.Count -eq 0) { Write-Host "WARNING: 'msstore apps list' failed (exit $appsListExitCode) but MS_PRODUCT_ID is explicitly configured; proceeding with the configured Product ID." Write-StoreLog "WARNING: 'msstore apps list' failed (exit $appsListExitCode); proceeding with configured Product ID: $productId" } elseif ($productId -notin $productIds) { Write-Host "WARNING: Configured Store Product ID '$productId' was not found in the accessible app list; proceeding with the configured value anyway." Write-StoreLog "WARNING: Configured Store Product ID '$productId' not in accessible app list; proceeding." } else { Write-Host "Configured Store Product ID '$productId' is visible to this account." } $knownWrongAppIds = @( 'org.electerm.electerm', 'electerm', '64159ZHAOXudong.electerm' ) if ($knownWrongAppIds -contains $productId) { throw "Configured store identifier '$productId' is a package identifier. Use the Microsoft Store Partner Center product id instead." } Write-Host "Resolved publish Product ID: $productId" Write-StoreLog "Resolved publish Product ID: $productId" # ── Direct Ingestion API publish ─────────────────────────────────────── # msstore publish copies listing features verbatim from the live submission. # If any locale has >20 features the Ingestion API rejects the update. # We bypass msstore publish and call the API directly so we can cap features. Write-StoreLog '' Write-StoreLog "=== direct-api publish $(Get-Date -Format o) ===" # 1. Get OAuth token for the Partner Center Ingestion API $tokenParts = @( "grant_type=client_credentials", "client_id=$([uri]::EscapeDataString($env:MS_CLIENT_ID))", "client_secret=$([uri]::EscapeDataString($env:MS_CLIENT_SECRET))", "scope=$([uri]::EscapeDataString('https://manage.devcenter.microsoft.com/.default'))" ) $tokenResp = Invoke-WithRetry -Description 'Get OAuth token' { Invoke-RestMethod ` -Uri "https://login.microsoftonline.com/$env:MS_TENANT_ID/oauth2/v2.0/token" ` -Method Post -ContentType 'application/x-www-form-urlencoded' ` -Body ($tokenParts -join '&') } $token = $tokenResp.access_token Write-StoreLog "OAuth token acquired." $h = @{ Authorization = "Bearer $token" } $apiBase = "https://manage.devcenter.microsoft.com/v1.0/my/applications/$productId" # 2. Get app info; delete any pending submission Write-Host "Getting app info..." $appInfo = Invoke-WithRetry -Description 'Get app info' { Invoke-RestMethod -Uri $apiBase -Headers $h } Write-StoreLog "App info fetched. lastPublishedSubmission: $($appInfo.lastPublishedApplicationSubmission.id)" if ($appInfo.pendingApplicationSubmission.id) { $pendingId = $appInfo.pendingApplicationSubmission.id Write-Host "Deleting pending submission $pendingId ..." Write-StoreLog "Deleting pending submission: $pendingId" Invoke-WithRetry -Description 'Delete pending submission' { Invoke-RestMethod -Uri "$apiBase/submissions/$pendingId" -Method Delete -Headers $h } | Out-Null Write-Host "Deleted pending submission." Write-StoreLog "Deleted pending submission." } # 3. Fetch last published submission to use as the body for POST /submissions. # The API does NOT auto-clone — it requires the full submission object. $lastPubSubId = $appInfo.lastPublishedApplicationSubmission.id if (-not $lastPubSubId) { throw 'No published submission found to clone from.' } Write-Host "Fetching published submission $lastPubSubId as template..." Write-StoreLog "Fetching published submission: $lastPubSubId" $pubSub = Invoke-WithRetry -Description 'Fetch published submission template' { Invoke-RestMethod -Uri "$apiBase/submissions/$lastPubSubId" -Headers $h } # 4. Create new submission using the published submission data as the request body. # A 504/502 gateway timeout can still result in the submission being created # server-side. A naive retry would then fail with HTTP 409 Conflict # (InvalidState) because the Ingestion API only allows one pending # submission at a time. After any failure we re-fetch app info and reuse # the pending submission if one now exists instead of blindly retrying. Write-Host "Creating new submission..." Write-StoreLog "Creating new submission (POST with published data as body)..." $pubSubBytes = [System.Text.Encoding]::UTF8.GetBytes(($pubSub | ConvertTo-Json -Depth 30 -Compress)) $newSub = $null $maxCreateRetries = 5 $submissionReady = $false for ($i = 0; $i -lt $maxCreateRetries -and -not $submissionReady; $i++) { try { $newSub = Invoke-RestMethod -Uri "$apiBase/submissions" -Method Post -Headers $h -ContentType 'application/json' -Body $pubSubBytes $submissionReady = $true } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } $errBody = '' if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $errBody = $_.ErrorDetails.Message } $attempt = $i + 1 $isRecoverable = ($statusCode -in 409, 502, 503, 504) -or ($errBody -match 'Conflict|InvalidState') -or ($_.Exception.Message -match 'timed?\s*out|gateway|service unavailable|connection was closed') if ($isRecoverable -and $attempt -lt $maxCreateRetries) { $delay = 10 * [Math]::Pow(2, $i) Write-Host " ⚠ Create new submission: HTTP $statusCode on attempt $attempt/$maxCreateRetries — checking for pending submission in ${delay}s..." Write-StoreLog "Create new submission: HTTP $statusCode attempt $attempt/$maxCreateRetries, retry in ${delay}s. $errBody" Start-Sleep -Seconds $delay # Re-fetch app info — the failed POST may have created a pending submission. # Use Invoke-WithRetry because the API may be experiencing widespread 504s. try { $appInfoRetry = Invoke-WithRetry -Description 'Re-fetch app info for pending submission' { Invoke-RestMethod -Uri $apiBase -Headers $h } if ($appInfoRetry.pendingApplicationSubmission.id) { $pendingSubId = $appInfoRetry.pendingApplicationSubmission.id Write-Host " Found pending submission $pendingSubId — reusing it." Write-StoreLog "Reusing existing pending submission: $pendingSubId" $pendingSub = Invoke-WithRetry -Description 'Fetch pending submission details' { Invoke-RestMethod -Uri "$apiBase/submissions/$pendingSubId" -Headers $h } $newSub = [PSCustomObject]@{ id = $pendingSubId fileUploadUrl = $pendingSub.fileUploadUrl } $submissionReady = $true } } catch { Write-Host " Could not re-fetch app info or submission: $($_.Exception.Message)" Write-StoreLog "Re-fetch failed: $($_.Exception.Message)" } } else { Write-StoreLog "Create new submission: FAILED on attempt $attempt (HTTP $statusCode). $errBody" throw } } } if (-not $newSub) { throw 'Failed to create new submission after retries.' } $subId = $newSub.id $fileUploadUrl = $newSub.fileUploadUrl Write-Host "Submission ID: $subId" Write-StoreLog "Submission ID: $subId" # 5. Get full data for the new submission $subData = Invoke-WithRetry -Description 'Get new submission data' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Headers $h } # 6. Replace packages: mark existing for deletion, add new one as PendingUpload $newPackages = @() foreach ($pkg in @($subData.applicationPackages)) { $pkg.fileStatus = 'PendingDelete' $newPackages += $pkg } $newPackages += [PSCustomObject]@{ fileName = $appx.Name; fileStatus = 'PendingUpload' } $subData.applicationPackages = $newPackages Write-StoreLog "applicationPackages: $($newPackages.Count) entries (last = new PendingUpload)." # Auto-publish after certification passes. $subData.targetPublishMode = 'Immediate' # 7. Push updated submission metadata Write-Host "Updating submission metadata..." Write-StoreLog "PUT submission metadata..." $updateBodyBytes = [System.Text.Encoding]::UTF8.GetBytes(($subData | ConvertTo-Json -Depth 30 -Compress)) Invoke-WithRetry -Description 'PUT submission metadata' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Method Put -Headers $h ` -ContentType 'application/json' -Body $updateBodyBytes } | Out-Null Write-Host "Submission metadata updated." Write-StoreLog "Submission metadata updated." # 8. Create ZIP and upload to Azure Blob SAS URL # (SAS URL carries its own auth — do not include the Bearer token here) $zipPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'electerm-appx-upload.zip') if (Test-Path $zipPath) { Remove-Item $zipPath } Compress-Archive -Path $appx.FullName -DestinationPath $zipPath $zipSizeMB = [Math]::Round((Get-Item $zipPath).Length / 1MB, 1) Write-Host "Uploading package ZIP ($zipSizeMB MB)..." Write-StoreLog "Uploading package ZIP ($zipSizeMB MB) to Azure Blob SAS URL..." $blobHeaders = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = 'application/octet-stream' } Invoke-WithRetry -Description 'Upload package ZIP to Azure Blob' { Invoke-RestMethod -Uri $fileUploadUrl -Method Put -Headers $blobHeaders -InFile $zipPath } | Out-Null Write-Host "Package uploaded." Write-StoreLog "Package uploaded." # 9. Commit submission to trigger certification. # targetPublishMode = 'Immediate' means it will go live automatically after passing certification. Write-Host "Committing submission to trigger certification..." Write-StoreLog "Committing submission (Immediate publish mode)..." $commitResp = Invoke-WithRetry -Description 'Commit submission' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId/commit" -Method Post -Headers $h -ContentType 'application/json' } Write-Host "Submission committed. Certification will start shortly." Write-StoreLog "Commit response status: $($commitResp.status)" if ($commitResp.statusDetails) { $errors = @($commitResp.statusDetails.errors) $warnings = @($commitResp.statusDetails.warnings) if ($errors.Count -gt 0) { foreach ($e in $errors) { Write-Host " ERROR: $($e.code) - $($e.details)" Write-StoreLog " Commit error: $($e.code) - $($e.details)" } throw "Submission commit returned $($errors.Count) error(s)." } if ($warnings.Count -gt 0) { foreach ($w in $warnings) { Write-Host " WARNING: $($w.code) - $($w.details)" Write-StoreLog " Commit warning: $($w.code) - $($w.details)" } } } Write-StoreLog "Done (committed, Immediate mode). SubmissionId: $subId" # 10. Verify submission status after commit Write-Host "Verifying submission status..." Start-Sleep -Seconds 10 $subStatus = Invoke-WithRetry -Description 'Check submission status' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Headers $h } Write-Host "Submission status: $($subStatus.status)" Write-StoreLog "Post-commit submission status: $($subStatus.status)" if ($subStatus.status -eq 'CommitFailed') { $errDetails = if ($subStatus.statusDetails.errors) { $subStatus.statusDetails.errors | ForEach-Object { "$($_.code): $($_.details)" } | Out-String } else { 'no details' } throw "Submission commit failed. Status: $($subStatus.status). Details: $errDetails" } } catch { $errMsg = $_.ToString() $errDetail = $_.Exception.Message $errStack = $_.ScriptStackTrace Write-Host "ERROR: $errMsg" Write-StoreLog '' Write-StoreLog "=== FATAL ERROR $(Get-Date -Format o) ===" Write-StoreLog "Message : $errMsg" Write-StoreLog "Detail : $errDetail" Write-StoreLog "Stack :" Write-StoreLog "$errStack" throw $_ } shell: pwsh - name: Upload Microsoft Store raw log to R2 if: ${{ contains(github.event.head_commit.message, '[r2]') }} env: MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} run: | if (-not (Test-Path $env:MSSTORE_LOG_FILE)) { Write-Host "Microsoft Store raw log file was not created: $env:MSSTORE_LOG_FILE" exit 0 } $key = "store-logs/ms-store-upload/${{ github.run_id }}-${{ github.run_attempt }}.log" node build/bin/upload-file-to-r2.js "$env:MSSTORE_LOG_FILE" "$key" shell: pwsh