diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..484007f06f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Enforce required code-owner approval on main with no publisher-code bypass. +# CODEOWNERS alone does not configure a GitHub ruleset or protect old OIDC trust. +/.github/CODEOWNERS @openclaw/release-managers-openclaw +/.github/workflows/ @openclaw/release-managers-openclaw +/.github/scripts/ @openclaw/release-managers-openclaw +/.github/msix-version-baseline.json @openclaw/release-managers-openclaw +/build.ps1 @openclaw/release-managers-openclaw +/Directory.Build.props @openclaw/release-managers-openclaw +/Directory.Build.targets @openclaw/release-managers-openclaw +/Migration.Build.props @openclaw/release-managers-openclaw +/scripts/StorePublicationRequest.ps1 @openclaw/release-managers-openclaw +/scripts/test-store-publication-request.ps1 @openclaw/release-managers-openclaw +/scripts/AlphaPromotion.ps1 @openclaw/release-managers-openclaw +/scripts/Build-StoreMsix*.ps1 @openclaw/release-managers-openclaw +/scripts/MsixVersioning.ps1 @openclaw/release-managers-openclaw +/scripts/Resolve-MsixPackageVersion.ps1 @openclaw/release-managers-openclaw +/src/OpenClaw.Tray.WinUI/Package.appxmanifest @openclaw/release-managers-openclaw +/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @openclaw/release-managers-openclaw diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e072edc7e..6f32580021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,11 +216,9 @@ jobs: shell: pwsh run: ./scripts/Test-InstallerScriptCompiles.ps1 -RequireCompiler - - name: Validate Store submission and source authorization + - name: Validate central Store publication requests shell: pwsh - run: | - ./scripts/test-microsoft-store-submission.ps1 - ./scripts/Test-StoreSubmissionSource.Tests.ps1 + run: ./scripts/test-store-publication-request.ps1 - name: Validate Store MSIX bundle construction shell: pwsh diff --git a/.github/workflows/submit-microsoft-store.yml b/.github/workflows/submit-microsoft-store.yml index f5becd587b..d83f7a0a10 100644 --- a/.github/workflows/submit-microsoft-store.yml +++ b/.github/workflows/submit-microsoft-store.yml @@ -1,4 +1,4 @@ -name: Submit Microsoft Store +name: Request Microsoft Store publication on: workflow_dispatch: @@ -16,173 +16,61 @@ permissions: actions: read concurrency: - group: microsoft-store-submission + group: microsoft-store-publication-request cancel-in-progress: false jobs: - source: + request: if: >- github.repository == 'openclaw/openclaw-windows-node' && github.ref == 'refs/heads/main' && (github.event_name == 'workflow_dispatch' || (vars.MSSTORE_ENABLED == 'true' && github.event.workflow_run.conclusion == 'success')) runs-on: ubuntu-latest - outputs: - eligible: ${{ steps.source.outputs.eligible }} - artifact_id: ${{ steps.source.outputs.artifact_id }} - bundle_name: ${{ steps.source.outputs.bundle_name }} - bundle_sha256: ${{ steps.source.outputs.bundle_sha256 }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - name: Resolve authorized release artifact + - name: Select eligible publication request id: source shell: pwsh env: GH_TOKEN: ${{ github.token }} - SOURCE_RUN_ID: ${{ inputs.run_id || github.event.workflow_run.id }} + SOURCE_RUN_ID: ${{ inputs.run_id }} run: | - $source = ./scripts/Get-StoreSubmissionSource.ps1 -RunId $env:SOURCE_RUN_ID - if ($null -eq $source) { - if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { throw 'The selected run is not an eligible Store release.' } - 'eligible=false' >> $env:GITHUB_OUTPUT + . ./scripts/StorePublicationRequest.ps1 + $runId = Get-CompanionStoreRequestRunId -Repository $env:GITHUB_REPOSITORY ` + -Ref $env:GITHUB_REF -EventName $env:GITHUB_EVENT_NAME ` + -RunId $env:SOURCE_RUN_ID -EventPath $env:GITHUB_EVENT_PATH + if ($null -eq $runId) { + 'This completed workflow is not a stable-release publication request.' >> $env:GITHUB_STEP_SUMMARY exit 0 } - 'eligible=true' >> $env:GITHUB_OUTPUT - "artifact_id=$($source.artifactId)" >> $env:GITHUB_OUTPUT - "bundle_name=$($source.bundleName)" >> $env:GITHUB_OUTPUT - "bundle_sha256=$($source.bundleSha256)" >> $env:GITHUB_OUTPUT - "Store source: run $env:SOURCE_RUN_ID, artifact $($source.artifactId), bundle $($source.bundleName)" >> $env:GITHUB_STEP_SUMMARY + "run_id=$runId" >> $env:GITHUB_OUTPUT - submit-microsoft-store: - needs: source - if: needs.source.outputs.eligible == 'true' - runs-on: ubuntu-latest - environment: microsoft-store - timeout-minutes: 60 - permissions: - contents: read - actions: read - id-token: write - steps: - - uses: actions/checkout@v7 + - name: Mint central request token + if: steps.source.outputs.run_id != '' + id: request-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 with: - persist-credentials: false + app-id: 2729701 + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: openclaw + repositories: releases + permission-actions: write - - name: Download verified Store bundle - uses: actions/download-artifact@v8 - with: - artifact-ids: ${{ needs.source.outputs.artifact_id }} - run-id: ${{ inputs.run_id || github.event.workflow_run.id }} - github-token: ${{ github.token }} - merge-multiple: true - path: store - - - name: Validate Microsoft Store environment + - name: Request release-manager-approved publication + if: steps.source.outputs.run_id != '' shell: pwsh env: - MSSTORE_TENANT_ID: ${{ vars.MSSTORE_TENANT_ID }} - MSSTORE_CLIENT_ID: ${{ vars.MSSTORE_CLIENT_ID }} - MSSTORE_APPLICATION_ID: ${{ vars.MSSTORE_APPLICATION_ID }} + GH_TOKEN: ${{ steps.request-token.outputs.token }} + SOURCE_RUN_ID: ${{ steps.source.outputs.run_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 }} - EXPECTED_BUNDLE_NAME: ${{ needs.source.outputs.bundle_name }} - EXPECTED_BUNDLE_SHA256: ${{ needs.source.outputs.bundle_sha256 }} - run: | - [xml]$identityDocument = Get-Content ./src/OpenClaw.Tray.WinUI/Package.appxmanifest -Raw - $identityName = [string]$identityDocument.Package.Identity.Name - $publisher = [string]$identityDocument.Package.Identity.Publisher - $bundle = @(Get-ChildItem ./store -File -Filter *.msixbundle) - if ($bundle.Count -ne 1 -or $bundle[0].Name -cne $env:EXPECTED_BUNDLE_NAME) { - throw 'Downloaded Store artifact must contain the selected bundle.' - } - if ($env:EXPECTED_BUNDLE_SHA256 -and - (Get-FileHash $bundle[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne $env:EXPECTED_BUNDLE_SHA256) { - throw 'Store bundle does not match the release witness.' - } - .\scripts\Submit-MicrosoftStore.ps1 ` - -BundlePath $bundle[0].FullName ` - -ApplicationId $env:MSSTORE_APPLICATION_ID ` - -PackageIdentityName $identityName -Publisher $publisher ` - -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 - } + . ./scripts/StorePublicationRequest.ps1 + Request-CompanionStorePublication -RunId $env:SOURCE_RUN_ID + @( + '## Central Store publication requested' + "Source: https://github.com/openclaw/openclaw-windows-node/actions/runs/$env:SOURCE_RUN_ID" + 'Publisher: https://github.com/openclaw/releases/actions/workflows/windows-store-publish.yml' + 'This confirms request acceptance only. Central provenance checks, release-manager approval, Store commit, and certification remain separate.' + ) >> $env:GITHUB_STEP_SUMMARY diff --git a/docs/MICROSOFT_STORE.md b/docs/MICROSOFT_STORE.md index 9897d2756f..710bb138aa 100644 --- a/docs/MICROSOFT_STORE.md +++ b/docs/MICROSOFT_STORE.md @@ -1,44 +1,62 @@ -# Submit a release to Microsoft Store - -Use this guide to submit an already validated release bundle, retry a failed -Store delivery without rebuilding, or enable automatic Store delivery. This -workflow does not change signing, release approval, or install/upgrade acceptance. -GitHub releases and the existing unsigned Store artifact handoff remain available. - -## Configure the publishing identity - -1. Link the correct Microsoft Entra tenant to Partner Center. Add a dedicated - Entra application to Partner Center with the **Manager (Windows)** role. - The Store product must already have a completed, published first submission - and completed age ratings. Do not reuse the Azure code-signing identity. -2. Add one federated credential to that Entra application: - - Issuer: `https://token.actions.githubusercontent.com` - - Audience: `api://AzureADTokenExchange` - - Subject: `repo:openclaw/openclaw-windows-node:environment:microsoft-store` - Verify the repository's current OIDC subject configuration before provisioning; - immutable-subject settings affect the exact subject. The dedicated application - must have no client secrets, certificates, or other federated writers. -3. In the repository's **Settings > Environments > microsoft-store**, set these - environment variables (not secrets): - - `MSSTORE_TENANT_ID`: Entra tenant GUID. - - `MSSTORE_CLIENT_ID`: dedicated application's client GUID. - - `MSSTORE_APPLICATION_ID`: Partner Center Store product ID, not the client GUID. -4. Allow deployments from the `main` **branch** in this environment. The submission - workflow always runs trusted main code, including when it consumes a tag's - bundle. Restrict the environment to main, and configure release-owner approval - if required by your publishing policy. Tag-only protection will block this job. -5. Leave the repository variable `MSSTORE_ENABLED` unset until the first manual - submission and the required Store install/upgrade acceptance have succeeded. - No persistent Store password is required. - -## Submit manually - -Merge the workflow onto main first. Select the successful run ID for a stable/correction tag **Build and Test** run or an approved **Promote alpha to stable** run. -The ID is the number after `/actions/runs/` in its URL. The source run must have -completed successfully and retained its unexpired authorized Store bundle. - -From **Actions > Submit Microsoft Store > Run workflow**, select `main` and -enter `run_id`. The equivalent CLI command is: +# Request Microsoft Store publication + +Use this guide to request central publication of an already validated Companion +release bundle. This repository builds the unsigned x64/ARM64 Store inputs and +records release evidence. The standalone +[`windows-store-publish.yml` publisher in `openclaw/releases`](https://github.com/openclaw/releases/actions/workflows/windows-store-publish.yml) +authorizes the exact source, obtains release-manager approval, and submits to +Partner Center. It never runs publisher code from this repository. + +A successful native request is not approval, a Store commit, certification, or +public rollout. The existing stable-promotion approval, signing, GitHub release, +MSIX version reservation, and install/upgrade acceptance remain separate gates. + +## Required administrator cutover + +Complete the central publisher setup before merging or using this request-only +workflow. These are administrator changes, not effects of merging this PR: + +1. Configure the central `microsoft-store-companion` environment to allow only + `main`, require `@openclaw/release-managers-openclaw` approval, prevent + self-review, and disallow administrator deployment bypass. Central release + code and its identity policy also need enforced release-manager review. +2. Move the dedicated Companion Store application's Entra federation to the + approved central environment. Remove the old native-repository federated + credential and every other publishing writer. Removing the old workflow from + main does not revoke the identity available to historical workflow code. + Keep Store identifiers and the short-lived OIDC exchange in the central + publisher, not a native-repository environment. No new persistent Store + secret is required. +3. Make the existing release GitHub App (ID `2729701`) and + `GH_APP_PRIVATE_KEY` available to this workflow, with its installation able + to request workflows in `openclaw/releases`. The minted installation token + selects only `releases` and requests only Actions write permission. It has no + Store authority. Verify the installation grant; do not assume the existing + key alone grants access. No new PAT is required. +4. Enforce main's required release-manager code-owner review for the paths in + [CODEOWNERS](../.github/CODEOWNERS), including CODEOWNERS itself. Dismiss stale + approvals, require approval of the latest reviewable push, and prevent direct + push or actor bypass for protected automation changes. The file alone does + not turn on GitHub enforcement. +5. Leave repository Actions variable `MSSTORE_ENABLED` unset or `false` until a + manual central request, the protected approval path, and required Store + install/upgrade acceptance have been verified. Do not re-enable the retired + native publisher as a fallback. + +The central publisher owns the fixed Companion product identity, Store policy, +OIDC trust checks, source validation, artifact download, and evidence. An +operator with write access here can request publication, but cannot approve the +central publishing environment merely by having that access. + +## Request manually + +Select the successful run ID for a stable/correction-tag **Build and Test** run +or an approved **Promote alpha to stable** run. The number follows +`/actions/runs/` in its URL. The source must retain its unexpired authorized +unsigned bundle and current-attempt release witness. + +From **Actions > Request Microsoft Store publication > Run workflow**, select +`main` and enter `run_id`. The equivalent command is: ```powershell gh workflow run submit-microsoft-store.yml ` @@ -46,54 +64,60 @@ gh workflow run submit-microsoft-store.yml ` -f run_id=RELEASE_RUN_ID ``` -Manual dispatch deliberately does not require `MSSTORE_ENABLED`. It uses the same -source checks, protected environment, OIDC identity, and publisher as automatic -submission. It never rebuilds a package or republishes the GitHub release. +The main workflow sends exactly `product=companion` and the source `run_id` to +`windows-store-publish.yml` on central `main`. It neither rebuilds the package +nor republishes the GitHub release. Manual requests do not require +`MSSTORE_ENABLED`, but always require central source authorization and +release-manager publication approval. + +Open the linked central Actions workflow, inspect the matching product and +source run, and review its source commit, release version, run attempt, +artifact identity, and bundle hash before approving publication. Approval of +stable promotion here does not substitute for central Store approval. + +## Enable automatic requests + +After the manual central path and release acceptance are verified, set the +**repository** Actions variable `MSSTORE_ENABLED` to `true`. Its retained name +now enables automatic requests, never automatic Store publishing. The main +workflow cheaply filters successful stable/correction-tag CI runs and stable +promotion runs with a current, unexpired release witness before minting the +request token. Preview-only promotion, PR, alpha, ordinary-main, failed, and +foreign-repository runs do not request publication. The central +publisher still makes the authoritative release and artifact decision. + +Unset the variable or set it to `false` to pause future automatic requests. +This does not cancel an accepted central request or an active Store upload. +Native request concurrency does not replace central per-product publication +serialization or approval. -Only the multi-architecture Store `.msixbundle` is uploaded. The publisher checks -its identity against repository policy and the actual Partner Center product -before creating a draft. It preserves listing, pricing, availability, and other -product metadata while replacing the packages with the selected bundle. - -## Enable automatic delivery - -After the manual rehearsal and release acceptance, set the **repository** Actions -variable `MSSTORE_ENABLED` to `true`. It must be repository-scoped because the -source job checks it before entering the protected environment. Successful eligible -release runs then trigger **Submit Microsoft Store** through `workflow_run`. -PRs, ordinary main builds, failed runs, and prereleases do not submit. Ineligible -automatic runs are skipped; selecting an ineligible run manually fails explicitly. +## Verify and recover -Remove the variable or set it to `false` to pause future automatic submissions. -This does not cancel a submission already in progress. Both manual and automatic -runs share one concurrency group and never cancel an active Store upload. +The native workflow summary confirms only that GitHub accepted the central +request. Retain that workflow URL and the corresponding central run URL. The +central publisher owns the approval, submission receipt, exact bundle SHA-256, +Store product and submission IDs, metadata-preservation evidence, and commit +result. Partner Center's `CommitStarted` means commit accepted, not certification +or public rollout completed. Follow those states in Partner Center. -## Verify and recover +If request dispatch fails or its response is lost, inspect central Actions for +an existing request before retrying. If central source verification fails, +resolve the reported source, witness, or artifact issue rather than bypassing +it. If a bundle expired, produce and validate a new candidate through the +release process. -A successful submission means Partner Center returned `CommitStarted`, not that -certification or public rollout has finished. Retain the Store workflow URL and -its 90-day submission evidence artifact. The evidence includes the bundle SHA-256, -product ID, exact submission ID, metadata hashes, and commit acceptance. Follow -certification and publication in Partner Center before declaring Store delivery -complete. The current policy requests a 100% package rollout and preserves the -product's publication mode, including manual or scheduled publication. - -Before dispatch, finish or explicitly discard any existing draft in Partner -Center. Automation rejects existing drafts and never replaces them. Do not edit -an API-created draft in the portal while automation runs. A failure before commit -can remove only the draft created by this run. A lost or ambiguous commit response -preserves that draft: inspect Partner Center before retrying. Never blindly rerun -a submission that may already have been accepted. If the release artifact expired, -use the release process to produce and validate a new candidate. - -See Microsoft's [submission API](https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions) -and [application identity resource](https://learn.microsoft.com/en-us/windows/uwp/monetize/get-app-data). +For Store draft or ambiguous-commit recovery, use the central publisher's +runbook and Partner Center state. Do not rerun a possibly accepted submission +or restore native Store credentials. A denied or waiting central approval is +not a failed build and must not cause the native release to be rebuilt. ## Source binding -The release pipeline records `store-release-source-` only after stable -GitHub publication and bundle validation succeed. The submission workflow checks -that witness against the source run, its current attempt, the published stable -tag and commit, and the downloaded bundle hash. Promotion keeps its product source -separate from the main pipeline commit. Releases created before this witness was -introduced cannot be used as manual submission sources. +The native release pipeline still records `store-release-source-` only +after stable GitHub publication and bundle validation succeed. The central +publisher verifies that witness against the canonical source run, its current +attempt, the published stable tag and commit, and the downloaded bundle hash. +Promotion keeps its product source separate from the main pipeline commit. +Releases predating the witness cannot be selected. This request adapter does +not duplicate that authorization policy or accept caller-supplied product +identities, artifact hashes, scripts, or publishing credentials. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4a3b942b04..d2ae1a57de 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -730,11 +730,13 @@ extract the matching architecture archive and follow its instructions, including explicitly trusting the included public certificate. Unsigned Store submission packages, the multi-architecture bundle, and their -metadata remain Actions artifacts only. Use [Store submission](MICROSOFT_STORE.md) -to upload the validated unsigned bundle from a successful release run. No -production signing step is applied to those Store inputs. +metadata remain Actions artifacts only. Use [Store publication requests](MICROSOFT_STORE.md) +to request release-manager-approved central publication of the validated unsigned +bundle from a successful release run. No production signing step is applied to +those Store inputs. -Automatic Partner Center submission is staged behind `MSSTORE_ENABLED`. +Automatic central publication requests are staged behind `MSSTORE_ENABLED`. +The variable never bypasses central release-manager approval. Store-signed retrieval and publication, and official lifecycle acceptance remain release gates tracked in #1375. Submission automation and release artifacts do not clear those rollout gates. @@ -1062,6 +1064,12 @@ only receive a stable tag through the controlled promotion workflow above. - CI computes GitVersion outputs for artifact naming, while product builds use GitVersion-backed assembly metadata. -## Automated Store submission +## Central Store publication -See [Microsoft Store submission](MICROSOFT_STORE.md) for protected OIDC setup, manual submission from an existing release run, automatic activation, and recovery. Automatic submission remains off until the repository variable `MSSTORE_ENABLED` is `true`; the existing release and artifact handoffs are unchanged. +See [Store publication requests](MICROSOFT_STORE.md) for the central publishing +boundary, required administrator cutover, manual requests from existing release +runs, automatic request activation, and recovery. This repository only sends +requests to `openclaw/releases`; it does not hold Store publishing authority. +Automatic requests remain off until the repository variable `MSSTORE_ENABLED` +is `true`. The stable-promotion approval, MSIX version reservations, unsigned +x64/ARM64 artifacts, and release witness remain unchanged. diff --git a/scripts/Get-StoreSubmissionSource.ps1 b/scripts/Get-StoreSubmissionSource.ps1 deleted file mode 100644 index ec5ec812e6..0000000000 --- a/scripts/Get-StoreSubmissionSource.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][ValidatePattern('^[1-9][0-9]*$')][string]$RunId, - [scriptblock]$ApiInvoker, - [scriptblock]$DownloadInvoker -) -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -$repository = 'openclaw/openclaw-windows-node' -if ($null -eq $ApiInvoker) { - $ApiInvoker = { - param($Path) - $json = & gh api --paginate --slurp "repos/$repository/$Path" - if ($LASTEXITCODE -ne 0) { throw 'Unable to read Store source provenance from GitHub.' } - return @($json | ConvertFrom-Json) - } -} -if ($null -eq $DownloadInvoker) { - $DownloadInvoker = { - param($Name, $Directory) - & gh run download $RunId --repo $repository --name $Name --dir $Directory - if ($LASTEXITCODE -ne 0) { throw 'Unable to download Store release witness.' } - } -} -$run = @(& $ApiInvoker "actions/runs/$RunId")[0] - -$tagRun = $run.path -ceq '.github/workflows/ci.yml' -and - $run.event -cin @('push', 'workflow_dispatch') -and - $run.head_branch -cmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?$' -$promotionRun = $run.path -ceq '.github/workflows/promote-alpha-release.yml' -and - $run.event -ceq 'workflow_dispatch' -and $run.head_branch -ceq 'main' -if (-not ($tagRun -or $promotionRun)) { return $null } -if ($run.repository.full_name -cne $repository -or $run.head_repository.full_name -cne $repository -or - [string]$run.id -cne $RunId -or $run.status -cne 'completed' -or $run.conclusion -cne 'success') { - throw 'Store submission requires a successful completed run in the canonical repository.' -} -$pages = @(& $ApiInvoker "actions/runs/$RunId/artifacts?per_page=100") -$artifacts = @($pages | ForEach-Object { $_.artifacts }) -$selected = @($artifacts | Where-Object name -CEQ 'openclaw-msix-store-unsigned-bundle') -if ($selected.Count -eq 0) { return $null } -if ($selected.Count -ne 1 -or $selected[0].expired) { throw 'Store bundle artifact is ambiguous or expired.' } -$witnessName = "store-release-source-$($run.run_attempt)" -$witnessArtifacts = @($artifacts | Where-Object name -CEQ $witnessName) -if ($witnessArtifacts.Count -ne 1 -or $witnessArtifacts[0].expired) { - throw 'The release run has no current, unexpired Store release witness.' -} -$directory = Join-Path ([IO.Path]::GetTempPath()) "store-source-$([guid]::NewGuid().ToString('N'))" -New-Item $directory -ItemType Directory | Out-Null -try { - & $DownloadInvoker $witnessName $directory | Out-Null - $witness = Get-Content (Join-Path $directory 'store-source.json') -Raw | ConvertFrom-Json - if ([string]$witness.runId -cne $RunId -or [int]$witness.runAttempt -ne [int]$run.run_attempt -or - $witness.releaseTag -cnotmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?$' -or - $witness.sourceSha -cnotmatch '^[0-9a-f]{40}$' -or - $witness.bundleSha256 -cnotmatch '^[0-9a-f]{64}$') { - throw 'Store release witness does not match this run and attempt.' - } - if ($tagRun -and ($witness.releaseTag -cne $run.head_branch -or $witness.sourceSha -cne $run.head_sha)) { - throw 'Store release witness does not match the release tag run.' - } - $release = @(& $ApiInvoker "releases/tags/$($witness.releaseTag)")[0] - $commit = @(& $ApiInvoker "commits/$($witness.releaseTag)")[0] - if ($release.draft -or $release.prerelease -or -not $release.published_at -or - $release.tag_name -cne $witness.releaseTag -or $commit.sha -cne $witness.sourceSha) { - throw 'Store source is not the published stable release commit.' - } - $bundleName = 'OpenClaw.msixbundle' - $bundleSha256 = [string]$witness.bundleSha256 -} finally { Remove-Item $directory -Recurse -Force } - -[pscustomobject]@{ - artifactId = [string]$selected[0].id - bundleName = $bundleName - bundleSha256 = $bundleSha256 -} diff --git a/scripts/New-GitHubOidcRequestUri.ps1 b/scripts/New-GitHubOidcRequestUri.ps1 deleted file mode 100644 index 303af7f32a..0000000000 --- a/scripts/New-GitHubOidcRequestUri.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -[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/StorePublicationRequest.ps1 b/scripts/StorePublicationRequest.ps1 new file mode 100644 index 0000000000..3ee9c4d56f --- /dev/null +++ b/scripts/StorePublicationRequest.ps1 @@ -0,0 +1,78 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CompanionStoreRequestRunId { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$Ref, + [Parameter(Mandatory)][string]$EventName, + [string]$RunId, + [string]$EventPath + ) + + $canonicalRepository = 'openclaw/openclaw-windows-node' + if ($Repository -cne $canonicalRepository -or $Ref -cne 'refs/heads/main') { + throw 'Request Store publication from main in openclaw/openclaw-windows-node.' + } + if ($EventName -ceq 'workflow_dispatch') { + if ($RunId -cnotmatch '\A[1-9][0-9]*\z') { + throw 'The source run ID must be a canonical positive integer.' + } + return $RunId + } + if ($EventName -cne 'workflow_run') { + throw 'Store publication requests require workflow_dispatch or workflow_run.' + } + + $run = (Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json).workflow_run + if ($run.status -cne 'completed' -or $run.conclusion -cne 'success' -or + $run.repository.full_name -cne $canonicalRepository -or + $run.head_repository.full_name -cne $canonicalRepository) { + return $null + } + + # This is only a cheap event filter. The central publisher owns provenance, + # artifact authorization, and release-manager approval. + $tagRun = $run.path -ceq '.github/workflows/ci.yml' -and + $run.event -cin @('push', 'workflow_dispatch') -and + $run.head_branch -cmatch '\Av[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?\z' + $promotionRun = $run.path -ceq '.github/workflows/promote-alpha-release.yml' -and + $run.event -ceq 'workflow_dispatch' -and $run.head_branch -ceq 'main' + if (-not ($tagRun -or $promotionRun)) { return $null } + + $sourceRunId = [string]$run.id + if ($sourceRunId -cnotmatch '\A[1-9][0-9]*\z') { + throw 'The source run ID must be a canonical positive integer.' + } + if ($promotionRun) { + # A successful promotion preview has no prepared release. Its completed + # event alone must not mint a cross-repository publication request. + $attempt = [string]$run.run_attempt + if ($attempt -cnotmatch '\A[1-9][0-9]*\z') { + throw 'The source run attempt must be a canonical positive integer.' + } + $json = & gh api --paginate --slurp "repos/$canonicalRepository/actions/runs/$sourceRunId/artifacts?per_page=100" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read promotion release evidence.' } + $witnesses = @($json | ConvertFrom-Json | ForEach-Object { $_.artifacts } | + Where-Object { $_.name -ceq "store-release-source-$attempt" -and -not $_.expired }) + if ($witnesses.Count -ne 1) { return $null } + } + return $sourceRunId +} + +function Request-CompanionStorePublication { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('\A[1-9][0-9]*\z')] + [string]$RunId + ) + + & gh workflow run windows-store-publish.yml --repo openclaw/releases --ref main ` + -f product=companion -f "run_id=$RunId" | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'The central publication request was not confirmed. Inspect openclaw/releases Actions before retrying.' + } + Write-Host "Central Store publication requested for Companion source run $RunId. Release-manager approval and Store acceptance are separate." +} diff --git a/scripts/Submit-MicrosoftStore.ps1 b/scripts/Submit-MicrosoftStore.ps1 deleted file mode 100644 index c3debca32d..0000000000 --- a/scripts/Submit-MicrosoftStore.ps1 +++ /dev/null @@ -1,375 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string]$BundlePath, - [Parameter(Mandatory)][string]$ApplicationId, - [Parameter(Mandatory)][string]$PackageIdentityName, - [Parameter(Mandatory)][string]$Publisher, - [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' - )) { - # Conditional metadata is omitted by valid Store responses. Preserve it - # when applicable, without requiring an Immediate submission's date. - if ($name -eq 'TargetPublishDate' -and $Submission.TargetPublishMode -ne 'SpecificDate') { continue } - $property = $Submission.PSObject.Properties[$name] - if ($name -in @('ApplicationCategory', 'Pricing', 'Visibility', 'TargetPublishMode', 'Listings', 'TargetPublishDate')) { - $metadata[$name] = ConvertTo-CanonicalValue (Get-RequiredProperty $Submission $name) - } else { - $metadata[$name] = if ($null -eq $property) { $null } else { ConvertTo-CanonicalValue $property.Value } - } - } - $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) - # Partner Center owns package IDs, architecture, version and upload status. - # Compare only our requested files/actions and editable delivery settings. - $packages = @(foreach ($package in @(Get-RequiredProperty $Submission 'ApplicationPackages')) { - $status = [string](Get-RequiredProperty $package 'FileStatus') - if ($status -notin @('PendingUpload', 'Uploaded', 'PendingDelete')) { throw 'Unexpected Store package status.' } - [ordered]@{ - FileName = [string](Get-RequiredProperty $package 'FileName') - Action = if ($status -eq 'PendingDelete') { 'delete' } else { 'upload' } - MinimumDirectXVersion = if ($null -ne $package.PSObject.Properties['minimumDirectXVersion']) { $package.PSObject.Properties['minimumDirectXVersion'].Value } else { $null } - MinimumSystemRam = if ($null -ne $package.PSObject.Properties['minimumSystemRam']) { $package.PSObject.Properties['minimumSystemRam'].Value } else { $null } - } - }) - $delivery = ConvertTo-CanonicalValue (Get-RequiredProperty $Submission 'PackageDeliveryOptions') - if ($null -ne $delivery -and $null -ne $delivery['PackageRollout']) { - $delivery['PackageRollout'].Remove('PackageRolloutStatus') - $delivery['PackageRollout'].Remove('FallbackSubmissionId') - } - $mutationState = [ordered]@{ - ApplicationPackages = @($packages | Sort-Object { $_.FileName }) - PackageDeliveryOptions = $delivery - } - $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" } -$bundleArchive = [IO.Compression.ZipFile]::OpenRead($resolvedBundle) -try { - $manifestEntry = $bundleArchive.GetEntry('AppxMetadata/AppxBundleManifest.xml') - if ($null -eq $manifestEntry) { throw 'Bundle manifest is missing.' } - $reader = [IO.StreamReader]::new($manifestEntry.Open()) - try { [xml]$manifest = $reader.ReadToEnd() } finally { $reader.Dispose() } - if ([string]$manifest.Bundle.Identity.Name -cne $PackageIdentityName -or - [string]$manifest.Bundle.Identity.Publisher -cne $Publisher) { - throw 'Bundle does not match the approved Store package identity.' - } -} finally { $bundleArchive.Dispose() } - -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 ([string](Get-RequiredProperty $application 'PackageIdentityName') -cne $PackageIdentityName -or - [string](Get-RequiredProperty $application 'PublisherName') -cne $Publisher) { - throw 'Microsoft Store product does not match the approved package identity.' - } - 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) { - # Even a 100% gradual rollout needs manual finalization before the next submission. - $packageRollout.IsPackageRollout = $rollout -lt 100 - $packageRollout.PackageRolloutPercentage = $rollout - } - } - - $draftMutationHash = Get-SubmissionMutationHash $draft - $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 - - 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-StoreSubmissionSource.Tests.ps1 b/scripts/Test-StoreSubmissionSource.Tests.ps1 deleted file mode 100644 index f9886f6781..0000000000 --- a/scripts/Test-StoreSubmissionSource.Tests.ps1 +++ /dev/null @@ -1,109 +0,0 @@ -[CmdletBinding()] -param() -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -$scriptPath = Join-Path $PSScriptRoot 'Get-StoreSubmissionSource.ps1' -$repository = 'openclaw/openclaw-windows-node' -$script:checks = 0 -function Assert-Fails([scriptblock]$Action, [string]$Pattern) { - try { & $Action } catch { - if ($_.Exception.Message -notmatch $Pattern) { throw } - $script:checks++ - return - } - throw "Expected rejection: $Pattern" -} -function Reset-Fixture { - $global:StoreSourceTest_run = [pscustomobject]@{ - id = 123; status = 'completed'; conclusion = 'success'; run_attempt = 2 - repository = [pscustomobject]@{ full_name = $repository } - head_repository = [pscustomobject]@{ full_name = $repository } - path = '.github/workflows/ci.yml'; event = 'push'; head_branch = 'v2026.10.21'; head_sha = ('a' * 40) - } - $global:StoreSourceTest_artifacts = @([pscustomobject]@{ id = 456; name = 'openclaw-msix-store-unsigned-bundle'; expired = $false }) - $global:StoreSourceTest_artifacts += [pscustomobject]@{ id = 789; name = 'store-release-source-2'; expired = $false } - $global:StoreSourceTest_witness = [pscustomobject]@{ runId = '123'; runAttempt = 2; releaseTag = 'v2026.10.21'; sourceSha = ('a' * 40); bundleSha256 = ('b' * 64) } - $global:StoreSourceTest_release = [pscustomobject]@{ draft = $false; prerelease = $false; published_at = '2026-10-21'; tag_name = 'v2026.10.21' } - $global:StoreSourceTest_commit = [pscustomobject]@{ sha = ('a' * 40) } -} -$api = { - param($Path) - switch -Wildcard ($Path) { - 'actions/runs/123' { return $global:StoreSourceTest_run } - 'actions/runs/123/artifacts*' { return [pscustomobject]@{ artifacts = $global:StoreSourceTest_artifacts } } - 'releases/tags/v2026.10.21' { return $global:StoreSourceTest_release } - 'commits/v2026.10.21' { return $global:StoreSourceTest_commit } - default { throw "Unexpected API path: $Path" } - } -} -$download = { - param($Name, $Directory) - if ($Name -cne 'store-release-source-2') { throw 'Wrong release witness downloaded.' } - $global:StoreSourceTest_witness | ConvertTo-Json | Set-Content (Join-Path $Directory 'store-source.json') -Encoding utf8 -} -function Invoke-Source { & $scriptPath -RunId 123 -ApiInvoker $api -DownloadInvoker $download } -Reset-Fixture -$result = Invoke-Source -if ($result.artifactId -cne '456') { throw 'Authorized release selected the wrong artifact.' } -$script:checks++ -foreach ($field in @('conclusion', 'status')) { - Reset-Fixture - $global:StoreSourceTest_run.$field = 'failure' - Assert-Fails { Invoke-Source } 'successful completed run' -} -Reset-Fixture -$global:StoreSourceTest_run.head_repository.full_name = 'fork/untrusted' -Assert-Fails { Invoke-Source } 'canonical repository' -Reset-Fixture -$global:StoreSourceTest_run.event = 'pull_request' -$global:StoreSourceTest_run.head_repository.full_name = 'fork/untrusted' -if ($null -ne (Invoke-Source)) { throw 'A fork PR was not skipped.' } -$script:checks++ -foreach ($field in @('path', 'event', 'head_branch')) { - Reset-Fixture - $global:StoreSourceTest_run.$field = 'pull_request' - if ($null -ne (Invoke-Source)) { throw "Untrusted $field was eligible." } - $script:checks++ -} -Reset-Fixture -$global:StoreSourceTest_artifacts[0].expired = $true -Assert-Fails { Invoke-Source } 'ambiguous or expired' -Reset-Fixture -$global:StoreSourceTest_artifacts += $global:StoreSourceTest_artifacts[0] -Assert-Fails { Invoke-Source } 'ambiguous or expired' -Reset-Fixture -$global:StoreSourceTest_artifacts = @() -if ($null -ne (Invoke-Source)) { throw 'A run without an authorized bundle was eligible.' } -$script:checks++ -Reset-Fixture -$global:StoreSourceTest_run.head_branch = 'v2026.10.21-alpha.1' -if ($null -ne (Invoke-Source)) { throw 'Alpha was eligible.' } -$script:checks++ -Reset-Fixture -$global:StoreSourceTest_run.path = '.github/workflows/promote-alpha-release.yml' -$global:StoreSourceTest_run.event = 'workflow_dispatch' -$global:StoreSourceTest_run.head_branch = 'main' -$global:StoreSourceTest_run.head_sha = ('c' * 40) -if ((Invoke-Source).bundleSha256 -cne ('b' * 64)) { throw 'Promotion did not bind the product source and bundle.' } -$script:checks++ -foreach ($field in @('runId', 'runAttempt', 'releaseTag', 'sourceSha', 'bundleSha256')) { - Reset-Fixture - $global:StoreSourceTest_witness.$field = if ($field -eq 'runAttempt') { 1 } else { '1' } - Assert-Fails { Invoke-Source } 'witness does not match' -} -Reset-Fixture -$global:StoreSourceTest_artifacts = @($global:StoreSourceTest_artifacts[0]) -Assert-Fails { Invoke-Source } 'no current, unexpired' -Reset-Fixture -$global:StoreSourceTest_release.prerelease = $true -Assert-Fails { Invoke-Source } 'published stable release' -Reset-Fixture -$global:StoreSourceTest_commit.sha = ('d' * 40) -Assert-Fails { Invoke-Source } 'published stable release' -Reset-Fixture -$global:StoreSourceTest_run.head_sha = ('d' * 40) -Assert-Fails { Invoke-Source } 'release tag run' - -Write-Host "Store source authorization: $script:checks scenarios passed." - -Get-Variable -Name StoreSourceTest_* -Scope Global | Remove-Variable -Scope Global diff --git a/scripts/test-microsoft-store-submission.ps1 b/scripts/test-microsoft-store-submission.ps1 deleted file mode 100644 index 89f391a37d..0000000000 --- a/scripts/test-microsoft-store-submission.ps1 +++ /dev/null @@ -1,350 +0,0 @@ -[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 = 'Uploaded'; minimumDirectXVersion = 'None'; minimumSystemRam = 'None' }, - [pscustomobject]@{ FileName = 'old-arm64.msix'; FileStatus = 'Uploaded'; minimumDirectXVersion = 'None'; minimumSystemRam = 'None' } - ) - 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_wrongIdentity = $false - $global:OpenClawStoreTest_serverFields = $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 - PackageIdentityName = if ($global:OpenClawStoreTest_wrongIdentity) { 'Other.Product' } else { 'Test.OpenClaw' } - PublisherName = 'CN=Test' - 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_serverFields) { - $package = $global:OpenClawStoreTest_draft.ApplicationPackages[-1] - $package.FileStatus = 'Uploaded' - $package | Add-Member Id 'server-package-id' - $package | Add-Member Architecture 'x64' - $global:OpenClawStoreTest_draft.PackageDeliveryOptions.PackageRollout | Add-Member PackageRolloutStatus 'PackageRolloutNotStarted' - $global:OpenClawStoreTest_draft.PackageDeliveryOptions.PackageRollout | Add-Member FallbackSubmissionId 'published-1' - } - 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 ` - -PackageIdentityName 'Test.OpenClaw' -Publisher 'CN=Test' ` - -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 { - $bundleRoot = Join-Path $testRoot 'bundle/AppxMetadata' - New-Item $bundleRoot -ItemType Directory -Force | Out-Null - [IO.File]::WriteAllText((Join-Path $bundleRoot 'AppxBundleManifest.xml'), '') - [IO.Compression.ZipFile]::CreateFromDirectory((Split-Path $bundleRoot -Parent), (Join-Path $testRoot 'OpenClaw.msixbundle')) - [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 - $global:OpenClawStoreTest_published.PackageDeliveryOptions.PackageRollout.IsPackageRollout = $true - $global:OpenClawStoreTest_published.PackageDeliveryOptions.PackageRollout.PackageRolloutPercentage = 25 - Invoke-Submission - $delivery = $global:OpenClawStoreTest_draft.PackageDeliveryOptions.PackageRollout - if ($delivery.IsPackageRollout -ne $false -or $delivery.PackageRolloutPercentage -ne 100) { - throw 'Full distribution must disable gradual rollout so another submission can follow without manual finalization.' - } - 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 - $partialPolicy = Get-Content -LiteralPath (Join-Path $testRoot 'policy.json') -Raw | ConvertFrom-Json - $partialPolicy.packageRolloutPercentage = 25 - $partialPolicyPath = Join-Path $testRoot 'partial-policy.json' - [IO.File]::WriteAllText($partialPolicyPath, ($partialPolicy | ConvertTo-Json)) - Invoke-Submission -PolicyPath $partialPolicyPath - $delivery = $global:OpenClawStoreTest_draft.PackageDeliveryOptions.PackageRollout - if ($delivery.IsPackageRollout -ne $true -or $delivery.PackageRolloutPercentage -ne 25) { - throw 'Partial distribution must retain the requested gradual rollout.' - } - - Reset-Fixture - $global:OpenClawStoreTest_wrongIdentity = $true - Assert-Fails -MessagePattern 'product does not match' -Action { Invoke-Submission } - if ($global:OpenClawStoreTest_calls -match 'isMinimalResponse') { throw 'Wrong-product preflight created a draft.' } - - Reset-Fixture - $global:OpenClawStoreTest_serverFields = $true - $global:OpenClawStoreTest_published.PSObject.Properties.Remove('TargetPublishDate') - Invoke-Submission - - 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/scripts/test-store-publication-request.ps1 b/scripts/test-store-publication-request.ps1 new file mode 100644 index 0000000000..da3aa4b586 --- /dev/null +++ b/scripts/test-store-publication-request.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'StorePublicationRequest.ps1') + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "companion-store-request-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $testRoot | Out-Null +$eventPath = Join-Path $testRoot 'event.json' +$script:checks = 0 +$script:calls = [Collections.Generic.List[object]]::new() +$script:dispatchExitCode = 0 + +function gh { + if ($args[0] -ceq 'api') { + $expected = @('api', '--paginate', '--slurp', 'repos/openclaw/openclaw-windows-node/actions/runs/123/artifacts?per_page=100') + if (($args -join '|') -cne ($expected -join '|')) { throw 'Unexpected artifact request.' } + $global:LASTEXITCODE = 0 + return ConvertTo-Json -InputObject @(@{ artifacts = $script:artifacts }) -Depth 10 + } + $script:calls.Add(@($args)) + $global:LASTEXITCODE = $script:dispatchExitCode +} + +function Reset-Fixture { + $script:calls.Clear() + $script:dispatchExitCode = 0 + $script:artifacts = @(@{ name = 'store-release-source-2'; expired = $false }) + $script:event = @{ + workflow_run = @{ + id = 123; run_attempt = 2; status = 'completed'; conclusion = 'success' + repository = @{ full_name = 'openclaw/openclaw-windows-node' } + head_repository = @{ full_name = 'openclaw/openclaw-windows-node' } + path = '.github/workflows/ci.yml'; event = 'push'; head_branch = 'v2026.10.21' + } + } +} + +function Invoke-Request { + param( + [string]$Repository = 'openclaw/openclaw-windows-node', + [string]$Ref = 'refs/heads/main', + [string]$EventName = 'workflow_run', + [string]$RunId + ) + [IO.File]::WriteAllText($eventPath, ($script:event | ConvertTo-Json -Depth 10)) + $selected = Get-CompanionStoreRequestRunId -Repository $Repository -Ref $Ref ` + -EventName $EventName -RunId $RunId -EventPath $eventPath + if ($null -ne $selected) { Request-CompanionStorePublication -RunId $selected } + return $selected +} + +function Assert-Requested { + param([string]$RunId) + $expected = @('workflow', 'run', 'windows-store-publish.yml', '--repo', 'openclaw/releases', + '--ref', 'main', '-f', 'product=companion', '-f', "run_id=$RunId") + if ($script:calls.Count -ne 1 -or + ($script:calls[0] -join '|') -cne ($expected -join '|')) { + throw 'The request did not dispatch the fixed central main publisher with the exact source run ID.' + } + $script:checks++ +} + +function Assert-Skipped { + if ($null -ne (Invoke-Request) -or $script:calls.Count -ne 0) { + throw 'An ineligible completed event dispatched a central publication request.' + } + $script:checks++ +} + +function Assert-Rejected { + param([scriptblock]$Action, [string]$Pattern) + try { & $Action | Out-Null } catch { + if ($_.Exception.Message -notmatch $Pattern) { throw } + if ($script:calls.Count -ne 0) { throw 'A rejected request reached GitHub.' } + $script:checks++ + return + } + throw "Expected request rejection: $Pattern" +} + +try { + Reset-Fixture + Invoke-Request -EventName workflow_dispatch -RunId 456 | Out-Null + Assert-Requested 456 + + Reset-Fixture + Invoke-Request | Out-Null + Assert-Requested 123 + + foreach ($artifactCase in @( + @{ items = @() }, + @{ items = @(@{ name = 'store-release-source-1'; expired = $false }) }, + @{ items = @(@{ name = 'store-release-source-2'; expired = $true }) } + )) { + Reset-Fixture + $script:event.workflow_run.path = '.github/workflows/promote-alpha-release.yml' + $script:event.workflow_run.event = 'workflow_dispatch' + $script:event.workflow_run.head_branch = 'main' + $script:artifacts = $artifactCase.items + Assert-Skipped + } + + Reset-Fixture + $script:event.workflow_run.event = 'workflow_dispatch' + $script:event.workflow_run.head_branch = 'v2026.10.21-1' + Invoke-Request | Out-Null + Assert-Requested 123 + + Reset-Fixture + $script:event.workflow_run.path = '.github/workflows/promote-alpha-release.yml' + $script:event.workflow_run.event = 'workflow_dispatch' + $script:event.workflow_run.head_branch = 'main' + Invoke-Request | Out-Null + Assert-Requested 123 + + foreach ($runId in @('', '0', '0123', '-1', '1e3', '123/other', "123`n")) { + Reset-Fixture + Assert-Rejected { Invoke-Request -EventName workflow_dispatch -RunId $runId } 'canonical positive integer' + } + foreach ($origin in @( + @{ Repository = 'fork/openclaw-windows-node' }, + @{ Ref = 'refs/heads/feature' }, + @{ EventName = 'pull_request' } + )) { + Reset-Fixture + Assert-Rejected { Invoke-Request @origin -RunId 123 } 'main in|workflow_dispatch or workflow_run' + } + + foreach ($change in @( + @{ Field = 'status'; Value = 'in_progress' }, + @{ Field = 'conclusion'; Value = 'failure' }, + @{ Field = 'path'; Value = '.github/workflows/other.yml' }, + @{ Field = 'event'; Value = 'pull_request' }, + @{ Field = 'head_branch'; Value = 'main' }, + @{ Field = 'head_branch'; Value = 'v2026.10.21-alpha.1' }, + @{ Field = 'head_branch'; Value = "v2026.10.21`n" } + )) { + Reset-Fixture + $script:event.workflow_run[$change.Field] = $change.Value + Assert-Skipped + } + foreach ($field in @('repository', 'head_repository')) { + Reset-Fixture + $script:event.workflow_run[$field].full_name = 'fork/openclaw-windows-node' + Assert-Skipped + } + foreach ($change in @( + @{ Field = 'event'; Value = 'push' }, + @{ Field = 'head_branch'; Value = 'v2026.10.21' } + )) { + Reset-Fixture + $script:event.workflow_run.path = '.github/workflows/promote-alpha-release.yml' + $script:event.workflow_run.event = 'workflow_dispatch' + $script:event.workflow_run.head_branch = 'main' + $script:event.workflow_run[$change.Field] = $change.Value + Assert-Skipped + } + + Reset-Fixture + $script:event.workflow_run.id = '0123' + Assert-Rejected { Invoke-Request } 'canonical positive integer' + + Reset-Fixture + Assert-Rejected { Request-CompanionStorePublication -RunId "123`n" } 'ValidatePattern|does not match' + + Reset-Fixture + $script:dispatchExitCode = 1 + try { + Invoke-Request | Out-Null + throw 'Expected an unconfirmed request failure.' + } catch { + if ($_.Exception.Message -notmatch 'not confirmed.*before retrying') { throw } + } + Assert-Requested 123 + $global:LASTEXITCODE = 0 + + Write-Host "Companion Store publication requests: $script:checks scenarios passed." +} finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force +} diff --git a/store-submission.json b/store-submission.json deleted file mode 100644 index b2f37da8ce..0000000000 --- a/store-submission.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 -}