diff --git a/.github/msix-version-baseline.json b/.github/msix-version-baseline.json new file mode 100644 index 000000000..3c21b0183 --- /dev/null +++ b/.github/msix-version-baseline.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "lastAllocated": { + "2026.9.4": 400 + }, + "evidence": { + "2026.9.4": { + "storePackageVersion": "2026.9.400.0", + "workflowRun": "https://github.com/natalie-aguinaldo/openclaw-windows-node/actions/runs/35303248183", + "workflowHeadCommit": "01f2bf7ef407f8b9125f40bf1a4638c0361818c2", + "packageSourceCommit": "ded1d4aed4d1a69859a817dc57087f2c6142deab", + "artifacts": { + "x64": { + "artifactId": 10531666502, + "packageSha256": "4612ff57a12489584a853a5eed38f687d6a8ac2f89836eb74c4eab8b1514d4db" + }, + "arm64": { + "artifactId": 10531117192, + "packageSha256": "febd4c4db99d153eea85c97000dd3583364ce671c3e8598bd7bf972a89cc788d" + } + } + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7993ebe97..fd72d177a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,14 @@ jobs: shell: pwsh run: ./scripts/test-msix-ci-artifacts.ps1 + - name: Validate MSIX version allocation + shell: pwsh + run: ./scripts/test-msix-versioning.ps1 + + - name: Validate MSIX preview source selection + shell: pwsh + run: ./scripts/test-msix-preview-source-version.ps1 + - name: Validate Store MSIX alpha release assets shell: pwsh run: ./scripts/test-msix-alpha-release.ps1 @@ -163,6 +171,8 @@ jobs: isPrerelease: ${{ steps.release_version.outputs.isPrerelease }} isStableCorrection: ${{ steps.release_version.outputs.isStableCorrection }} isMsixAlpha: ${{ steps.release_version.outputs.isMsixAlpha }} + msixVersionInfo: ${{ steps.msix_preview.outputs.versionInfo }} + msixSourceVersion: ${{ steps.msix_preview.outputs.sourceVersion }} steps: - uses: actions/checkout@v7 with: @@ -224,6 +234,51 @@ jobs: $isMsixAlpha = $isPrerelease -and ($env:GITHUB_REF -cmatch '^refs/tags/v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-alpha\.(?:0|[1-9]\d*)$') "isMsixAlpha=$($isMsixAlpha.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + - name: Resolve MSIX preview version + id: msix_preview + if: ${{ !(github.repository == 'openclaw/openclaw-windows-node' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')) }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $sourceVersion = .\scripts\Get-OpenClawMsixPreviewSourceVersion.ps1 ` + -GitHubToken $env:GH_TOKEN + $info = .\scripts\Resolve-MsixPackageVersion.ps1 ` + -SourceVersion $sourceVersion -SourceCommit $env:GITHUB_SHA ` + -SourceRef $env:GITHUB_REF -Repository openclaw/openclaw-windows-node ` + -GitHubToken $env:GH_TOKEN + "sourceVersion=$sourceVersion" >> $env:GITHUB_OUTPUT + "versionInfo=$($info | ConvertTo-Json -Depth 4 -Compress)" >> $env:GITHUB_OUTPUT + + reserve-msix-version: + name: Reserve official MSIX version + needs: [change-classification, metadata] + if: ${{ !cancelled() && needs.metadata.result == 'success' && github.repository == 'openclaw/openclaw-windows-node' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + runs-on: windows-latest + permissions: + contents: write + outputs: + versionInfo: ${{ steps.reserve.outputs.versionInfo }} + sourceVersion: ${{ steps.reserve.outputs.sourceVersion }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Reserve or reuse release package version + id: reserve + shell: pwsh + env: + SOURCE_VERSION: ${{ needs.metadata.outputs.semVer }} + GH_TOKEN: ${{ github.token }} + run: | + $info = .\scripts\Resolve-MsixPackageVersion.ps1 ` + -SourceVersion $env:SOURCE_VERSION -SourceCommit $env:GITHUB_SHA ` + -SourceRef $env:GITHUB_REF -Repository $env:GITHUB_REPOSITORY -Reserve + "sourceVersion=$($info.sourceVersion)" >> $env:GITHUB_OUTPUT + "versionInfo=$($info | ConvertTo-Json -Depth 4 -Compress)" >> $env:GITHUB_OUTPUT + core-tests: name: Core and CLI tests needs: [change-classification, fast-validation] @@ -820,13 +875,15 @@ jobs: build-msix: name: MSIX artifacts (${{ matrix.architecture }}) - needs: [change-classification, metadata] - if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} + needs: [change-classification, metadata, reserve-msix-version] + if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped') && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} # Unsigned Store packages may publish to alpha releases only. Dev packages stay workflow-only. runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest' }} env: OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} DEV_MSIX_REVISION: ${{ github.run_number }} + MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo || needs.metadata.outputs.msixVersionInfo }} + MSIX_SOURCE_VERSION: ${{ needs.reserve-msix-version.outputs.sourceVersion || needs.metadata.outputs.msixSourceVersion }} strategy: fail-fast: false matrix: @@ -850,9 +907,23 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- + - name: Validate shared MSIX version allocation + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:MSIX_VERSION_INFO)) { + throw 'Missing MSIX version allocation; refusing to build with a fallback version.' + } + . .\scripts\MsixVersioning.ps1 + $info = Assert-MsixVersionInfo -VersionInfo ($env:MSIX_VERSION_INFO | ConvertFrom-Json) ` + -SourceCommit $env:GITHUB_SHA -SourceVersion $env:MSIX_SOURCE_VERSION + $info | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Encoding utf8 + - name: Build and validate unsigned Store MSIX shell: pwsh - run: .\scripts\Build-StoreMsix.ps1 -Architecture ${{ matrix.architecture }} + run: > + .\scripts\Build-StoreMsix.ps1 -Architecture ${{ matrix.architecture }} + -VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json" - name: Upload unsigned Store submission artifact uses: actions/upload-artifact@v7 @@ -869,20 +940,23 @@ jobs: - name: Build signed Dev MSIX shell: pwsh - run: > - .\build.ps1 -Project WinUI -Configuration Release -Msix Dev - -MsixRevision $env:DEV_MSIX_REVISION - -MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx" + run: | + $info = Get-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Raw | ConvertFrom-Json + .\build.ps1 -Project WinUI -Configuration Release -Msix Dev ` + -MsixRevision $env:DEV_MSIX_REVISION -MsixBaseVersion $info.packageBaseVersion ` + -MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx" - name: Validate and stage Dev tester artifact shell: pwsh run: | + $info = Get-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Raw | ConvertFrom-Json $thumbprint = (Get-Content "$env:LOCALAPPDATA\OpenClawDevelopment\MSIX\dev-msix-thumbprint.txt" -Raw).Trim() .\scripts\Export-DevMsixArtifact.ps1 ` -Architecture ${{ matrix.architecture }} ` -PackageDirectory "$env:RUNNER_TEMP\openclaw-dev-appx" ` -ExpectedRevision $env:DEV_MSIX_REVISION ` - -ExpectedVersion $env:OPENCLAW_BUILD_VERSION ` + -ExpectedVersion $info.packageBaseVersion ` + -VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json" ` -CertificateThumbprint $thumbprint ` -OutputDirectory "artifacts\msix-dev\${{ matrix.architecture }}" @@ -964,8 +1038,8 @@ jobs: "CI Gate passed $validatedMode validation." >> $env:GITHUB_STEP_SUMMARY release: - needs: [change-classification, metadata, build-x64, build-arm64, ci-gate] - if: startsWith(github.ref, 'refs/tags/v') && needs.ci-gate.result == 'success' && needs.change-classification.outputs.full == 'true' && needs.metadata.result == 'success' && needs.build-x64.result == 'success' && needs.build-arm64.result == 'success' && !cancelled() + needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, ci-gate] + if: startsWith(github.ref, 'refs/tags/v') && needs.ci-gate.result == 'success' && needs.change-classification.outputs.full == 'true' && needs.metadata.result == 'success' && needs.reserve-msix-version.result == 'success' && needs.build-x64.result == 'success' && needs.build-arm64.result == 'success' && !cancelled() runs-on: windows-latest environment: release-signing permissions: @@ -1181,11 +1255,17 @@ jobs: shell: pwsh env: RELEASE_VERSION: ${{ needs.metadata.outputs.semVer }} + MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo }} run: | + if ([string]::IsNullOrWhiteSpace($env:MSIX_VERSION_INFO)) { + throw 'Missing official MSIX reservation.' + } + $env:MSIX_VERSION_INFO | Set-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Encoding utf8 $assets = .\scripts\Stage-StoreMsixReleaseAssets.ps1 ` -ArtifactDirectory 'artifacts\msix-alpha' ` -OutputDirectory 'msix-alpha-release' ` -Version $env:RELEASE_VERSION ` + -VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json" ` -ExpectedSourceCommit $env:GITHUB_SHA @('files<> $env:GITHUB_OUTPUT @('notes<> $env:GITHUB_OUTPUT diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 10e51513b..3e9aebd7a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -356,15 +356,88 @@ CI passes `-MsixRevision $env:GITHUB_RUN_NUMBER` to the existing `build.ps1 -Project WinUI -Configuration Release -Msix Dev` path, with a fresh `-MsixOutputDirectory`. Explicit revisions must be 1-65535; overflow fails instead of wrapping. Omitting these options preserves local build behavior. -The same run's reruns keep the same version, not a new upgrade. Version -ordering is not guaranteed across forks, branches, local builds, or decreasing -base versions. Do not uninstall/downgrade an existing Dev package just to +Tagged-release reruns reuse their reserved package base. PR/main previews use +the latest published stable Windows release line from the canonical upstream +repository and do not consume numbers. For example, while Latest is +`v2026.9.4`, previews use the `2026.9.4` allocation range and currently produce +Store `2026.9.402.0`; a future official `v2026.9.5` release still starts in the +`500-599` range. A preview candidate can advance after another official release +reserves a number. Version ordering is not guaranteed across forks, branches, +local builds, or decreasing base versions. +Do not uninstall/downgrade an existing Dev package just to resolve a version conflict without considering its settings and data. +When `-MsixBaseVersion` is omitted, local `-Msix Dev` builds compare the +application-derived base with the installed Dev package and reuse the installed +three-part base when it is higher. The fourth component still increments from +the installed revision. This keeps ordinary local builds upgrade-compatible +after installing an encoded CI Dev package without changing GitVersion. + +CI allocates a separate MSIX base without changing the application's GitVersion, +assembly metadata, EXE/ZIP versions, or GitHub release tags. For app `X.Y.Z`, +the third package component starts at `Z * 100` and advances within that patch's +100-number range. For example: + +| App release line | MSIX Store versions | +|---|---| +| `2026.9.4`, including its alpha/correction tags | `2026.9.400.0` through `2026.9.499.0` | +| `2026.9.5`, including its alpha/correction tags | `2026.9.500.0` through `2026.9.599.0` | +| `2026.10.1`, including its alpha/correction tags | `2026.10.100.0` through `2026.10.199.0` | + +The already-used `2026.9.400.0` is recorded with its workflow and artifact +provenance in `.github/msix-version-baseline.json`. The canonical ledger also +contains reservation `msix-package/2026.9.4/401`, so the next unreserved +`2026.9.4` packaging candidate is `2026.9.402.0`. Correction suffixes do not +occupy their own digit. + +All tags on the same app patch share the counter, including revisions 10, 11, +and onward. Exhaustion fails rather than entering the next patch's range. +Every component must fit `uint16`; patch 655 has only the remaining 65500-65535 +slots, and larger patches cannot be encoded. + +Only `push` or `workflow_dispatch` builds of `v*` tags in the upstream repository +reserve versions. A separate write-scoped job allocates once before the +architecture matrix. PRs, ordinary main builds, and fork builds resolve Latest +from `openclaw/openclaw-windows-node`, then read the next candidate from that +same canonical reservation ledger. They are marked `preview` in +`msixVersionAllocation` in each metadata sidecar and are not official release +or Store-submission versions. +Both Store architectures share the reserved base and end in `.0`. Both Dev +architectures use the same base with the CI run number as the final component. + +The allocator records reservations as append-only annotated Git tags under +`msix-package//`. Concurrent claims use atomic +create-ref requests; reruns of the same source tag/commit reuse their record. +Failed builds keep their reservations, so numbers are never recycled. +Do not delete, move, or repurpose reservation tags, including during alpha +release cleanup. The repository's active `Protect MSIX package reservations` +ruleset blocks deletion and non-fast-forward updates under this namespace while +permitting the official workflow to create refs. Preserve that ruleset as part +of the release contract. +See [MSIX version allocation](docs/RELEASING.md#msix-version-allocation). + +For an encoded local preview, save the readonly resolver result outside tracked +source, then pass it through the validated builder: + +```powershell +$info = .\scripts\Resolve-MsixPackageVersion.ps1 ` + -SourceVersion (.\scripts\Get-OpenClawMsixPreviewSourceVersion.ps1) ` + -SourceCommit (git rev-parse HEAD) ` + -SourceRef "refs/heads/$(git branch --show-current)" ` + -Repository openclaw/openclaw-windows-node +$info | ConvertTo-Json -Depth 4 | + Set-Content "$env:TEMP\openclaw-msix-preview.json" -Encoding utf8 +.\scripts\Build-StoreMsix.ps1 -Architecture x64 ` + -VersionInfoPath "$env:TEMP\openclaw-msix-preview.json" +.\build.ps1 -Project WinUI -Configuration Release -Msix Dev ` + -MsixBaseVersion $info.packageBaseVersion +``` -The Store version stays `X.Y.Z.0`. Prerelease and stable-correction suffixes -can therefore produce the same Store version; CI artifacts do not promise -unique Store submissions for every tag. Store submission version allocation -must be resolved before distribution is enabled in #1375. +`VersionInfoPath` validates the source commit and expected actual package +version before writing metadata. `MsixBaseVersion` changes only the package +manifest base; it does not override the app's assembly versions. +Ordinary local builds that omit these options preserve their previous +unallocated version calculation. Store distribution remains gated by #1375; +this allocator does not submit packages to Partner Center. Canonical `vX.Y.Z-alpha.N` releases also attach the **unsigned Store** MSIX files and architecture-specific metadata, for manual upload to Partner Center. diff --git a/build.ps1 b/build.ps1 index 9161ec2b2..0bc5c32de 100644 --- a/build.ps1 +++ b/build.ps1 @@ -51,6 +51,12 @@ the repository root. The directory is never cleared automatically. Only valid with -Msix Dev; omission preserves the local AppPackages path. +.PARAMETER MsixBaseVersion + Explicit three-part package base from the CI MSIX allocator. Only valid + with -Msix Dev. Does not override the application's GitVersion or assembly + metadata. When omitted, a local build reuses an installed Dev package's + higher three-part base so Windows accepts the build as an upgrade. + .EXAMPLE .\build.ps1 .\build.ps1 -Project WinUI -Configuration Release @@ -83,6 +89,16 @@ param( [ValidateNotNullOrEmpty()] [string]$MsixOutputDirectory, + [ValidatePattern('^[1-9]\d*\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$')] + [ValidateScript({ + foreach ($part in $_.Split('.')) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($part, [ref]$value)) { return $false } + } + return $true + })] + [string]$MsixBaseVersion, + [switch]$NoTrustRepository ) @@ -99,6 +115,9 @@ if (($PSBoundParameters.ContainsKey("MsixRevision") -or throw "-MsixRevision and -MsixOutputDirectory require -Msix Dev." } $explicitMsixRevision = $PSBoundParameters.ContainsKey("MsixRevision") +if ($PSBoundParameters.ContainsKey("MsixBaseVersion") -and -not $buildDevMsix) { + throw "-MsixBaseVersion requires -Msix Dev." +} if ($MsixOutputDirectory) { $MsixOutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repoRoot, $MsixOutputDirectory)) if ((Test-Path -LiteralPath $MsixOutputDirectory) -and @@ -470,6 +489,37 @@ function Invoke-DotNetCaptured($arguments) { } } +function Get-InstalledDevMsixPackage { + Get-AppxPackage -Name "OpenClawFoundation.OpenClaw.Dev" -ErrorAction SilentlyContinue | + Where-Object Publisher -eq "CN=OpenClaw Local Development" | + Sort-Object { [version]$_.Version.ToString() } -Descending | + Select-Object -First 1 +} + +function Get-CurrentAppBaseVersion { + $version = & (Join-Path $repoRoot "scripts\Get-OpenClawVersion.ps1") -Variable MajorMinorPatch + if ($LASTEXITCODE -ne 0 -or $version -notmatch '^[1-9]\d*\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$') { + throw "Could not resolve the current three-part application version for Dev MSIX packaging." + } + + return $version.Trim() +} + +function Select-LocalDevMsixBaseVersion($installedPackageVersion, $appBaseVersion) { + if ($null -eq $installedPackageVersion) { + return $null + } + + $installed = [version]$installedPackageVersion.ToString() + $installedBase = [version]::new($installed.Major, $installed.Minor, $installed.Build) + $appBase = [version]$appBaseVersion + if ($installedBase -le $appBase) { + return $null + } + + return "$($installed.Major).$($installed.Minor).$($installed.Build)" +} + function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { Write-Host "`nBuilding $name..." -ForegroundColor White @@ -479,13 +529,23 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { } if ($packageMsix) { + $installedDevPackage = if (-not $explicitMsixRevision -or -not $MsixBaseVersion) { + Get-InstalledDevMsixPackage + } else { + $null + } + $effectiveMsixBaseVersion = if ($MsixBaseVersion) { + $MsixBaseVersion + } elseif ($installedDevPackage) { + Select-LocalDevMsixBaseVersion ` + -installedPackageVersion $installedDevPackage.Version ` + -appBaseVersion (Get-CurrentAppBaseVersion) + } else { + $null + } $msixRevision = if ($explicitMsixRevision) { $MsixRevision } else { - $installedDevPackage = Get-AppxPackage -Name "OpenClawFoundation.OpenClaw.Dev" -ErrorAction SilentlyContinue | - Where-Object Publisher -eq "CN=OpenClaw Local Development" | - Sort-Object { [version]$_.Version.ToString() } -Descending | - Select-Object -First 1 if ($installedDevPackage) { ([version]$installedDevPackage.Version.ToString()).Revision + 1 } else { @@ -529,6 +589,9 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { "-p:UapAppxPackageBuildMode=SideloadOnly", "-p:AppxPackageDir=$appxOutput" ) + if ($effectiveMsixBaseVersion) { + $dotnetArgs += "-p:MsixPackageBaseVersion=$effectiveMsixBaseVersion" + } } $result = Invoke-DotNetCaptured $dotnetArgs $exitCode = $LASTEXITCODE diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e5b81aa06..8462059ec 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -196,11 +196,89 @@ Store-signed retrieval and publication, and official lifecycle acceptance remain follow-up work in #1375. Alpha submission artifacts do not clear those rollout gates. -Store versions still end in `.0`; different prerelease/correction tags with -the same `X.Y.Z` base can produce the same Store version. These build artifacts -are not an automatic submission/version-allocation policy. See -[CI MSIX downloads](../DEVELOPMENT.md#ci-msix-downloads) for Dev certificate -handling, workflow revision limits, and installation instructions. +Store versions still end in `.0`. Official tagged builds now reserve distinct +package versions as described below; reruns reuse the same reservation. +See [CI MSIX downloads](../DEVELOPMENT.md#ci-msix-downloads) for Dev certificate +handling, preview limitations, and installation instructions. + +## MSIX version allocation + +Application release tags and assembly versions remain GitVersion-owned. +MSIX uses `X.Y.(Z * 100 + packaging revision).0` for app base `X.Y.Z`, with +packaging revisions 0-99 shared across alpha, stable, and correction tags on +that base. The correction suffix is not a separate encoded digit. A different +patch or year/month starts its own range. Windows' 65535 component limit still +applies, including to the last partial range. + +`.github/msix-version-baseline.json` imports already-used versions. It marks +`2026.9.400.0` used and records the workflow run, source commits, artifact IDs, +and package hashes that substantiate that migration floor. This file is +migration state, not a value to bump for each release. New `2026.9.5` releases +start at `2026.9.500.0`. + +The canonical ledger now contains the first live reservation for this release +line at `refs/tags/msix-package/2026.9.4/401`. It targets the commit behind +`v2026.9.4`; an identical second allocator run reused the same reservation +instead of consuming another number. Therefore the next unreserved +`2026.9.4` candidate is `2026.9.402.0`. + +PR/main metadata jobs resolve the latest published stable Windows release from +the canonical upstream repository and call +`scripts\Resolve-MsixPackageVersion.ps1` in read-only mode against that release +line and the canonical reservation ledger. While Latest is `v2026.9.4`, their +Store preview is `2026.9.402.0`, even if GitVersion on main or the PR has moved +to a `2026.9.5` development line. This selection affects only MSIX manifests; +assemblies, EXE/ZIP artifacts, GitVersion output, and release tags are unchanged. + +Only the upstream `reserve-msix-version` job, guarded to tagged +`push`/`workflow_dispatch` runs and scoped to `contents: write`, passes +`-Reserve`. The matrix consumes its one shared JSON result, avoiding independent +x64/ARM64 allocation. All other jobs retain their existing permissions. + +Reservations live in annotated tags +`refs/tags/msix-package//`, targeting the source +commit and containing the allocation JSON. The allocator uses atomic ref +creation, not mutable release assets or the expiring Actions artifact store. +If another run wins the same number, it verifies the competing record and +retries. The same source release tag must always resolve to the same commit +and reservation; moving a source tag is an error. + +Do not delete or force-update these records. Failed or cancelled builds keep +their reservations and must be retried with the same source tag. Alpha release +retention deletes release objects/assets, not the allocation tags. They do not +begin with `v` and therefore do not trigger tag-driven release builds. The +active `Protect MSIX package reservations` tag ruleset blocks deletion and +non-fast-forward updates under `refs/tags/msix-package/**/*`; preserving that +ruleset is part of the release contract. + +The canonical reservation ledger is an intentional, fail-closed dependency of +the release workflow. If allocation, authentication, permissions, or ledger +validation fails, the tagged release stops before publishing EXE/ZIP assets. +Maintainers must repair and rerun the same source tag rather than bypassing the +allocator or publishing a partial release. + +API, authentication, malformed-state, exhaustion, and retry-limit errors fail +closed. No workflow should replace such a failure with a guessed version. + +Package metadata contains `msixVersionAllocation`, including source version, +source commit/ref, package base, allocation kind, and reservation ref. +Preview candidates never reserve a number and can change between reruns; +they must not be treated as official Store submissions. + +The alpha stager requires `-VersionInfoPath` for the exact reserved result. It +checks the app alpha version, source commit, reserved allocation, package +version, and both architectures' metadata before copying any assets: + +```powershell +.\scripts\Stage-StoreMsixReleaseAssets.ps1 ` + -ArtifactDirectory 'artifacts\msix-alpha' ` + -OutputDirectory 'msix-alpha-release' ` + -Version $appVersion -ExpectedSourceCommit $sourceCommit ` + -VersionInfoPath $reservedVersionInfoPath +``` + +This does not change stable/alpha asset selection, bypass CI Gate or signing +approvals, move existing application tags, or automate Store submission. ## Manual alpha releases diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index b5263e569..b213ef22b 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -156,6 +156,19 @@ and metadata, so they can run in parallel with tests and E2E. Ordinary product pull requests produce no release artifact. Packaging/build/release-sensitive pull requests run only x64 publish smoke; main and tags run x64 plus ARM64. +MSIX packaging also consumes the readonly preview or the single official +tagged-release reservation before starting its x64/ARM64 matrix. +`scripts\test-msix-versioning.ps1` covers allocation arithmetic, durable state, +retry/idempotence, competing claims, provenance, preview isolation, and failure +boundaries without modifying remote refs. +`scripts\test-msix-preview-source-version.ps1` covers stable and numeric +correction tags, the canonical upstream Latest API request, fail-closed release +validation, and token-safe errors. Artifact and alpha-staging contracts verify +that the actual package versions and metadata match the selected allocation. +`test-ci-workflow-contract.ps1` executes the actual preview/write context +guards, including fork, PR, branch, tag, cancellation, and failure cases, and +requires fork previews to read the canonical upstream reservation ledger. + The always-running **CI Gate** validates every classifier output against the corresponding job result. A required lane must succeed, an unrequired lane must be skipped, and classification, fast validation, proof-contract selection, diff --git a/scripts/Build-StoreMsix.ps1 b/scripts/Build-StoreMsix.ps1 index af0ba85f8..44dc4dea6 100644 --- a/scripts/Build-StoreMsix.ps1 +++ b/scripts/Build-StoreMsix.ps1 @@ -36,6 +36,11 @@ which is cleaned on each run. A caller-supplied directory is never deleted; the build fails if it already exists and is not empty. +.PARAMETER VersionInfoPath + Optional repository-relative or absolute allocator JSON path. Validates + provenance and overrides only the package manifest base, not GitVersion + assembly metadata. Omission preserves unallocated local build behavior. + .EXAMPLE .\scripts\Build-StoreMsix.ps1 -Architecture x64 .\scripts\Build-StoreMsix.ps1 -Architecture arm64 @@ -53,7 +58,10 @@ param( [ValidateSet('Release')] [string]$Configuration = 'Release', - [string]$OutputDirectory + [string]$OutputDirectory, + + [ValidateNotNullOrEmpty()] + [string]$VersionInfoPath ) Set-StrictMode -Version Latest @@ -127,6 +135,18 @@ function Test-PackageVersion { } } +$versionInfo = $null +$versionArguments = @() +if ($VersionInfoPath) { + . (Join-Path $PSScriptRoot 'MsixVersioning.ps1') + $currentCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' + if ($LASTEXITCODE -ne 0) { throw 'Unable to resolve source for the MSIX allocation.' } + $versionInfo = Read-MsixVersionInfo ` + -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) ` + -SourceCommit $currentCommit + $versionArguments = @("-p:MsixPackageBaseVersion=$($versionInfo.packageBaseVersion)") +} + # The tracked manifest is the single source of truth for the release identity. # A packaged build that drifts from it is a packaging bug, not a new identity. [xml]$sourceManifest = Get-Content -LiteralPath $sourceManifestPath -Raw @@ -191,6 +211,7 @@ try { -p:UapAppxPackageBuildMode=SideloadOnly ` -p:AppxPackageSigningEnabled=false ` "-p:AppxPackageDir=$appxOutput" ` + @versionArguments ` --nologo } @@ -281,6 +302,9 @@ try { $packagedIdentity = $packagedManifest.Package.Identity $packageVersion = [string]$packagedIdentity.Version Test-PackageVersion -Version $packageVersion + if ($versionInfo -and $packageVersion -ne $versionInfo.storePackageVersion) { + throw "MSIX package version '$packageVersion' does not match the allocated version '$($versionInfo.storePackageVersion)'." + } if ([string]$packagedIdentity.Name -ne $expectedIdentityName) { throw ( @@ -309,6 +333,10 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect the current source tree.' } + if ($versionInfo -and ($sourceCommit -ne $versionInfo.sourceCommit -or + ($versionInfo.allocation -eq 'reserved' -and $sourceTreeDirty))) { + throw 'A reserved MSIX requires its exact clean source commit.' + } $msixHash = ( Get-FileHash -LiteralPath $msixPath -Algorithm SHA256 @@ -325,13 +353,17 @@ try { identityName = $expectedIdentityName packageVersion = $packageVersion publisher = $expectedPublisher - } | ConvertTo-Json | + msixVersionAllocation = $versionInfo + } | ConvertTo-Json -Depth 4 | Set-Content ` -LiteralPath (Join-Path $OutputDirectory 'msix-metadata.json') ` -Encoding utf8 Write-Host "Created unsigned MSIX: $msixPath" Write-Host " Identity: $expectedIdentityName $packageVersion $Architecture" + if ($versionInfo -and $versionInfo.allocation -eq 'preview') { + Write-Host ' Preview only: this package version has not been reserved for an official release.' + } } finally { Remove-DirectoryIfPresent -Path $workRoot diff --git a/scripts/Export-DevMsixArtifact.ps1 b/scripts/Export-DevMsixArtifact.ps1 index 6366dddcf..55aad554d 100644 --- a/scripts/Export-DevMsixArtifact.ps1 +++ b/scripts/Export-DevMsixArtifact.ps1 @@ -14,7 +14,8 @@ param( [Parameter(Mandatory)][ValidateRange(1, 65535)][int]$ExpectedRevision, [Parameter(Mandatory)][string]$ExpectedVersion, [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$CertificateThumbprint, - [Parameter(Mandatory)][string]$OutputDirectory + [Parameter(Mandatory)][string]$OutputDirectory, + [ValidateNotNullOrEmpty()][string]$VersionInfoPath ) Set-StrictMode -Version Latest @@ -33,6 +34,18 @@ if ($baseVersion -notmatch '^\d+\.\d+\.\d+$') { throw "Expected a three-part base version: $ExpectedVersion" } $expectedPackageVersion = "$baseVersion.$ExpectedRevision" +$versionInfo = $null +if ($VersionInfoPath) { + . (Join-Path $PSScriptRoot 'MsixVersioning.ps1') + $currentCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' + if ($LASTEXITCODE -ne 0) { throw 'Unable to resolve source for the MSIX allocation.' } + $versionInfo = Read-MsixVersionInfo ` + -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) ` + -SourceCommit $currentCommit + if ($baseVersion -ne $versionInfo.packageBaseVersion) { + throw 'The expected Dev base does not match the MSIX allocation.' + } +} $packages = @(Get-ChildItem -LiteralPath $PackageDirectory -Filter '*.msix' -File -Recurse) if ($packages.Count -ne 1) { throw "Expected one Dev MSIX in '$PackageDirectory'; found $($packages.Count)." @@ -76,6 +89,10 @@ if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-fA-F]{40}$') { } $sourceTreeDirty = [bool](& git -C $repositoryRoot status --porcelain) if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect the current source tree.' } +if ($versionInfo -and ($sourceCommit -ne $versionInfo.sourceCommit -or + ($versionInfo.allocation -eq 'reserved' -and $sourceTreeDirty))) { + throw 'A reserved MSIX requires its exact clean source commit.' +} $packageName = "OpenClaw-Dev-$Architecture.msix" New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null @@ -102,8 +119,10 @@ $certificateHash = (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256 certificateExpiresUtc = $certificate.NotAfter.ToUniversalTime().ToString('O') workflowRunId = $env:GITHUB_RUN_ID workflowRunAttempt = $env:GITHUB_RUN_ATTEMPT -} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $OutputDirectory 'msix-metadata.json') -Encoding utf8 + msixVersionAllocation = $versionInfo +} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'msix-metadata.json') -Encoding utf8 +$allocationNote = if ($versionInfo) { $versionInfo.allocation } else { 'unallocated local build' } @" OpenClaw (Dev) CI tester package ($Architecture) @@ -114,6 +133,7 @@ later runs and the other architecture may have different certificates. Source: $sourceCommit Package version: $($identity.Version) +MSIX version allocation: $allocationNote Package SHA-256: $packageHash Certificate thumbprint: $($certificate.Thumbprint) Certificate SHA-256: $certificateHash @@ -136,8 +156,10 @@ Certificate SHA-256: $certificateHash another isolated Dev installation. CI uses the workflow run number as the Dev revision, bounded to 1-65535. -Rerunning the same workflow run keeps the same package version and is not a -new upgrade. Versions from other branches, forks, or local builds may be newer. +Tagged release reruns reuse their reserved base. PR and main previews do not +reserve versions; their candidate base can change after an official allocation. +Preview packages are not official release or Store-submission versions. +Versions from other branches, forks, or local builds may be newer. Do not uninstall or downgrade an existing Dev installation merely to bypass a version error without first considering its retained settings and data. diff --git a/scripts/Get-OpenClawMsixPreviewSourceVersion.ps1 b/scripts/Get-OpenClawMsixPreviewSourceVersion.ps1 new file mode 100644 index 000000000..75184132c --- /dev/null +++ b/scripts/Get-OpenClawMsixPreviewSourceVersion.ps1 @@ -0,0 +1,83 @@ +<# +.SYNOPSIS + Resolves the numeric app base used for unreserved MSIX preview packages. +.DESCRIPTION + PR and ordinary main builds preview the next package version on the latest + published stable Windows release line. This affects only MSIX package + manifests; GitVersion, assemblies, EXE/ZIP artifacts, and release tags + retain their existing versions. + + Pass CurrentWindowsTag for deterministic/offline evaluation. Otherwise the + latest published release is read from the canonical upstream repository. +#> +[CmdletBinding()] +param( + [string]$GitHubToken = $env:GH_TOKEN, + [string]$CurrentWindowsTag, + [string]$LatestReleaseJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function ConvertTo-MsixPreviewBase { + param([Parameter(Mandatory)][string]$Tag) + + $match = [regex]::Match( + $Tag, + '^v(?[1-9]\d*\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:-[1-9]\d*)?$') + if (-not $match.Success) { + throw "Latest Windows release tag '$Tag' is not a stable or numeric-correction release." + } + return $match.Groups['base'].Value +} + +$tag = $CurrentWindowsTag +if ([string]::IsNullOrWhiteSpace($tag)) { + if (-not [string]::IsNullOrWhiteSpace($LatestReleaseJson)) { + try { + $release = ConvertFrom-Json -InputObject $LatestReleaseJson -ErrorAction Stop + } + catch { + throw 'The supplied latest Windows release response is malformed.' + } + } + else { + $headers = @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'openclaw-windows-node-msix-preview' + 'X-GitHub-Api-Version' = '2022-11-28' + } + if (-not [string]::IsNullOrWhiteSpace($GitHubToken)) { + $headers.Authorization = [string]::Concat('Bearer ', $GitHubToken) + } + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $release = Invoke-RestMethod ` + -Headers $headers ` + -Uri 'https://api.github.com/repos/openclaw/openclaw-windows-node/releases/latest' ` + -MaximumRedirection 0 ` + -ErrorAction Stop + break + } + catch { + if ($attempt -eq 3) { + throw 'Could not resolve the latest published Windows release for the MSIX preview after three attempts.' + } + Start-Sleep -Seconds $attempt + } + } + } + if ($null -eq $release -or + $null -eq $release.PSObject.Properties['tag_name'] -or + [string]::IsNullOrWhiteSpace([string]$release.tag_name) -or + ($release.PSObject.Properties['draft'] -and $release.draft) -or + ($release.PSObject.Properties['prerelease'] -and $release.prerelease) -or + $null -eq $release.PSObject.Properties['published_at'] -or + $null -eq $release.published_at) { + throw 'The latest Windows release is missing or is not a published stable release.' + } + $tag = [string]$release.tag_name +} + +ConvertTo-MsixPreviewBase -Tag $tag diff --git a/scripts/MsixVersioning.ps1 b/scripts/MsixVersioning.ps1 new file mode 100644 index 000000000..9e827f7ff --- /dev/null +++ b/scripts/MsixVersioning.ps1 @@ -0,0 +1,457 @@ +function Get-MsixAppVersion { + param([Parameter(Mandatory)][string]$SourceVersion) + + $pattern = '\A(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z'
+    $match = [regex]::Match($SourceVersion, $pattern)
+    if (-not $match.Success) { throw 'SourceVersion must be a canonical SemVer without a v prefix.' }
+    foreach ($identifier in $match.Groups['pre'].Value.Split('.')) {
+        if ($identifier -match '\A0[0-9]+\z') {
+            throw 'SourceVersion must not contain leading-zero numeric prerelease identifiers.'
+        }
+    }
+    $parts = @{}
+    foreach ($name in @('major', 'minor', 'patch')) {
+        $number = 0
+        if (-not [int]::TryParse($match.Groups[$name].Value, [ref]$number) -or $number -gt 65535) {
+            throw 'SourceVersion exceeds the MSIX UInt16 component limit.'
+        }
+        $parts[$name] = $number
+    }
+    if ($parts.major -eq 0) { throw 'MSIX requires a nonzero major version.' }
+    if ($parts.patch -gt 655) { throw 'The app patch cannot fit its MSIX allocation range within UInt16.' }
+    [pscustomobject]@{
+        major = $parts.major
+        minor = $parts.minor
+        patch = $parts.patch
+        baseVersion = '{0}.{1}.{2}' -f $parts.major, $parts.minor, $parts.patch
+        firstCounter = $parts.patch * 100
+        lastCounter = [Math]::Min(65535, $parts.patch * 100 + 99)
+    }
+}
+
+function Assert-MsixProvenance {
+    param([string]$SourceCommit, [string]$SourceRef, [string]$Repository)
+
+    if ($SourceCommit -cnotmatch '\A[0-9a-fA-F]{40}\z') { throw 'SourceCommit must be a 40-hex commit SHA.' }
+    if ([string]::IsNullOrWhiteSpace($SourceRef) -or $SourceRef -match '\p{Cc}' -or
+        $SourceRef -cnotmatch '\Arefs/(?:(?:heads|tags)/.+|pull/[1-9][0-9]*/(?:merge|head))\z') {
+        throw 'SourceRef must be a nonempty branch, tag, or pull-request ref without control characters.'
+    }
+    if ($Repository -cnotmatch '\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9_.-]{1,100}\z' -or
+        $Repository.EndsWith('/.') -or $Repository.EndsWith('/..')) {
+        throw 'Repository must be a valid GitHub owner/repo name.'
+    }
+}
+
+function Get-MsixRequiredProperty {
+    param([AllowNull()][object]$Value, [string]$Name)
+
+    if ($null -eq $Value -or $Value -isnot [pscustomobject]) {
+        throw 'Expected a JSON object in MSIX allocation data.'
+    }
+    $property = $Value.PSObject.Properties[$Name]
+    if ($null -eq $property -or $property.Name -cne $Name) {
+        throw "MSIX allocation data is missing field '$Name'."
+    }
+    return ,$property.Value
+}
+
+function Test-MsixInteger {
+    param([AllowNull()][object]$Value)
+    return ($Value -is [int] -or $Value -is [long])
+}
+
+function ConvertFrom-MsixJsonObject {
+    param([AllowNull()][string]$Json)
+
+    if ([string]::IsNullOrWhiteSpace($Json) -or -not $Json.TrimStart().StartsWith('{')) {
+        throw 'MSIX allocation JSON must contain one object.'
+    }
+    try { $value = ConvertFrom-Json -InputObject $Json -ErrorAction Stop }
+    catch { throw 'MSIX allocation JSON is malformed.' }
+    if ($value -isnot [pscustomobject]) { throw 'MSIX allocation JSON must contain one object.' }
+    return $value
+}
+
+function Assert-MsixVersionInfo {
+    <#
+    .SYNOPSIS
+        Validates allocation metadata and binds it to the expected source commit.
+    .DESCRIPTION
+        Returns a validated projection, not an authentication of offline metadata.
+        RequireReserved rejects previews. Preview versions are not reservations
+        and may advance between reruns when official builds reserve more numbers.
+    #>
+    [CmdletBinding()]
+    param(
+        [Parameter(Mandatory)][object]$VersionInfo,
+        [Parameter(Mandatory)][string]$SourceCommit,
+        [string]$SourceVersion,
+        [switch]$RequireReserved
+    )
+
+    $values = @{}
+    foreach ($name in @('schemaVersion', 'sourceVersion', 'sourceCommit', 'sourceRef', 'repository',
+        'baseVersion', 'packageBaseVersion', 'storePackageVersion', 'packagingRevision', 'allocation', 'reservationRef')) {
+        $values[$name] = Get-MsixRequiredProperty $VersionInfo $name
+    }
+    if (-not (Test-MsixInteger $values.schemaVersion) -or $values.schemaVersion -ne 1) {
+        throw 'Unsupported MSIX allocation schemaVersion.'
+    }
+    foreach ($name in @('sourceVersion', 'sourceCommit', 'sourceRef', 'repository',
+        'baseVersion', 'packageBaseVersion', 'storePackageVersion', 'allocation')) {
+        if ($values[$name] -isnot [string] -or [string]::IsNullOrWhiteSpace($values[$name])) {
+            throw "MSIX allocation field '$name' must be a nonempty string."
+        }
+    }
+    Assert-MsixProvenance $values.sourceCommit $values.sourceRef $values.repository
+    if ($SourceCommit -cnotmatch '\A[0-9a-fA-F]{40}\z' -or
+        $values.sourceCommit -cne $SourceCommit.ToLowerInvariant()) {
+        throw 'MSIX allocation sourceCommit does not match the expected lowercase source commit.'
+    }
+    if ($PSBoundParameters.ContainsKey('SourceVersion') -and $values.sourceVersion -cne $SourceVersion) {
+        throw 'MSIX allocation sourceVersion does not match the expected source version.'
+    }
+    $app = Get-MsixAppVersion $values.sourceVersion
+    if (-not (Test-MsixInteger $values.packagingRevision) -or $values.packagingRevision -lt 0 -or
+        $values.packagingRevision -gt ($app.lastCounter - $app.firstCounter)) {
+        throw 'MSIX packagingRevision is outside the app patch allocation range.'
+    }
+    $counter = $app.firstCounter + $values.packagingRevision
+    $packageBase = '{0}.{1}.{2}' -f $app.major, $app.minor, $counter
+    if ($values.baseVersion -cne $app.baseVersion -or $values.packageBaseVersion -cne $packageBase -or
+        $values.storePackageVersion -cne "$packageBase.0") {
+        throw 'MSIX version arithmetic does not match the source version and packagingRevision.'
+    }
+    if ($values.allocation -ceq 'reserved') {
+        $expectedRef = "refs/tags/msix-package/$($app.baseVersion)/$counter"
+        if ($values.reservationRef -isnot [string] -or $values.reservationRef -cne $expectedRef) {
+            throw 'MSIX reserved metadata has an invalid reservationRef.'
+        }
+        if ($values.sourceRef -cne "refs/tags/v$($values.sourceVersion)") {
+            throw 'MSIX reserved sourceRef must exactly match refs/tags/v plus sourceVersion.'
+        }
+    }
+    elseif ($values.allocation -ceq 'preview') {
+        if ($RequireReserved) { throw 'A reserved MSIX allocation is required; preview metadata is not allowed.' }
+        if ($null -ne $values.reservationRef) { throw 'MSIX preview reservationRef must be null.' }
+    }
+    else { throw 'MSIX allocation must be preview or reserved.' }
+
+    [pscustomobject][ordered]@{
+        schemaVersion = 1
+        sourceVersion = $values.sourceVersion
+        sourceCommit = $values.sourceCommit
+        sourceRef = $values.sourceRef
+        repository = $values.repository
+        baseVersion = $app.baseVersion
+        packageBaseVersion = $packageBase
+        storePackageVersion = "$packageBase.0"
+        packagingRevision = [int]$values.packagingRevision
+        allocation = $values.allocation
+        reservationRef = $values.reservationRef
+    }
+}
+
+function Read-MsixVersionInfo {
+    [CmdletBinding()]
+    param(
+        [Parameter(Mandatory)][string]$Path,
+        [Parameter(Mandatory)][string]$SourceCommit,
+        [string]$SourceVersion,
+        [switch]$RequireReserved
+    )
+
+    try { $info = ConvertFrom-MsixJsonObject (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop) }
+    catch { throw 'MSIX version info could not be read as JSON.' }
+    $arguments = @{ VersionInfo = $info; SourceCommit = $SourceCommit; RequireReserved = $RequireReserved }
+    if ($PSBoundParameters.ContainsKey('SourceVersion')) { $arguments.SourceVersion = $SourceVersion }
+    Assert-MsixVersionInfo @arguments
+}
+
+function Read-MsixBaseline {
+    param([string]$Path, [object]$App)
+
+    try { $baseline = ConvertFrom-MsixJsonObject (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop) }
+    catch { throw 'MSIX baseline file is missing, unreadable, or malformed JSON.' }
+    $schema = Get-MsixRequiredProperty $baseline 'schemaVersion'
+    if (-not (Test-MsixInteger $schema) -or $schema -ne 1) { throw 'Unsupported MSIX baseline schemaVersion.' }
+    $records = Get-MsixRequiredProperty $baseline 'lastAllocated'
+    if ($records -isnot [pscustomobject]) { throw 'MSIX baseline lastAllocated must be an object.' }
+    $last = $App.firstCounter - 1
+    foreach ($property in $records.PSObject.Properties) {
+        $recordApp = Get-MsixAppVersion $property.Name
+        if ($property.Name -cne $recordApp.baseVersion -or -not (Test-MsixInteger $property.Value) -or
+            $property.Value -lt $recordApp.firstCounter -or $property.Value -gt $recordApp.lastCounter) {
+            throw 'MSIX baseline contains an invalid base key or lastAllocated range.'
+        }
+        if ($property.Name -ceq $App.baseVersion) { $last = [int]$property.Value }
+    }
+    return $last
+}
+
+function Invoke-MsixGitHubApi {
+    param(
+        [ValidateSet('GET', 'POST')][string]$Method,
+        [string]$Repository,
+        [string]$Path,
+        [AllowEmptyString()][string]$Token,
+        [object]$Body
+    )
+
+    $headers = @{ Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' }
+    if (-not [string]::IsNullOrEmpty($Token)) {
+        $headers.Authorization = [string]::Concat('Bearer ', $Token)
+    }
+    $arguments = @{
+        Method = $Method
+        Uri = "https://api.github.com/repos/$Repository/git/$Path"
+        Headers = $headers
+        UserAgent = 'OpenClaw-MsixVersionAllocator'
+        MaximumRedirection = 0
+        ErrorAction = 'Stop'
+        Verbose = $false
+        Debug = $false
+    }
+    if ($Method -eq 'POST') {
+        $arguments.ContentType = 'application/json; charset=utf-8'
+        $arguments.Body = $Body | ConvertTo-Json -Depth 10 -Compress
+    }
+    try {
+        $response = Invoke-RestMethod @arguments
+        return $response
+    }
+    catch {
+        # Do not propagate response bodies, request headers, or token-bearing inner exceptions.
+        $status = 0
+        $responseProperty = $_.Exception.PSObject.Properties['Response']
+        if ($null -ne $responseProperty -and $null -ne $responseProperty.Value) {
+            $statusProperty = $responseProperty.Value.PSObject.Properties['StatusCode']
+            if ($null -ne $statusProperty) { $status = [int]$statusProperty.Value }
+        }
+        $message = if ($status -gt 0) { "MSIX GitHub API $Method failed (HTTP $status)." } else { "MSIX GitHub API $Method failed (network or transport error)." }
+        $failure = [InvalidOperationException]::new($message)
+        $failure.Data['MsixHttpStatus'] = $status
+        throw $failure
+    }
+}
+
+function Assert-MsixSourceTag {
+    param([string]$Repository, [string]$SourceRef, [string]$SourceCommit, [string]$Token)
+
+    $name = [Uri]::EscapeDataString($SourceRef.Substring('refs/tags/'.Length))
+    $ref = Invoke-MsixGitHubApi -Method GET -Repository $Repository -Path "ref/tags/$name" -Token $Token
+    $returnedRef = Get-MsixRequiredProperty $ref 'ref'
+    if ($returnedRef -isnot [string] -or $returnedRef -cne $SourceRef) {
+        throw 'MSIX source tag lookup did not return the exact requested sourceRef.'
+    }
+    $target = Get-MsixRequiredProperty $ref 'object'
+    $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+    for ($depth = 0; $depth -le 8; $depth++) {
+        $type = Get-MsixRequiredProperty $target 'type'
+        $sha = Get-MsixRequiredProperty $target 'sha'
+        if ($type -isnot [string] -or $sha -isnot [string] -or $sha -cnotmatch '\A[0-9a-f]{40}\z') {
+            throw 'MSIX source tag has an invalid object type or SHA.'
+        }
+        if ($type -ceq 'commit') {
+            if ($sha -cne $SourceCommit) { throw 'MSIX source tag does not resolve to the requested SourceCommit.' }
+            return
+        }
+        if ($type -cne 'tag') { throw 'MSIX source tag must resolve to a commit, not a tree or blob.' }
+        if (-not $seen.Add($sha)) { throw 'MSIX source tag contains a cycle in its annotated tag chain.' }
+        if ($depth -eq 8) { throw 'MSIX source tag exceeds the annotated tag depth limit of eight.' }
+        $tag = Invoke-MsixGitHubApi -Method GET -Repository $Repository -Path "tags/$sha" -Token $Token
+        $returnedSha = Get-MsixRequiredProperty $tag 'sha'
+        if ($returnedSha -isnot [string] -or $returnedSha -cne $sha) {
+            throw 'MSIX source annotated tag response does not match the requested SHA.'
+        }
+        $target = Get-MsixRequiredProperty $tag 'object'
+    }
+}
+
+function Get-MsixReservationRefs {
+    param([string]$Repository, [object]$App, [string]$Token)
+
+    $prefix = "refs/tags/msix-package/$($App.baseVersion)/"
+    $response = @(Invoke-MsixGitHubApi -Method GET -Repository $Repository -Path "matching-refs/tags/msix-package/$($App.baseVersion)/" -Token $Token)
+    if ($response.Count -gt 100) {
+        throw 'MSIX matching-refs must return an array of at most 100 reservations.'
+    }
+    $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+    foreach ($item in $response) {
+        $ref = Get-MsixRequiredProperty $item 'ref'
+        $target = Get-MsixRequiredProperty $item 'object'
+        $type = Get-MsixRequiredProperty $target 'type'
+        $sha = Get-MsixRequiredProperty $target 'sha'
+        if ($ref -isnot [string] -or -not $ref.StartsWith($prefix, [StringComparison]::Ordinal) -or
+            $ref.Substring($prefix.Length) -cnotmatch '\A(?:0|[1-9][0-9]*)\z') {
+            throw 'MSIX matching-refs returned an unrelated or malformed reservation ref.'
+        }
+        $counter = 0
+        if (-not [int]::TryParse($ref.Substring($prefix.Length), [ref]$counter) -or
+            $counter -lt $App.firstCounter -or $counter -gt $App.lastCounter -or -not $seen.Add($ref)) {
+            throw 'MSIX matching-refs returned an out-of-range or duplicate reservation ref.'
+        }
+        if ($type -isnot [string] -or $type -cne 'tag' -or $sha -isnot [string] -or $sha -cnotmatch '\A[0-9a-f]{40}\z') {
+            throw 'MSIX reservation refs must target annotated tag SHAs.'
+        }
+        [pscustomobject]@{ ref = $ref; sha = $sha; counter = $counter }
+    }
+}
+
+function Assert-MsixAnnotatedTag {
+    param([object]$Tag, [string]$TagSha, [string]$Ref, [string]$Repository)
+
+    $sha = Get-MsixRequiredProperty $Tag 'sha'
+    $name = Get-MsixRequiredProperty $Tag 'tag'
+    $target = Get-MsixRequiredProperty $Tag 'object'
+    $type = Get-MsixRequiredProperty $target 'type'
+    $commit = Get-MsixRequiredProperty $target 'sha'
+    $message = Get-MsixRequiredProperty $Tag 'message'
+    if ($sha -isnot [string] -or $sha -cnotmatch '\A[0-9a-f]{40}\z' -or $sha -cne $TagSha -or
+        $name -isnot [string] -or $name -cne $Ref.Substring('refs/tags/'.Length) -or
+        $type -isnot [string] -or $type -cne 'commit' -or
+        $commit -isnot [string] -or $commit -cnotmatch '\A[0-9a-f]{40}\z' -or $message -isnot [string]) {
+        throw 'MSIX annotated tag response does not match its SHA, name, or commit target.'
+    }
+    try { $record = ConvertFrom-MsixJsonObject $message }
+    catch { throw 'MSIX annotated tag message is not valid allocation JSON.' }
+    $info = Assert-MsixVersionInfo -VersionInfo $record -SourceCommit $commit -RequireReserved
+    if ($info.reservationRef -cne $Ref -or $info.repository -cne $Repository) {
+        throw 'MSIX annotated allocation does not match its reservation ref or repository.'
+    }
+    return $info
+}
+
+function New-MsixVersionInfo {
+    param([object]$App, [int]$Counter, [string]$SourceVersion, [string]$SourceCommit,
+        [string]$SourceRef, [string]$Repository, [switch]$Reserve)
+
+    $packageBase = '{0}.{1}.{2}' -f $App.major, $App.minor, $Counter
+    $record = [pscustomobject][ordered]@{
+        schemaVersion = 1
+        sourceVersion = $SourceVersion
+        sourceCommit = $SourceCommit
+        sourceRef = $SourceRef
+        repository = $Repository
+        baseVersion = $App.baseVersion
+        packageBaseVersion = $packageBase
+        storePackageVersion = "$packageBase.0"
+        packagingRevision = $Counter - $App.firstCounter
+        allocation = if ($Reserve) { 'reserved' } else { 'preview' }
+        reservationRef = if ($Reserve) { "refs/tags/msix-package/$($App.baseVersion)/$Counter" } else { $null }
+    }
+    Assert-MsixVersionInfo -VersionInfo $record -SourceCommit $SourceCommit -SourceVersion $SourceVersion
+}
+
+function Resolve-MsixPackageVersion {
+    <#
+    .SYNOPSIS
+        Previews or durably reserves the next MSIX version for a numeric app base.
+    .DESCRIPTION
+        Official tags share one counter across prerelease, stable, and correction
+        versions. Reservations are immutable annotated Git tags. Failed builds
+        still consume their reservation; identical source reruns reuse it.
+        Reserve verifies the exact existing source tag resolves to SourceCommit
+        before each allocation attempt, peeling at most eight annotated tags.
+        Preview is read-only and returns the next unallocated number, which may
+        change on reruns after intervening official reservations.
+    #>
+    [CmdletBinding()]
+    param(
+        [Parameter(Mandatory)][string]$SourceVersion,
+        [Parameter(Mandatory)][string]$SourceCommit,
+        [Parameter(Mandatory)][string]$SourceRef,
+        [Parameter(Mandatory)][string]$Repository,
+        [switch]$Reserve,
+        [string]$GitHubToken = $env:GH_TOKEN,
+        [string]$BaselinePath = (Join-Path $PSScriptRoot '..\.github\msix-version-baseline.json')
+    )
+
+    $app = Get-MsixAppVersion $SourceVersion
+    Assert-MsixProvenance $SourceCommit $SourceRef $Repository
+    $SourceCommit = $SourceCommit.ToLowerInvariant()
+    if ($Reserve -and $SourceRef -cne "refs/tags/v$SourceVersion") {
+        throw 'Reserved sourceRef must exactly match refs/tags/v plus sourceVersion.'
+    }
+    if ($Reserve -and [string]::IsNullOrWhiteSpace($GitHubToken)) { throw 'A GitHub token is required to reserve an MSIX version.' }
+    if ($GitHubToken -match '\p{Cc}') { throw 'GitHub token must not contain control characters.' }
+    $baseline = Read-MsixBaseline -Path $BaselinePath -App $app
+    $cache = @{}
+    $collisionRef = $null
+    # Eight writes maximum. The final read still recognizes a successful competing duplicate run.
+    for ($attempt = 0; $attempt -le 8; $attempt++) {
+        if ($Reserve) {
+            Assert-MsixSourceTag -Repository $Repository -SourceRef $SourceRef -SourceCommit $SourceCommit -Token $GitHubToken
+        }
+        $refs = @(Get-MsixReservationRefs -Repository $Repository -App $app -Token $GitHubToken | Sort-Object counter)
+        if ($null -ne $collisionRef -and @($refs | Where-Object { $_.ref -ceq $collisionRef }).Count -ne 1) {
+            throw 'MSIX create-ref conflict had no competing reservation; refusing to retry the API failure.'
+        }
+        $last = $baseline
+        foreach ($ref in $refs) { $last = [Math]::Max($last, $ref.counter) }
+        $inspect = if ($Reserve) { $refs } else { @($refs | Select-Object -Last 1) }
+        $records = @(
+            foreach ($ref in $inspect) {
+                if (-not $cache.ContainsKey($ref.sha)) {
+                    $tag = Invoke-MsixGitHubApi -Method GET -Repository $Repository -Path "tags/$($ref.sha)" -Token $GitHubToken
+                    $cache[$ref.sha] = Assert-MsixAnnotatedTag -Tag $tag -TagSha $ref.sha -Ref $ref.ref -Repository $Repository
+                }
+                $record = $cache[$ref.sha]
+                if ($record.reservationRef -cne $ref.ref) { throw 'An MSIX annotated tag was reused for a different reservation ref.' }
+                $record
+            }
+        )
+        if ($Reserve) {
+            $sources = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+            foreach ($record in $records) {
+                if (-not $sources.Add($record.sourceRef)) { throw 'MSIX reservations contain duplicate sourceRef allocations.' }
+            }
+            $existing = @($records | Where-Object { $_.sourceRef -ceq $SourceRef })
+            if ($existing.Count -eq 1) {
+                if ($existing[0].sourceCommit -cne $SourceCommit -or $existing[0].sourceVersion -cne $SourceVersion) {
+                    throw 'The source tag moved or its version changed after MSIX reservation.'
+                }
+                return $existing[0]
+            }
+        }
+        if ($last -ge $app.lastCounter) { throw 'MSIX allocation range exhausted for this app base; advance the app version.' }
+        $info = New-MsixVersionInfo -App $app -Counter ($last + 1) -SourceVersion $SourceVersion `
+            -SourceCommit $SourceCommit -SourceRef $SourceRef -Repository $Repository -Reserve:$Reserve
+        if (-not $Reserve) { return $info }
+        if ($attempt -eq 8) { throw 'MSIX reservation retry limit exhausted after eight competing allocations.' }
+
+        $tagBody = @{
+            tag = $info.reservationRef.Substring('refs/tags/'.Length)
+            message = ($info | ConvertTo-Json -Depth 10 -Compress)
+            object = $SourceCommit
+            type = 'commit'
+        }
+        $tag = Invoke-MsixGitHubApi -Method POST -Repository $Repository -Path 'tags' -Token $GitHubToken -Body $tagBody
+        $tagSha = Get-MsixRequiredProperty $tag 'sha'
+        $created = Assert-MsixAnnotatedTag -Tag $tag -TagSha $tagSha -Ref $info.reservationRef -Repository $Repository
+        if (($created | ConvertTo-Json -Compress) -cne ($info | ConvertTo-Json -Compress)) {
+            throw 'MSIX create-tag response changed the requested allocation record.'
+        }
+        try {
+            $createdRef = Invoke-MsixGitHubApi -Method POST -Repository $Repository -Path 'refs' -Token $GitHubToken `
+                -Body @{ ref = $info.reservationRef; sha = $tagSha }
+        }
+        catch {
+            $status = $_.Exception.Data['MsixHttpStatus']
+            if ($status -ne 409 -and $status -ne 422) { throw }
+            $collisionRef = $info.reservationRef
+            continue
+        }
+        $refName = Get-MsixRequiredProperty $createdRef 'ref'
+        $refObject = Get-MsixRequiredProperty $createdRef 'object'
+        $refType = Get-MsixRequiredProperty $refObject 'type'
+        $refSha = Get-MsixRequiredProperty $refObject 'sha'
+        if ($refName -isnot [string] -or $refName -cne $info.reservationRef -or
+            $refType -isnot [string] -or $refType -cne 'tag' -or
+            $refSha -isnot [string] -or $refSha -cne $tagSha) {
+            throw 'MSIX create-ref response does not match the requested annotated reservation.'
+        }
+        return $info
+    }
+}
diff --git a/scripts/Resolve-MsixPackageVersion.ps1 b/scripts/Resolve-MsixPackageVersion.ps1
new file mode 100644
index 000000000..083f3ba7a
--- /dev/null
+++ b/scripts/Resolve-MsixPackageVersion.ps1
@@ -0,0 +1,27 @@
+<#
+.SYNOPSIS
+    Returns validated MSIX version metadata. Only -Reserve creates Git refs.
+.DESCRIPTION
+    Reservation requires an existing source tag resolving to SourceCommit.
+    Read-only previews use the next unallocated counter and may change between
+    reruns when official releases reserve additional versions.
+#>
+[CmdletBinding()]
+param(
+    [Parameter(Mandatory)][string]$SourceVersion,
+    [Parameter(Mandatory)][string]$SourceCommit,
+    [Parameter(Mandatory)][string]$SourceRef,
+    [Parameter(Mandatory)][string]$Repository,
+    [switch]$Reserve,
+    [string]$GitHubToken = $env:GH_TOKEN,
+    [string]$BaselinePath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'MsixVersioning.ps1')
+if (-not $PSBoundParameters.ContainsKey('BaselinePath')) {
+    $BaselinePath = Join-Path $PSScriptRoot '..\.github\msix-version-baseline.json'
+}
+Resolve-MsixPackageVersion -SourceVersion $SourceVersion -SourceCommit $SourceCommit -SourceRef $SourceRef `
+    -Repository $Repository -Reserve:$Reserve -GitHubToken $GitHubToken -BaselinePath $BaselinePath
diff --git a/scripts/Stage-StoreMsixReleaseAssets.ps1 b/scripts/Stage-StoreMsixReleaseAssets.ps1
index 2e749bdbe..795f01d69 100644
--- a/scripts/Stage-StoreMsixReleaseAssets.ps1
+++ b/scripts/Stage-StoreMsixReleaseAssets.ps1
@@ -14,7 +14,8 @@ param(
     [Parameter(Mandatory)]
     [ValidatePattern('^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-alpha\.(?:0|[1-9]\d*)$')]
     [string]$Version,
-    [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$ExpectedSourceCommit
+    [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$ExpectedSourceCommit,
+    [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$VersionInfoPath
 )
 
 Set-StrictMode -Version Latest
@@ -29,7 +30,11 @@ if ((Test-Path -LiteralPath $OutputDirectory) -and
 }
 
 [xml]$manifest = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw
-$expectedVersion = ($Version -replace '-alpha\.\d+$', '') + '.0'
+. (Join-Path $PSScriptRoot 'MsixVersioning.ps1')
+$versionInfo = Read-MsixVersionInfo `
+    -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) `
+    -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved
+$expectedVersion = $versionInfo.storePackageVersion
 $packages = foreach ($architecture in @('x64', 'arm64')) {
     $directory = Join-Path $ArtifactDirectory "openclaw-msix-store-unsigned-$architecture"
     $packageName = "OpenClaw-$architecture.msix"
@@ -43,6 +48,16 @@ $packages = foreach ($architecture in @('x64', 'arm64')) {
 
     $metadataPath = Join-Path $directory 'msix-metadata.json'
     $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
+    if (-not $metadata.PSObject.Properties['msixVersionAllocation']) {
+        throw "The $architecture Store artifact is missing its release allocation."
+    }
+    $packageAllocation = Assert-MsixVersionInfo -VersionInfo $metadata.msixVersionAllocation `
+        -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved
+    foreach ($property in $versionInfo.PSObject.Properties) {
+        if ($packageAllocation.($property.Name) -cne $property.Value) {
+            throw "The $architecture Store artifact does not match the expected release allocation."
+        }
+    }
     if ($metadata.sourceTreeDirty -isnot [bool] -or $metadata.sourceTreeDirty -or
         $metadata.sourceCommit -ne $ExpectedSourceCommit) {
         throw "The $architecture Store artifact does not belong to the expected clean source commit."
@@ -86,9 +101,9 @@ metadata files record the source commit, package version, and SHA-256.
 Upload the MSIX files manually to Partner Center; Microsoft signs accepted
 Store submissions. This workflow does not submit or retrieve Store packages.
 
-The Windows package version is $expectedVersion. Different alpha tags with
-the same base version produce the same Store version, so verify it against
-previous submissions before uploading. Stable releases do not include these
+The Windows package version is $expectedVersion, reserved for $Version.
+Different official tags share the app patch's packaging counter; reruns of
+this tag reuse its reservation. Stable releases do not include these
 experimental submission assets. Dev-signed tester downloads remain in Actions.
 "@
 }
diff --git a/scripts/test-ci-change-classifier.ps1 b/scripts/test-ci-change-classifier.ps1
index f72c17acf..c87cae90d 100644
--- a/scripts/test-ci-change-classifier.ps1
+++ b/scripts/test-ci-change-classifier.ps1
@@ -75,6 +75,12 @@ $allProductLanes = @(
     "network_e2e"
 )
 $cases = @(
+    @{
+        Scenario = "MSIX allocation baseline"
+        Paths = @(".github/msix-version-baseline.json")
+        Classification = "full"
+        Required = $fullPrLanes
+    },
     @{
         Scenario = "Maintained documentation"
         Paths = @("README.md", "docs/TEST_COVERAGE.md", "docs/diagrams/ci.svg")
diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1
index fc284b92d..6fa4b573c 100644
--- a/scripts/test-ci-workflow-contract.ps1
+++ b/scripts/test-ci-workflow-contract.ps1
@@ -604,6 +604,8 @@ foreach ($token in @(
         "majorMinorPatch: `${{ steps.release_version.outputs.majorMinorPatch }}",
         "isPrerelease: `${{ steps.release_version.outputs.isPrerelease }}",
         "isStableCorrection: `${{ steps.release_version.outputs.isStableCorrection }}",
+        "msixVersionInfo: `${{ steps.msix_preview.outputs.versionInfo }}",
+        "msixSourceVersion: `${{ steps.msix_preview.outputs.sourceVersion }}",
         "Test-OpenClawStableCorrectionRelease.ps1"
     )) {
     Assert-Contains -Text $metadataJob -Expected $token -Message "Metadata job is missing '$token'."
@@ -649,8 +651,9 @@ foreach ($build in $releaseBuilds.GetEnumerator()) {
 
 $buildMsixJob = Get-JobBlock "build-msix"
 foreach ($token in @(
-        "needs: [change-classification, metadata]",
+        "needs: [change-classification, metadata, reserve-msix-version]",
         "needs.metadata.result == 'success'",
+        "(needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped')",
         "needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true'",
         "architecture: [x64, arm64]",
         "matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest'",
@@ -664,7 +667,14 @@ foreach ($token in @(
         '-MsixRevision $env:DEV_MSIX_REVISION',
         '-MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx"',
         '.\scripts\Export-DevMsixArtifact.ps1',
-        '-ExpectedVersion $env:OPENCLAW_BUILD_VERSION',
+        'MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo || needs.metadata.outputs.msixVersionInfo }}',
+        'MSIX_SOURCE_VERSION: ${{ needs.reserve-msix-version.outputs.sourceVersion || needs.metadata.outputs.msixSourceVersion }}',
+        '-ExpectedVersion $info.packageBaseVersion',
+        '-MsixBaseVersion $info.packageBaseVersion',
+        '-VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json"',
+        'Assert-MsixVersionInfo',
+        '-SourceCommit $env:GITHUB_SHA -SourceVersion $env:MSIX_SOURCE_VERSION',
+        'refusing to build with a fallback version',
         '-CertificateThumbprint $thumbprint',
         'name: openclaw-msix-store-unsigned-${{ matrix.architecture }}',
         'name: openclaw-msix-dev-${{ matrix.architecture }}',
@@ -678,10 +688,71 @@ foreach ($token in @(
     )) {
     Assert-Contains -Text $buildMsixJob -Expected $token -Message "MSIX artifact lane is missing '$token'."
 }
-foreach ($token in @('if: false', "`n    continue-on-error: true", 'Set-Content global.json', 'msbuild src/', 'Select-Object -First 1', 'Export-PfxCertificate', 'secrets.', 'id-token: write')) {
+foreach ($token in @('if: false', "`n    continue-on-error: true", 'Set-Content global.json', 'msbuild src/', 'Select-Object -First 1', 'Export-PfxCertificate', 'secrets.', 'id-token: write', 'contents: write', '-Reserve')) {
     Assert-NotContains -Text $buildMsixJob -Unexpected $token -Message "MSIX artifacts must not contain '$token'."
 }
 
+$reserveMsixJob = Get-JobBlock 'reserve-msix-version'
+foreach ($token in @(
+    'needs: [change-classification, metadata]',
+    "needs.metadata.result == 'success'",
+    "github.repository == 'openclaw/openclaw-windows-node'",
+    "startsWith(github.ref, 'refs/tags/v')",
+    "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')",
+    'contents: write', 'persist-credentials: false',
+    'versionInfo: ${{ steps.reserve.outputs.versionInfo }}',
+    'sourceVersion: ${{ steps.reserve.outputs.sourceVersion }}',
+    '-SourceRef $env:GITHUB_REF -Repository $env:GITHUB_REPOSITORY -Reserve'
+)) {
+    Assert-Contains -Text $reserveMsixJob -Expected $token -Message "Official MSIX allocation is missing '$token'."
+}
+Assert-NotContains -Text $metadataJob -Unexpected 'contents: write' -Message 'Preview metadata must remain read-only.'
+Assert-NotContains -Text $metadataJob -Unexpected '-Reserve' -Message 'Preview metadata must not allocate releases.'
+$previewStep = Get-StepBlock -Text $metadataJob -Name 'Resolve MSIX preview version'
+foreach ($token in @(
+    '.\scripts\Get-OpenClawMsixPreviewSourceVersion.ps1',
+    '-Repository openclaw/openclaw-windows-node',
+    '-GitHubToken $env:GH_TOKEN',
+    '"sourceVersion=$sourceVersion" >> $env:GITHUB_OUTPUT'
+)) {
+    Assert-Contains -Text $previewStep -Expected $token -Message "MSIX preview source contract is missing '$token'."
+}
+Assert-NotContains -Text $previewStep -Unexpected '$env:GITHUB_REPOSITORY' -Message 'Fork previews must read the canonical upstream reservation ledger.'
+Assert-NotContains -Text $previewStep -Unexpected '${{ steps.release_version.outputs.semVer }}' -Message 'PR/main MSIX previews must not use the development GitVersion line.'
+
+# Evaluate the actual context guards, including the complement used for previews.
+function Convert-MsixGuard {
+    param([string]$Text)
+    $guard = [regex]::Match($Text, '(?m)^\s+if: \$\{\{\s*(?.*?)\s*\}\}\s*$')
+    if (-not $guard.Success) { throw 'Missing MSIX workflow context guard.' }
+    $condition = $guard.Groups['condition'].Value.
+        Replace('!cancelled()', '(-not $cancelled)').
+        Replace("startsWith(github.ref, 'refs/tags/v')", '$ref.StartsWith(''refs/tags/v'')').
+        Replace('needs.metadata.result', '$metadataResult').
+        Replace('github.repository', '$repository').
+        Replace('github.event_name', '$eventName').
+        Replace('==', '-eq').Replace('&&', '-and').Replace('||', '-or').Replace('!(', '-not (')
+    [scriptblock]::Create('param($repository,$ref,$eventName,$metadataResult,$cancelled)' + "`n($condition)")
+}
+$reserveGuard = Convert-MsixGuard $reserveMsixJob
+$previewGuard = Convert-MsixGuard $previewStep
+foreach ($repository in @('openclaw/openclaw-windows-node', 'contributor/openclaw-windows-node')) {
+    foreach ($ref in @('refs/tags/v2026.9.4', 'refs/tags/v2026.9.4-1', 'refs/tags/v2026.9.4-alpha.1', 'refs/heads/main', 'refs/pull/1/merge', 'refs/tags/msix-package/2026.9.4/401')) {
+        foreach ($eventName in @('push', 'workflow_dispatch', 'pull_request', 'pull_request_target')) {
+            $official = $repository -eq 'openclaw/openclaw-windows-node' -and
+                $ref.StartsWith('refs/tags/v') -and $eventName -in @('push', 'workflow_dispatch')
+            if ((& $reserveGuard $repository $ref $eventName 'success' $false) -ne $official -or
+                (& $previewGuard $repository $ref $eventName 'success' $false) -ne (-not $official)) {
+                throw "Incorrect MSIX allocation scope: $repository, $ref, $eventName"
+            }
+            if ((& $reserveGuard $repository $ref $eventName 'failure' $false) -or
+                (& $reserveGuard $repository $ref $eventName 'success' $true)) {
+                throw 'Failed or cancelled metadata must not reserve MSIX versions.'
+            }
+        }
+    }
+}
+
 $ciGateJob = Get-JobBlock "ci-gate"
 foreach ($token in @(
         "name: CI Gate",
@@ -706,7 +777,8 @@ foreach ($token in @(
 
 $releaseJob = Get-JobBlock "release"
 foreach ($token in @(
-        "needs: [change-classification, metadata, build-x64, build-arm64, ci-gate]",
+        "needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, ci-gate]",
+        "needs.reserve-msix-version.result == 'success'",
         "needs.ci-gate.result == 'success'",
         "needs.metadata.outputs.semVer",
         "needs.metadata.outputs.isPrerelease",
@@ -723,6 +795,8 @@ Assert-Contains -Text $alphaDownload -Expected 'pattern: openclaw-msix-store-uns
 Assert-NotContains -Text $alphaDownload -Unexpected 'openclaw-msix-dev-' -Message "Dev packages must stay workflow-only."
 Assert-Contains -Text $alphaStage -Expected '-ExpectedSourceCommit $env:GITHUB_SHA' -Message "Release staging must bind artifacts to the tag's source."
 Assert-Contains -Text $alphaStage -Expected '-Version $env:RELEASE_VERSION' -Message "Release staging must validate the alpha version."
+Assert-Contains -Text $alphaStage -Expected '-VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json"' -Message 'Release staging must require its exact reserved MSIX version.'
+Assert-Contains -Text $alphaStage -Expected 'MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo }}' -Message 'Release staging must not accept a preview.'
 $createRelease = Get-StepBlock -Text $releaseJob -Name 'Create Release'
 Assert-Contains -Text $createRelease -Expected '${{ steps.msix_alpha.outputs.files }}' -Message "Only the gated alpha stage may add MSIX release files."
 Assert-Contains -Text $createRelease -Expected '${{ steps.msix_alpha.outputs.notes }}' -Message "Only alpha release notes may mention MSIX downloads."
@@ -732,6 +806,8 @@ Assert-NotContains -Text $createRelease -Unexpected 'OpenClaw-x64.msix' -Message
 Assert-NotContains -Text $createRelease -Unexpected 'OpenClaw-arm64.msix' -Message "MSIX must not be an unconditional stable release asset."
 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 alpha release staging."
+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.'
 
 $triggerPaths = @(
     ".github/workflows/ci.yml",
diff --git a/scripts/test-msix-alpha-release.ps1 b/scripts/test-msix-alpha-release.ps1
index 9f8d042a0..fd46efa44 100644
--- a/scripts/test-msix-alpha-release.ps1
+++ b/scripts/test-msix-alpha-release.ps1
@@ -33,6 +33,21 @@ function Assert-Fails {
 function New-Fixture {
     $script:scenario++
     $inputPath = Join-Path $temporaryRoot "input-$scenario"
+    $allocation = [ordered]@{
+        schemaVersion = 1
+        sourceVersion = '2026.7.2-alpha.4'
+        sourceCommit = $sourceCommit
+        sourceRef = 'refs/tags/v2026.7.2-alpha.4'
+        repository = 'openclaw/openclaw-windows-node'
+        baseVersion = '2026.7.2'
+        packageBaseVersion = '2026.7.202'
+        storePackageVersion = '2026.7.202.0'
+        packagingRevision = 2
+        allocation = 'reserved'
+        reservationRef = 'refs/tags/msix-package/2026.7.2/202'
+    }
+    $allocationPath = Join-Path $temporaryRoot "allocation-$scenario.json"
+    $allocation | ConvertTo-Json | Set-Content -LiteralPath $allocationPath
     foreach ($architecture in @('x64', 'arm64')) {
         $directory = Join-Path $inputPath "openclaw-msix-store-unsigned-$architecture"
         New-Item -ItemType Directory -Path $directory -Force | Out-Null
@@ -47,16 +62,18 @@ function New-Fixture {
             identityName = [string]$manifest.Package.Identity.Name
             publisher = [string]$manifest.Package.Identity.Publisher
             architecture = $architecture
-            packageVersion = '2026.7.2.0'
+            packageVersion = '2026.7.202.0'
             archive = $packageName
             sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash
-        } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $directory 'msix-metadata.json')
+            msixVersionAllocation = $allocation
+        } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $directory 'msix-metadata.json')
     }
     @{
         ArtifactDirectory = $inputPath
         OutputDirectory = Join-Path $temporaryRoot "output-$scenario"
         Version = '2026.7.2-alpha.4'
         ExpectedSourceCommit = $sourceCommit
+        VersionInfoPath = $allocationPath
     }
 }
 
@@ -90,7 +107,7 @@ try {
             throw 'Published metadata did not describe the released file.'
         }
     }
-    foreach ($warning in @('OpenClaw-x64.msix', 'OpenClaw-arm64.msix', 'unsigned', 'not installers', '2026.7.2.0', 'same Store version', 'Dev-signed tester downloads remain in Actions')) {
+    foreach ($warning in @('OpenClaw-x64.msix', 'OpenClaw-arm64.msix', 'unsigned', 'not installers', '2026.7.202.0', 'reuse its reservation', 'Dev-signed tester downloads remain in Actions')) {
         if (-not $assets.Notes.Contains($warning)) { throw "Release notes are missing '$warning'." }
     }
     Assert-Fails { & $stager @arguments } 'absent or empty'
@@ -118,7 +135,7 @@ try {
         $path = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-arm64\msix-metadata.json'
         $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
         $metadata.($mutation.Field) = $mutation.Value
-        $metadata | ConvertTo-Json | Set-Content -LiteralPath $path
+        $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path
         Assert-Fails { & $stager @arguments } $mutation.Error
         if (Test-Path -LiteralPath $arguments.OutputDirectory) { throw 'Rejected ARM64 input left partial release assets.' }
     }
@@ -130,7 +147,34 @@ try {
     Assert-Fails { & $stager @arguments } 'exactly'
     $arguments = New-Fixture
     $arguments.Version = '2026.7.3-alpha.4'
-    Assert-Fails { & $stager @arguments } 'unsigned metadata'
+    Assert-Fails { & $stager @arguments } 'source version'
+
+    foreach ($mutation in @(
+        @{ Field = 'allocation'; Value = 'preview' },
+        @{ Field = 'sourceCommit'; Value = ('b' * 40) },
+        @{ Field = 'sourceVersion'; Value = '2026.7.2-alpha.3' },
+        @{ Field = 'reservationRef'; Value = 'refs/tags/msix-package/2026.7.2/203' }
+    )) {
+        $arguments = New-Fixture
+        $path = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-arm64\msix-metadata.json'
+        $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
+        $metadata.msixVersionAllocation.($mutation.Field) = $mutation.Value
+        $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path
+        Assert-Fails { & $stager @arguments } 'MSIX'
+        if (Test-Path -LiteralPath $arguments.OutputDirectory) { throw 'Rejected allocation left partial release assets.' }
+    }
+    $arguments = New-Fixture
+    $path = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-arm64\msix-metadata.json'
+    $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
+    $metadata.PSObject.Properties.Remove('msixVersionAllocation')
+    $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path
+    Assert-Fails { & $stager @arguments } 'missing its release allocation'
+    $arguments = New-Fixture
+    $allocation = Get-Content -LiteralPath $arguments.VersionInfoPath -Raw | ConvertFrom-Json
+    $allocation.allocation = 'preview'
+    $allocation.reservationRef = $null
+    $allocation | ConvertTo-Json | Set-Content -LiteralPath $arguments.VersionInfoPath
+    Assert-Fails { & $stager @arguments } 'MSIX'
 
     # Execute the actual metadata selector rather than a test-only copy of its regex.
     $workflow = Get-Content -LiteralPath (Join-Path $RepoRoot '.github\workflows\ci.yml') -Raw
diff --git a/scripts/test-msix-ci-artifacts.ps1 b/scripts/test-msix-ci-artifacts.ps1
index c6234ebdd..81fcc02c1 100644
--- a/scripts/test-msix-ci-artifacts.ps1
+++ b/scripts/test-msix-ci-artifacts.ps1
@@ -54,7 +54,11 @@ function New-Package {
     New-Item -ItemType Directory -Path $Directory -Force | Out-Null
     $zip = [IO.Compression.ZipFile]::Open((Join-Path $Directory $Name), [IO.Compression.ZipArchiveMode]::Create)
     try {
-        foreach ($name in @('AppxManifest.xml', 'AppxSignature.p7x', 'OpenClaw.Tray.WinUI.exe', 'OpenClaw.Tray.WinUI.dll', 'coreclr.dll')) {
+        foreach ($name in @(
+            'AppxManifest.xml', 'AppxSignature.p7x', 'OpenClaw.Tray.WinUI.exe', 'OpenClaw.Tray.WinUI.dll',
+            'coreclr.dll', 'hostfxr.dll', 'hostpolicy.dll', 'System.Private.CoreLib.dll', 'Microsoft.ui.xaml.dll',
+            'OpenClaw.SetupEngine.dll', 'OpenClaw.SetupEngine.UI.dll', "tools/mxc/$Architecture/wxc-exec.exe"
+        )) {
             if ($name -eq $Omit) { continue }
             $writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open())
             try {
@@ -81,6 +85,26 @@ function New-Arguments {
     }
 }
 
+function New-VersionInfo {
+    param([string]$Path, [string]$SourceVersion, [string]$BaseVersion, [int]$Counter)
+    $sourceCommit = (& git -C $RepoRoot rev-parse HEAD) -join ''
+    if ($LASTEXITCODE -ne 0) { throw 'Could not resolve fixture source commit.' }
+    $parts = $BaseVersion.Split('.')
+    [ordered]@{
+        schemaVersion = 1
+        sourceVersion = $SourceVersion
+        sourceCommit = $sourceCommit
+        sourceRef = 'refs/pull/1/merge'
+        repository = 'openclaw/openclaw-windows-node'
+        baseVersion = $BaseVersion
+        packageBaseVersion = "$($parts[0]).$($parts[1]).$Counter"
+        storePackageVersion = "$($parts[0]).$($parts[1]).$Counter.0"
+        packagingRevision = $Counter - [int]$parts[2] * 100
+        allocation = 'preview'
+        reservationRef = $null
+    } | ConvertTo-Json | Set-Content -LiteralPath $Path
+}
+
 try {
     Add-Type -AssemblyName System.IO.Compression.FileSystem
     foreach ($architecture in @('x64', 'arm64')) {
@@ -118,8 +142,30 @@ try {
             throw 'Dev installation instructions did not name the exported package.'
         }
         Assert-Fails { & $exporter @arguments } 'must be absent or empty'
+
+        $arguments = New-Arguments
+        $arguments.Architecture = $architecture
+        $arguments.ExpectedVersion = '2026.9.411'
+        $arguments.VersionInfoPath = Join-Path $temporaryRoot "dev-allocation-$architecture.json"
+        New-VersionInfo $arguments.VersionInfoPath '2026.9.4-1' '2026.9.4' 411
+        New-Package -Directory $arguments.PackageDirectory -Architecture $architecture -Version '2026.9.411.123'
+        & $exporter @arguments
+        $metadata = Get-Content (Join-Path $arguments.OutputDirectory 'msix-metadata.json') -Raw | ConvertFrom-Json
+        if ($metadata.packageVersion -ne '2026.9.411.123' -or
+            $metadata.msixVersionAllocation.sourceVersion -ne '2026.9.4-1' -or
+            $metadata.msixVersionAllocation.allocation -ne 'preview') {
+            throw 'Dev artifact lost its shared packaging allocation.'
+        }
+        if (-not (Get-Content (Join-Path $arguments.OutputDirectory 'INSTALL.txt') -Raw).Contains('MSIX version allocation: preview')) {
+            throw 'Dev instructions must identify unreserved preview versions.'
+        }
     }
 
+    $arguments = New-Arguments
+    $arguments.VersionInfoPath = Join-Path $temporaryRoot 'mismatched-dev-allocation.json'
+    New-VersionInfo $arguments.VersionInfoPath '2026.9.4' '2026.9.4' 411
+    Assert-Fails { & $exporter @arguments } 'expected Dev base does not match'
+
     $arguments = New-Arguments
     New-Item -ItemType Directory -Path $arguments.PackageDirectory | Out-Null
     Assert-Fails { & $exporter @arguments } 'found 0'
@@ -156,6 +202,62 @@ try {
         Assert-Fails { & $exporter @arguments } $mismatch.Error
     }
 
+    # Exercise the real Store builder/validator, replacing only the native publish.
+    & {
+        $storeBuilder = Join-Path $RepoRoot 'scripts\Build-StoreMsix.ps1'
+        [xml]$sourceManifest = Get-Content (Join-Path $RepoRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw
+        $probe = @{ Arguments = @(); Calls = 0; ProducedVersion = $null }
+        function dotnet {
+            $probe.Arguments = @($args)
+            $probe.Calls++
+            $output = ($args | Where-Object { $_ -like '-p:AppxPackageDir=*' }) -replace '^-p:AppxPackageDir=', ''
+            $architecture = if ($args -contains 'win-arm64') { 'arm64' } else { 'x64' }
+            $base = @($args | Where-Object { $_ -like '-p:MsixPackageBaseVersion=*' })
+            $version = if ($probe.ProducedVersion) { $probe.ProducedVersion }
+                elseif ($base.Count) { ($base[0] -replace '^-p:MsixPackageBaseVersion=', '') + '.0' }
+                else { '2026.9.5.0' }
+            New-Package -Directory $output -Name 'Store.msix' -Architecture $architecture -Version $version `
+                -Identity $sourceManifest.Package.Identity.Name -Publisher $sourceManifest.Package.Identity.Publisher `
+                -Omit 'AppxSignature.p7x'
+            $global:LASTEXITCODE = 0
+        }
+        foreach ($architecture in @('x64', 'arm64')) {
+            foreach ($counter in @(0, 401, 411)) {
+                $arguments = @{ Architecture = $architecture; OutputDirectory = (Join-Path $temporaryRoot "store-$($probe.Calls)") }
+                $expected = '2026.9.5.0'
+                if ($counter) {
+                    $arguments.VersionInfoPath = Join-Path $temporaryRoot "store-allocation-$counter.json"
+                    New-VersionInfo $arguments.VersionInfoPath '2026.9.4-alpha.3' '2026.9.4' $counter
+                    $expected = "2026.9.$counter.0"
+                }
+                & $storeBuilder @arguments
+                $metadata = Get-Content (Join-Path $arguments.OutputDirectory 'msix-metadata.json') -Raw | ConvertFrom-Json
+                if ($metadata.packageVersion -ne $expected -or $metadata.signed -or
+                    $metadata.archive -ne "OpenClaw-$architecture.msix") {
+                    throw 'Store metadata did not describe the actual allocated package.'
+                }
+                if ($counter -and ($probe.Arguments -notcontains "-p:MsixPackageBaseVersion=2026.9.$counter" -or
+                    $metadata.msixVersionAllocation.storePackageVersion -ne $expected)) {
+                    throw 'Store build/export did not use the selected manifest allocation.'
+                }
+                if (-not $counter -and $null -ne $metadata.msixVersionAllocation) {
+                    throw 'Local unallocated builds must not claim a CI reservation.'
+                }
+                if (@($probe.Arguments | Where-Object {
+                    $_ -match '^-p:(Version|UpdateVersionProperties|UpdateAssemblyInfo|AssemblyVersion|FileVersion|InformationalVersion)='
+                }).Count) {
+                    throw 'MSIX allocation must not change the app GitVersion or assembly metadata.'
+                }
+            }
+        }
+        $probe.ProducedVersion = '2026.9.5.0'
+        $output = Join-Path $temporaryRoot 'store-version-mismatch'
+        Assert-Fails {
+            & $storeBuilder -Architecture x64 -OutputDirectory $output -VersionInfoPath $arguments.VersionInfoPath
+        } 'does not match the allocated version'
+        if (Test-Path (Join-Path $output 'msix-metadata.json')) { throw 'Rejected package received validated metadata.' }
+    }
+
     # Exercise the real parameter binder without executing build.ps1's body.
     $tokens = $null
     $errors = $null
@@ -172,6 +274,93 @@ try {
         Assert-Fails { & $exporter @arguments } 'cannot validate argument'
     }
     Assert-Fails { & $bind -PackageMsix } 'parameter cannot be found'
+    $bindBase = [scriptblock]::Create($attributes + "`n" + $ast.ParamBlock.Extent.Text + "`n`$MsixBaseVersion")
+    foreach ($version in @('2026.9.401', '2026.9.411', '65535.65535.65535')) {
+        if ((& $bindBase -Msix Dev -MsixBaseVersion $version) -ne $version) { throw 'Valid MSIX base was rejected.' }
+    }
+    foreach ($version in @('', '0.9.401', '2026.09.401', 'v2026.9.401', '2026.9.401.0', '65536.9.401', '2026.9.65536')) {
+        Assert-Fails { & $bindBase -Msix Dev -MsixBaseVersion $version } 'cannot validate argument'
+    }
+    Assert-Fails { & (Join-Path $RepoRoot 'build.ps1') -MsixBaseVersion '2026.9.401' } '-MsixBaseVersion requires -Msix Dev.'
+    & {
+        $baseSelector = $ast.Find({
+            param($node)
+            $node -is [Management.Automation.Language.FunctionDefinitionAst] -and
+                $node.Name -eq 'Select-LocalDevMsixBaseVersion'
+        }, $true)
+        . ([scriptblock]::Create($baseSelector.Extent.Text))
+        if ((Select-LocalDevMsixBaseVersion $null '2026.9.5') -ne $null) {
+            throw 'Missing installed package must preserve the application-derived base.'
+        }
+        if ((Select-LocalDevMsixBaseVersion ([version]'2026.9.401.123') '2026.9.5') -ne '2026.9.401') {
+            throw 'A higher installed Dev base must be reused.'
+        }
+        if ((Select-LocalDevMsixBaseVersion ([version]'2026.9.5.123') '2026.9.5') -ne $null) {
+            throw 'An equal installed Dev base must preserve the application-derived base.'
+        }
+        if ((Select-LocalDevMsixBaseVersion ([version]'2026.8.999.123') '2026.9.5') -ne $null) {
+            throw 'An older installed Dev base must not override a newer application base.'
+        }
+
+        $buildFunction = $ast.Find({
+            param($node)
+            $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Build-Project'
+        }, $true)
+        . ([scriptblock]::Create($buildFunction.Extent.Text))
+        $probe = @{ Arguments = @() }
+        function Invoke-DotNetCaptured($arguments) { $probe.Arguments = @($arguments); $global:LASTEXITCODE = 0 }
+        $probeInstalledDevPackage = $null
+        function Get-InstalledDevMsixPackage { $probeInstalledDevPackage }
+        function Get-CurrentAppBaseVersion { '2026.9.5' }
+        function Write-Success($message) {}
+        $explicitMsixRevision = $true
+        $MsixRevision = 123
+        $MsixOutputDirectory = Join-Path $temporaryRoot 'dev-build'
+        $DevBuild = $true
+        $Configuration = 'Release'
+        foreach ($rid in @('win-x64', 'win-arm64')) {
+            foreach ($MsixBaseVersion in @('', '2026.9.411')) {
+                foreach ($packageMsix in @($false, $true)) {
+                    if (-not (Build-Project 'WinUI' (Join-Path $RepoRoot 'build.ps1') $true $packageMsix)) { throw 'Build argument probe failed.' }
+                    $hasBase = @($probe.Arguments | Where-Object { $_ -like '-p:MsixPackageBaseVersion=*' }).Count -gt 0
+                    if ($hasBase -ne ($packageMsix -and [bool]$MsixBaseVersion)) { throw 'MSIX base escaped its package-only scope.' }
+                    if ($hasBase -and $probe.Arguments -notcontains '-p:MsixPackageBaseVersion=2026.9.411') { throw 'Wrong Dev package base.' }
+                    if (@($probe.Arguments | Where-Object {
+                        $_ -match '^-p:(Version|UpdateVersionProperties|UpdateAssemblyInfo|AssemblyVersion|FileVersion|InformationalVersion)='
+                    }).Count) { throw 'Dev packaging must preserve app version metadata.' }
+                }
+            }
+        }
+
+        $explicitMsixRevision = $false
+        $MsixBaseVersion = ''
+        $probeInstalledDevPackage = [pscustomobject]@{ Version = [version]'2026.9.401.123' }
+        if (-not (Build-Project 'WinUI' (Join-Path $RepoRoot 'build.ps1') $true $true)) {
+            throw 'Installed Dev package build argument probe failed.'
+        }
+        if ($probe.Arguments -notcontains '-p:MsixPackageBaseVersion=2026.9.401' -or
+            $probe.Arguments -notcontains '-p:MsixRevision=124') {
+            throw 'A higher installed Dev package must supply its base and next revision.'
+        }
+
+        $MsixBaseVersion = '2026.9.411'
+        if (-not (Build-Project 'WinUI' (Join-Path $RepoRoot 'build.ps1') $true $true)) {
+            throw 'Explicit Dev package base precedence probe failed.'
+        }
+        if ($probe.Arguments -notcontains '-p:MsixPackageBaseVersion=2026.9.411') {
+            throw 'An explicit Dev package base must override the installed package base.'
+        }
+
+        $MsixBaseVersion = ''
+        $probeInstalledDevPackage = [pscustomobject]@{ Version = [version]'2026.8.999.123' }
+        if (-not (Build-Project 'WinUI' (Join-Path $RepoRoot 'build.ps1') $true $true)) {
+            throw 'Older installed Dev package build argument probe failed.'
+        }
+        if (@($probe.Arguments | Where-Object { $_ -like '-p:MsixPackageBaseVersion=*' }).Count -ne 0 -or
+            $probe.Arguments -notcontains '-p:MsixRevision=124') {
+            throw 'An older installed Dev base must not override the app base, but its next revision must remain monotonic.'
+        }
+    }
     Write-Host 'MSIX CI artifact contracts passed: version bounds, identity, architecture, signature rejection, exact package selection, provenance, and public-only exports.'
 }
 finally {
diff --git a/scripts/test-msix-preview-source-version.ps1 b/scripts/test-msix-preview-source-version.ps1
new file mode 100644
index 000000000..c079d624c
--- /dev/null
+++ b/scripts/test-msix-preview-source-version.ps1
@@ -0,0 +1,94 @@
+<#
+.SYNOPSIS
+    Tests stable-line selection for read-only MSIX previews.
+#>
+[CmdletBinding()]
+param([string]$RepoRoot)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
+    $RepoRoot = Split-Path $scriptRoot -Parent
+}
+$resolver = Join-Path $RepoRoot 'scripts\Get-OpenClawMsixPreviewSourceVersion.ps1'
+
+function Assert-Fails {
+    param([scriptblock]$Action, [string]$Expected)
+    try {
+        & $Action | Out-Null
+        throw 'The operation unexpectedly succeeded.'
+    }
+    catch {
+        if ($_.Exception.Message.IndexOf($Expected, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
+            throw "Expected '$Expected', received '$($_.Exception.Message)'."
+        }
+    }
+}
+
+foreach ($case in @(
+    @{ Tag = 'v2026.9.4'; Expected = '2026.9.4' },
+    @{ Tag = 'v2026.9.4-1'; Expected = '2026.9.4' },
+    @{ Tag = 'v2026.10.1-11'; Expected = '2026.10.1' },
+    @{ Tag = 'v65535.65535.655'; Expected = '65535.65535.655' }
+)) {
+    if ((& $resolver -CurrentWindowsTag $case.Tag) -cne $case.Expected) {
+        throw "Unexpected preview base for $($case.Tag)."
+    }
+}
+foreach ($tag in @(
+    '', '2026.9.4', 'v0.9.4', 'v2026.09.4', 'v2026.9.04',
+    'v2026.9.4-0', 'v2026.9.4-01', 'v2026.9.4-alpha.1', 'v2026.9.4+meta'
+)) {
+    if ($tag -eq '') {
+        continue
+    }
+    Assert-Fails { & $resolver -CurrentWindowsTag $tag } 'not a stable or numeric-correction'
+}
+
+$releaseJson = @{
+    tag_name = 'v2026.9.4'
+    draft = $false
+    prerelease = $false
+    published_at = '2026-09-15T04:42:44Z'
+} | ConvertTo-Json -Compress
+if ((& $resolver -LatestReleaseJson $releaseJson) -cne '2026.9.4') {
+    throw 'Published latest-release response did not resolve the stable app base.'
+}
+
+foreach ($mutation in @(
+    @{ tag_name = 'v2026.9.4-alpha.1'; draft = $false; prerelease = $true; published_at = 'x' },
+    @{ tag_name = 'v2026.9.4'; draft = $true; prerelease = $false; published_at = 'x' },
+    @{ tag_name = 'v2026.9.4'; draft = $false; prerelease = $false; published_at = $null },
+    @{ tag_name = ''; draft = $false; prerelease = $false; published_at = 'x' }
+)) {
+    Assert-Fails {
+        & $resolver -LatestReleaseJson ($mutation | ConvertTo-Json -Compress)
+    } 'missing or is not a published stable'
+}
+
+Assert-Fails { & $resolver -LatestReleaseJson '{not-json' } 'malformed'
+
+$source = Get-Content -LiteralPath $resolver -Raw
+foreach ($token in @(
+    'https://api.github.com/repos/openclaw/openclaw-windows-node/releases/latest',
+    "Accept = 'application/vnd.github+json'",
+    "'X-GitHub-Api-Version' = '2022-11-28'",
+    '$headers.Authorization = [string]::Concat(''Bearer '', $GitHubToken)',
+    '-MaximumRedirection 0',
+    'for ($attempt = 1; $attempt -le 3; $attempt++)',
+    'Start-Sleep -Seconds $attempt',
+    'after three attempts'
+)) {
+    if ($source.IndexOf($token, [StringComparison]::Ordinal) -lt 0) {
+        throw "Latest-release request contract is missing '$token'."
+    }
+}
+foreach ($throwMatch in [regex]::Matches($source, "(?m)^\s*throw\s+(?.+)$")) {
+    if ($throwMatch.Groups['message'].Value.IndexOf('GitHubToken', [StringComparison]::Ordinal) -ge 0 -or
+        $throwMatch.Groups['message'].Value.IndexOf('Authorization', [StringComparison]::Ordinal) -ge 0) {
+        throw 'Latest-release errors must not include token-bearing request details.'
+    }
+}
+
+Write-Host 'MSIX preview source tests passed: stable/correction bases, canonical API lookup, and fail-closed release validation.'
diff --git a/scripts/test-msix-versioning.ps1 b/scripts/test-msix-versioning.ps1
new file mode 100644
index 000000000..33605560b
--- /dev/null
+++ b/scripts/test-msix-versioning.ps1
@@ -0,0 +1,834 @@
+<#
+.SYNOPSIS
+    Offline, dependency-free tests of durable MSIX allocations and metadata.
+.DESCRIPTION
+    Replaces Invoke-RestMethod with an in-memory Git database. Races interleave
+    independent allocators at the atomic create-ref boundary. No real GitHub
+    requests, builds, ref mutations, or certificate operations are performed.
+#>
+[CmdletBinding()]
+param([string]$RepoRoot)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = Split-Path $PSScriptRoot -Parent }
+$library = Join-Path $RepoRoot 'scripts\MsixVersioning.ps1'
+$entrypoint = Join-Path $RepoRoot 'scripts\Resolve-MsixPackageVersion.ps1'
+$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-msix-version-tests-$([guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
+$baselinePath = Join-Path $temporaryRoot 'baseline.json'
+$metadataPath = Join-Path $temporaryRoot 'version-info.json'
+$token = 'test-token'
+$commit = 'a' * 40
+$otherCommit = 'b' * 40
+$repository = 'openclaw/openclaw-windows-node'
+$msixTestState = @{
+    cases = 0; assertions = 0; totalRequests = 0
+    refs = @{}; tags = @{}; sourceRefs = @{}; requests = $null; nextSha = 0
+    onCreateRef = $null; onRequest = $null; tamper = $null
+    collisionStatus = 422; raceResults = $null; expectAuthorization = $true; token = $token
+}
+$oldToken = $env:GH_TOKEN
+
+function Assert-Equal {
+    param([AllowNull()][object]$Actual, [AllowNull()][object]$Expected)
+    $msixTestState.assertions++
+    if ($Actual -cne $Expected) { throw "Expected '$Expected', received '$Actual'." }
+}
+
+function Assert-Throws {
+    param([scriptblock]$Action, [string]$Expected)
+    $msixTestState.assertions++
+    $failure = $null
+    try { & $Action | Out-Null }
+    catch { $failure = $_ }
+    if ($null -eq $failure) { throw "Expected failure containing '$Expected', but the operation succeeded." }
+    if ($failure.Exception.Message.IndexOf($Expected, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
+        throw "Expected '$Expected', received '$($failure.Exception.Message)'."
+    }
+    if (($failure | Out-String).Contains($token) -or $failure.Exception.ToString().Contains($token)) {
+        throw 'A failure leaked the synthetic API token.'
+    }
+}
+
+function Reset-Fixture {
+    $msixTestState.refs = @{}
+    $msixTestState.tags = @{}
+    $msixTestState.sourceRefs = @{}
+    $msixTestState.requests = [Collections.Generic.List[object]]::new()
+    $msixTestState.nextSha = 0
+    $msixTestState.onCreateRef = $null
+    $msixTestState.onRequest = $null
+    $msixTestState.tamper = $null
+    $msixTestState.collisionStatus = 422
+    $msixTestState.raceResults = [Collections.Generic.List[object]]::new()
+    $msixTestState.expectAuthorization = $true
+    Set-Content -LiteralPath $baselinePath -Value '{"schemaVersion":1,"lastAllocated":{"2026.9.4":400}}'
+}
+
+function Test-Case {
+    param([string]$Name, [scriptblock]$Action)
+    Reset-Fixture
+    try { & $Action | Out-Null }
+    catch { throw "Case '$Name' failed: $($_.Exception.Message)" }
+    $msixTestState.cases++
+}
+
+function New-Arguments {
+    param([string]$Version = '2026.9.4-alpha.1', [switch]$Preview, [string]$Commit = $commit)
+    if (-not $Preview) {
+        $ref = "refs/tags/v$Version"
+        $msixTestState.sourceRefs[$ref] = [pscustomobject]@{
+            ref = $ref
+            object = [pscustomobject]@{ type = 'commit'; sha = $Commit.ToLowerInvariant() }
+        }
+    }
+    @{
+        SourceVersion = $Version
+        SourceCommit = $Commit
+        SourceRef = if ($Preview) { 'refs/pull/1445/merge' } else { "refs/tags/v$Version" }
+        Repository = $repository
+        Reserve = -not $Preview
+        GitHubToken = $token
+        BaselinePath = $baselinePath
+    }
+}
+
+function New-Record {
+    param([int]$Counter, [string]$Version = '2026.9.4-alpha.1', [string]$Commit = $commit)
+    $base = ($Version -split '[-+]')[0]
+    $parts = $base.Split('.')
+    $packageBase = '{0}.{1}.{2}' -f $parts[0], $parts[1], $Counter
+    [pscustomobject][ordered]@{
+        schemaVersion = 1
+        sourceVersion = $Version
+        sourceCommit = $Commit
+        sourceRef = "refs/tags/v$Version"
+        repository = $repository
+        baseVersion = $base
+        packageBaseVersion = $packageBase
+        storePackageVersion = "$packageBase.0"
+        packagingRevision = $Counter - ([int]$parts[2] * 100)
+        allocation = 'reserved'
+        reservationRef = "refs/tags/msix-package/$base/$Counter"
+    }
+}
+
+function Add-TagObject {
+    param([string]$Name, [string]$Message, [string]$Commit)
+    $msixTestState.nextSha++
+    $sha = '{0:x40}' -f $msixTestState.nextSha
+    $tag = [pscustomobject]@{
+        sha = $sha
+        tag = $Name
+        message = $Message
+        object = [pscustomobject]@{ type = 'commit'; sha = $Commit }
+    }
+    $msixTestState.tags[$sha] = $tag
+    return $tag
+}
+
+function Add-Reservation {
+    param([object]$Record)
+    $tag = Add-TagObject -Name $Record.reservationRef.Substring(10) `
+        -Message ($Record | ConvertTo-Json -Compress) -Commit $Record.sourceCommit
+    $msixTestState.refs[$Record.reservationRef] = [pscustomobject]@{
+        ref = $Record.reservationRef
+        object = [pscustomobject]@{ type = 'tag'; sha = $tag.sha }
+    }
+    return $tag
+}
+
+function Set-SourceTagChain {
+    param([string]$Ref, [string]$Commit, [int]$Depth)
+    $target = [pscustomobject]@{ type = 'commit'; sha = $Commit }
+    for ($level = 0; $level -lt $Depth; $level++) {
+        $tag = Add-TagObject -Name "release-$level" -Message 'Release notes, not allocation JSON.' -Commit $Commit
+        $tag.object = $target
+        $target = [pscustomobject]@{ type = 'tag'; sha = $tag.sha }
+    }
+    $msixTestState.sourceRefs[$Ref] = [pscustomobject]@{ ref = $Ref; object = $target }
+}
+
+function Throw-ApiError {
+    param([int]$Status = 0)
+    $exception = [InvalidOperationException]::new("Synthetic private response includes $token.")
+    if ($Status -gt 0) {
+        $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = $Status })
+    }
+    throw $exception
+}
+
+function Invoke-RestMethod {
+    [CmdletBinding()]
+    param([string]$Method, [string]$Uri, [hashtable]$Headers, [string]$UserAgent,
+        [int]$MaximumRedirection, [string]$ContentType, [string]$Body)
+
+    $msixTestState.totalRequests++
+    if ($Method -cne 'GET' -and $Method -cne 'POST') { throw 'Destructive or unexpected HTTP method.' }
+    $prefix = "https://api.github.com/repos/$repository/git/"
+    if (-not $Uri.StartsWith($prefix, [StringComparison]::Ordinal) -or $Uri.Contains('?')) {
+        throw 'Unexpected API origin, repository, or undocumented pagination query.'
+    }
+    if ($Uri.Contains($msixTestState.token) -or ($null -ne $Body -and $Body.Contains($msixTestState.token))) {
+        throw 'Token escaped its Authorization header.'
+    }
+    Assert-Equal $Headers.Accept 'application/vnd.github+json'
+    Assert-Equal $Headers['X-GitHub-Api-Version'] '2022-11-28'
+    if ($msixTestState.expectAuthorization) {
+        Assert-Equal $Headers.Authorization ([string]::Concat('Bearer ', $token))
+    }
+    else { Assert-Equal $Headers.ContainsKey('Authorization') $false }
+    Assert-Equal $UserAgent 'OpenClaw-MsixVersionAllocator'
+    Assert-Equal $MaximumRedirection 0
+    $path = $Uri.Substring($prefix.Length)
+    $data = if ($Body) { $Body | ConvertFrom-Json } else { $null }
+    $msixTestState.requests.Add([pscustomobject]@{ method = $Method; path = $path; body = $data })
+    if ($null -ne $msixTestState.onRequest) { & $msixTestState.onRequest $Method $path $data }
+
+    $result = $null
+    if ($Method -ceq 'GET' -and $path.StartsWith('matching-refs/')) {
+        if ($path -cnotmatch '\Amatching-refs/tags/msix-package/[1-9][0-9]*\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)/\z') {
+            throw 'Matching-refs was not narrowly scoped to one canonical base.'
+        }
+        $refPrefix = 'refs/' + $path.Substring('matching-refs/'.Length)
+        $result = @($msixTestState.refs.Values | Where-Object { $_.ref.StartsWith($refPrefix, [StringComparison]::Ordinal) })
+    }
+    elseif ($Method -ceq 'GET' -and $path.StartsWith('ref/tags/')) {
+        $ref = 'refs/tags/' + [Uri]::UnescapeDataString($path.Substring('ref/tags/'.Length))
+        if (-not $msixTestState.sourceRefs.ContainsKey($ref)) { Throw-ApiError 404 }
+        $result = $msixTestState.sourceRefs[$ref]
+    }
+    elseif ($Method -ceq 'GET' -and $path -cmatch '\Atags/[0-9a-f]{40}\z') {
+        $sha = $path.Substring(5)
+        if (-not $msixTestState.tags.ContainsKey($sha)) { Throw-ApiError 404 }
+        $result = $msixTestState.tags[$sha]
+    }
+    elseif ($Method -ceq 'POST' -and $path -ceq 'tags') {
+        Assert-Equal $ContentType 'application/json; charset=utf-8'
+        Assert-Equal $data.type 'commit'
+        $result = Add-TagObject -Name $data.tag -Message $data.message -Commit $data.object
+    }
+    elseif ($Method -ceq 'POST' -and $path -ceq 'refs') {
+        if ($null -ne $msixTestState.onCreateRef) { & $msixTestState.onCreateRef $data }
+        if ($msixTestState.refs.ContainsKey($data.ref)) { Throw-ApiError $msixTestState.collisionStatus }
+        if (-not $msixTestState.tags.ContainsKey($data.sha)) { Throw-ApiError 422 }
+        $result = [pscustomobject]@{ ref = $data.ref; object = [pscustomobject]@{ type = 'tag'; sha = $data.sha } }
+        $msixTestState.refs[$data.ref] = $result
+    }
+    else { throw 'Unexpected HTTP route, including a forbidden ref update or delete.' }
+    if ($null -ne $msixTestState.tamper) { $result = & $msixTestState.tamper $Method $path $result }
+    return ,$result
+}
+
+try {
+    $env:GH_TOKEN = $token
+    Reset-Fixture
+    $loaded = @(. $library)
+    Assert-Equal $loaded.Count 0
+    Assert-Equal $msixTestState.requests.Count 0
+    $msixTestState.cases++
+
+    Test-Case 'entrypoint returns one object and bootstraps the default baseline' {
+        $arguments = New-Arguments
+        $arguments.Remove('BaselinePath')
+        $arguments.Remove('GitHubToken')
+        $arguments.SourceCommit = $commit.ToUpperInvariant()
+        $output = @(& $entrypoint @arguments)
+        Assert-Equal $output.Count 1
+        Assert-Equal ($output[0] -is [pscustomobject]) $true
+        Assert-Equal $output[0].storePackageVersion '2026.9.401.0'
+        Assert-Equal $output[0].packagingRevision 1
+        Assert-Equal $output[0].sourceCommit $commit
+        Assert-Equal $output[0].allocation 'reserved'
+        Assert-Equal $msixTestState.refs.Count 1
+        Assert-Equal $msixTestState.requests.Count 4
+        Assert-Equal $msixTestState.requests[0].path 'ref/tags/v2026.9.4-alpha.1'
+        $stored = $msixTestState.tags[$msixTestState.refs[$output[0].reservationRef].object.sha].message | ConvertFrom-Json
+        Assert-Equal ($stored | ConvertTo-Json -Compress) ($output[0] | ConvertTo-Json -Compress)
+    }
+    Test-Case 'first allocation without a baseline entry starts at patch times 100' {
+        Set-Content -LiteralPath $baselinePath '{"schemaVersion":1,"lastAllocated":{}}'
+        $arguments = New-Arguments
+        $result = Resolve-MsixPackageVersion @arguments
+        Assert-Equal $result.storePackageVersion '2026.9.400.0'
+        Assert-Equal $result.packagingRevision 0
+    }
+    Test-Case 'alpha stable and multi-digit correction share one counter' {
+        $counter = 401
+        foreach ($version in @('2026.9.4-alpha.1', '2026.9.4-alpha.10', '2026.9.4', '2026.9.4-10', '2026.9.4-11')) {
+            $arguments = New-Arguments $version
+            $result = Resolve-MsixPackageVersion @arguments
+            Assert-Equal $result.storePackageVersion "2026.9.$counter.0"
+            Assert-Equal $result.sourceVersion $version
+            $counter++
+        }
+    }
+    foreach ($case in @(
+        @{ Version = '2026.9.5-alpha.1'; Expected = '2026.9.500.0' },
+        @{ Version = '2026.10.1'; Expected = '2026.10.100.0' },
+        @{ Version = '2026.11.10-11'; Expected = '2026.11.1000.0' },
+        @{ Version = '2026.11.11-alpha.999'; Expected = '2026.11.1100.0' },
+        @{ Version = '65535.65535.655'; Expected = '65535.65535.65500.0' },
+        @{ Version = '1.0.0'; Expected = '1.0.0.0' }
+    )) {
+        Test-Case "fresh range $($case.Version)" {
+            $arguments = New-Arguments $case.Version
+            Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion $case.Expected
+        }
+    }
+    Test-Case 'greatest ref or baseline determines the next number' {
+        Add-Reservation (New-Record 420) | Out-Null
+        $arguments = New-Arguments '2026.9.4-alpha.2'
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.421.0'
+        Set-Content -LiteralPath $baselinePath '{"schemaVersion":1,"lastAllocated":{"2026.9.4":450}}'
+        $arguments = New-Arguments '2026.9.4-alpha.3'
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.451.0'
+    }
+    Test-Case 'revision 99 then exhausted with idempotent rerun still available' {
+        Add-Reservation (New-Record 498) | Out-Null
+        $arguments = New-Arguments '2026.9.4-alpha.2'
+        $reserved = Resolve-MsixPackageVersion @arguments
+        Assert-Equal $reserved.packagingRevision 99
+        Assert-Equal $reserved.storePackageVersion '2026.9.499.0'
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).reservationRef $reserved.reservationRef
+        Set-Content -LiteralPath $baselinePath '{"schemaVersion":1,"lastAllocated":{"2026.9.4":499}}'
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).reservationRef $reserved.reservationRef
+        $arguments = New-Arguments '2026.9.4-alpha.3'
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'range exhausted'
+        $arguments.Reserve = $false
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'range exhausted'
+    }
+    Test-Case 'partial UInt16 range stops at 65535 and never wraps' {
+        Add-Reservation (New-Record 65534 '2026.9.655-alpha.1') | Out-Null
+        $arguments = New-Arguments '2026.9.655-alpha.2'
+        $result = Resolve-MsixPackageVersion @arguments
+        Assert-Equal $result.storePackageVersion '2026.9.65535.0'
+        Assert-Equal $result.packagingRevision 35
+        $arguments = New-Arguments '2026.9.655-alpha.3'
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'range exhausted'
+    }
+    Test-Case 'rerun reuses failed-build reservation without any new POST' {
+        $arguments = New-Arguments
+        $first = Resolve-MsixPackageVersion @arguments
+        $postCount = @($msixTestState.requests | Where-Object method -eq POST).Count
+        $second = Resolve-MsixPackageVersion @arguments
+        Assert-Equal ($second | ConvertTo-Json -Compress) ($first | ConvertTo-Json -Compress)
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count $postCount
+    }
+    Test-Case 'moved source tag is rejected' {
+        Add-Reservation (New-Record 401) | Out-Null
+        $arguments = New-Arguments -Commit $otherCommit
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'source tag moved'
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+    }
+    foreach ($depth in @(1, 2, 8)) {
+        Test-Case "official source resolves through $depth annotated tags" {
+            $arguments = New-Arguments
+            Set-SourceTagChain -Ref $arguments.SourceRef -Commit $commit -Depth $depth
+            $result = Resolve-MsixPackageVersion @arguments
+            Assert-Equal $result.sourceCommit $commit
+            Assert-Equal $result.storePackageVersion '2026.9.401.0'
+            Assert-Equal $msixTestState.requests.Count (4 + $depth)
+            Assert-Equal @($msixTestState.requests | Where-Object { $_.method -eq 'GET' -and $_.path.StartsWith('tags/') }).Count $depth
+        }
+    }
+    Test-Case 'source lookup URI escapes SemVer metadata without changing provenance' {
+        $arguments = New-Arguments '2026.9.4-alpha.1+build.7'
+        $result = Resolve-MsixPackageVersion @arguments
+        Assert-Equal $result.sourceRef 'refs/tags/v2026.9.4-alpha.1+build.7'
+        Assert-Equal $msixTestState.requests[0].path 'ref/tags/v2026.9.4-alpha.1%2Bbuild.7'
+    }
+    Test-Case 'a source tag must exist even when a reservation already exists' {
+        $arguments = New-Arguments
+        Add-Reservation (New-Record 401) | Out-Null
+        $msixTestState.sourceRefs.Clear()
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'HTTP 404'
+        Assert-Equal $msixTestState.requests.Count 1
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+    }
+    foreach ($depth in @(0, 2)) {
+        Test-Case "wrong source commit after $depth tag peels is rejected" {
+            $arguments = New-Arguments
+            Set-SourceTagChain -Ref $arguments.SourceRef -Commit $otherCommit -Depth $depth
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } 'does not resolve to the requested SourceCommit'
+            Assert-Equal $msixTestState.requests.Count (1 + $depth)
+            Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+        }
+    }
+    Test-Case 'existing reservation cannot bypass a source tag moved since the original run' {
+        $arguments = New-Arguments
+        Add-Reservation (New-Record 401) | Out-Null
+        $msixTestState.sourceRefs[$arguments.SourceRef].object.sha = $otherCommit
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'does not resolve'
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+    }
+    Test-Case 'source tag is reverified after a competing reservation' {
+        $arguments = New-Arguments
+        $msixTestState.onCreateRef = {
+            param($data)
+            Add-Reservation (New-Record 401 '2026.9.4-alpha.2' $otherCommit) | Out-Null
+            $msixTestState.sourceRefs['refs/tags/v2026.9.4-alpha.1'].object.sha = $otherCommit
+        }
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'does not resolve'
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.path.StartsWith('ref/tags/') }).Count 2
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.method -eq 'POST' -and $_.path -eq 'refs' }).Count 1
+        Assert-Equal $msixTestState.refs.Count 1
+    }
+    foreach ($annotated in @($false, $true)) {
+        foreach ($status in @(0, 401, 404, 409, 422)) {
+            Test-Case "source provenance lookup annotated=$annotated HTTP $status fails closed" {
+                $arguments = New-Arguments
+                if ($annotated) { Set-SourceTagChain -Ref $arguments.SourceRef -Commit $commit -Depth 1 }
+                $msixTestState.onRequest = {
+                    param($method, $path, $data)
+                    if (($annotated -and $path.StartsWith('tags/')) -or
+                        (-not $annotated -and $path.StartsWith('ref/tags/'))) { Throw-ApiError $status }
+                }
+                Assert-Throws { Resolve-MsixPackageVersion @arguments } 'MSIX GitHub API GET failed'
+                Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+                Assert-Equal $msixTestState.refs.Count 0
+            }
+        }
+    }
+    foreach ($change in @('ref name', 'array ref name', 'type', 'array type', 'sha', 'array sha', 'uppercase sha', 'missing object')) {
+        Test-Case "source ref response rejects $change" {
+            $arguments = New-Arguments
+            $source = $msixTestState.sourceRefs[$arguments.SourceRef]
+            switch ($change) {
+                'ref name' { $source.ref = 'refs/tags/v2026.9.4-alpha.2' }
+                'array ref name' { $source.ref = @($source.ref) }
+                'type' { $source.object.type = 'tree' }
+                'array type' { $source.object.type = @('commit') }
+                'sha' { $source.object.sha = 'invalid' }
+                'array sha' { $source.object.sha = @($commit) }
+                'uppercase sha' { $source.object.sha = $commit.ToUpperInvariant() }
+                'missing object' { $source.PSObject.Properties.Remove('object') }
+            }
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+        }
+    }
+    foreach ($change in @('sha', 'array sha', 'type', 'array type', 'target sha', 'array target sha', 'missing object')) {
+        Test-Case "source annotated response rejects $change" {
+            $arguments = New-Arguments
+            Set-SourceTagChain -Ref $arguments.SourceRef -Commit $commit -Depth 1
+            $sourceTag = $msixTestState.tags[$msixTestState.sourceRefs[$arguments.SourceRef].object.sha]
+            switch ($change) {
+                'sha' { $sourceTag.sha = 'f' * 40 }
+                'array sha' { $sourceTag.sha = @($sourceTag.sha) }
+                'type' { $sourceTag.object.type = 'blob' }
+                'array type' { $sourceTag.object.type = @('commit') }
+                'target sha' { $sourceTag.object.sha = 'invalid' }
+                'array target sha' { $sourceTag.object.sha = @($commit) }
+                'missing object' { $sourceTag.PSObject.Properties.Remove('object') }
+            }
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+        }
+    }
+    Test-Case 'source annotated cycles fail without writes' {
+        $arguments = New-Arguments
+        Set-SourceTagChain -Ref $arguments.SourceRef -Commit $commit -Depth 1
+        $sourceTag = $msixTestState.tags[$msixTestState.sourceRefs[$arguments.SourceRef].object.sha]
+        $sourceTag.object = [pscustomobject]@{ type = 'tag'; sha = $sourceTag.sha }
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'cycle'
+        Assert-Equal $msixTestState.requests.Count 2
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+    }
+    Test-Case 'source annotated depth nine exceeds the eight-object bound' {
+        $arguments = New-Arguments
+        Set-SourceTagChain -Ref $arguments.SourceRef -Commit $commit -Depth 9
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'depth limit'
+        Assert-Equal $msixTestState.requests.Count 9
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+    }
+    foreach ($same in @($false, $true)) {
+        foreach ($status in @(409, 422)) {
+            Test-Case "concurrent same=$same source with HTTP $status collision" {
+                $msixTestState.collisionStatus = $status
+                $msixTestState.onCreateRef = {
+                    param($data)
+                    $msixTestState.onCreateRef = $null
+                    $competitor = if ($same) { New-Arguments } else { New-Arguments '2026.9.4-alpha.2' -Commit $otherCommit }
+                    $msixTestState.raceResults.Add((Resolve-MsixPackageVersion @competitor))
+                }
+                $arguments = New-Arguments
+                $result = Resolve-MsixPackageVersion @arguments
+                Assert-Equal $msixTestState.raceResults.Count 1
+                Assert-Equal $msixTestState.raceResults[0].storePackageVersion '2026.9.401.0'
+                if ($same) {
+                    Assert-Equal $result.reservationRef $msixTestState.raceResults[0].reservationRef
+                    Assert-Equal $msixTestState.refs.Count 1
+                }
+                else {
+                    Assert-Equal $result.storePackageVersion '2026.9.402.0'
+                    Assert-Equal $msixTestState.refs.Count 2
+                }
+            }
+        }
+    }
+    Test-Case 'bounded retry exhaustion leaves all competing reservations intact' {
+        $msixTestState.onCreateRef = {
+            param($data)
+            $number = [int]($data.ref.Split('/')[-1])
+            Add-Reservation (New-Record $number "2026.9.4-alpha.$number" $otherCommit) | Out-Null
+        }
+        $arguments = New-Arguments
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'retry limit exhausted'
+        Assert-Equal $msixTestState.refs.Count 8
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.method -eq 'POST' -and $_.path -eq 'refs' }).Count 8
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.path.StartsWith('matching-refs/') }).Count 9
+    }
+    Test-Case 'last allowed collision still converges on duplicate source' {
+        $msixTestState.onCreateRef = {
+            param($data)
+            $number = [int]($data.ref.Split('/')[-1])
+            $record = if ($number -eq 408) { New-Record $number } else { New-Record $number "2026.9.4-alpha.$number" $otherCommit }
+            Add-Reservation $record | Out-Null
+        }
+        $arguments = New-Arguments
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.408.0'
+        Assert-Equal $msixTestState.refs.Count 8
+    }
+    Test-Case 'a lost response after persisted reservation is recovered by rerun' {
+        $msixTestState.tamper = {
+            param($method, $path, $result)
+            if ($method -eq 'POST' -and $path -eq 'refs') { Throw-ApiError }
+            return ,$result
+        }
+        $arguments = New-Arguments
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'network or transport'
+        Assert-Equal $msixTestState.refs.Count 1
+        $msixTestState.tamper = $null
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.401.0'
+    }
+    Test-Case 'preview reads only newest record and never creates anything' {
+        $oldTag = Add-Reservation (New-Record 401)
+        $oldTag.message = 'not read by preview'
+        Add-Reservation (New-Record 415 '2026.9.4-alpha.2') | Out-Null
+        $arguments = New-Arguments '2026.9.4-PullRequest1445.11+Branch.feature.Sha.abcdef' -Preview
+        $result = Resolve-MsixPackageVersion @arguments
+        Assert-Equal $result.storePackageVersion '2026.9.416.0'
+        Assert-Equal $result.allocation 'preview'
+        Assert-Equal $result.reservationRef $null
+        Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.path.StartsWith('tags/') }).Count 1
+        Add-Reservation (New-Record 416 '2026.9.4-alpha.3') | Out-Null
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.417.0'
+        $arguments = New-Arguments $arguments.SourceVersion
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'not valid allocation JSON'
+    }
+    Test-Case 'preview normalizes a single matching ref unwrapped by Windows PowerShell' {
+        Add-Reservation (New-Record 401) | Out-Null
+        $msixTestState.tamper = {
+            param($method, $path, $result)
+            if ($method -eq 'GET' -and $path.StartsWith('matching-refs/')) {
+                return $result[0]
+            }
+            return ,$result
+        }
+        $arguments = New-Arguments -Preview
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.402.0'
+    }
+    foreach ($sourceRef in @('refs/heads/main', 'refs/tags/v2026.9.4-alpha.1', 'refs/pull/1445/merge')) {
+        Test-Case "preview accepts $sourceRef" {
+            $arguments = New-Arguments -Preview
+            $arguments.SourceRef = $sourceRef
+            $result = Resolve-MsixPackageVersion @arguments
+            Assert-Equal $result.storePackageVersion '2026.9.401.0'
+            Assert-Equal $result.sourceRef $sourceRef
+            Assert-Equal $msixTestState.requests.Count 1
+            Assert-Equal $msixTestState.refs.Count 0
+        }
+    }
+    Test-Case 'anonymous preview is read-only and omits Authorization' {
+        $msixTestState.expectAuthorization = $false
+        $arguments = New-Arguments -Preview
+        $arguments.GitHubToken = ''
+        Assert-Equal (& $entrypoint @arguments).allocation 'preview'
+        Assert-Equal $msixTestState.requests.Count 1
+        Assert-Equal $msixTestState.refs.Count 0
+    }
+    foreach ($version in @('v2026.9.4', '2026.09.4', '02026.9.4', '2026.9.04', '2026.9', '2026.9.4.1',
+        '2026.9.4-', '2026.9.4-alpha..1', '2026.9.4-alpha.01', '2026.9.4-01', '2026.9.4+',
+        '2026.9.4+bad..meta', '2026.9.4-alpha_1', "2026.9.4`n", '0.9.4', '65536.9.4',
+        '2026.65536.4', '2026.9.656', '2026.9.999999999999999999999', '2026.9.4;Write-Host hacked')) {
+        Test-Case "reject invalid source version $version" {
+            $arguments = New-Arguments $version
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal $msixTestState.requests.Count 0
+        }
+    }
+    foreach ($mutation in @(
+        @{ Field = 'SourceCommit'; Value = 'a' * 39 },
+        @{ Field = 'SourceCommit'; Value = 'g' * 40 },
+        @{ Field = 'SourceRef'; Value = 'refs/heads/main' },
+        @{ Field = 'SourceRef'; Value = 'refs/tags/v2026.9.4-alpha.2' },
+        @{ Field = 'SourceRef'; Value = "refs/tags/v2026.9.4-alpha.1`n" },
+        @{ Field = 'Repository'; Value = 'openclaw/../evil' },
+        @{ Field = 'Repository'; Value = 'https://example.com/repo' },
+        @{ Field = 'Repository'; Value = 'openclaw/repo?access_token=x' },
+        @{ Field = 'Repository'; Value = 'openclaw/..' },
+        @{ Field = 'GitHubToken'; Value = '' },
+        @{ Field = 'GitHubToken'; Value = "token`r`nHeader: injected" }
+    )) {
+        Test-Case "reject invalid input $($mutation.Field)" {
+            $arguments = New-Arguments
+            $arguments[$mutation.Field] = $mutation.Value
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal $msixTestState.requests.Count 0
+        }
+    }
+    foreach ($baseline in @(
+        'not JSON', 'null', '[]', '[{"schemaVersion":1,"lastAllocated":{}}]', '{"schemaVersion":2,"lastAllocated":{}}',
+        '{"schemaVersion":"1","lastAllocated":{}}', '{"schemaVersion":1}',
+        '{"schemaVersion":1,"lastAllocated":[]}', '{"schemaVersion":1,"lastAllocated":{"2026.09.4":400}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.4-alpha.1":400}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.4":399}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.4":500}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.4":"400"}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.4":400.5}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.655":65536}}',
+        '{"schemaVersion":1,"lastAllocated":{"2026.9.656":65600}}'
+    )) {
+        Test-Case 'reject corrupt baseline including unrelated base records' {
+            Set-Content -LiteralPath $baselinePath $baseline
+            $arguments = New-Arguments
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal $msixTestState.requests.Count 0
+        }
+    }
+    Test-Case 'missing baseline is an error' {
+        $arguments = New-Arguments
+        $arguments.BaselinePath = Join-Path $temporaryRoot 'absent.json'
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'baseline file'
+        Assert-Equal $msixTestState.requests.Count 0
+    }
+    foreach ($status in @(0, 401, 404, 409, 422)) {
+        Test-Case "matching-refs error $status is never a zero fallback" {
+            $msixTestState.onRequest = { param($method, $path, $data) Throw-ApiError $status }
+            $arguments = New-Arguments -Preview
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } 'MSIX GitHub API GET failed'
+            Assert-Equal $msixTestState.requests.Count 1
+        }
+    }
+    foreach ($route in @('tags', 'refs')) {
+        foreach ($status in @(0, 401, 404, 409, 422)) {
+            Test-Case "$route HTTP $status does not silently succeed" {
+                $msixTestState.onRequest = {
+                    param($method, $path, $data)
+                    if ($method -eq 'POST' -and $path -eq $route) { Throw-ApiError $status }
+                }
+                $arguments = New-Arguments
+                $expected = if ($route -eq 'refs' -and $status -in @(409, 422)) { 'no competing reservation' } else { 'MSIX GitHub API POST failed' }
+                Assert-Throws { Resolve-MsixPackageVersion @arguments } $expected
+                Assert-Equal $msixTestState.refs.Count 0
+                Assert-Equal @($msixTestState.requests | Where-Object { $_.method -eq 'POST' -and $_.path -eq $route }).Count 1
+            }
+        }
+    }
+    foreach ($status in @(0, 401, 404, 422)) {
+        Test-Case "annotated tag GET error $status fails closed" {
+            Add-Reservation (New-Record 401) | Out-Null
+            $msixTestState.onRequest = {
+                param($method, $path, $data)
+                if ($path.StartsWith('tags/')) { Throw-ApiError $status }
+            }
+            $arguments = New-Arguments
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } 'MSIX GitHub API GET failed'
+        }
+    }
+    foreach ($badRef in @('refs/tags/msix-package/2026.9.5/500', 'refs/tags/msix-package/2026.9.4/0401',
+        'refs/tags/msix-package/2026.9.4/399', 'refs/tags/msix-package/2026.9.4/500',
+        'refs/tags/msix-package/2026.9.4/401/extra', 'refs/tags/msix-package/2026.9.4/999999999999999',
+        'refs/tags/msix-package/2026.9.4/401x')) {
+        Test-Case "reject malformed or unrelated matching ref $badRef" {
+            Add-Reservation (New-Record 401) | Out-Null
+            $msixTestState.tamper = {
+                param($method, $path, $result)
+                if ($path.StartsWith('matching-refs/')) { $result[0].ref = $badRef }
+                return ,$result
+            }
+            $arguments = New-Arguments -Preview
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } 'reservation ref'
+        }
+    }
+    foreach ($change in @('lightweight', 'array type', 'bad sha', 'duplicate', 'too many', 'missing field')) {
+        Test-Case "reject matching-refs shape: $change" {
+            Add-Reservation (New-Record 401) | Out-Null
+            $msixTestState.tamper = {
+                param($method, $path, $result)
+                if ($path.StartsWith('matching-refs/')) {
+                    switch ($change) {
+                        'lightweight' { $result[0].object.type = 'commit' }
+                        'array type' { $result[0].object.type = @('tag') }
+                        'bad sha' { $result[0].object.sha = 'bad' }
+                        'duplicate' { $result = @($result[0], $result[0]) }
+                        'too many' { $result = @($result[0]) * 101 }
+                        'missing field' { $result[0].PSObject.Properties.Remove('object') }
+                    }
+                }
+                return ,$result
+            }
+            $arguments = New-Arguments -Preview
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+        }
+    }
+    foreach ($change in @('sha', 'name', 'array name', 'type', 'array type', 'target', 'json', 'array message', 'message type', 'record commit',
+        'record ref', 'record version', 'record repository', 'record preview', 'record sourceRef')) {
+        Test-Case "reject corrupt annotated reservation: $change" {
+            $tag = Add-Reservation (New-Record 401)
+            $record = $tag.message | ConvertFrom-Json
+            switch ($change) {
+                'sha' { $tag.sha = 'f' * 40 }
+                'name' { $tag.tag = 'msix-package/2026.9.4/402' }
+                'array name' { $tag.tag = @($tag.tag) }
+                'type' { $tag.object.type = 'tag' }
+                'array type' { $tag.object.type = @('commit') }
+                'target' { $tag.object.sha = $otherCommit }
+                'json' { $tag.message = 'not JSON' }
+                'array message' { $tag.message = '[' + $tag.message + ']' }
+                'message type' { $tag.message = 1 }
+                'record commit' { $record.sourceCommit = $otherCommit }
+                'record ref' { $record.reservationRef = 'refs/tags/msix-package/2026.9.4/402' }
+                'record version' { $record.sourceVersion = '2026.9.5-alpha.1' }
+                'record repository' { $record.repository = 'other/repo' }
+                'record preview' { $record.allocation = 'preview'; $record.reservationRef = $null }
+                'record sourceRef' { $record.sourceRef = 'refs/tags/v2026.9.4-alpha.2' }
+            }
+            if ($change.StartsWith('record ')) { $tag.message = $record | ConvertTo-Json -Compress }
+            $arguments = New-Arguments -Preview
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+            Assert-Equal @($msixTestState.requests | Where-Object method -eq POST).Count 0
+        }
+    }
+    Test-Case 'duplicate stored allocations for one source are corrupt' {
+        Add-Reservation (New-Record 401) | Out-Null
+        Add-Reservation (New-Record 402) | Out-Null
+        $arguments = New-Arguments
+        Assert-Throws { Resolve-MsixPackageVersion @arguments } 'duplicate sourceRef'
+    }
+    Test-Case 'official inspection is bounded to 100 annotated records' {
+        foreach ($number in 400..499) {
+            Add-Reservation (New-Record $number "2026.9.4-alpha.$number") | Out-Null
+        }
+        $arguments = New-Arguments '2026.9.4-alpha.400'
+        Assert-Equal (Resolve-MsixPackageVersion @arguments).storePackageVersion '2026.9.400.0'
+        Assert-Equal @($msixTestState.requests | Where-Object { $_.path.StartsWith('tags/') }).Count 100
+        Assert-Equal $msixTestState.requests.Count 102
+    }
+    foreach ($change in @('tag SHA', 'tag target', 'tag message', 'ref name', 'array ref name', 'ref type', 'array ref type', 'ref SHA', 'array ref SHA')) {
+        Test-Case "reject POST response tampering: $change" {
+            $msixTestState.tamper = {
+                param($method, $path, $result)
+                if ($method -eq 'POST' -and $path -eq 'tags') {
+                    switch ($change) {
+                        'tag SHA' { $result.sha = 'bad' }
+                        'tag target' { $result.object.sha = $otherCommit }
+                        'tag message' {
+                            $record = $result.message | ConvertFrom-Json
+                            $record.sourceVersion = '2026.9.4-alpha.2'
+                            $record.sourceRef = 'refs/tags/v2026.9.4-alpha.2'
+                            $result.message = $record | ConvertTo-Json -Compress
+                        }
+                    }
+                }
+                if ($method -eq 'POST' -and $path -eq 'refs') {
+                    switch ($change) {
+                        'ref name' { $result.ref = 'refs/tags/msix-package/2026.9.4/402' }
+                        'array ref name' { $result.ref = @($result.ref) }
+                        'ref type' { $result.object.type = 'commit' }
+                        'array ref type' { $result.object.type = @('tag') }
+                        'ref SHA' { $result.object.sha = 'f' * 40 }
+                        'array ref SHA' { $result.object.sha = @($result.object.sha) }
+                    }
+                }
+                return ,$result
+            }
+            $arguments = New-Arguments
+            Assert-Throws { Resolve-MsixPackageVersion @arguments } ''
+        }
+    }
+    Test-Case 'metadata validation and JSON reader bind exact source and reservation' {
+        $info = New-Record 401
+        $info | ConvertTo-Json | Set-Content -LiteralPath $metadataPath
+        $result = Read-MsixVersionInfo -Path $metadataPath -SourceCommit $commit.ToUpperInvariant() -SourceVersion $info.sourceVersion -RequireReserved
+        Assert-Equal ($result | ConvertTo-Json -Compress) ($info | ConvertTo-Json -Compress)
+        Assert-Throws { Read-MsixVersionInfo -Path $metadataPath -SourceCommit $otherCommit } 'sourceCommit'
+        Assert-Throws { Read-MsixVersionInfo -Path $metadataPath -SourceCommit $commit -SourceVersion '2026.9.4-alpha.2' } 'sourceVersion'
+        $info.allocation = 'preview'
+        $info.reservationRef = $null
+        $info.sourceRef = 'refs/heads/main'
+        Assert-Equal (Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit).allocation 'preview'
+        Assert-Throws { Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit -RequireReserved } 'reserved'
+        $info | ConvertTo-Json | Set-Content -LiteralPath $metadataPath
+        Assert-Throws { Read-MsixVersionInfo -Path $metadataPath -SourceCommit $commit -RequireReserved } 'reserved'
+        Set-Content -LiteralPath $metadataPath ('[' + ($info | ConvertTo-Json -Compress) + ']')
+        Assert-Throws { Read-MsixVersionInfo -Path $metadataPath -SourceCommit $commit } 'could not be read'
+        Set-Content -LiteralPath $metadataPath 'invalid JSON'
+        Assert-Throws { Read-MsixVersionInfo -Path $metadataPath -SourceCommit $commit } 'could not be read'
+        Assert-Throws { Read-MsixVersionInfo -Path (Join-Path $temporaryRoot 'absent.json') -SourceCommit $commit } 'could not be read'
+        foreach ($name in @('Assert-MsixVersionInfo', 'Read-MsixVersionInfo')) {
+            $attributes = (Get-Command $name).Parameters.SourceCommit.Attributes
+            $mandatory = @($attributes | Where-Object { $_ -is [Management.Automation.ParameterAttribute] -and $_.Mandatory })
+            Assert-Equal $mandatory.Count 1
+        }
+    }
+    foreach ($field in (New-Record 401).PSObject.Properties.Name) {
+        Test-Case "metadata rejects missing $field" {
+            $info = New-Record 401
+            $info.PSObject.Properties.Remove($field)
+            Assert-Throws { Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit } 'missing field'
+        }
+    }
+    foreach ($mutation in @(
+        @{ Field = 'schemaVersion'; Value = 2 }, @{ Field = 'schemaVersion'; Value = '1' },
+        @{ Field = 'schemaVersion'; Value = $true }, @{ Field = 'schemaVersion'; Value = 1.0 },
+        @{ Field = 'sourceVersion'; Value = 2026 }, @{ Field = 'sourceVersion'; Value = '2026.9.5-alpha.1' },
+        @{ Field = 'sourceCommit'; Value = $commit.ToUpperInvariant() },
+        @{ Field = 'sourceCommit'; Value = $otherCommit },
+        @{ Field = 'sourceCommit'; Value = @($commit) },
+        @{ Field = 'sourceRef'; Value = 'refs/heads/main' },
+        @{ Field = 'sourceRef'; Value = "refs/tags/v2026.9.4-alpha.1`n" },
+        @{ Field = 'repository'; Value = 'https://example.org/repo' },
+        @{ Field = 'baseVersion'; Value = '2026.09.4' },
+        @{ Field = 'packageBaseVersion'; Value = '2026.9.0401' },
+        @{ Field = 'packageBaseVersion'; Value = '2026.9.402' },
+        @{ Field = 'storePackageVersion'; Value = '2026.9.401.1' },
+        @{ Field = 'storePackageVersion'; Value = '2026.9.65536.0' },
+        @{ Field = 'packagingRevision'; Value = -1 }, @{ Field = 'packagingRevision'; Value = 100 },
+        @{ Field = 'packagingRevision'; Value = 1.5 }, @{ Field = 'packagingRevision'; Value = '1' },
+        @{ Field = 'packagingRevision'; Value = $true },
+        @{ Field = 'allocation'; Value = 'Reserved' }, @{ Field = 'allocation'; Value = 'other' },
+        @{ Field = 'reservationRef'; Value = $null },
+        @{ Field = 'reservationRef'; Value = 'refs/tags/v2026.9.401.0' },
+        @{ Field = 'reservationRef'; Value = 'refs/tags/msix-package/2026.9.4/402' }
+    )) {
+        Test-Case "metadata rejects wrong $($mutation.Field)" {
+            $info = New-Record 401
+            $info.($mutation.Field) = $mutation.Value
+            Assert-Throws { Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit } ''
+        }
+    }
+    Test-Case 'preview reservation must be null, not empty' {
+        $info = New-Record 401
+        $info.allocation = 'preview'
+        $info.reservationRef = ''
+        Assert-Throws { Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit } 'must be null'
+    }
+    Test-Case 'metadata cannot exceed the partial patch 655 range' {
+        $info = New-Record 65536 '2026.9.655'
+        Assert-Throws { Assert-MsixVersionInfo -VersionInfo $info -SourceCommit $commit } 'allocation range'
+    }
+    Test-Case 'success and verbose output never contain the HTTP token' {
+        $arguments = New-Arguments
+        $output = Resolve-MsixPackageVersion @arguments -Verbose *>&1 | Out-String
+        Assert-Equal $output.Contains($token) $false
+    }
+    Write-Host "MSIX versioning tests passed: $($msixTestState.cases) cases, $($msixTestState.assertions) assertions, $($msixTestState.totalRequests) mocked HTTP requests. No real network or destructive ref operations."
+}
+finally {
+    $env:GH_TOKEN = $oldToken
+    [IO.Directory]::Delete($temporaryRoot, $true)
+}
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/PackageHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/PackageHelper.cs
index 0667b164d..7cc8dc8a5 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/PackageHelper.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/PackageHelper.cs
@@ -7,31 +7,31 @@ namespace OpenClawTray.Helpers;
 /// 
 internal static class PackageHelper
 {
-    private static bool? _isPackaged;
+    private static readonly Lazy CurrentPackageVersion = new(DetectPackageVersion);
 
     /// 
     /// Returns true if the app is running with package identity (MSIX).
     /// 
-    public static bool IsPackaged
-    {
-        get
-        {
-            _isPackaged ??= DetectPackaged();
-            return _isPackaged.Value;
-        }
-    }
+    public static bool IsPackaged => PackageVersion is not null;
+
+    /// 
+    /// Returns the four-part Windows package identity version, or null when unpackaged.
+    /// 
+    public static string? PackageVersion => CurrentPackageVersion.Value;
 
-    private static bool DetectPackaged()
+    private static string? DetectPackageVersion()
     {
         try
         {
             // Package.Current throws if not running in a packaged context
             var package = global::Windows.ApplicationModel.Package.Current;
-            return package != null;
+            var version = package.Id.Version;
+            return FormattableString.Invariant(
+                $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}");
         }
         catch
         {
-            return false;
+            return null;
         }
     }
 }
diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
index 8b22399b7..7291a2c0e 100644
--- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
+++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
@@ -156,7 +156,11 @@
     
       
          65535 || packageVersion.Minor > 65535 ||
+            packageVersion.Build > 65535 || packageVersion.Revision > 65535) {
             Log.LogError("GenerateOpenClawAppxManifest: '" + FourPartVersion + "' is not a valid four-part MSIX version.");
             return false;
         }
@@ -263,6 +267,8 @@
       <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '3'">$([System.Text.RegularExpressions.Regex]::Replace('$(_StrippedVersion)', '\.\d+$', ''))
       <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '2'">$(_StrippedVersion)
       <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0
+      
+      <_AppxBaseVersion Condition="'$(MsixPackageBaseVersion)' != ''">$(MsixPackageBaseVersion)
       <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(MsixRevision)' != ''">$(MsixRevision)
       <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(_AppxRevision)' == '' and '$(GitVersion_CommitsSinceVersionSource)' != ''">$(GitVersion_CommitsSinceVersionSource)
       <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(_AppxRevision)' == ''">1
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
index 1e42caee2..a990d4fd7 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
@@ -119,7 +119,8 @@ private void ShowSavedIndicator()
 
     private void PopulateAppInfo()
     {
-        AppInfoVersionText.Text = AppVersionInfo.DisplayVersion;
+        AppInfoVersionText.Text = SettingsAppInfoProjection.ResolveDisplayVersion(
+            AppVersionInfo.DisplayVersion, PackageHelper.PackageVersion);
         var windowsAppSdk = SettingsAppInfoProjection.ResolveWindowsAppSdkDisplayName(
             Assembly.GetEntryAssembly()?.GetName().Name, AppContext.BaseDirectory);
         AppInfoRuntimeText.Text = SettingsAppInfoProjection.BuildRuntimeStack(
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsAppInfoProjection.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsAppInfoProjection.cs
index dfd2d0ec9..3dec13253 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsAppInfoProjection.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsAppInfoProjection.cs
@@ -24,6 +24,13 @@ public static string BuildRuntimeStack(string frameworkDescription, string winUi
     /// Maps the packaged flag to the installation-kind label.
     public static string InstallKind(bool isPackaged) => isPackaged ? PackagedInstallText : UnpackagedInstallText;
 
+    /// 
+    /// Displays the Windows package identity version for MSIX installs and the application
+    /// GitVersion for unpackaged developer runs.
+    /// 
+    public static string ResolveDisplayVersion(string appDisplayVersion, string? packageVersion) =>
+        string.IsNullOrWhiteSpace(packageVersion) ? appDisplayVersion : packageVersion.Trim();
+
     /// Resolves the update-channel label, defaulting to stable when unset.
     public static string ResolveUpdateChannel(string? channelEnvironmentValue) =>
         string.IsNullOrWhiteSpace(channelEnvironmentValue) ? DefaultChannel : channelEnvironmentValue.Trim();
diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
index 4347b91b2..e12775bd2 100644
--- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
@@ -425,6 +425,8 @@ public void SettingsPage_HostsAboutAndGatewayInfoAfterAboutPageRemoval()
         Assert.Contains("OnGitHubLink", settingsXaml);
         Assert.Contains("OnDashboardLink", settingsXaml);
         Assert.Contains("RefreshGatewayInfo", settingsCs);
+        Assert.Contains("SettingsAppInfoProjection.ResolveDisplayVersion(", settingsCs);
+        Assert.Contains("PackageHelper.PackageVersion", settingsCs);
         Assert.Contains("\"settings\" or \"info\" or \"about\" => HubPageKind.Settings", registry);
         Assert.Contains("HubPageKind.Settings => typeof(SettingsPage)", registry);
         var repoRoot = TestRepositoryPaths.GetRepositoryRoot();
diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs
index 6a9cd325d..4b705f976 100644
--- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs
+++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs
@@ -53,6 +53,10 @@ public void BuildScript_DevelopmentMsixPathStaysLocallySigned()
         Assert.Contains("[ValidateRange(1, 65535)]", buildScript);
         Assert.Contains("$explicitMsixRevision", buildScript);
         Assert.Contains("-MsixRevision and -MsixOutputDirectory require -Msix Dev.", buildScript);
+        Assert.Contains("-MsixBaseVersion requires -Msix Dev.", buildScript);
+        Assert.Contains("Select-LocalDevMsixBaseVersion", buildScript);
+        Assert.Contains("Get-CurrentAppBaseVersion", buildScript);
+        Assert.Contains("-p:MsixPackageBaseVersion=$effectiveMsixBaseVersion", buildScript);
         Assert.Contains("The Dev MSIX output directory must be absent or empty:", buildScript);
         Assert.Contains("([version]$installedDevPackage.Version.ToString()).Revision + 1", buildScript);
         Assert.Contains("setup-dev-msix-cert.ps1", buildScript);
@@ -118,6 +122,27 @@ public void BuildScript_StoreMsixPathIsUnsignedReleaseIdentityForBothArchitectur
         Assert.DoesNotContain("DevBuild", packagingScript);
         Assert.DoesNotContain("PackageCertificate", packagingScript);
         Assert.DoesNotContain("MsixRevision", packagingScript);
+        Assert.Contains("Read-MsixVersionInfo", packagingScript);
+        Assert.Contains("-p:MsixPackageBaseVersion=", packagingScript);
+        Assert.Contains("msixVersionAllocation = $versionInfo", packagingScript);
+        Assert.DoesNotContain("UpdateVersionProperties=false", packagingScript);
+        Assert.DoesNotContain("UpdateAssemblyInfo=false", packagingScript);
+    }
+
+    [Fact]
+    public void MsixPackageAllocation_DoesNotOverrideApplicationVersionMetadata()
+    {
+        var root = TestRepositoryPaths.GetRepositoryRoot();
+        var project = File.ReadAllText(Path.Combine(
+            root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj"));
+        var buildScript = File.ReadAllText(Path.Combine(root, "build.ps1"));
+
+        Assert.Contains("<_AppxBaseVersion Condition=\"'$(MsixPackageBaseVersion)' != ''\">$(MsixPackageBaseVersion)", project);
+        Assert.Contains("packageVersion.Build > 65535", project);
+        Assert.DoesNotContain("$(MsixPackageBaseVersion)", project);
+        Assert.DoesNotContain("-p:Version=$MsixBaseVersion", buildScript);
+        Assert.DoesNotContain("UpdateVersionProperties=false", buildScript);
+        Assert.DoesNotContain("UpdateAssemblyInfo=false", buildScript);
     }
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsAppInfoProjectionTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsAppInfoProjectionTests.cs
index a82da414a..33d296274 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsAppInfoProjectionTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsAppInfoProjectionTests.cs
@@ -21,6 +21,23 @@ public void InstallKind_MapsPackagedFlag(bool packaged, string expected)
         Assert.Equal(expected, SettingsAppInfoProjection.InstallKind(packaged));
     }
 
+    [Theory]
+    [InlineData(null, "v2026.9.5-PullRequest1448.9")]
+    [InlineData("", "v2026.9.5-PullRequest1448.9")]
+    [InlineData("   ", "v2026.9.5-PullRequest1448.9")]
+    [InlineData("2026.9.401.0", "2026.9.401.0")]
+    [InlineData(" 2026.9.401.123 ", "2026.9.401.123")]
+    public void ResolveDisplayVersion_PrefersPackageIdentityVersion(
+        string? packageVersion,
+        string expected)
+    {
+        Assert.Equal(
+            expected,
+            SettingsAppInfoProjection.ResolveDisplayVersion(
+                "v2026.9.5-PullRequest1448.9",
+                packageVersion));
+    }
+
     [Theory]
     [InlineData(null, "stable")]
     [InlineData("", "stable")]
diff --git a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs
index 1c2242294..6ad7b880a 100644
--- a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs
@@ -126,7 +126,7 @@ public void ReleaseWorkflow_PublishesOnlyUnsignedStoreMsixForAlphaTags()
         Assert.Contains("name: openclaw-msix-store-unsigned-", workflow);
         Assert.Contains("name: openclaw-msix-dev-", workflow);
         Assert.Contains("MSIX_RESULT: ${{ needs.build-msix.result }}", workflow);
-        Assert.Contains("needs: [change-classification, metadata, build-x64, build-arm64, ci-gate]", workflow);
+        Assert.Contains("needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, ci-gate]", workflow);
         Assert.DoesNotContain("Download win-x64 MSIX artifact", workflow);
         Assert.DoesNotContain("Download win-arm64 MSIX artifact", workflow);
         Assert.DoesNotContain("Sign Release MSIX Packages", workflow);