Files
2026-07-13 12:46:20 +08:00

505 lines
26 KiB
YAML

# 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: <empty>'
}
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