diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cace93457..2d0ba0643 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,127 @@ 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_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} + MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} + run: | + foreach ($name in @( + 'MSSTORE_TENANT_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: 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_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 ` + -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..d38f7c336 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,59 @@ 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 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_CLIENT_ID` + - `MSSTORE_APPLICATION_ID` +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. +`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. +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. Store versions still end in `.0`. Official tagged builds now reserve distinct package versions as described below; reruns reuse the same reservation. @@ -399,6 +452,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 +479,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..25f58dfa4 --- /dev/null +++ b/scripts/Submit-MicrosoftStore.ps1 @@ -0,0 +1,333 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BundlePath, + [Parameter(Mandatory)][string]$ApplicationId, + [Parameter(Mandatory)][guid]$TenantId, + [Parameter(Mandatory)][guid]$ClientId, + [Parameter(Mandatory)][string]$ClientAssertionFile, + [string]$PolicyPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'store-submission.json'), + [string]$EvidencePath, + [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." } +} + +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 ($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', 'Trailers' + )) { + $metadata[$name] = ConvertTo-CanonicalValue (Get-RequiredProperty $Submission $name) + } + $json = $metadata | ConvertTo-Json -Depth 100 -Compress + $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json)) + 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" +} +$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" +} +$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)" } + +$requiredProperties = @( + 'schemaVersion', 'environment', 'oidcAudience', 'apiBaseUri', 'oauthScope', + 'commitSubmission', 'submissionWriterPolicy', '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 -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.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 ([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-before-commit') { + throw 'Store submission policy must commit safely and reject unowned drafts.' +} +$rollout = [float]$policy.packageRolloutPercentage +$uploadTimeout = [long]$policy.uploadTimeoutSeconds +$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.' +} + +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 + } +} + +$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.' + } +} + +$draftId = $null +$committed = $false +$draftDeletionAllowed = $true +$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 (Get-RequiredProperty $application 'PendingApplicationSubmission')) { + throw 'Partner Center already has a pending submission; automation will not replace it.' + } + $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 + + $packages = @(Get-RequiredProperty $draft 'ApplicationPackages') + foreach ($package in $packages) { $package.FileStatus = 'PendingDelete' } + $packages += [pscustomobject]@{ + FileName = [IO.Path]::GetFileName($resolvedBundle) + FileStatus = 'PendingUpload' + minimumDirectXVersion = 'None' + minimumSystemRam = 'None' + } + $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 + } + } + + $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.' + } + $draftMutationHash = Get-SubmissionMutationHash $draft + 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 + + 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.' + } + $verifiedMutationHash = Get-SubmissionMutationHash $verifiedDraft + if ($verifiedMutationHash -cne $draftMutationHash) { + 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 + + if (-not [string]::IsNullOrWhiteSpace($EvidencePath)) { + $resolvedEvidence = [IO.Path]::GetFullPath($EvidencePath) + 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() + publishedSubmissionId = $publishedId + draftSubmissionId = $draftId + publishedMetadataSha256 = $publishedMetadataHash + draftMetadataSha256 = $draftMetadataHash + preparedMutationSha256 = $draftMutationHash + verifiedMutationSha256 = $verifiedMutationHash + commitStatus = $commitStatus + packageRolloutPercentage = $rollout + submittedAtUtc = [DateTimeOffset]::UtcNow.ToString('O') + } | ConvertTo-Json | Set-Content -LiteralPath $resolvedEvidence -Encoding utf8 + } +} +catch { + $failure = $_ + if ($draftDeletionAllowed -and -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 { + 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 804c5f23e..d0fbb004a 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -827,8 +827,43 @@ 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', + '.\scripts\New-GitHubOidcRequestUri.ps1', + '& chmod 600 $assertionPath', + '.\scripts\Submit-MicrosoftStore.ps1', + '-BundlePath .\store\OpenClaw.msixbundle', + 'MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_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-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.' 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..3478702cd --- /dev/null +++ b/scripts/test-microsoft-store-submission.ps1 @@ -0,0 +1,307 @@ +[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' +$applicationId = '9NTESTOPENCLAW' +$apiBase = 'https://manage.devcenter.microsoft.com' + +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 Copy-Object([object]$Value) { + return $Value | ConvertTo-Json -Depth 100 | ConvertFrom-Json +} + +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 = [pscustomobject]@{ + 'en-us' = [pscustomobject]@{ + 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 = [pscustomobject]@{ Desktop = $true } + FriendlyName = 'OpenClaw' + Trailers = @() + 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 + } + } + } +} + +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_commitStatus = 'CommitStarted' + $global:OpenClawStoreTest_commitThrows = $false + $global:OpenClawStoreTest_replaceAfterUpload = $false + $global:OpenClawStoreTest_driftAfterUpload = $false + $global:OpenClawStoreTest_mutatePackagesAfterUpload = $false + $global:OpenClawStoreTest_uploadHash = $null + $global:OpenClawStoreTest_uploadedEntries = @() +} + +$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 } + } + 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.' + } + + $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 ($Method -eq 'Get' -and $Uri -ceq "$applicationPath/submissions/published-1") { + return Copy-Object $global:OpenClawStoreTest_published + } + 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_draft.FriendlyName = 'Generated draft 2' + $global:OpenClawStoreTest_pendingId = 'draft-2' + return Copy-Object $global:OpenClawStoreTest_draft + } + 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 + } + if ($Method -eq 'Put' -and $Uri -ceq "$applicationPath/submissions/draft-2") { + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before update.' } + $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") { + if ($global:OpenClawStoreTest_pendingId -cne 'draft-2') { throw 'Draft ownership changed before commit.' } + 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") { + if ($global:OpenClawStoreTest_pendingId -ceq 'draft-2') { $global:OpenClawStoreTest_pendingId = $null } + $global:OpenClawStoreTest_draft = $null + return $null + } + throw "Unexpected Store request: $Method $Uri" +} + +$uploadInvoker = { + param($Uri, $Path, $TimeoutSeconds) + if ($Uri -cne 'https://storage.example.invalid/upload?sas=redacted' -or $TimeoutSeconds -ne 1800) { + throw 'Unexpected Store upload request.' + } + $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_mutatePackagesAfterUpload) { + $global:OpenClawStoreTest_draft.ApplicationPackages += [pscustomobject]@{ + FileName = 'competing.msix' + FileStatus = 'PendingUpload' + } + } + if ($global:OpenClawStoreTest_replaceAfterUpload) { $global:OpenClawStoreTest_pendingId = 'competing-draft' } +} + +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 +} + +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' + apiBaseUri = $apiBase + oauthScope = 'https://manage.devcenter.microsoft.com/.default' + commitSubmission = $true + submissionWriterPolicy = 'exclusive-github-environment' + pendingSubmissionPolicy = 'reject' + failedDraftPolicy = 'delete-owned-before-commit' + packageRolloutPercentage = 100 + uploadTimeoutSeconds = 1800 + 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 -or + [string]$evidence.preparedMutationSha256 -cne [string]$evidence.verifiedMutationSha256) { + 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_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_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_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 } + 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 { + Invoke-Submission -BundlePath $wrongPackagePath + } + + $uriWithoutQuery = & $oidcUriScriptPath ` + -RequestUri 'https://token.actions.githubusercontent.com/oidc' ` + -Audience 'api://AzureADTokenExchange' + $uriWithQuery = & $oidcUriScriptPath ` + -RequestUri 'https://token.actions.githubusercontent.com/oidc?api-version=1' ` + -Audience 'api://AzureADTokenExchange' + 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 + Get-Variable -Scope Global -Name 'OpenClawStoreTest_*' -ErrorAction SilentlyContinue | + Remove-Variable -Scope Global -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..b2f37da8c --- /dev/null +++ b/store-submission.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "environment": "microsoft-store", + "oidcAudience": "api://AzureADTokenExchange", + "apiBaseUri": "https://manage.devcenter.microsoft.com", + "oauthScope": "https://manage.devcenter.microsoft.com/.default", + "commitSubmission": true, + "submissionWriterPolicy": "exclusive-github-environment", + "pendingSubmissionPolicy": "reject", + "failedDraftPolicy": "delete-owned-before-commit", + "packageRolloutPercentage": 100, + "uploadTimeoutSeconds": 1800, + "minimumAccessTokenLifetimeSeconds": 2400 +}