From 859accbf28efc2ac9bfc6dcffece92f173687bdc Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 18:40:23 -0700 Subject: [PATCH 1/7] feat(release): automate Microsoft Store submission --- .github/workflows/ci.yml | 138 +++++++++ docs/RELEASING.md | 60 +++- scripts/New-GitHubOidcRequestUri.ps1 | 28 ++ scripts/Submit-MicrosoftStore.ps1 | 306 ++++++++++++++++++++ scripts/test-ci-workflow-contract.ps1 | 36 +++ scripts/test-microsoft-store-submission.ps1 | 298 +++++++++++++++++++ store-submission.json | 10 + 7 files changed, 866 insertions(+), 10 deletions(-) create mode 100644 scripts/New-GitHubOidcRequestUri.ps1 create mode 100644 scripts/Submit-MicrosoftStore.ps1 create mode 100644 scripts/test-microsoft-store-submission.ps1 create mode 100644 store-submission.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cace93457..2b28d2514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,10 @@ jobs: shell: pwsh run: ./scripts/test-msix-alpha-release.ps1 + - name: Validate Microsoft Store submission contracts + shell: pwsh + run: ./scripts/test-microsoft-store-submission.ps1 + - name: Validate stable correction release ordering regressions shell: pwsh run: ./scripts/test-stable-correction-release-validator.ps1 @@ -1418,3 +1422,137 @@ jobs: | @tsv ' <<< "$releases_json" ) + + submit-microsoft-store: + name: Submit Node bundle to Microsoft Store + needs: [metadata, build-msix-bundle, release] + if: >- + ${{ startsWith(github.ref, 'refs/tags/v') && + needs.metadata.outputs.isPrerelease == 'false' && + needs.build-msix-bundle.result == 'success' && + needs.release.result == 'success' && + !cancelled() }} + runs-on: ubuntu-latest + environment: microsoft-store + timeout-minutes: 60 + concurrency: + group: openclaw-windows-node-store-submission + cancel-in-progress: false + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download validated Store bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-msix-store-unsigned-bundle + path: store + + - name: Validate Microsoft Store environment + shell: pwsh + env: + MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }} + MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }} + MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} + MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} + run: | + foreach ($name in @( + 'MSSTORE_TENANT_ID' + 'MSSTORE_SELLER_ID' + 'MSSTORE_CLIENT_ID' + 'MSSTORE_APPLICATION_ID' + )) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "The microsoft-store environment is missing $name." + } + } + foreach ($name in @('MSSTORE_TENANT_ID', 'MSSTORE_CLIENT_ID')) { + $value = [Environment]::GetEnvironmentVariable($name) + $parsed = [guid]::Empty + if (-not [guid]::TryParse($value, [ref]$parsed)) { + throw "$name must be a GUID." + } + } + + - name: Set up Microsoft Store Developer CLI + uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 + with: + version: v0.4.3 + + - name: Request short-lived Microsoft Store assertion + id: oidc + shell: pwsh + run: | + $policy = Get-Content -LiteralPath .\store-submission.json -Raw | + ConvertFrom-Json + $requestUrl = $env:ACTIONS_ID_TOKEN_REQUEST_URL + $requestToken = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN + if ([string]::IsNullOrWhiteSpace($requestUrl) -or + [string]::IsNullOrWhiteSpace($requestToken)) { + throw 'GitHub did not provide an OIDC token endpoint.' + } + $oidcRequestUri = .\scripts\New-GitHubOidcRequestUri.ps1 ` + -RequestUri $requestUrl ` + -Audience ([string]$policy.oidcAudience) + $response = Invoke-RestMethod ` + -Headers @{ Authorization = "Bearer $requestToken" } ` + -Uri $oidcRequestUri + $assertion = [string]$response.value + if ([string]::IsNullOrWhiteSpace($assertion)) { + throw 'GitHub returned an empty OIDC assertion.' + } + Write-Output "::add-mask::$assertion" + $assertionPath = Join-Path $env:RUNNER_TEMP 'msstore-assertion.jwt' + [IO.File]::WriteAllText($assertionPath, $assertion) + & chmod 600 $assertionPath + if ($LASTEXITCODE -ne 0) { + throw 'Unable to restrict the OIDC assertion file permissions.' + } + "assertion_path=$assertionPath" >> $env:GITHUB_OUTPUT + + - name: Submit bundle and commit Store update + shell: pwsh + env: + MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }} + MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }} + MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} + MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} + MSSTORE_ASSERTION_FILE: ${{ steps.oidc.outputs.assertion_path }} + run: | + .\scripts\Submit-MicrosoftStore.ps1 ` + -BundlePath .\store\OpenClaw.msixbundle ` + -ApplicationId $env:MSSTORE_APPLICATION_ID ` + -TenantId $env:MSSTORE_TENANT_ID ` + -SellerId $env:MSSTORE_SELLER_ID ` + -ClientId $env:MSSTORE_CLIENT_ID ` + -ClientAssertionFile $env:MSSTORE_ASSERTION_FILE ` + -PolicyPath .\store-submission.json ` + -EvidencePath .\evidence\store-submission.json + + - name: Upload Store submission evidence + if: ${{ always() }} + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: openclaw-node-store-submission-evidence + path: evidence/store-submission.json + if-no-files-found: error + retention-days: 90 + + - name: Remove short-lived assertion + if: ${{ always() }} + shell: pwsh + env: + MSSTORE_ASSERTION_FILE: ${{ steps.oidc.outputs.assertion_path }} + run: | + if (-not [string]::IsNullOrWhiteSpace($env:MSSTORE_ASSERTION_FILE)) { + Remove-Item ` + -LiteralPath $env:MSSTORE_ASSERTION_FILE ` + -Force ` + -ErrorAction SilentlyContinue + } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0faaa70d2..47deb46e8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -14,8 +14,10 @@ smoke only; ARM64 portable publish remains required on `main` and tags. When either release-build lane is selected, CI also builds both architectures of Dev-signed and unsigned Store MSIX **workflow artifacts**. CI Gate requires that MSIX job to succeed. Every tag release also attaches the unsigned Store -MSIX bundle, standalone packages, and metadata for manual Partner Center -submission. Dev-signed packages stay in Actions. +MSIX bundle, standalone packages, and metadata. After a stable or correction +GitHub release is published, CI submits only the bundle to Partner Center. +Prereleases are never submitted to the Store. Dev-signed packages stay in +Actions. ## Release checklist @@ -179,10 +181,12 @@ Every stable, correction, and prerelease additionally contains: - `OpenClaw-x64.msix-metadata.json` and `OpenClaw-arm64.msix-metadata.json` -These are **unsigned Store submission inputs, not installers**. Upload the -bundle to Partner Center for one architecture-selecting submission. The -standalone packages remain available for inspection or fallback. Microsoft -signs accepted Store submissions. The release step checks both +These are **unsigned Store submission inputs, not installers**. The automated +Store job uploads only the bundle for one architecture-selecting submission. +Do not upload the bundle and standalone packages together: Partner Center +correctly rejects that as duplicate x64 and ARM64 packages. The standalone +packages remain available for inspection or fallback. Microsoft signs accepted +Store submissions. The release step checks both architectures' clean source provenance, identity, version, and package hashes, then proves that the bundle embeds those exact bytes. It fails rather than publishing a partial or mismatched set. @@ -191,10 +195,43 @@ Dev-signed tester MSIX packages, public certificates, and instructions remain Actions artifacts only. No production signing step is applied to the unsigned Store packages. -Store distribution remains paused: automatic Partner Center submission, -Store-signed retrieval and publication, and official lifecycle acceptance -remain follow-up work in #1375. Release submission artifacts do not clear those -rollout gates. +Stable and correction releases publish to Partner Center after the GitHub +release succeeds. The Store submission refuses to replace a pending draft and +requires an existing published submission. It creates the package update +without committing, verifies that the draft preserved the published product +metadata, and only then commits it. Store certification and rollout remain +Microsoft-managed asynchronous stages. + +## Microsoft Store publication setup + +The `submit-microsoft-store` job uses the official Microsoft Store Developer +CLI with a short-lived GitHub OIDC assertion. It stores no client secret. The +job runs in the `microsoft-store` GitHub environment after `release`, and only +for non-prerelease `v*` tags. + +Configure that environment before the next stable release: + +1. Limit deployment tags to `v*`; do not permit branch deployments. +2. Set these environment variables (they are identifiers, not credentials): + - `MSSTORE_TENANT_ID` + - `MSSTORE_SELLER_ID` + - `MSSTORE_CLIENT_ID` + - `MSSTORE_APPLICATION_ID` +3. Add an Entra federated credential for: + `repo:openclaw/openclaw-windows-node:environment:microsoft-store` + with audience `api://AzureADTokenExchange`. +4. Associate the Entra application with the Partner Center account and grant + it access to the existing OpenClaw product. +5. Ensure that product has a published submission and no pending draft. +6. Keep the Store product free; the current Store Developer CLI supports + automated app updates for free products. + +The git-controlled policy is [`store-submission.json`](../store-submission.json). +It pins the CLI version, OIDC audience, rollout percentage, timeout, environment, +and pending-draft behavior. `scripts\Submit-MicrosoftStore.ps1` validates those +values, the product, and the published metadata before committing an update. +The workflow uploads a 90-day evidence artifact containing the submitted bundle +hash and Store submission identifiers. It never includes the OIDC assertion. Store versions still end in `.0`. Official tagged builds now reserve distinct package versions as described below; reruns reuse the same reservation. @@ -399,6 +436,7 @@ For release tags, the **Build and Test** workflow should run: - `build` matrix entries shown by GitHub as `build (win-x64)` and `build (win-arm64)` - `CI Gate` - `release` +- `submit-microsoft-store` for stable and correction tags only The `setup-connect` E2E shard contains the MXC proof tests for the gateway -> Windows node -> `system.run` path and validates that the expected proof test @@ -425,6 +463,8 @@ The release job should: bundle, standalone packages, and metadata. 9. Create a GitHub release whose prerelease flag matches the tag, with installer and portable ZIP assets plus the Store submission assets. +10. For stable and correction tags, submit only `OpenClaw.msixbundle` to + Partner Center after the GitHub release succeeds. ## Post-release verification diff --git a/scripts/New-GitHubOidcRequestUri.ps1 b/scripts/New-GitHubOidcRequestUri.ps1 new file mode 100644 index 000000000..303af7f32 --- /dev/null +++ b/scripts/New-GitHubOidcRequestUri.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [uri]$RequestUri, + + [Parameter(Mandatory)] + [string]$Audience +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $RequestUri.IsAbsoluteUri) { + throw 'The GitHub OIDC request URI must be absolute.' +} +if ([string]::IsNullOrWhiteSpace($Audience)) { + throw 'The GitHub OIDC audience must not be empty.' +} + +$requestText = $RequestUri.AbsoluteUri +$separator = if ($requestText.Contains('?')) { + if ($requestText.EndsWith('?') -or $requestText.EndsWith('&')) { '' } else { '&' } +} +else { + '?' +} + +"${requestText}${separator}audience=$([Uri]::EscapeDataString($Audience))" diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 new file mode 100644 index 000000000..fb8fee07d --- /dev/null +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -0,0 +1,306 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BundlePath, + [Parameter(Mandatory)][string]$ApplicationId, + [Parameter(Mandatory)][guid]$TenantId, + [Parameter(Mandatory)][string]$SellerId, + [Parameter(Mandatory)][guid]$ClientId, + [Parameter(Mandatory)][string]$ClientAssertionFile, + [string]$PolicyPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'store-submission.json'), + [string]$EvidencePath, + [string]$MSStoreCommand = 'msstore' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-NonEmptyValue { + param( + [Parameter(Mandatory)][string]$Name, + [AllowEmptyString()][string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "$Name must not be empty." + } +} + +function Invoke-MSStore { + param( + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$Operation, + [switch]$CaptureOutput + ) + + $output = @(& $MSStoreCommand @Arguments) + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Microsoft Store CLI $Operation failed with exit code $exitCode." + } + if ($CaptureOutput) { + return ($output -join "`n") + } + foreach ($line in $output) { + Write-Output $line + } +} + +function ConvertFrom-MSStoreJson { + param( + [Parameter(Mandatory)][string]$Json, + [Parameter(Mandatory)][string]$Operation + ) + + if ([string]::IsNullOrWhiteSpace($Json)) { + throw "Microsoft Store CLI $Operation returned no JSON." + } + try { + return $Json | ConvertFrom-Json + } + catch { + throw "Microsoft Store CLI $Operation returned invalid JSON: $($_.Exception.Message)" + } +} + +function ConvertTo-CanonicalValue { + param([AllowNull()][object]$Value) + + if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { + return $Value + } + if ($Value -is [Collections.IDictionary]) { + $dictionary = [ordered]@{} + foreach ($key in @($Value.Keys | Sort-Object)) { + $dictionary[[string]$key] = ConvertTo-CanonicalValue $Value[$key] + } + return $dictionary + } + if ($Value -is [Collections.IEnumerable]) { + $items = @($Value | ForEach-Object { ConvertTo-CanonicalValue $_ }) + return ,$items + } + + $properties = [ordered]@{} + foreach ($property in @($Value.PSObject.Properties | Sort-Object Name)) { + $properties[$property.Name] = ConvertTo-CanonicalValue $property.Value + } + return $properties +} + +function Get-SubmissionMetadataHash { + param([Parameter(Mandatory)][psobject]$Submission) + + $metadata = [ordered]@{} + foreach ($name in @( + 'ApplicationCategory' + 'Pricing' + 'Visibility' + 'TargetPublishMode' + 'TargetPublishDate' + 'Listings' + 'HardwarePreferences' + 'AutomaticBackupEnabled' + 'CanInstallOnRemovableMedia' + 'IsGameDvrEnabled' + 'GamingOptions' + 'HasExternalInAppProducts' + 'MeetAccessibilityGuidelines' + 'NotesForCertification' + 'EnterpriseLicensing' + 'AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies' + 'AllowTargetFutureDeviceFamilies' + 'FriendlyName' + 'Trailers' + )) { + $property = $Submission.PSObject.Properties[$name] + if ($null -eq $property) { + throw "Store submission JSON is missing preserved field '$name'." + } + $metadata[$name] = ConvertTo-CanonicalValue $property.Value + } + + $json = $metadata | ConvertTo-Json -Depth 100 -Compress + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $hash = [Security.Cryptography.SHA256]::HashData($bytes) + return [Convert]::ToHexString($hash).ToLowerInvariant() +} + +Assert-NonEmptyValue -Name 'ApplicationId' -Value $ApplicationId +Assert-NonEmptyValue -Name 'SellerId' -Value $SellerId +Assert-NonEmptyValue -Name 'MSStoreCommand' -Value $MSStoreCommand + +if (-not (Test-Path -LiteralPath $BundlePath -PathType Leaf)) { + throw "MSIX bundle does not exist: $BundlePath" +} +$resolvedBundle = (Resolve-Path -LiteralPath $BundlePath).Path +if ([IO.Path]::GetExtension($resolvedBundle) -cne '.msixbundle') { + throw "Store submission requires one .msixbundle: $resolvedBundle" +} +if ((Get-Item -LiteralPath $resolvedBundle).Length -eq 0) { + throw "MSIX bundle is empty: $resolvedBundle" +} + +if (-not (Test-Path -LiteralPath $ClientAssertionFile -PathType Leaf)) { + throw "OIDC client assertion file does not exist: $ClientAssertionFile" +} +$resolvedAssertion = (Resolve-Path -LiteralPath $ClientAssertionFile).Path +if ([string]::IsNullOrWhiteSpace([IO.File]::ReadAllText($resolvedAssertion))) { + throw 'OIDC client assertion file is empty.' +} + +if (-not (Test-Path -LiteralPath $PolicyPath -PathType Leaf)) { + throw "Store submission policy does not exist: $PolicyPath" +} +try { + $policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json +} +catch { + throw "Unable to parse Store submission policy: $($_.Exception.Message)" +} + +$requiredProperties = @( + 'schemaVersion' + 'environment' + 'oidcAudience' + 'msstoreCliVersion' + 'commitSubmission' + 'pendingSubmissionPolicy' + 'packageRolloutPercentage' + 'uploadTimeoutSeconds' +) +foreach ($property in $requiredProperties) { + if ($policy.PSObject.Properties.Name -notcontains $property) { + throw "Store submission policy is missing '$property'." + } +} +if ([int]$policy.schemaVersion -ne 1) { + throw "Unsupported Store submission policy schema: $($policy.schemaVersion)" +} +if ([string]$policy.environment -cne 'microsoft-store') { + throw 'Store submission policy must use the microsoft-store environment.' +} +if ([string]$policy.oidcAudience -cne 'api://AzureADTokenExchange') { + throw 'Store submission policy must use the Azure token-exchange audience.' +} +if ([string]$policy.msstoreCliVersion -notmatch '^v\d+\.\d+\.\d+$') { + throw 'Store submission policy must pin an exact MSStore CLI version.' +} +if ([bool]$policy.commitSubmission -ne $true) { + throw 'Store submission policy must commit the Partner Center update.' +} +if ([string]$policy.pendingSubmissionPolicy -cne 'reject') { + throw 'Store submission policy must reject pending drafts.' +} +$rollout = [float]$policy.packageRolloutPercentage +if ($rollout -lt 0 -or $rollout -gt 100) { + throw 'Store package rollout percentage must be between 0 and 100.' +} +$uploadTimeout = [long]$policy.uploadTimeoutSeconds +if ($uploadTimeout -lt 100 -or $uploadTimeout -gt 100000) { + throw 'Store upload timeout must be between 100 and 100000 seconds.' +} + +$previousAssertion = [Environment]::GetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION') +$previousAssertionFile = [Environment]::GetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE') +try { + [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION', $null) + [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE', $resolvedAssertion) + + Invoke-MSStore -Operation 'configuration' -Arguments @( + 'reconfigure' + '--tenantId' + $TenantId.ToString() + '--sellerId' + $SellerId + '--clientId' + $ClientId.ToString() + '--clientAssertion' + ) + + $application = ConvertFrom-MSStoreJson ` + -Operation 'application preflight' ` + -Json (Invoke-MSStore ` + -Operation 'application preflight' ` + -CaptureOutput ` + -Arguments @('apps', 'get', $ApplicationId)) + if ([string]$application.Id -cne $ApplicationId) { + throw 'Microsoft Store application preflight returned the wrong product.' + } + if ($null -ne $application.PendingApplicationSubmission) { + throw ( + 'Partner Center already has a pending submission. ' + + 'Finish or delete that draft before automated publication.' + ) + } + if ([string]::IsNullOrWhiteSpace([string]$application.LastPublishedApplicationSubmission.Id)) { + throw 'The Partner Center product must have a published submission.' + } + + $publishedSubmission = ConvertFrom-MSStoreJson ` + -Operation 'published submission snapshot' ` + -Json (Invoke-MSStore ` + -Operation 'published submission snapshot' ` + -CaptureOutput ` + -Arguments @('submission', 'get', $ApplicationId)) + $publishedMetadataHash = Get-SubmissionMetadataHash $publishedSubmission + + Invoke-MSStore -Operation 'publication' -Arguments @( + 'publish' + $resolvedBundle + '--appId' + $ApplicationId + '--packageRolloutPercentage' + $rollout.ToString([Globalization.CultureInfo]::InvariantCulture) + '--uploadTimeout' + $uploadTimeout.ToString([Globalization.CultureInfo]::InvariantCulture) + '--noCommit' + ) + + $draftSubmission = ConvertFrom-MSStoreJson ` + -Operation 'draft submission verification' ` + -Json (Invoke-MSStore ` + -Operation 'draft submission verification' ` + -CaptureOutput ` + -Arguments @('submission', 'get', $ApplicationId)) + $draftMetadataHash = Get-SubmissionMetadataHash $draftSubmission + if ($draftMetadataHash -cne $publishedMetadataHash) { + throw ( + 'The Store draft did not preserve published product metadata. ' + + 'The draft was left uncommitted for inspection.' + ) + } + + Invoke-MSStore -Operation 'submission commit' -Arguments @( + 'submission' + 'publish' + $ApplicationId + ) + + if (-not [string]::IsNullOrWhiteSpace($EvidencePath)) { + $resolvedEvidence = [IO.Path]::GetFullPath($EvidencePath) + $evidenceDirectory = Split-Path $resolvedEvidence -Parent + if (-not [string]::IsNullOrEmpty($evidenceDirectory)) { + New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null + } + [ordered]@{ + schemaVersion = 1 + applicationId = $ApplicationId + bundleFileName = [IO.Path]::GetFileName($resolvedBundle) + bundleSha256 = (Get-FileHash -LiteralPath $resolvedBundle -Algorithm SHA256).Hash.ToLowerInvariant() + msstoreCliVersion = [string]$policy.msstoreCliVersion + pendingSubmissionPolicy = [string]$policy.pendingSubmissionPolicy + publishedSubmissionId = [string]$publishedSubmission.Id + draftSubmissionId = [string]$draftSubmission.Id + publishedMetadataSha256 = $publishedMetadataHash + draftMetadataSha256 = $draftMetadataHash + packageRolloutPercentage = $rollout + submittedAtUtc = [DateTimeOffset]::UtcNow.ToString('O') + } | + ConvertTo-Json | + Set-Content -LiteralPath $resolvedEvidence -Encoding utf8 + } +} +finally { + [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION', $previousAssertion) + [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE', $previousAssertionFile) +} diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 804c5f23e..6bbeb08cc 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -827,8 +827,44 @@ Assert-Contains -Text $createRelease -Expected '${{ steps.msix_release.outputs.f Assert-Contains -Text $createRelease -Expected '${{ steps.msix_release.outputs.notes }}' -Message "Every tag release must include MSIX submission notes." Assert-Contains -Text $createRelease -Expected 'fail_on_unmatched_files: true' -Message "Missing release files must fail publication." Assert-Contains -Text $createRelease -Expected "make_latest: `${{ needs.metadata.outputs.isPrerelease == 'true' && 'false' || 'true' }}" -Message "Alpha releases must not become Latest." + +$storeSubmissionJob = Get-JobBlock 'submit-microsoft-store' +foreach ($token in @( + 'name: Submit Node bundle to Microsoft Store', + 'needs: [metadata, build-msix-bundle, release]', + "startsWith(github.ref, 'refs/tags/v')", + "needs.metadata.outputs.isPrerelease == 'false'", + "needs.build-msix-bundle.result == 'success'", + "needs.release.result == 'success'", + 'environment: microsoft-store', + 'timeout-minutes: 60', + 'group: openclaw-windows-node-store-submission', + 'cancel-in-progress: false', + 'actions: read', + 'contents: read', + 'id-token: write', + 'name: openclaw-msix-store-unsigned-bundle', + 'path: store', + 'microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4', + 'version: v0.4.3', + '.\scripts\New-GitHubOidcRequestUri.ps1', + '& chmod 600 $assertionPath', + '.\scripts\Submit-MicrosoftStore.ps1', + '-BundlePath .\store\OpenClaw.msixbundle', + 'MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }}', + 'MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }}', + 'MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }}', + 'MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }}', + 'name: openclaw-node-store-submission-evidence', + 'Remove short-lived assertion' + )) { + Assert-Contains -Text $storeSubmissionJob -Expected $token -Message "Store submission job is missing '$token'." +} +Assert-NotContains -Text $storeSubmissionJob -Unexpected 'openclaw-msix-store-unsigned-x64' -Message 'Store submission must not upload a standalone x64 package with the bundle.' +Assert-NotContains -Text $storeSubmissionJob -Unexpected 'openclaw-msix-store-unsigned-arm64' -Message 'Store submission must not upload a standalone ARM64 package with the bundle.' Assert-Contains -Text $workflow -Expected "./scripts/test-msix-ci-artifacts.ps1" -Message "Fast validation must exercise the Dev artifact contracts." Assert-Contains -Text $workflow -Expected "./scripts/test-msix-alpha-release.ps1" -Message "Fast validation must exercise Store release staging." +Assert-Contains -Text $workflow -Expected "./scripts/test-microsoft-store-submission.ps1" -Message 'Fast validation must exercise Microsoft Store publication contracts.' Assert-Contains -Text $workflow -Expected "./scripts/test-msix-versioning.ps1" -Message 'Fast validation must exercise allocation races and boundaries.' Assert-Contains -Text $workflow -Expected "./scripts/test-msix-preview-source-version.ps1" -Message 'Fast validation must exercise latest-stable MSIX preview selection.' diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 new file mode 100644 index 000000000..77bf2be86 --- /dev/null +++ b/scripts/test-microsoft-store-submission.ps1 @@ -0,0 +1,298 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Submit-MicrosoftStore.ps1' +$oidcUriScriptPath = Join-Path $PSScriptRoot 'New-GitHubOidcRequestUri.ps1' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-store-submission-$([guid]::NewGuid().ToString('N'))" +) +$tenantId = [guid]'11111111-1111-1111-1111-111111111111' +$clientId = [guid]'22222222-2222-2222-2222-222222222222' +$sellerId = '12345678' +$applicationId = '9NTESTOPENCLAW' + +function Assert-Fails { + param( + [Parameter(Mandatory)][scriptblock]$Action, + [Parameter(Mandatory)][string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + $global:LASTEXITCODE = 0 + return + } + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +function Invoke-Submission { + param( + [string]$PolicyPath = (Join-Path $testRoot 'policy.json'), + [string]$BundlePath = (Join-Path $testRoot 'OpenClaw.msixbundle'), + [string]$EvidencePath = (Join-Path $testRoot 'evidence.json') + ) + + & $scriptPath ` + -BundlePath $BundlePath ` + -ApplicationId $applicationId ` + -TenantId $tenantId ` + -SellerId $sellerId ` + -ClientId $clientId ` + -ClientAssertionFile (Join-Path $testRoot 'assertion.jwt') ` + -PolicyPath $PolicyPath ` + -EvidencePath $EvidencePath ` + -MSStoreCommand $script:fakeMSStore +} + +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + [IO.File]::WriteAllText( + (Join-Path $testRoot 'OpenClaw.msixbundle'), + 'bundle-fixture') + [IO.File]::WriteAllText( + (Join-Path $testRoot 'assertion.jwt'), + 'header.payload.signature') + [ordered]@{ + schemaVersion = 1 + environment = 'microsoft-store' + oidcAudience = 'api://AzureADTokenExchange' + msstoreCliVersion = 'v0.4.3' + commitSubmission = $true + pendingSubmissionPolicy = 'reject' + packageRolloutPercentage = 100 + uploadTimeoutSeconds = 1800 + } | + ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $testRoot 'policy.json') -Encoding utf8 + + $script:fakeMSStore = Join-Path $testRoot 'fake-msstore.ps1' + $fakeCommand = @' +param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + +($Arguments -join ' ') | Add-Content -LiteralPath $env:FAKE_MSSTORE_LOG +if ($Arguments[0] -eq $env:FAKE_MSSTORE_FAIL_COMMAND) { + exit 23 +} +if (($Arguments[0..1] -join ' ') -eq 'apps get') { + Write-Output $env:FAKE_MSSTORE_APPLICATION_JSON +} +if (($Arguments[0..1] -join ' ') -eq 'submission get') { + $count = if (Test-Path -LiteralPath $env:FAKE_MSSTORE_GET_COUNT) { + [int](Get-Content -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -Raw) + } + else { + 0 + } + ($count + 1) | Set-Content -LiteralPath $env:FAKE_MSSTORE_GET_COUNT + if ($count -eq 0) { + Write-Output $env:FAKE_MSSTORE_PUBLISHED_JSON + } + else { + Write-Output $env:FAKE_MSSTORE_DRAFT_JSON + } +} +exit 0 +'@ + [IO.File]::WriteAllText($script:fakeMSStore, $fakeCommand) + + $preservedMetadata = [ordered]@{ + ApplicationCategory = @{ category = 'Productivity' } + Pricing = @{ priceId = 'Free'; trialPeriod = 'NoFreeTrial' } + Visibility = 'Public' + TargetPublishMode = 'Immediate' + TargetPublishDate = $null + Listings = [ordered]@{ + 'en-us' = [ordered]@{ + title = 'OpenClaw' + description = 'Personal AI assistant' + } + } + HardwarePreferences = @('Touch') + AutomaticBackupEnabled = $true + CanInstallOnRemovableMedia = $false + IsGameDvrEnabled = $false + GamingOptions = @() + HasExternalInAppProducts = $false + MeetAccessibilityGuidelines = $true + NotesForCertification = 'OpenClaw Windows Hub.' + EnterpriseLicensing = 'Online' + AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies = $true + AllowTargetFutureDeviceFamilies = @{ Desktop = $true } + FriendlyName = 'OpenClaw' + Trailers = @() + } + $publishedSubmission = [ordered]@{ Id = 'published-1' } + $preservedMetadata + $draftSubmission = [ordered]@{ Id = 'draft-2' } + $preservedMetadata + $draftSubmission['Listings'] = [ordered]@{ + 'en-us' = [ordered]@{ + description = 'Personal AI assistant' + title = 'OpenClaw' + } + } + $env:FAKE_MSSTORE_APPLICATION_JSON = [ordered]@{ + Id = $applicationId + PendingApplicationSubmission = $null + LastPublishedApplicationSubmission = @{ Id = 'published-1' } + } | ConvertTo-Json -Depth 100 -Compress + $env:FAKE_MSSTORE_PUBLISHED_JSON = $publishedSubmission | + ConvertTo-Json -Depth 100 -Compress + $env:FAKE_MSSTORE_DRAFT_JSON = $draftSubmission | + ConvertTo-Json -Depth 100 -Compress + + $logPath = Join-Path $testRoot 'msstore.log' + $env:FAKE_MSSTORE_LOG = $logPath + $env:FAKE_MSSTORE_GET_COUNT = Join-Path $testRoot 'get-count.txt' + $env:FAKE_MSSTORE_FAIL_COMMAND = '' + $env:MSSTORE_CLIENT_ASSERTION = 'previous-assertion' + $env:MSSTORE_CLIENT_ASSERTION_FILE = 'previous-file' + + Invoke-Submission + + $calls = @(Get-Content -LiteralPath $logPath) + if ($calls.Count -ne 6) { + throw "Expected six MSStore CLI calls; received $($calls.Count)." + } + if ($calls[0] -cne ( + "reconfigure --tenantId $tenantId --sellerId $sellerId " + + "--clientId $clientId --clientAssertion")) { + throw "Unexpected reconfigure call: $($calls[0])" + } + $resolvedBundle = (Resolve-Path -LiteralPath ( + Join-Path $testRoot 'OpenClaw.msixbundle')).Path + if ($calls[1] -cne "apps get $applicationId" -or + $calls[2] -cne "submission get $applicationId") { + throw 'Store publication did not snapshot the existing product state.' + } + if ($calls[3] -cne ( + "publish $resolvedBundle --appId $applicationId " + + '--packageRolloutPercentage 100 --uploadTimeout 1800 --noCommit')) { + throw "Unexpected publish call: $($calls[3])" + } + if ($calls[4] -cne "submission get $applicationId" -or + $calls[5] -cne "submission publish $applicationId") { + throw 'Store publication did not verify and commit the draft.' + } + if ($calls -match 'header\.payload|assertion\.jwt') { + throw 'The Store CLI command line exposed the OIDC assertion.' + } + if ($env:MSSTORE_CLIENT_ASSERTION -cne 'previous-assertion' -or + $env:MSSTORE_CLIENT_ASSERTION_FILE -cne 'previous-file') { + throw 'Store submission did not restore the assertion environment.' + } + + $evidence = Get-Content -LiteralPath (Join-Path $testRoot 'evidence.json') -Raw | + ConvertFrom-Json + $expectedHash = (Get-FileHash -LiteralPath $resolvedBundle -Algorithm SHA256).Hash.ToLowerInvariant() + if ([string]$evidence.bundleSha256 -cne $expectedHash -or + [string]$evidence.applicationId -cne $applicationId -or + [string]$evidence.msstoreCliVersion -cne 'v0.4.3' -or + [string]$evidence.publishedMetadataSha256 -cne + [string]$evidence.draftMetadataSha256) { + throw 'Store submission evidence did not bind the submitted bundle.' + } + + Clear-Content -LiteralPath $logPath + Remove-Item -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $testRoot 'evidence.json') -Force + $env:FAKE_MSSTORE_FAIL_COMMAND = 'publish' + Assert-Fails -MessagePattern 'publication failed with exit code 23' -Action { + Invoke-Submission + } + if (Test-Path -LiteralPath (Join-Path $testRoot 'evidence.json')) { + throw 'A failed Store submission must not write success evidence.' + } + + Clear-Content -LiteralPath $logPath + Remove-Item -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue + $env:FAKE_MSSTORE_FAIL_COMMAND = '' + $changedDraft = [ordered]@{ Id = 'draft-3' } + $preservedMetadata + $changedDraft.Listings = @{ + 'en-us' = @{ description = 'Unexpected replacement' } + } + $env:FAKE_MSSTORE_DRAFT_JSON = $changedDraft | + ConvertTo-Json -Depth 100 -Compress + Assert-Fails -MessagePattern 'did not preserve published product metadata' -Action { + Invoke-Submission + } + $calls = @(Get-Content -LiteralPath $logPath) + if ($calls -contains "submission publish $applicationId") { + throw 'Metadata drift must block committing the Store draft.' + } + + $env:FAKE_MSSTORE_APPLICATION_JSON = [ordered]@{ + Id = $applicationId + PendingApplicationSubmission = @{ Id = 'pending-1' } + LastPublishedApplicationSubmission = @{ Id = 'published-1' } + } | ConvertTo-Json -Depth 100 -Compress + Assert-Fails -MessagePattern 'already has a pending submission' -Action { + Invoke-Submission + } + + $invalidPolicyPath = Join-Path $testRoot 'invalid-policy.json' + [ordered]@{ + schemaVersion = 1 + environment = 'microsoft-store' + oidcAudience = 'api://AzureADTokenExchange' + msstoreCliVersion = 'latest' + commitSubmission = $true + pendingSubmissionPolicy = 'reject' + packageRolloutPercentage = 100 + uploadTimeoutSeconds = 1800 + } | + ConvertTo-Json | + Set-Content -LiteralPath $invalidPolicyPath -Encoding utf8 + Assert-Fails -MessagePattern 'pin an exact' -Action { + Invoke-Submission -PolicyPath $invalidPolicyPath + } + + $wrongPackagePath = Join-Path $testRoot 'OpenClaw-x64.msix' + [IO.File]::WriteAllText($wrongPackagePath, 'standalone-fixture') + Assert-Fails -MessagePattern 'requires one .msixbundle' -Action { + Invoke-Submission -BundlePath $wrongPackagePath + } + + $uriWithoutQuery = & $oidcUriScriptPath ` + -RequestUri 'https://token.actions.githubusercontent.com/oidc' ` + -Audience 'api://AzureADTokenExchange' + if ($uriWithoutQuery -cne ( + 'https://token.actions.githubusercontent.com/oidc?' + + 'audience=api%3A%2F%2FAzureADTokenExchange')) { + throw "Unexpected OIDC URI without a query: $uriWithoutQuery" + } + $uriWithQuery = & $oidcUriScriptPath ` + -RequestUri 'https://token.actions.githubusercontent.com/oidc?api-version=1' ` + -Audience 'api://AzureADTokenExchange' + if ($uriWithQuery -cne ( + 'https://token.actions.githubusercontent.com/oidc?api-version=1&' + + 'audience=api%3A%2F%2FAzureADTokenExchange')) { + throw "Unexpected OIDC URI with a query: $uriWithQuery" + } + if ($uriWithQuery.Contains([char]7)) { + throw 'The OIDC URI contains PowerShell alert escape U+0007.' + } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_LOG -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_FAIL_COMMAND -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_APPLICATION_JSON -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_PUBLISHED_JSON -ErrorAction SilentlyContinue + Remove-Item Env:FAKE_MSSTORE_DRAFT_JSON -ErrorAction SilentlyContinue + Remove-Item Env:MSSTORE_CLIENT_ASSERTION -ErrorAction SilentlyContinue + Remove-Item Env:MSSTORE_CLIENT_ASSERTION_FILE -ErrorAction SilentlyContinue +} + +$global:LASTEXITCODE = 0 +Write-Host 'Microsoft Store submission tests passed.' diff --git a/store-submission.json b/store-submission.json new file mode 100644 index 000000000..e7214717b --- /dev/null +++ b/store-submission.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "environment": "microsoft-store", + "oidcAudience": "api://AzureADTokenExchange", + "msstoreCliVersion": "v0.4.3", + "commitSubmission": true, + "pendingSubmissionPolicy": "reject", + "packageRolloutPercentage": 100, + "uploadTimeoutSeconds": 1800 +} From e70e101b3f3be89c6a33347e8cc7eeee0e272694 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 18:51:15 -0700 Subject: [PATCH 2/7] fix(release): bind Store mutations to owned draft --- .github/workflows/ci.yml | 10 - docs/RELEASING.md | 23 +- scripts/Submit-MicrosoftStore.ps1 | 392 ++++++++++---------- scripts/test-ci-workflow-contract.ps1 | 5 +- scripts/test-microsoft-store-submission.ps1 | 372 ++++++++----------- store-submission.json | 7 +- 6 files changed, 376 insertions(+), 433 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b28d2514..2d0ba0643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1457,13 +1457,11 @@ jobs: shell: pwsh env: MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }} - MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }} MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} run: | foreach ($name in @( 'MSSTORE_TENANT_ID' - 'MSSTORE_SELLER_ID' 'MSSTORE_CLIENT_ID' 'MSSTORE_APPLICATION_ID' )) { @@ -1478,12 +1476,6 @@ jobs: throw "$name must be a GUID." } } - - - name: Set up Microsoft Store Developer CLI - uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 - with: - version: v0.4.3 - - name: Request short-lived Microsoft Store assertion id: oidc shell: pwsh @@ -1519,7 +1511,6 @@ jobs: shell: pwsh env: MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }} - MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }} MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} MSSTORE_ASSERTION_FILE: ${{ steps.oidc.outputs.assertion_path }} @@ -1528,7 +1519,6 @@ jobs: -BundlePath .\store\OpenClaw.msixbundle ` -ApplicationId $env:MSSTORE_APPLICATION_ID ` -TenantId $env:MSSTORE_TENANT_ID ` - -SellerId $env:MSSTORE_SELLER_ID ` -ClientId $env:MSSTORE_CLIENT_ID ` -ClientAssertionFile $env:MSSTORE_ASSERTION_FILE ` -PolicyPath .\store-submission.json ` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 47deb46e8..7fc8f5de1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -204,17 +204,16 @@ Microsoft-managed asynchronous stages. ## Microsoft Store publication setup -The `submit-microsoft-store` job uses the official Microsoft Store Developer -CLI with a short-lived GitHub OIDC assertion. It stores no client secret. The -job runs in the `microsoft-store` GitHub environment after `release`, and only -for non-prerelease `v*` tags. +The `submit-microsoft-store` job uses the official packaged-app submission API +with a short-lived GitHub OIDC assertion exchanged once for a Dev Center access +token. It stores no client secret. The job runs in the `microsoft-store` GitHub +environment after `release`, and only for non-prerelease `v*` tags. Configure that environment before the next stable release: 1. Limit deployment tags to `v*`; do not permit branch deployments. 2. Set these environment variables (they are identifiers, not credentials): - `MSSTORE_TENANT_ID` - - `MSSTORE_SELLER_ID` - `MSSTORE_CLIENT_ID` - `MSSTORE_APPLICATION_ID` 3. Add an Entra federated credential for: @@ -223,13 +222,17 @@ Configure that environment before the next stable release: 4. Associate the Entra application with the Partner Center account and grant it access to the existing OpenClaw product. 5. Ensure that product has a published submission and no pending draft. -6. Keep the Store product free; the current Store Developer CLI supports - automated app updates for free products. +6. Record Store channel acceptance in the release PR before enabling the first + production submission. The git-controlled policy is [`store-submission.json`](../store-submission.json). -It pins the CLI version, OIDC audience, rollout percentage, timeout, environment, -and pending-draft behavior. `scripts\Submit-MicrosoftStore.ps1` validates those -values, the product, and the published metadata before committing an update. +It pins the API origin and scope, OIDC audience, rollout percentage, timeout, +minimum access-token lifetime, environment, and draft ownership behavior. +`scripts\Submit-MicrosoftStore.ps1` rejects existing drafts, creates a new draft +without deleting anything, updates and commits that exact submission ID, and +deletes only its own draft if a pre-commit check fails. It refuses to commit if +another Partner Center writer replaces the draft or if published metadata +changes. The workflow uploads a 90-day evidence artifact containing the submitted bundle hash and Store submission identifiers. It never includes the OIDC assertion. diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index fb8fee07d..af8253405 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -3,71 +3,32 @@ param( [Parameter(Mandatory)][string]$BundlePath, [Parameter(Mandatory)][string]$ApplicationId, [Parameter(Mandatory)][guid]$TenantId, - [Parameter(Mandatory)][string]$SellerId, [Parameter(Mandatory)][guid]$ClientId, [Parameter(Mandatory)][string]$ClientAssertionFile, [string]$PolicyPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'store-submission.json'), [string]$EvidencePath, - [string]$MSStoreCommand = 'msstore' + [scriptblock]$HttpInvoker, + [scriptblock]$UploadInvoker ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-NonEmptyValue { - param( - [Parameter(Mandatory)][string]$Name, - [AllowEmptyString()][string]$Value - ) - - if ([string]::IsNullOrWhiteSpace($Value)) { - throw "$Name must not be empty." - } + param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { throw "$Name must not be empty." } } -function Invoke-MSStore { - param( - [Parameter(Mandatory)][string[]]$Arguments, - [Parameter(Mandatory)][string]$Operation, - [switch]$CaptureOutput - ) - - $output = @(& $MSStoreCommand @Arguments) - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { - throw "Microsoft Store CLI $Operation failed with exit code $exitCode." - } - if ($CaptureOutput) { - return ($output -join "`n") - } - foreach ($line in $output) { - Write-Output $line - } -} - -function ConvertFrom-MSStoreJson { - param( - [Parameter(Mandatory)][string]$Json, - [Parameter(Mandatory)][string]$Operation - ) - - if ([string]::IsNullOrWhiteSpace($Json)) { - throw "Microsoft Store CLI $Operation returned no JSON." - } - try { - return $Json | ConvertFrom-Json - } - catch { - throw "Microsoft Store CLI $Operation returned invalid JSON: $($_.Exception.Message)" - } +function Get-RequiredProperty { + param([Parameter(Mandatory)][object]$Object, [Parameter(Mandatory)][string]$Name) + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { throw "Store response is missing '$Name'." } + return $property.Value } function ConvertTo-CanonicalValue { param([AllowNull()][object]$Value) - - if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { - return $Value - } + if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { return $Value } if ($Value -is [Collections.IDictionary]) { $dictionary = [ordered]@{} foreach ($key in @($Value.Keys | Sort-Object)) { @@ -79,7 +40,6 @@ function ConvertTo-CanonicalValue { $items = @($Value | ForEach-Object { ConvertTo-CanonicalValue $_ }) return ,$items } - $properties = [ordered]@{} foreach ($property in @($Value.PSObject.Properties | Sort-Object Name)) { $properties[$property.Name] = ConvertTo-CanonicalValue $property.Value @@ -89,46 +49,24 @@ function ConvertTo-CanonicalValue { function Get-SubmissionMetadataHash { param([Parameter(Mandatory)][psobject]$Submission) - $metadata = [ordered]@{} foreach ($name in @( - 'ApplicationCategory' - 'Pricing' - 'Visibility' - 'TargetPublishMode' - 'TargetPublishDate' - 'Listings' - 'HardwarePreferences' - 'AutomaticBackupEnabled' - 'CanInstallOnRemovableMedia' - 'IsGameDvrEnabled' - 'GamingOptions' - 'HasExternalInAppProducts' - 'MeetAccessibilityGuidelines' - 'NotesForCertification' - 'EnterpriseLicensing' - 'AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies' - 'AllowTargetFutureDeviceFamilies' - 'FriendlyName' - 'Trailers' + 'ApplicationCategory', 'Pricing', 'Visibility', 'TargetPublishMode', + 'TargetPublishDate', 'Listings', 'HardwarePreferences', + 'AutomaticBackupEnabled', 'CanInstallOnRemovableMedia', 'IsGameDvrEnabled', + 'GamingOptions', 'HasExternalInAppProducts', 'MeetAccessibilityGuidelines', + 'NotesForCertification', 'EnterpriseLicensing', + 'AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies', + 'AllowTargetFutureDeviceFamilies', 'FriendlyName', 'Trailers' )) { - $property = $Submission.PSObject.Properties[$name] - if ($null -eq $property) { - throw "Store submission JSON is missing preserved field '$name'." - } - $metadata[$name] = ConvertTo-CanonicalValue $property.Value + $metadata[$name] = ConvertTo-CanonicalValue (Get-RequiredProperty $Submission $name) } - $json = $metadata | ConvertTo-Json -Depth 100 -Compress - $bytes = [Text.Encoding]::UTF8.GetBytes($json) - $hash = [Security.Cryptography.SHA256]::HashData($bytes) + $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json)) return [Convert]::ToHexString($hash).ToLowerInvariant() } Assert-NonEmptyValue -Name 'ApplicationId' -Value $ApplicationId -Assert-NonEmptyValue -Name 'SellerId' -Value $SellerId -Assert-NonEmptyValue -Name 'MSStoreCommand' -Value $MSStoreCommand - if (-not (Test-Path -LiteralPath $BundlePath -PathType Leaf)) { throw "MSIX bundle does not exist: $BundlePath" } @@ -136,171 +74,225 @@ $resolvedBundle = (Resolve-Path -LiteralPath $BundlePath).Path if ([IO.Path]::GetExtension($resolvedBundle) -cne '.msixbundle') { throw "Store submission requires one .msixbundle: $resolvedBundle" } -if ((Get-Item -LiteralPath $resolvedBundle).Length -eq 0) { - throw "MSIX bundle is empty: $resolvedBundle" -} - +if ((Get-Item -LiteralPath $resolvedBundle).Length -eq 0) { throw "MSIX bundle is empty: $resolvedBundle" } if (-not (Test-Path -LiteralPath $ClientAssertionFile -PathType Leaf)) { throw "OIDC client assertion file does not exist: $ClientAssertionFile" } -$resolvedAssertion = (Resolve-Path -LiteralPath $ClientAssertionFile).Path -if ([string]::IsNullOrWhiteSpace([IO.File]::ReadAllText($resolvedAssertion))) { - throw 'OIDC client assertion file is empty.' -} - +$assertion = [IO.File]::ReadAllText((Resolve-Path -LiteralPath $ClientAssertionFile).Path).Trim() +if ([string]::IsNullOrWhiteSpace($assertion)) { throw 'OIDC client assertion file is empty.' } if (-not (Test-Path -LiteralPath $PolicyPath -PathType Leaf)) { throw "Store submission policy does not exist: $PolicyPath" } -try { - $policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json -} -catch { - throw "Unable to parse Store submission policy: $($_.Exception.Message)" -} +try { $policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json } +catch { throw "Unable to parse Store submission policy: $($_.Exception.Message)" } $requiredProperties = @( - 'schemaVersion' - 'environment' - 'oidcAudience' - 'msstoreCliVersion' - 'commitSubmission' - 'pendingSubmissionPolicy' - 'packageRolloutPercentage' - 'uploadTimeoutSeconds' + 'schemaVersion', 'environment', 'oidcAudience', 'apiBaseUri', 'oauthScope', + 'commitSubmission', 'pendingSubmissionPolicy', 'failedDraftPolicy', + 'packageRolloutPercentage', 'uploadTimeoutSeconds', 'minimumAccessTokenLifetimeSeconds' ) foreach ($property in $requiredProperties) { if ($policy.PSObject.Properties.Name -notcontains $property) { throw "Store submission policy is missing '$property'." } } -if ([int]$policy.schemaVersion -ne 1) { - throw "Unsupported Store submission policy schema: $($policy.schemaVersion)" +if ([int]$policy.schemaVersion -ne 1 -or [string]$policy.environment -cne 'microsoft-store' -or + [string]$policy.oidcAudience -cne 'api://AzureADTokenExchange') { + throw 'Store submission policy has an unsupported identity boundary.' } -if ([string]$policy.environment -cne 'microsoft-store') { - throw 'Store submission policy must use the microsoft-store environment.' +if ([string]$policy.apiBaseUri -cne 'https://manage.devcenter.microsoft.com' -or + [string]$policy.oauthScope -cne 'https://manage.devcenter.microsoft.com/.default') { + throw 'Store submission policy has an unsupported API boundary.' } -if ([string]$policy.oidcAudience -cne 'api://AzureADTokenExchange') { - throw 'Store submission policy must use the Azure token-exchange audience.' -} -if ([string]$policy.msstoreCliVersion -notmatch '^v\d+\.\d+\.\d+$') { - throw 'Store submission policy must pin an exact MSStore CLI version.' -} -if ([bool]$policy.commitSubmission -ne $true) { - throw 'Store submission policy must commit the Partner Center update.' -} -if ([string]$policy.pendingSubmissionPolicy -cne 'reject') { - throw 'Store submission policy must reject pending drafts.' +if ([bool]$policy.commitSubmission -ne $true -or + [string]$policy.pendingSubmissionPolicy -cne 'reject' -or + [string]$policy.failedDraftPolicy -cne 'delete-owned') { + throw 'Store submission policy must commit safely and reject unowned drafts.' } $rollout = [float]$policy.packageRolloutPercentage -if ($rollout -lt 0 -or $rollout -gt 100) { - throw 'Store package rollout percentage must be between 0 and 100.' -} $uploadTimeout = [long]$policy.uploadTimeoutSeconds -if ($uploadTimeout -lt 100 -or $uploadTimeout -gt 100000) { - throw 'Store upload timeout must be between 100 and 100000 seconds.' +$minimumLifetime = [long]$policy.minimumAccessTokenLifetimeSeconds +if ($rollout -lt 0 -or $rollout -gt 100) { throw 'Store package rollout percentage must be between 0 and 100.' } +if ($uploadTimeout -lt 100 -or $uploadTimeout -gt 100000 -or + $minimumLifetime -lt ($uploadTimeout + 300)) { + throw 'Store access-token lifetime must cover upload timeout plus five minutes.' } -$previousAssertion = [Environment]::GetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION') -$previousAssertionFile = [Environment]::GetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE') -try { - [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION', $null) - [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE', $resolvedAssertion) +if ($null -eq $HttpInvoker) { + $HttpInvoker = { + param($Method, $Uri, $Headers, $Body, $ContentType) + $parameters = @{ Method = $Method; Uri = $Uri; ErrorAction = 'Stop' } + if ($null -ne $Headers) { $parameters.Headers = $Headers } + if ($null -ne $Body) { $parameters.Body = $Body } + if (-not [string]::IsNullOrWhiteSpace($ContentType)) { $parameters.ContentType = $ContentType } + Invoke-RestMethod @parameters + } +} +if ($null -eq $UploadInvoker) { + $UploadInvoker = { + param($Uri, $Path, $TimeoutSeconds) + Invoke-WebRequest -Method Put -Uri $Uri -InFile $Path -TimeoutSec $TimeoutSeconds ` + -ContentType 'application/zip' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } | Out-Null + } +} - Invoke-MSStore -Operation 'configuration' -Arguments @( - 'reconfigure' - '--tenantId' - $TenantId.ToString() - '--sellerId' - $SellerId - '--clientId' - $ClientId.ToString() - '--clientAssertion' +$tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" +$tokenResponse = & $HttpInvoker 'Post' $tokenEndpoint $null ([ordered]@{ + client_id = $ClientId.ToString() + scope = [string]$policy.oauthScope + client_assertion = $assertion + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + grant_type = 'client_credentials' +}) 'application/x-www-form-urlencoded' +$accessToken = [string](Get-RequiredProperty $tokenResponse 'access_token') +$expiresIn = [long](Get-RequiredProperty $tokenResponse 'expires_in') +if ([string]::IsNullOrWhiteSpace($accessToken) -or $expiresIn -lt $minimumLifetime) { + throw 'Microsoft Store access token is missing or too short-lived for the upload.' +} +if ($env:GITHUB_ACTIONS -eq 'true') { Write-Output "::add-mask::$accessToken" } + +$apiBase = [string]$policy.apiBaseUri +$encodedApplicationId = [Uri]::EscapeDataString($ApplicationId) +$apiHeaders = @{ Authorization = "Bearer $accessToken"; TenantId = $TenantId.ToString() } +function Invoke-StoreApi { + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Path, + [AllowNull()][object]$Body = $null ) + $json = if ($null -eq $Body) { $null } else { $Body | ConvertTo-Json -Depth 100 -Compress } + return & $HttpInvoker $Method ($apiBase + $Path) $apiHeaders $json 'application/json' +} +function Assert-OwnedDraft { + param([Parameter(Mandatory)][string]$SubmissionId) + $application = Invoke-StoreApi -Method Get -Path "/v1.0/my/applications/$encodedApplicationId" + $pending = Get-RequiredProperty $application 'PendingApplicationSubmission' + if ($null -eq $pending -or [string](Get-RequiredProperty $pending 'Id') -cne $SubmissionId) { + throw 'The Store pending submission is no longer the automation-owned draft.' + } +} - $application = ConvertFrom-MSStoreJson ` - -Operation 'application preflight' ` - -Json (Invoke-MSStore ` - -Operation 'application preflight' ` - -CaptureOutput ` - -Arguments @('apps', 'get', $ApplicationId)) - if ([string]$application.Id -cne $ApplicationId) { +$draftId = $null +$committed = $false +$temporaryDirectory = $null +try { + $application = Invoke-StoreApi -Method Get -Path "/v1.0/my/applications/$encodedApplicationId" + if ([string](Get-RequiredProperty $application 'Id') -cne $ApplicationId) { throw 'Microsoft Store application preflight returned the wrong product.' } - if ($null -ne $application.PendingApplicationSubmission) { - throw ( - 'Partner Center already has a pending submission. ' + - 'Finish or delete that draft before automated publication.' - ) + if ($null -ne (Get-RequiredProperty $application 'PendingApplicationSubmission')) { + throw 'Partner Center already has a pending submission; automation will not replace it.' } - if ([string]::IsNullOrWhiteSpace([string]$application.LastPublishedApplicationSubmission.Id)) { - throw 'The Partner Center product must have a published submission.' + $publishedInfo = Get-RequiredProperty $application 'LastPublishedApplicationSubmission' + if ($null -eq $publishedInfo) { throw 'The Partner Center product must have a published submission.' } + $publishedId = [string](Get-RequiredProperty $publishedInfo 'Id') + Assert-NonEmptyValue -Name 'Published submission ID' -Value $publishedId + $encodedPublishedId = [Uri]::EscapeDataString($publishedId) + $published = Invoke-StoreApi -Method Get ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedPublishedId" + $publishedMetadataHash = Get-SubmissionMetadataHash $published + + $draft = Invoke-StoreApi -Method Post ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions?isMinimalResponse=true" + $draftId = [string](Get-RequiredProperty $draft 'Id') + Assert-NonEmptyValue -Name 'Draft submission ID' -Value $draftId + $encodedDraftId = [Uri]::EscapeDataString($draftId) + Assert-OwnedDraft -SubmissionId $draftId + $draft = Invoke-StoreApi -Method Get ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId" + if ([string](Get-RequiredProperty $draft 'Id') -cne $draftId) { + throw 'Store returned a different draft than the automation created.' } + $uploadUri = [string](Get-RequiredProperty $draft 'FileUploadUrl') + Assert-NonEmptyValue -Name 'Draft upload URL' -Value $uploadUri - $publishedSubmission = ConvertFrom-MSStoreJson ` - -Operation 'published submission snapshot' ` - -Json (Invoke-MSStore ` - -Operation 'published submission snapshot' ` - -CaptureOutput ` - -Arguments @('submission', 'get', $ApplicationId)) - $publishedMetadataHash = Get-SubmissionMetadataHash $publishedSubmission + $packages = @(Get-RequiredProperty $draft 'ApplicationPackages') + foreach ($package in $packages) { $package.FileStatus = 'PendingDelete' } + $packages += [pscustomobject]@{ + FileName = [IO.Path]::GetFileName($resolvedBundle) + FileStatus = 'PendingUpload' + } + $draft.ApplicationPackages = $packages + $deliveryOptions = Get-RequiredProperty $draft 'PackageDeliveryOptions' + if ($null -ne $deliveryOptions) { + $packageRollout = Get-RequiredProperty $deliveryOptions 'PackageRollout' + if ($null -ne $packageRollout) { + $packageRollout.IsPackageRollout = $true + $packageRollout.PackageRolloutPercentage = $rollout + } + } - Invoke-MSStore -Operation 'publication' -Arguments @( - 'publish' - $resolvedBundle - '--appId' - $ApplicationId - '--packageRolloutPercentage' - $rollout.ToString([Globalization.CultureInfo]::InvariantCulture) - '--uploadTimeout' - $uploadTimeout.ToString([Globalization.CultureInfo]::InvariantCulture) - '--noCommit' - ) + $draft = Invoke-StoreApi -Method Put ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId" ` + -Body $draft + if ([string](Get-RequiredProperty $draft 'Id') -cne $draftId) { + throw 'Store updated a different draft than the automation owns.' + } + Assert-OwnedDraft -SubmissionId $draftId + + $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-store-upload-$([guid]::NewGuid().ToString('N'))") + $uploadDirectory = Join-Path $temporaryDirectory 'payload' + $uploadArchive = Join-Path $temporaryDirectory 'Upload.zip' + New-Item -ItemType Directory -Path $uploadDirectory -Force | Out-Null + Copy-Item -LiteralPath $resolvedBundle -Destination $uploadDirectory + [IO.Compression.ZipFile]::CreateFromDirectory($uploadDirectory, $uploadArchive) + & $UploadInvoker $uploadUri $uploadArchive $uploadTimeout - $draftSubmission = ConvertFrom-MSStoreJson ` - -Operation 'draft submission verification' ` - -Json (Invoke-MSStore ` - -Operation 'draft submission verification' ` - -CaptureOutput ` - -Arguments @('submission', 'get', $ApplicationId)) - $draftMetadataHash = Get-SubmissionMetadataHash $draftSubmission + Assert-OwnedDraft -SubmissionId $draftId + $verifiedDraft = Invoke-StoreApi -Method Get ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId" + if ([string](Get-RequiredProperty $verifiedDraft 'Id') -cne $draftId) { + throw 'Store verification returned an unowned draft.' + } + $draftMetadataHash = Get-SubmissionMetadataHash $verifiedDraft if ($draftMetadataHash -cne $publishedMetadataHash) { - throw ( - 'The Store draft did not preserve published product metadata. ' + - 'The draft was left uncommitted for inspection.' - ) + throw 'The Store draft did not preserve published product metadata.' } - - Invoke-MSStore -Operation 'submission commit' -Arguments @( - 'submission' - 'publish' - $ApplicationId - ) + Assert-OwnedDraft -SubmissionId $draftId + $commit = Invoke-StoreApi -Method Post ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId/Commit" + $commitStatus = [string](Get-RequiredProperty $commit 'Status') + Assert-NonEmptyValue -Name 'Store commit status' -Value $commitStatus + $committed = $true if (-not [string]::IsNullOrWhiteSpace($EvidencePath)) { $resolvedEvidence = [IO.Path]::GetFullPath($EvidencePath) - $evidenceDirectory = Split-Path $resolvedEvidence -Parent - if (-not [string]::IsNullOrEmpty($evidenceDirectory)) { - New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null - } + New-Item -Path (Split-Path $resolvedEvidence -Parent) -ItemType Directory -Force | Out-Null [ordered]@{ schemaVersion = 1 applicationId = $ApplicationId bundleFileName = [IO.Path]::GetFileName($resolvedBundle) bundleSha256 = (Get-FileHash -LiteralPath $resolvedBundle -Algorithm SHA256).Hash.ToLowerInvariant() - msstoreCliVersion = [string]$policy.msstoreCliVersion - pendingSubmissionPolicy = [string]$policy.pendingSubmissionPolicy - publishedSubmissionId = [string]$publishedSubmission.Id - draftSubmissionId = [string]$draftSubmission.Id + publishedSubmissionId = $publishedId + draftSubmissionId = $draftId publishedMetadataSha256 = $publishedMetadataHash draftMetadataSha256 = $draftMetadataHash + commitStatus = $commitStatus packageRolloutPercentage = $rollout submittedAtUtc = [DateTimeOffset]::UtcNow.ToString('O') - } | - ConvertTo-Json | - Set-Content -LiteralPath $resolvedEvidence -Encoding utf8 + } | ConvertTo-Json | Set-Content -LiteralPath $resolvedEvidence -Encoding utf8 + } +} +catch { + $failure = $_ + if (-not $committed -and -not [string]::IsNullOrWhiteSpace($draftId)) { + try { + $encodedDraftId = [Uri]::EscapeDataString($draftId) + Invoke-StoreApi -Method Delete ` + -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId" | + Out-Null + } + catch { + Write-Warning "Could not delete automation-owned draft '$draftId': $($_.Exception.Message)" + } } + throw $failure } finally { - [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION', $previousAssertion) - [Environment]::SetEnvironmentVariable('MSSTORE_CLIENT_ASSERTION_FILE', $previousAssertionFile) + if ($null -ne $temporaryDirectory) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + $assertion = $null + $accessToken = $null } diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 6bbeb08cc..d0fbb004a 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -845,14 +845,11 @@ foreach ($token in @( 'id-token: write', 'name: openclaw-msix-store-unsigned-bundle', 'path: store', - 'microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4', - 'version: v0.4.3', '.\scripts\New-GitHubOidcRequestUri.ps1', '& chmod 600 $assertionPath', '.\scripts\Submit-MicrosoftStore.ps1', '-BundlePath .\store\OpenClaw.msixbundle', 'MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }}', - 'MSSTORE_SELLER_ID: ${{ vars.MSSTORE_SELLER_ID }}', 'MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }}', 'MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }}', 'name: openclaw-node-store-submission-evidence', @@ -862,6 +859,8 @@ foreach ($token in @( } Assert-NotContains -Text $storeSubmissionJob -Unexpected 'openclaw-msix-store-unsigned-x64' -Message 'Store submission must not upload a standalone x64 package with the bundle.' Assert-NotContains -Text $storeSubmissionJob -Unexpected 'openclaw-msix-store-unsigned-arm64' -Message 'Store submission must not upload a standalone ARM64 package with the bundle.' +Assert-NotContains -Text $storeSubmissionJob -Unexpected 'microsoft-store-apppublisher' -Message 'Store submission must use submission-ID-bound API operations, not mutable high-level publication.' +Assert-NotContains -Text $storeSubmissionJob -Unexpected 'MSSTORE_SELLER_ID' -Message 'Packaged Store API publication does not need a seller ID.' Assert-Contains -Text $workflow -Expected "./scripts/test-msix-ci-artifacts.ps1" -Message "Fast validation must exercise the Dev artifact contracts." Assert-Contains -Text $workflow -Expected "./scripts/test-msix-alpha-release.ps1" -Message "Fast validation must exercise Store release staging." Assert-Contains -Text $workflow -Expected "./scripts/test-microsoft-store-submission.ps1" -Message 'Fast validation must exercise Microsoft Store publication contracts.' diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index 77bf2be86..22fbcd606 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -3,32 +3,20 @@ param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - $scriptPath = Join-Path $PSScriptRoot 'Submit-MicrosoftStore.ps1' $oidcUriScriptPath = Join-Path $PSScriptRoot 'New-GitHubOidcRequestUri.ps1' -$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( - "openclaw-store-submission-$([guid]::NewGuid().ToString('N'))" -) +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-store-submission-$([guid]::NewGuid().ToString('N'))" $tenantId = [guid]'11111111-1111-1111-1111-111111111111' $clientId = [guid]'22222222-2222-2222-2222-222222222222' -$sellerId = '12345678' $applicationId = '9NTESTOPENCLAW' +$apiBase = 'https://manage.devcenter.microsoft.com' function Assert-Fails { - param( - [Parameter(Mandatory)][scriptblock]$Action, - [Parameter(Mandatory)][string]$MessagePattern - ) - - try { - & $Action - } + param([Parameter(Mandatory)][scriptblock]$Action, [Parameter(Mandatory)][string]$MessagePattern) + try { & $Action } catch { if ($_.Exception.Message -notmatch $MessagePattern) { - throw ( - "Expected failure matching '$MessagePattern'; received: " + - $_.Exception.Message - ) + throw "Expected failure matching '$MessagePattern'; received: $($_.Exception.Message)" } $global:LASTEXITCODE = 0 return @@ -36,84 +24,20 @@ function Assert-Fails { throw "Expected failure matching '$MessagePattern', but the action succeeded." } -function Invoke-Submission { - param( - [string]$PolicyPath = (Join-Path $testRoot 'policy.json'), - [string]$BundlePath = (Join-Path $testRoot 'OpenClaw.msixbundle'), - [string]$EvidencePath = (Join-Path $testRoot 'evidence.json') - ) - - & $scriptPath ` - -BundlePath $BundlePath ` - -ApplicationId $applicationId ` - -TenantId $tenantId ` - -SellerId $sellerId ` - -ClientId $clientId ` - -ClientAssertionFile (Join-Path $testRoot 'assertion.jwt') ` - -PolicyPath $PolicyPath ` - -EvidencePath $EvidencePath ` - -MSStoreCommand $script:fakeMSStore -} - -New-Item -Path $testRoot -ItemType Directory | Out-Null -try { - [IO.File]::WriteAllText( - (Join-Path $testRoot 'OpenClaw.msixbundle'), - 'bundle-fixture') - [IO.File]::WriteAllText( - (Join-Path $testRoot 'assertion.jwt'), - 'header.payload.signature') - [ordered]@{ - schemaVersion = 1 - environment = 'microsoft-store' - oidcAudience = 'api://AzureADTokenExchange' - msstoreCliVersion = 'v0.4.3' - commitSubmission = $true - pendingSubmissionPolicy = 'reject' - packageRolloutPercentage = 100 - uploadTimeoutSeconds = 1800 - } | - ConvertTo-Json | - Set-Content -LiteralPath (Join-Path $testRoot 'policy.json') -Encoding utf8 - - $script:fakeMSStore = Join-Path $testRoot 'fake-msstore.ps1' - $fakeCommand = @' -param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) - -($Arguments -join ' ') | Add-Content -LiteralPath $env:FAKE_MSSTORE_LOG -if ($Arguments[0] -eq $env:FAKE_MSSTORE_FAIL_COMMAND) { - exit 23 +function Copy-Object([object]$Value) { + return $Value | ConvertTo-Json -Depth 100 | ConvertFrom-Json } -if (($Arguments[0..1] -join ' ') -eq 'apps get') { - Write-Output $env:FAKE_MSSTORE_APPLICATION_JSON -} -if (($Arguments[0..1] -join ' ') -eq 'submission get') { - $count = if (Test-Path -LiteralPath $env:FAKE_MSSTORE_GET_COUNT) { - [int](Get-Content -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -Raw) - } - else { - 0 - } - ($count + 1) | Set-Content -LiteralPath $env:FAKE_MSSTORE_GET_COUNT - if ($count -eq 0) { - Write-Output $env:FAKE_MSSTORE_PUBLISHED_JSON - } - else { - Write-Output $env:FAKE_MSSTORE_DRAFT_JSON - } -} -exit 0 -'@ - [IO.File]::WriteAllText($script:fakeMSStore, $fakeCommand) - $preservedMetadata = [ordered]@{ - ApplicationCategory = @{ category = 'Productivity' } - Pricing = @{ priceId = 'Free'; trialPeriod = 'NoFreeTrial' } +function New-Submission([string]$Id) { + [pscustomobject]@{ + Id = $Id + ApplicationCategory = [pscustomobject]@{ category = 'Productivity' } + Pricing = [pscustomobject]@{ priceId = 'Free'; trialPeriod = 'NoFreeTrial' } Visibility = 'Public' TargetPublishMode = 'Immediate' TargetPublishDate = $null - Listings = [ordered]@{ - 'en-us' = [ordered]@{ + Listings = [pscustomobject]@{ + 'en-us' = [pscustomobject]@{ title = 'OpenClaw' description = 'Personal AI assistant' } @@ -128,134 +52,180 @@ exit 0 NotesForCertification = 'OpenClaw Windows Hub.' EnterpriseLicensing = 'Online' AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies = $true - AllowTargetFutureDeviceFamilies = @{ Desktop = $true } + AllowTargetFutureDeviceFamilies = [pscustomobject]@{ Desktop = $true } FriendlyName = 'OpenClaw' Trailers = @() - } - $publishedSubmission = [ordered]@{ Id = 'published-1' } + $preservedMetadata - $draftSubmission = [ordered]@{ Id = 'draft-2' } + $preservedMetadata - $draftSubmission['Listings'] = [ordered]@{ - 'en-us' = [ordered]@{ - description = 'Personal AI assistant' - title = 'OpenClaw' + FileUploadUrl = 'https://storage.example.invalid/upload?sas=redacted' + ApplicationPackages = @( + [pscustomobject]@{ FileName = 'old-x64.msix'; FileStatus = 'Published' }, + [pscustomobject]@{ FileName = 'old-arm64.msix'; FileStatus = 'Published' } + ) + PackageDeliveryOptions = [pscustomobject]@{ + PackageRollout = [pscustomobject]@{ + IsPackageRollout = $false + PackageRolloutPercentage = 0 + } } } - $env:FAKE_MSSTORE_APPLICATION_JSON = [ordered]@{ - Id = $applicationId - PendingApplicationSubmission = $null - LastPublishedApplicationSubmission = @{ Id = 'published-1' } - } | ConvertTo-Json -Depth 100 -Compress - $env:FAKE_MSSTORE_PUBLISHED_JSON = $publishedSubmission | - ConvertTo-Json -Depth 100 -Compress - $env:FAKE_MSSTORE_DRAFT_JSON = $draftSubmission | - ConvertTo-Json -Depth 100 -Compress - - $logPath = Join-Path $testRoot 'msstore.log' - $env:FAKE_MSSTORE_LOG = $logPath - $env:FAKE_MSSTORE_GET_COUNT = Join-Path $testRoot 'get-count.txt' - $env:FAKE_MSSTORE_FAIL_COMMAND = '' - $env:MSSTORE_CLIENT_ASSERTION = 'previous-assertion' - $env:MSSTORE_CLIENT_ASSERTION_FILE = 'previous-file' +} - Invoke-Submission +function Reset-Fixture { + $global:OpenClawStoreTest_calls = [Collections.Generic.List[string]]::new() + $global:OpenClawStoreTest_published = New-Submission 'published-1' + $global:OpenClawStoreTest_draft = $null + $global:OpenClawStoreTest_pendingId = $null + $global:OpenClawStoreTest_tokenLifetime = 3600 + $global:OpenClawStoreTest_replaceAfterUpload = $false + $global:OpenClawStoreTest_driftAfterUpload = $false + $global:OpenClawStoreTest_uploadHash = $null + $global:OpenClawStoreTest_uploadedEntries = @() +} - $calls = @(Get-Content -LiteralPath $logPath) - if ($calls.Count -ne 6) { - throw "Expected six MSStore CLI calls; received $($calls.Count)." - } - if ($calls[0] -cne ( - "reconfigure --tenantId $tenantId --sellerId $sellerId " + - "--clientId $clientId --clientAssertion")) { - throw "Unexpected reconfigure call: $($calls[0])" +$httpInvoker = { + param($Method, $Uri, $Headers, $Body, $ContentType) + $safeUri = $Uri -replace 'sas=[^&]+', 'sas=redacted' + $global:OpenClawStoreTest_calls.Add("$Method $safeUri") + if ($Uri -like 'https://login.microsoftonline.com/*') { + if ($Body.client_assertion -cne 'header.payload.signature' -or + $Body.scope -cne 'https://manage.devcenter.microsoft.com/.default') { + throw 'Unexpected OAuth client assertion request.' + } + return [pscustomobject]@{ access_token = 'access-token-secret'; expires_in = $global:OpenClawStoreTest_tokenLifetime } } - $resolvedBundle = (Resolve-Path -LiteralPath ( - Join-Path $testRoot 'OpenClaw.msixbundle')).Path - if ($calls[1] -cne "apps get $applicationId" -or - $calls[2] -cne "submission get $applicationId") { - throw 'Store publication did not snapshot the existing product state.' + if ([string]$Headers.Authorization -cne 'Bearer access-token-secret' -or + [string]$Headers.TenantId -cne $tenantId.ToString()) { + throw 'Store API request did not carry the expected authorization boundary.' } - if ($calls[3] -cne ( - "publish $resolvedBundle --appId $applicationId " + - '--packageRolloutPercentage 100 --uploadTimeout 1800 --noCommit')) { - throw "Unexpected publish call: $($calls[3])" + + $applicationPath = "$apiBase/v1.0/my/applications/$applicationId" + if ($Method -eq 'Get' -and $Uri -ceq $applicationPath) { + return [pscustomobject]@{ + Id = $applicationId + PendingApplicationSubmission = if ($null -eq $global:OpenClawStoreTest_pendingId) { $null } else { [pscustomobject]@{ Id = $global:OpenClawStoreTest_pendingId } } + LastPublishedApplicationSubmission = [pscustomobject]@{ Id = 'published-1' } + } } - if ($calls[4] -cne "submission get $applicationId" -or - $calls[5] -cne "submission publish $applicationId") { - throw 'Store publication did not verify and commit the draft.' + if ($Method -eq 'Get' -and $Uri -ceq "$applicationPath/submissions/published-1") { + return Copy-Object $global:OpenClawStoreTest_published } - if ($calls -match 'header\.payload|assertion\.jwt') { - throw 'The Store CLI command line exposed the OIDC assertion.' + if ($Method -eq 'Post' -and $Uri -ceq "$applicationPath/submissions?isMinimalResponse=true") { + if ($null -ne $global:OpenClawStoreTest_pendingId) { throw 'Store refused to create a second draft.' } + $global:OpenClawStoreTest_draft = Copy-Object $global:OpenClawStoreTest_published + $global:OpenClawStoreTest_draft.Id = 'draft-2' + $global:OpenClawStoreTest_pendingId = 'draft-2' + return Copy-Object $global:OpenClawStoreTest_draft } - if ($env:MSSTORE_CLIENT_ASSERTION -cne 'previous-assertion' -or - $env:MSSTORE_CLIENT_ASSERTION_FILE -cne 'previous-file') { - throw 'Store submission did not restore the assertion environment.' + if ($Method -eq 'Get' -and $Uri -ceq "$applicationPath/submissions/draft-2") { + if ($null -eq $global:OpenClawStoreTest_draft) { throw 'Draft does not exist.' } + return Copy-Object $global:OpenClawStoreTest_draft } - - $evidence = Get-Content -LiteralPath (Join-Path $testRoot 'evidence.json') -Raw | - ConvertFrom-Json - $expectedHash = (Get-FileHash -LiteralPath $resolvedBundle -Algorithm SHA256).Hash.ToLowerInvariant() - if ([string]$evidence.bundleSha256 -cne $expectedHash -or - [string]$evidence.applicationId -cne $applicationId -or - [string]$evidence.msstoreCliVersion -cne 'v0.4.3' -or - [string]$evidence.publishedMetadataSha256 -cne - [string]$evidence.draftMetadataSha256) { - throw 'Store submission evidence did not bind the submitted bundle.' + if ($Method -eq 'Put' -and $Uri -ceq "$applicationPath/submissions/draft-2") { + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before update.' } + $global:OpenClawStoreTest_draft = $Body | ConvertFrom-Json + return Copy-Object $global:OpenClawStoreTest_draft } - - Clear-Content -LiteralPath $logPath - Remove-Item -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $testRoot 'evidence.json') -Force - $env:FAKE_MSSTORE_FAIL_COMMAND = 'publish' - Assert-Fails -MessagePattern 'publication failed with exit code 23' -Action { - Invoke-Submission + if ($Method -eq 'Post' -and $Uri -ceq "$applicationPath/submissions/draft-2/Commit") { + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before commit.' } + $global:OpenClawStoreTest_pendingId = $null + return [pscustomobject]@{ Status = 'CommitStarted' } } - if (Test-Path -LiteralPath (Join-Path $testRoot 'evidence.json')) { - throw 'A failed Store submission must not write success evidence.' + if ($Method -eq 'Delete' -and $Uri -ceq "$applicationPath/submissions/draft-2") { + if ($global:OpenClawStoreTest_pendingId -ceq 'draft-2') { $global:OpenClawStoreTest_pendingId = $null } + $global:OpenClawStoreTest_draft = $null + return $null } + throw "Unexpected Store request: $Method $Uri" +} - Clear-Content -LiteralPath $logPath - Remove-Item -LiteralPath $env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue - $env:FAKE_MSSTORE_FAIL_COMMAND = '' - $changedDraft = [ordered]@{ Id = 'draft-3' } + $preservedMetadata - $changedDraft.Listings = @{ - 'en-us' = @{ description = 'Unexpected replacement' } - } - $env:FAKE_MSSTORE_DRAFT_JSON = $changedDraft | - ConvertTo-Json -Depth 100 -Compress - Assert-Fails -MessagePattern 'did not preserve published product metadata' -Action { - Invoke-Submission +$uploadInvoker = { + param($Uri, $Path, $TimeoutSeconds) + if ($Uri -cne 'https://storage.example.invalid/upload?sas=redacted' -or $TimeoutSeconds -ne 1800) { + throw 'Unexpected Store upload request.' } - $calls = @(Get-Content -LiteralPath $logPath) - if ($calls -contains "submission publish $applicationId") { - throw 'Metadata drift must block committing the Store draft.' + $global:OpenClawStoreTest_uploadHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { $global:OpenClawStoreTest_uploadedEntries = @($archive.Entries.FullName) } + finally { $archive.Dispose() } + if ($global:OpenClawStoreTest_driftAfterUpload) { + $global:OpenClawStoreTest_draft.Listings.'en-us'.description = 'Unexpected replacement' } + if ($global:OpenClawStoreTest_replaceAfterUpload) { $global:OpenClawStoreTest_pendingId = 'competing-draft' } +} - $env:FAKE_MSSTORE_APPLICATION_JSON = [ordered]@{ - Id = $applicationId - PendingApplicationSubmission = @{ Id = 'pending-1' } - LastPublishedApplicationSubmission = @{ Id = 'published-1' } - } | ConvertTo-Json -Depth 100 -Compress - Assert-Fails -MessagePattern 'already has a pending submission' -Action { - Invoke-Submission - } +function Invoke-Submission { + param( + [string]$PolicyPath = (Join-Path $testRoot 'policy.json'), + [string]$BundlePath = (Join-Path $testRoot 'OpenClaw.msixbundle'), + [string]$EvidencePath = (Join-Path $testRoot 'evidence.json') + ) + & $scriptPath -BundlePath $BundlePath -ApplicationId $applicationId ` + -TenantId $tenantId -ClientId $clientId ` + -ClientAssertionFile (Join-Path $testRoot 'assertion.jwt') ` + -PolicyPath $PolicyPath -EvidencePath $EvidencePath ` + -HttpInvoker $httpInvoker -UploadInvoker $uploadInvoker +} - $invalidPolicyPath = Join-Path $testRoot 'invalid-policy.json' +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + [IO.File]::WriteAllText((Join-Path $testRoot 'OpenClaw.msixbundle'), 'bundle-fixture') + [IO.File]::WriteAllText((Join-Path $testRoot 'assertion.jwt'), 'header.payload.signature') [ordered]@{ schemaVersion = 1 environment = 'microsoft-store' oidcAudience = 'api://AzureADTokenExchange' - msstoreCliVersion = 'latest' + apiBaseUri = $apiBase + oauthScope = 'https://manage.devcenter.microsoft.com/.default' commitSubmission = $true pendingSubmissionPolicy = 'reject' + failedDraftPolicy = 'delete-owned' packageRolloutPercentage = 100 uploadTimeoutSeconds = 1800 - } | - ConvertTo-Json | - Set-Content -LiteralPath $invalidPolicyPath -Encoding utf8 - Assert-Fails -MessagePattern 'pin an exact' -Action { - Invoke-Submission -PolicyPath $invalidPolicyPath + minimumAccessTokenLifetimeSeconds = 2400 + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $testRoot 'policy.json') -Encoding utf8 + + Reset-Fixture + Invoke-Submission + if ($null -ne $global:OpenClawStoreTest_pendingId -or $global:OpenClawStoreTest_uploadedEntries.Count -ne 1 -or + $global:OpenClawStoreTest_uploadedEntries[0] -cne 'OpenClaw.msixbundle') { + throw 'Successful publication did not upload exactly one bundle and commit its owned draft.' + } + if (@($global:OpenClawStoreTest_calls | Where-Object { $_ -match '/submissions/draft-2/Commit$' }).Count -ne 1 -or + @($global:OpenClawStoreTest_calls | Where-Object { $_ -match 'submission publish|msstore' }).Count -ne 0) { + throw 'Publication was not bound to the verified submission ID.' + } + $evidence = Get-Content -LiteralPath (Join-Path $testRoot 'evidence.json') -Raw | ConvertFrom-Json + if ([string]$evidence.draftSubmissionId -cne 'draft-2' -or + [string]$evidence.commitStatus -cne 'CommitStarted' -or + [string]$evidence.publishedMetadataSha256 -cne [string]$evidence.draftMetadataSha256) { + throw 'Store evidence did not bind the verified and committed draft.' } + Reset-Fixture + $global:OpenClawStoreTest_pendingId = 'human-draft' + Assert-Fails -MessagePattern 'already has a pending submission' -Action { Invoke-Submission } + if ($global:OpenClawStoreTest_pendingId -cne 'human-draft' -or $global:OpenClawStoreTest_calls -match 'isMinimalResponse') { + throw 'Pre-existing draft rejection mutated Partner Center.' + } + + Reset-Fixture + $global:OpenClawStoreTest_replaceAfterUpload = $true + Assert-Fails -MessagePattern 'no longer the automation-owned draft' -Action { Invoke-Submission } + if ($global:OpenClawStoreTest_pendingId -cne 'competing-draft' -or $global:OpenClawStoreTest_calls -match '/Commit$') { + throw 'A replaced draft was deleted or committed.' + } + + Reset-Fixture + $global:OpenClawStoreTest_driftAfterUpload = $true + Assert-Fails -MessagePattern 'did not preserve published product metadata' -Action { Invoke-Submission } + if ($null -ne $global:OpenClawStoreTest_pendingId -or $global:OpenClawStoreTest_calls -match '/Commit$') { + throw 'Metadata drift did not delete only the automation-owned draft.' + } + + Reset-Fixture + $global:OpenClawStoreTest_tokenLifetime = 2000 + Assert-Fails -MessagePattern 'too short-lived' -Action { Invoke-Submission } + if ($global:OpenClawStoreTest_calls.Count -ne 1) { throw 'Short-lived authorization reached the Store API.' } + $wrongPackagePath = Join-Path $testRoot 'OpenClaw-x64.msix' [IO.File]::WriteAllText($wrongPackagePath, 'standalone-fixture') Assert-Fails -MessagePattern 'requires one .msixbundle' -Action { @@ -265,33 +235,19 @@ exit 0 $uriWithoutQuery = & $oidcUriScriptPath ` -RequestUri 'https://token.actions.githubusercontent.com/oidc' ` -Audience 'api://AzureADTokenExchange' - if ($uriWithoutQuery -cne ( - 'https://token.actions.githubusercontent.com/oidc?' + - 'audience=api%3A%2F%2FAzureADTokenExchange')) { - throw "Unexpected OIDC URI without a query: $uriWithoutQuery" - } $uriWithQuery = & $oidcUriScriptPath ` -RequestUri 'https://token.actions.githubusercontent.com/oidc?api-version=1' ` -Audience 'api://AzureADTokenExchange' - if ($uriWithQuery -cne ( - 'https://token.actions.githubusercontent.com/oidc?api-version=1&' + - 'audience=api%3A%2F%2FAzureADTokenExchange')) { - throw "Unexpected OIDC URI with a query: $uriWithQuery" - } - if ($uriWithQuery.Contains([char]7)) { - throw 'The OIDC URI contains PowerShell alert escape U+0007.' + if ($uriWithoutQuery -cne 'https://token.actions.githubusercontent.com/oidc?audience=api%3A%2F%2FAzureADTokenExchange' -or + $uriWithQuery -cne 'https://token.actions.githubusercontent.com/oidc?api-version=1&audience=api%3A%2F%2FAzureADTokenExchange' -or + $uriWithQuery.Contains([char]7)) { + throw 'OIDC request URI construction is invalid.' } } finally { Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_LOG -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_FAIL_COMMAND -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_GET_COUNT -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_APPLICATION_JSON -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_PUBLISHED_JSON -ErrorAction SilentlyContinue - Remove-Item Env:FAKE_MSSTORE_DRAFT_JSON -ErrorAction SilentlyContinue - Remove-Item Env:MSSTORE_CLIENT_ASSERTION -ErrorAction SilentlyContinue - Remove-Item Env:MSSTORE_CLIENT_ASSERTION_FILE -ErrorAction SilentlyContinue + Get-Variable -Scope Global -Name 'OpenClawStoreTest_*' -ErrorAction SilentlyContinue | + Remove-Variable -Scope Global -ErrorAction SilentlyContinue } $global:LASTEXITCODE = 0 diff --git a/store-submission.json b/store-submission.json index e7214717b..802eef1c0 100644 --- a/store-submission.json +++ b/store-submission.json @@ -2,9 +2,12 @@ "schemaVersion": 1, "environment": "microsoft-store", "oidcAudience": "api://AzureADTokenExchange", - "msstoreCliVersion": "v0.4.3", + "apiBaseUri": "https://manage.devcenter.microsoft.com", + "oauthScope": "https://manage.devcenter.microsoft.com/.default", "commitSubmission": true, "pendingSubmissionPolicy": "reject", + "failedDraftPolicy": "delete-owned", "packageRolloutPercentage": 100, - "uploadTimeoutSeconds": 1800 + "uploadTimeoutSeconds": 1800, + "minimumAccessTokenLifetimeSeconds": 2400 } From dce6f967397e5482c05c78d0b473a616f25ca353 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 18:55:22 -0700 Subject: [PATCH 3/7] fix(release): verify Store package mutation state --- scripts/Submit-MicrosoftStore.ps1 | 20 ++++++++++++++++++++ scripts/test-microsoft-store-submission.ps1 | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index af8253405..1d8d8184c 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -66,6 +66,19 @@ function Get-SubmissionMetadataHash { return [Convert]::ToHexString($hash).ToLowerInvariant() } +function Get-SubmissionMutationHash { + param([Parameter(Mandatory)][psobject]$Submission) + $mutationState = [ordered]@{ + ApplicationPackages = ConvertTo-CanonicalValue ( + Get-RequiredProperty $Submission 'ApplicationPackages') + PackageDeliveryOptions = ConvertTo-CanonicalValue ( + Get-RequiredProperty $Submission 'PackageDeliveryOptions') + } + $json = $mutationState | ConvertTo-Json -Depth 100 -Compress + $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json)) + return [Convert]::ToHexString($hash).ToLowerInvariant() +} + Assert-NonEmptyValue -Name 'ApplicationId' -Value $ApplicationId if (-not (Test-Path -LiteralPath $BundlePath -PathType Leaf)) { throw "MSIX bundle does not exist: $BundlePath" @@ -228,6 +241,7 @@ try { if ([string](Get-RequiredProperty $draft 'Id') -cne $draftId) { throw 'Store updated a different draft than the automation owns.' } + $draftMutationHash = Get-SubmissionMutationHash $draft Assert-OwnedDraft -SubmissionId $draftId $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ( @@ -249,6 +263,10 @@ try { if ($draftMetadataHash -cne $publishedMetadataHash) { throw 'The Store draft did not preserve published product metadata.' } + $verifiedMutationHash = Get-SubmissionMutationHash $verifiedDraft + if ($verifiedMutationHash -cne $draftMutationHash) { + throw 'The Store draft package mutation state changed after upload.' + } Assert-OwnedDraft -SubmissionId $draftId $commit = Invoke-StoreApi -Method Post ` -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId/Commit" @@ -268,6 +286,8 @@ try { draftSubmissionId = $draftId publishedMetadataSha256 = $publishedMetadataHash draftMetadataSha256 = $draftMetadataHash + preparedMutationSha256 = $draftMutationHash + verifiedMutationSha256 = $verifiedMutationHash commitStatus = $commitStatus packageRolloutPercentage = $rollout submittedAtUtc = [DateTimeOffset]::UtcNow.ToString('O') diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index 22fbcd606..8f5338534 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -77,6 +77,7 @@ function Reset-Fixture { $global:OpenClawStoreTest_tokenLifetime = 3600 $global:OpenClawStoreTest_replaceAfterUpload = $false $global:OpenClawStoreTest_driftAfterUpload = $false + $global:OpenClawStoreTest_mutatePackagesAfterUpload = $false $global:OpenClawStoreTest_uploadHash = $null $global:OpenClawStoreTest_uploadedEntries = @() } @@ -149,6 +150,12 @@ $uploadInvoker = { if ($global:OpenClawStoreTest_driftAfterUpload) { $global:OpenClawStoreTest_draft.Listings.'en-us'.description = 'Unexpected replacement' } + if ($global:OpenClawStoreTest_mutatePackagesAfterUpload) { + $global:OpenClawStoreTest_draft.ApplicationPackages += [pscustomobject]@{ + FileName = 'competing.msix' + FileStatus = 'PendingUpload' + } + } if ($global:OpenClawStoreTest_replaceAfterUpload) { $global:OpenClawStoreTest_pendingId = 'competing-draft' } } @@ -196,7 +203,8 @@ try { $evidence = Get-Content -LiteralPath (Join-Path $testRoot 'evidence.json') -Raw | ConvertFrom-Json if ([string]$evidence.draftSubmissionId -cne 'draft-2' -or [string]$evidence.commitStatus -cne 'CommitStarted' -or - [string]$evidence.publishedMetadataSha256 -cne [string]$evidence.draftMetadataSha256) { + [string]$evidence.publishedMetadataSha256 -cne [string]$evidence.draftMetadataSha256 -or + [string]$evidence.preparedMutationSha256 -cne [string]$evidence.verifiedMutationSha256) { throw 'Store evidence did not bind the verified and committed draft.' } @@ -221,6 +229,13 @@ try { throw 'Metadata drift did not delete only the automation-owned draft.' } + Reset-Fixture + $global:OpenClawStoreTest_mutatePackagesAfterUpload = $true + Assert-Fails -MessagePattern 'package mutation state changed' -Action { Invoke-Submission } + if ($null -ne $global:OpenClawStoreTest_pendingId -or $global:OpenClawStoreTest_calls -match '/Commit$') { + throw 'Package drift did not delete only the automation-owned draft.' + } + Reset-Fixture $global:OpenClawStoreTest_tokenLifetime = 2000 Assert-Fails -MessagePattern 'too short-lived' -Action { Invoke-Submission } From 8f40af6b69b53e2556bfa59be3a123552dc8f877 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 19:03:19 -0700 Subject: [PATCH 4/7] fix(release): conform to Store submission schema --- scripts/Submit-MicrosoftStore.ps1 | 4 +++- scripts/test-microsoft-store-submission.ps1 | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index 1d8d8184c..5fd4dd489 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -57,7 +57,7 @@ function Get-SubmissionMetadataHash { 'GamingOptions', 'HasExternalInAppProducts', 'MeetAccessibilityGuidelines', 'NotesForCertification', 'EnterpriseLicensing', 'AllowMicrosoftDecideAppAvailabilityToFutureDeviceFamilies', - 'AllowTargetFutureDeviceFamilies', 'FriendlyName', 'Trailers' + 'AllowTargetFutureDeviceFamilies', 'Trailers' )) { $metadata[$name] = ConvertTo-CanonicalValue (Get-RequiredProperty $Submission $name) } @@ -224,6 +224,8 @@ try { $packages += [pscustomobject]@{ FileName = [IO.Path]::GetFileName($resolvedBundle) FileStatus = 'PendingUpload' + minimumDirectXVersion = 'None' + minimumSystemRam = 'None' } $draft.ApplicationPackages = $packages $deliveryOptions = Get-RequiredProperty $draft 'PackageDeliveryOptions' diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index 8f5338534..c01b8a0c3 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -113,6 +113,7 @@ $httpInvoker = { if ($null -ne $global:OpenClawStoreTest_pendingId) { throw 'Store refused to create a second draft.' } $global:OpenClawStoreTest_draft = Copy-Object $global:OpenClawStoreTest_published $global:OpenClawStoreTest_draft.Id = 'draft-2' + $global:OpenClawStoreTest_draft.FriendlyName = 'Generated draft 2' $global:OpenClawStoreTest_pendingId = 'draft-2' return Copy-Object $global:OpenClawStoreTest_draft } @@ -122,7 +123,14 @@ $httpInvoker = { } if ($Method -eq 'Put' -and $Uri -ceq "$applicationPath/submissions/draft-2") { if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before update.' } - $global:OpenClawStoreTest_draft = $Body | ConvertFrom-Json + $request = $Body | ConvertFrom-Json + $pendingUpload = @($request.ApplicationPackages | Where-Object { $_.FileStatus -ceq 'PendingUpload' }) + if ($pendingUpload.Count -ne 1 -or + [string]$pendingUpload[0].minimumDirectXVersion -cne 'None' -or + [string]$pendingUpload[0].minimumSystemRam -cne 'None') { + throw 'Pending package omitted required compatibility fields.' + } + $global:OpenClawStoreTest_draft = $request return Copy-Object $global:OpenClawStoreTest_draft } if ($Method -eq 'Post' -and $Uri -ceq "$applicationPath/submissions/draft-2/Commit") { From bd91ae54eecb90f9eddde6e1e466e474a596721a Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 19:06:00 -0700 Subject: [PATCH 5/7] docs(release): enforce exclusive Store writer --- docs/RELEASING.md | 9 +++++++++ scripts/Submit-MicrosoftStore.ps1 | 3 ++- scripts/test-microsoft-store-submission.ps1 | 1 + store-submission.json | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7fc8f5de1..a16b580fc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -219,12 +219,21 @@ Configure that environment before the next stable release: 3. Add an Entra federated credential for: `repo:openclaw/openclaw-windows-node:environment:microsoft-store` with audience `api://AzureADTokenExchange`. + This must be the application's only credential: do not add client secrets, + certificates, or additional federated subjects, and do not share the Entra + application with another publisher. 4. Associate the Entra application with the Partner Center account and grant it access to the existing OpenClaw product. 5. Ensure that product has a published submission and no pending draft. 6. Record Store channel acceptance in the release PR before enabling the first production submission. +The Store identity is an exclusive writer. Only this serialized GitHub +environment may use it, and operators must not edit an API-created draft in +Partner Center. Microsoft documents that a Partner Center edit invalidates +further API update or commit operations; if that happens, let the workflow +delete its own failed draft and rerun after the product has no pending draft. + The git-controlled policy is [`store-submission.json`](../store-submission.json). It pins the API origin and scope, OIDC audience, rollout percentage, timeout, minimum access-token lifetime, environment, and draft ownership behavior. diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index 5fd4dd489..ecaf32855 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -101,7 +101,7 @@ catch { throw "Unable to parse Store submission policy: $($_.Exception.Message)" $requiredProperties = @( 'schemaVersion', 'environment', 'oidcAudience', 'apiBaseUri', 'oauthScope', - 'commitSubmission', 'pendingSubmissionPolicy', 'failedDraftPolicy', + 'commitSubmission', 'submissionWriterPolicy', 'pendingSubmissionPolicy', 'failedDraftPolicy', 'packageRolloutPercentage', 'uploadTimeoutSeconds', 'minimumAccessTokenLifetimeSeconds' ) foreach ($property in $requiredProperties) { @@ -118,6 +118,7 @@ if ([string]$policy.apiBaseUri -cne 'https://manage.devcenter.microsoft.com' -or throw 'Store submission policy has an unsupported API boundary.' } if ([bool]$policy.commitSubmission -ne $true -or + [string]$policy.submissionWriterPolicy -cne 'exclusive-github-environment' -or [string]$policy.pendingSubmissionPolicy -cne 'reject' -or [string]$policy.failedDraftPolicy -cne 'delete-owned') { throw 'Store submission policy must commit safely and reject unowned drafts.' diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index c01b8a0c3..114412159 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -191,6 +191,7 @@ try { apiBaseUri = $apiBase oauthScope = 'https://manage.devcenter.microsoft.com/.default' commitSubmission = $true + submissionWriterPolicy = 'exclusive-github-environment' pendingSubmissionPolicy = 'reject' failedDraftPolicy = 'delete-owned' packageRolloutPercentage = 100 diff --git a/store-submission.json b/store-submission.json index 802eef1c0..4909cdde1 100644 --- a/store-submission.json +++ b/store-submission.json @@ -5,6 +5,7 @@ "apiBaseUri": "https://manage.devcenter.microsoft.com", "oauthScope": "https://manage.devcenter.microsoft.com/.default", "commitSubmission": true, + "submissionWriterPolicy": "exclusive-github-environment", "pendingSubmissionPolicy": "reject", "failedDraftPolicy": "delete-owned", "packageRolloutPercentage": 100, From 549b2c3baefa8ed4a5e162cf861f8ac210d04b33 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 22 Sep 2026 19:16:49 -0700 Subject: [PATCH 6/7] fix(release): require Store commit acceptance --- scripts/Submit-MicrosoftStore.ps1 | 4 +++- scripts/test-microsoft-store-submission.ps1 | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index ecaf32855..ce1b65d06 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -274,7 +274,9 @@ try { $commit = Invoke-StoreApi -Method Post ` -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId/Commit" $commitStatus = [string](Get-RequiredProperty $commit 'Status') - Assert-NonEmptyValue -Name 'Store commit status' -Value $commitStatus + if ($commitStatus -cne 'CommitStarted') { + throw "Store did not accept the submission commit: $commitStatus" + } $committed = $true if (-not [string]::IsNullOrWhiteSpace($EvidencePath)) { diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index 114412159..dfb8dab72 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -75,6 +75,7 @@ function Reset-Fixture { $global:OpenClawStoreTest_draft = $null $global:OpenClawStoreTest_pendingId = $null $global:OpenClawStoreTest_tokenLifetime = 3600 + $global:OpenClawStoreTest_commitStatus = 'CommitStarted' $global:OpenClawStoreTest_replaceAfterUpload = $false $global:OpenClawStoreTest_driftAfterUpload = $false $global:OpenClawStoreTest_mutatePackagesAfterUpload = $false @@ -136,7 +137,7 @@ $httpInvoker = { if ($Method -eq 'Post' -and $Uri -ceq "$applicationPath/submissions/draft-2/Commit") { if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before commit.' } $global:OpenClawStoreTest_pendingId = $null - return [pscustomobject]@{ Status = 'CommitStarted' } + return [pscustomobject]@{ Status = $global:OpenClawStoreTest_commitStatus } } if ($Method -eq 'Delete' -and $Uri -ceq "$applicationPath/submissions/draft-2") { if ($global:OpenClawStoreTest_pendingId -ceq 'draft-2') { $global:OpenClawStoreTest_pendingId = $null } @@ -245,6 +246,14 @@ try { throw 'Package drift did not delete only the automation-owned draft.' } + Reset-Fixture + $global:OpenClawStoreTest_commitStatus = 'CommitFailed' + Assert-Fails -MessagePattern 'did not accept the submission commit' -Action { Invoke-Submission } + if ($null -ne $global:OpenClawStoreTest_pendingId -or + @($global:OpenClawStoreTest_calls | Where-Object { $_ -match '/Commit$' }).Count -ne 1) { + throw 'Rejected commit status did not fail and clean up the owned draft.' + } + Reset-Fixture $global:OpenClawStoreTest_tokenLifetime = 2000 Assert-Fails -MessagePattern 'too short-lived' -Action { Invoke-Submission } From ca0048158ec3b1f7d6c5768396e1a96978e14f28 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 23 Sep 2026 00:31:43 -0700 Subject: [PATCH 7/7] fix(release): preserve ambiguous Store commits --- docs/RELEASING.md | 4 ++++ scripts/Submit-MicrosoftStore.ps1 | 14 ++++++++++-- scripts/test-microsoft-store-submission.ps1 | 24 +++++++++++++++++++-- store-submission.json | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a16b580fc..d38f7c336 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -242,6 +242,10 @@ without deleting anything, updates and commits that exact submission ID, and deletes only its own draft if a pre-commit check fails. It refuses to commit if another Partner Center writer replaces the draft or if published metadata changes. +After the commit request starts, an unknown response is never cleaned up +automatically because Partner Center may already have accepted the commit. +Inspect the submission before retrying. Explicit terminal rejections such as +`CommitFailed` or `Canceled` remain eligible for owned-draft cleanup. The workflow uploads a 90-day evidence artifact containing the submitted bundle hash and Store submission identifiers. It never includes the OIDC assertion. diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 index ce1b65d06..25f58dfa4 100644 --- a/scripts/Submit-MicrosoftStore.ps1 +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -120,7 +120,7 @@ if ([string]$policy.apiBaseUri -cne 'https://manage.devcenter.microsoft.com' -or if ([bool]$policy.commitSubmission -ne $true -or [string]$policy.submissionWriterPolicy -cne 'exclusive-github-environment' -or [string]$policy.pendingSubmissionPolicy -cne 'reject' -or - [string]$policy.failedDraftPolicy -cne 'delete-owned') { + [string]$policy.failedDraftPolicy -cne 'delete-owned-before-commit') { throw 'Store submission policy must commit safely and reject unowned drafts.' } $rollout = [float]$policy.packageRolloutPercentage @@ -188,6 +188,7 @@ function Assert-OwnedDraft { $draftId = $null $committed = $false +$draftDeletionAllowed = $true $temporaryDirectory = $null try { $application = Invoke-StoreApi -Method Get -Path "/v1.0/my/applications/$encodedApplicationId" @@ -271,10 +272,18 @@ try { throw 'The Store draft package mutation state changed after upload.' } Assert-OwnedDraft -SubmissionId $draftId + # Once the commit request leaves this process, a transport failure is + # ambiguous: Partner Center may have accepted it even if no response + # arrived. Never delete that submission unless Partner Center explicitly + # reports a terminal rejection. + $draftDeletionAllowed = $false $commit = Invoke-StoreApi -Method Post ` -Path "/v1.0/my/applications/$encodedApplicationId/submissions/$encodedDraftId/Commit" $commitStatus = [string](Get-RequiredProperty $commit 'Status') if ($commitStatus -cne 'CommitStarted') { + if ($commitStatus -cin @('CommitFailed', 'Canceled')) { + $draftDeletionAllowed = $true + } throw "Store did not accept the submission commit: $commitStatus" } $committed = $true @@ -301,7 +310,8 @@ try { } catch { $failure = $_ - if (-not $committed -and -not [string]::IsNullOrWhiteSpace($draftId)) { + if ($draftDeletionAllowed -and -not $committed -and + -not [string]::IsNullOrWhiteSpace($draftId)) { try { $encodedDraftId = [Uri]::EscapeDataString($draftId) Invoke-StoreApi -Method Delete ` diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 index dfb8dab72..3478702cd 100644 --- a/scripts/test-microsoft-store-submission.ps1 +++ b/scripts/test-microsoft-store-submission.ps1 @@ -76,6 +76,7 @@ function Reset-Fixture { $global:OpenClawStoreTest_pendingId = $null $global:OpenClawStoreTest_tokenLifetime = 3600 $global:OpenClawStoreTest_commitStatus = 'CommitStarted' + $global:OpenClawStoreTest_commitThrows = $false $global:OpenClawStoreTest_replaceAfterUpload = $false $global:OpenClawStoreTest_driftAfterUpload = $false $global:OpenClawStoreTest_mutatePackagesAfterUpload = $false @@ -136,7 +137,10 @@ $httpInvoker = { } if ($Method -eq 'Post' -and $Uri -ceq "$applicationPath/submissions/draft-2/Commit") { if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before commit.' } - $global:OpenClawStoreTest_pendingId = $null + if ($global:OpenClawStoreTest_commitThrows) { throw 'Commit response was lost.' } + if ($global:OpenClawStoreTest_commitStatus -ceq 'CommitStarted') { + $global:OpenClawStoreTest_pendingId = $null + } return [pscustomobject]@{ Status = $global:OpenClawStoreTest_commitStatus } } if ($Method -eq 'Delete' -and $Uri -ceq "$applicationPath/submissions/draft-2") { @@ -194,7 +198,7 @@ try { commitSubmission = $true submissionWriterPolicy = 'exclusive-github-environment' pendingSubmissionPolicy = 'reject' - failedDraftPolicy = 'delete-owned' + failedDraftPolicy = 'delete-owned-before-commit' packageRolloutPercentage = 100 uploadTimeoutSeconds = 1800 minimumAccessTokenLifetimeSeconds = 2400 @@ -254,6 +258,22 @@ try { throw 'Rejected commit status did not fail and clean up the owned draft.' } + Reset-Fixture + $global:OpenClawStoreTest_commitStatus = 'PendingCommit' + Assert-Fails -MessagePattern 'did not accept the submission commit' -Action { Invoke-Submission } + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2' -or + $global:OpenClawStoreTest_calls -match 'Delete .*/submissions/draft-2$') { + throw 'Ambiguous commit status deleted the submission with an unknown outcome.' + } + + Reset-Fixture + $global:OpenClawStoreTest_commitThrows = $true + Assert-Fails -MessagePattern 'Commit response was lost' -Action { Invoke-Submission } + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2' -or + $global:OpenClawStoreTest_calls -match 'Delete .*/submissions/draft-2$') { + throw 'Ambiguous commit transport failure deleted the submission with an unknown outcome.' + } + Reset-Fixture $global:OpenClawStoreTest_tokenLifetime = 2000 Assert-Fails -MessagePattern 'too short-lived' -Action { Invoke-Submission } diff --git a/store-submission.json b/store-submission.json index 4909cdde1..b2f37da8c 100644 --- a/store-submission.json +++ b/store-submission.json @@ -7,7 +7,7 @@ "commitSubmission": true, "submissionWriterPolicy": "exclusive-github-environment", "pendingSubmissionPolicy": "reject", - "failedDraftPolicy": "delete-owned", + "failedDraftPolicy": "delete-owned-before-commit", "packageRolloutPercentage": 100, "uploadTimeoutSeconds": 1800, "minimumAccessTokenLifetimeSeconds": 2400