From bfc34c2d905c7b1d07695182f232e1b6c8beac23 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 19 Sep 2026 19:02:19 -0700 Subject: [PATCH 1/2] feat(msix): publish multi-architecture Store bundle --- .github/workflows/ci.yml | 67 ++++++++++- DEVELOPMENT.md | 7 +- docs/RELEASING.md | 28 +++-- scripts/Assert-CiGateResults.ps1 | 4 +- scripts/Build-StoreMsix.ps1 | 4 +- scripts/Build-StoreMsixBundle.ps1 | 106 ++++++++++++++++++ scripts/Stage-StoreMsixReleaseAssets.ps1 | 89 +++++++++++++-- scripts/test-ci-gate-results.ps1 | 13 +++ scripts/test-ci-workflow-contract.ps1 | 31 ++++- scripts/test-msix-alpha-release.ps1 | 49 +++++++- scripts/test-msix-bundle.ps1 | 77 +++++++++++++ .../ReleaseSigningWorkflowTests.cs | 6 +- 12 files changed, 445 insertions(+), 36 deletions(-) create mode 100644 scripts/Build-StoreMsixBundle.ps1 create mode 100644 scripts/test-msix-bundle.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd72d177a..c2108956d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,10 @@ jobs: shell: pwsh run: ./scripts/test-msix-ci-artifacts.ps1 + - name: Validate Store MSIX bundle construction + shell: pwsh + run: ./scripts/test-msix-bundle.ps1 + - name: Validate MSIX version allocation shell: pwsh run: ./scripts/test-msix-versioning.ps1 @@ -976,10 +980,63 @@ jobs: shell: pwsh run: .\scripts\setup-dev-msix-cert.ps1 -Remove + build-msix-bundle: + name: Multi-architecture Store MSIX bundle + needs: [change-classification, metadata, reserve-msix-version, build-msix] + if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped') && needs.build-msix.result == 'success' && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} + runs-on: windows-latest + env: + 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 }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Download unsigned x64 Store package + uses: actions/download-artifact@v8 + with: + name: openclaw-msix-store-unsigned-x64 + path: artifacts/msix/x64 + + - name: Download unsigned ARM64 Store package + uses: actions/download-artifact@v8 + with: + name: openclaw-msix-store-unsigned-arm64 + path: artifacts/msix/arm64 + + - name: Validate shared MSIX version allocation + id: version + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:MSIX_VERSION_INFO)) { + throw 'Missing MSIX version allocation; refusing to bundle 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 + "packageVersion=$($info.storePackageVersion)" >> $env:GITHUB_OUTPUT + + - name: Compose unsigned multi-architecture Store MSIX bundle + shell: pwsh + run: | + .\scripts\Build-StoreMsixBundle.ps1 ` + -X64Package artifacts\msix\x64\OpenClaw-x64.msix ` + -Arm64Package artifacts\msix\arm64\OpenClaw-arm64.msix ` + -PackageVersion '${{ steps.version.outputs.packageVersion }}' ` + -OutputPath artifacts\msix\bundle\OpenClaw.msixbundle + + - name: Upload unsigned multi-architecture Store submission artifact + uses: actions/upload-artifact@v7 + with: + name: openclaw-msix-store-unsigned-bundle + path: artifacts/msix/bundle/OpenClaw.msixbundle + if-no-files-found: error + ci-gate: name: CI Gate if: ${{ always() }} - needs: [change-classification, fast-validation, proof-pool-contracts, metadata, core-tests, tray-tests, ui-tests, setup-e2e, revocation-e2e, network-e2e, build-x64, build-arm64, build-msix] + needs: [change-classification, fast-validation, proof-pool-contracts, metadata, core-tests, tray-tests, ui-tests, setup-e2e, revocation-e2e, network-e2e, build-x64, build-arm64, build-msix, build-msix-bundle] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -1010,6 +1067,7 @@ jobs: ARM64_RELEASE_RESULT: ${{ needs.build-arm64.result }} METADATA_RESULT: ${{ needs.metadata.result }} MSIX_RESULT: ${{ needs.build-msix.result }} + MSIX_BUNDLE_RESULT: ${{ needs.build-msix-bundle.result }} run: | $validatedMode = ./scripts/Assert-CiGateResults.ps1 ` -ClassificationResult $env:CLASSIFICATION_RESULT ` @@ -1034,12 +1092,13 @@ jobs: -Arm64ReleaseRequired $env:ARM64_RELEASE_REQUIRED ` -Arm64ReleaseResult $env:ARM64_RELEASE_RESULT ` -MetadataResult $env:METADATA_RESULT ` - -MsixResult $env:MSIX_RESULT + -MsixResult $env:MSIX_RESULT ` + -MsixBundleResult $env:MSIX_BUNDLE_RESULT "CI Gate passed $validatedMode validation." >> $env:GITHUB_STEP_SUMMARY release: - 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() + needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, build-msix-bundle, 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' && needs.build-msix-bundle.result == 'success' && !cancelled() runs-on: windows-latest environment: release-signing permissions: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3e9aebd7a..e335a55f0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -305,7 +305,9 @@ runtime, the in-process SetupEngine UI, and the architecture-matched the loose Visual C++ runtime files that the Inno payload ships but the MSIX resolves through its VCLibs framework dependency). -Upload both `.msix` files to the same Partner Center submission. `Identity/@Name`, +CI combines both packages into `OpenClaw.msixbundle`, the recommended single +Partner Center submission input. The standalone `.msix` files remain available +for architecture-specific inspection or fallback. `Identity/@Name`, `Identity/@Publisher`, and `Properties/PublisherDisplayName` in `src\OpenClaw.Tray.WinUI\Package.appxmanifest` already hold the reserved Partner Center values and must keep matching **Product management > Product @@ -320,7 +322,8 @@ without it the build logs a warning and skips symbols. #### CI MSIX downloads -The **Build and Test** workflow builds both x64 and ARM64 MSIX variants whenever +The **Build and Test** workflow builds both x64 and ARM64 MSIX variants and a +multi-architecture bundle whenever the change classifier selects a release-build lane. This includes packaging, build, and workflow PRs, pushes to `main`/`master`, tags, and manual workflow dispatches. Ordinary targeted or documentation-only PRs intentionally skip them. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8462059ec..8d21d616a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -14,7 +14,8 @@ smoke only; ARM64 portable publish remains required on `main` and tags. When either release-build lane is selected, CI also builds both architectures of Dev-signed and unsigned Store MSIX **workflow artifacts**. CI Gate requires that MSIX job to succeed. Canonical alpha releases also attach the unsigned -Store MSIX packages and metadata for manual Partner Center submission. +Store MSIX bundle, standalone packages, and metadata for manual Partner Center +submission. Stable releases do not include MSIX assets; Dev-signed packages stay in Actions. ## Release checklist @@ -175,16 +176,18 @@ Current release artifacts are: Canonical alpha releases additionally contain: +- `OpenClaw.msixbundle` (recommended Partner Center submission input) - `OpenClaw-x64.msix` and `OpenClaw-arm64.msix` - `OpenClaw-x64.msix-metadata.json` and `OpenClaw-arm64.msix-metadata.json` -These are **unsigned Store submission inputs, not installers**. Download the -MSIX files and upload them manually to Partner Center. Microsoft signs accepted -Store submissions. The alpha release step checks both architectures' clean -source provenance, identity, version, and package hashes before staging the -unchanged bytes built by `Build-StoreMsix.ps1`. It fails rather than publishing -a partial or mismatched set. +These are **unsigned Store submission inputs, not installers**. Upload the +bundle to Partner Center for one architecture-selecting submission. The +standalone packages remain available for inspection or fallback. Microsoft +signs accepted Store submissions. The alpha release step checks both +architectures' clean source provenance, identity, version, and package hashes, +then proves that the bundle embeds those exact bytes. It fails rather than +publishing a partial or mismatched set. Stable, stable-correction, and non-alpha prereleases retain the existing EXE/ZIP asset set and do not receive MSIX download notes. Dev-signed tester @@ -407,10 +410,11 @@ proofs as skipped when the host is not MXC-capable; use `.\scripts\validate-mxc-e2e.ps1` for required local/self-hosted MXC merge validation. Release tags cannot enter the `release` job until **CI Gate** confirms classification, fast validation, tests, E2E, and release builds all -succeeded. The `build-msix` job must also succeed whenever release metadata is -required. The release job downloads and attaches its unsigned Store packages -only for canonical alpha tags. Stable releases and Dev tester distribution -do not gain MSIX release attachments. +succeeded. The `build-msix` and `build-msix-bundle` jobs must also succeed +whenever release metadata is required. The release job downloads and attaches +its unsigned Store bundle, standalone packages, and metadata only for canonical +alpha tags. Stable releases and Dev tester distribution do not gain MSIX +release attachments. The release job should: @@ -422,7 +426,7 @@ The release job should: 6. Build Inno installers. 7. Sign installers. 8. For canonical alpha tags only, stage the validated unsigned Store MSIX - packages and metadata. + bundle, standalone packages, and metadata. 9. Create a GitHub release whose prerelease flag matches the tag, with installer and portable ZIP assets plus any gated alpha submission assets. diff --git a/scripts/Assert-CiGateResults.ps1 b/scripts/Assert-CiGateResults.ps1 index 167bfc90b..d40234206 100644 --- a/scripts/Assert-CiGateResults.ps1 +++ b/scripts/Assert-CiGateResults.ps1 @@ -27,7 +27,8 @@ param( [Parameter(Mandatory)][string]$Arm64ReleaseRequired, [Parameter(Mandatory)][string]$Arm64ReleaseResult, [Parameter(Mandatory)][string]$MetadataResult, - [Parameter(Mandatory)][string]$MsixResult + [Parameter(Mandatory)][string]$MsixResult, + [Parameter(Mandatory)][string]$MsixBundleResult ) Set-StrictMode -Version Latest @@ -127,5 +128,6 @@ Assert-LaneResult "ARM64 release publish" $required.arm64_release $Arm64ReleaseR $metadataRequired = $required.x64_release -or $required.arm64_release Assert-LaneResult "release metadata" $metadataRequired $MetadataResult Assert-LaneResult "MSIX workflow artifacts" $metadataRequired $MsixResult +Assert-LaneResult "multi-architecture MSIX bundle" $metadataRequired $MsixBundleResult $Classification diff --git a/scripts/Build-StoreMsix.ps1 b/scripts/Build-StoreMsix.ps1 index 44dc4dea6..84da3a9f5 100644 --- a/scripts/Build-StoreMsix.ps1 +++ b/scripts/Build-StoreMsix.ps1 @@ -22,8 +22,8 @@ was dirty, the package version, publisher, and the package SHA-256. .PARAMETER Architecture - Target architecture: x64 or arm64. Defaults to x64. The Store serves a - separate package per architecture; upload both to one submission. + Target architecture: x64 or arm64. Defaults to x64. CI combines both + packages into the recommended multi-architecture Store submission bundle. .PARAMETER Configuration Build configuration. Release is the only accepted value: Store diff --git a/scripts/Build-StoreMsixBundle.ps1 b/scripts/Build-StoreMsixBundle.ps1 new file mode 100644 index 000000000..fce7734b6 --- /dev/null +++ b/scripts/Build-StoreMsixBundle.ps1 @@ -0,0 +1,106 @@ +<# +.SYNOPSIS + Builds one unsigned multi-architecture Store MSIX bundle. +.DESCRIPTION + Combines the validated x64 and ARM64 Store packages without changing their + bytes. The bundle receives the same four-part package version so Partner + Center can ingest one architecture-selecting submission asset. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$X64Package, + [Parameter(Mandatory)][string]$Arm64Package, + [Parameter(Mandatory)][string]$PackageVersion, + [Parameter(Mandatory)][string]$OutputPath, + [string]$MakeAppxPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-MakeAppx { + if (-not [string]::IsNullOrWhiteSpace($MakeAppxPath)) { + return (Resolve-Path -LiteralPath $MakeAppxPath).Path + } + + $command = Get-Command MakeAppx.exe -CommandType Application -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $windowsKits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $candidate = Get-ChildItem -LiteralPath $windowsKits -Filter MakeAppx.exe -File -Recurse ` + -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'x64' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $candidate) { + throw 'MakeAppx.exe was not found in PATH or the Windows 10 SDK.' + } + + $candidate.FullName +} + +foreach ($package in @($X64Package, $Arm64Package)) { + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "Required MSIX package was not found: $package" + } + if ([IO.Path]::GetExtension($package) -ine '.msix') { + throw "Bundle input must be an MSIX package: $package" + } +} + +$segments = @($PackageVersion.Split('.')) +if ($segments.Count -ne 4) { + throw 'PackageVersion must contain four numeric components.' +} +foreach ($segment in $segments) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($segment, [ref]$value) -or $value -gt 65534) { + throw "Invalid MSIX bundle version component: $segment" + } +} +if ($segments[3] -ne '0') { + throw "Store MSIX bundle versions must end in .0: $PackageVersion" +} + +$resolvedX64Package = (Resolve-Path -LiteralPath $X64Package).Path +$resolvedArm64Package = (Resolve-Path -LiteralPath $Arm64Package).Path +if ($resolvedX64Package -eq $resolvedArm64Package) { + throw 'The x64 and ARM64 bundle inputs must be different packages.' +} + +$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath) +if ([IO.Path]::GetExtension($resolvedOutputPath) -ine '.msixbundle') { + throw 'OutputPath must use the .msixbundle extension.' +} +if (Test-Path -LiteralPath $resolvedOutputPath) { + throw "MSIX bundle output already exists: $resolvedOutputPath" +} + +$outputDirectory = Split-Path $resolvedOutputPath -Parent +New-Item -Path $outputDirectory -ItemType Directory -Force | Out-Null +$workRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-msixbundle-$([guid]::NewGuid().ToString('N'))" +$bundleInput = Join-Path $workRoot 'packages' +New-Item -Path $bundleInput -ItemType Directory -Force | Out-Null + +try { + Copy-Item -LiteralPath $resolvedX64Package -Destination (Join-Path $bundleInput 'OpenClaw-x64.msix') + Copy-Item -LiteralPath $resolvedArm64Package -Destination (Join-Path $bundleInput 'OpenClaw-arm64.msix') + + $resolvedMakeAppx = Resolve-MakeAppx + & $resolvedMakeAppx bundle /v /bv $PackageVersion /d $bundleInput /p $resolvedOutputPath + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx.exe failed to build the MSIX bundle. Exit code: $LASTEXITCODE." + } + if (-not (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf)) { + throw 'MakeAppx.exe completed without producing the requested bundle.' + } + + Write-Host "Unsigned Store MSIX bundle is ready: $resolvedOutputPath" +} +finally { + if ([IO.Directory]::Exists($workRoot)) { + [IO.Directory]::Delete($workRoot, $true) + } +} diff --git a/scripts/Stage-StoreMsixReleaseAssets.ps1 b/scripts/Stage-StoreMsixReleaseAssets.ps1 index 795f01d69..385273ef7 100644 --- a/scripts/Stage-StoreMsixReleaseAssets.ps1 +++ b/scripts/Stage-StoreMsixReleaseAssets.ps1 @@ -2,7 +2,8 @@ .SYNOPSIS Stages validated unsigned Store MSIX packages for an alpha GitHub release. .DESCRIPTION - Checks both architectures' provenance and hashes before copying any files. + Checks both architectures' provenance and hashes, then verifies that the + multi-architecture bundle embeds those exact packages before copying files. Build-StoreMsix.ps1 owns package-content validation; this script preserves those exact bytes and never signs packages or submits them to Partner Center. Returns Files and Notes for the existing release publisher. @@ -76,12 +77,81 @@ $packages = foreach ($architecture in @('x64', 'arm64')) { throw "The $architecture Store package hash does not match its metadata." } - [pscustomobject]@{ Path = $packagePath; Name = $packageName; Metadata = $metadataPath } + [pscustomobject]@{ + Architecture = $architecture + Path = $packagePath + Name = $packageName + Metadata = $metadataPath + Sha256 = $metadata.sha256 + } +} + +$bundleDirectory = Join-Path $ArtifactDirectory 'openclaw-msix-store-unsigned-bundle' +$bundleName = 'OpenClaw.msixbundle' +$bundleEntries = @(Get-ChildItem -LiteralPath $bundleDirectory -Force) +if ($bundleEntries.Count -ne 1 -or $bundleEntries[0].PSIsContainer -or + $bundleEntries[0].LinkType -or $bundleEntries[0].Name -ne $bundleName) { + throw 'Expected exactly the unsigned multi-architecture Store MSIX bundle.' +} +$bundlePath = $bundleEntries[0].FullName + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$bundle = [IO.Compression.ZipFile]::OpenRead($bundlePath) +try { + $bundleManifestEntry = @($bundle.Entries | Where-Object { + $_.FullName -ceq 'AppxMetadata/AppxBundleManifest.xml' + }) + if ($bundleManifestEntry.Count -ne 1) { + throw 'The Store MSIX bundle is missing its unique bundle manifest.' + } + $reader = [IO.StreamReader]::new($bundleManifestEntry[0].Open()) + try { [xml]$bundleManifest = $reader.ReadToEnd() } + finally { $reader.Dispose() } + + $identity = $bundleManifest.Bundle.Identity + if ([string]$identity.Name -ne [string]$manifest.Package.Identity.Name -or + [string]$identity.Publisher -ne [string]$manifest.Package.Identity.Publisher -or + [string]$identity.Version -ne $expectedVersion) { + throw 'The Store MSIX bundle identity or version does not match its packages.' + } + + $manifestPackages = @($bundleManifest.Bundle.Packages.Package) + if ($manifestPackages.Count -ne 2) { + throw 'The Store MSIX bundle must contain exactly x64 and ARM64 packages.' + } + foreach ($package in $packages) { + $manifestPackage = @($manifestPackages | Where-Object { + [string]$_.Architecture -eq $package.Architecture -and + [string]$_.FileName -eq $package.Name + }) + $packageEntry = @($bundle.Entries | Where-Object { $_.FullName -ceq $package.Name }) + if ($manifestPackage.Count -ne 1 -or $packageEntry.Count -ne 1) { + throw "The Store MSIX bundle is missing its $($package.Architecture) package." + } + + $stream = $packageEntry[0].Open() + $sha = [Security.Cryptography.SHA256]::Create() + try { + $embeddedHash = ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '') + } + finally { + $sha.Dispose() + $stream.Dispose() + } + if ($embeddedHash -ne $package.Sha256) { + throw "The Store MSIX bundle changed the $($package.Architecture) package bytes." + } + } +} +finally { + $bundle.Dispose() } -# A bad second architecture must not leave a publishable partial set. +# A bad architecture or bundle must not leave a publishable partial set. New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null -$files = foreach ($package in $packages) { +$files = @($bundlePath | Copy-Item -Destination (Join-Path $OutputDirectory $bundleName) -PassThru | + Select-Object -ExpandProperty FullName) +$files += foreach ($package in $packages) { $packageDestination = Join-Path $OutputDirectory $package.Name $metadataDestination = Join-Path $OutputDirectory "$($package.Name)-metadata.json" Copy-Item -LiteralPath $package.Path -Destination $packageDestination @@ -95,11 +165,12 @@ $files = foreach ($package in $packages) { Notes = @" ### Unsigned Store submission packages (alpha only) -OpenClaw-x64.msix and OpenClaw-arm64.msix are unsigned -Partner Center submission inputs, not installers. Their architecture-specific -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. +OpenClaw.msixbundle is the recommended unsigned Partner Center submission +input. It contains the exact x64 and ARM64 packages selected by Windows. +The standalone OpenClaw-x64.msix and OpenClaw-arm64.msix files and their +metadata remain available for architecture-specific inspection or fallback. +These files are not installers. Microsoft signs accepted Store submissions; +this workflow does not submit or retrieve Store packages. The Windows package version is $expectedVersion, reserved for $Version. Different official tags share the app patch's packaging counter; reruns of diff --git a/scripts/test-ci-gate-results.ps1 b/scripts/test-ci-gate-results.ps1 index 2060c7c72..8d582e9e2 100644 --- a/scripts/test-ci-gate-results.ps1 +++ b/scripts/test-ci-gate-results.ps1 @@ -42,6 +42,7 @@ function New-GateArguments { Arm64ReleaseResult = "skipped" MetadataResult = "skipped" MsixResult = "skipped" + MsixBundleResult = "skipped" } } @@ -114,6 +115,7 @@ foreach ($prefix in @( } $fullArguments.MetadataResult = "success" $fullArguments.MsixResult = "success" +$fullArguments.MsixBundleResult = "success" $full = Invoke-Gate $fullArguments if ($full -ne "full") { throw "Expected the full gate to pass." @@ -135,6 +137,7 @@ foreach ($prefix in @( } $fullPrArguments.MetadataResult = "success" $fullPrArguments.MsixResult = "success" +$fullPrArguments.MsixBundleResult = "success" $fullPr = Invoke-Gate $fullPrArguments if ($fullPr -ne "full") { throw "Expected full pull request validation without ARM64 publish to pass." @@ -148,19 +151,29 @@ Assert-GateFails -Overrides @{ CoreResult = "skipped" } -Scenario "Required lane Assert-GateFails -Overrides @{ CoreResult = "cancelled" } -Scenario "Required lane cancelled" Assert-GateFails -Overrides @{ CoreResult = "failure" } -Scenario "Required lane failed" Assert-GateFails -Overrides @{ MsixResult = "success" } -Scenario "Unselected MSIX lane ran" +Assert-GateFails -Overrides @{ MsixBundleResult = "success" } -Scenario "Unselected MSIX bundle lane ran" foreach ($result in @("failure", "cancelled", "skipped", "")) { Assert-GateFails -Overrides @{ X64ReleaseRequired = "true" X64ReleaseResult = "success" MetadataResult = "success" MsixResult = $result + MsixBundleResult = "success" } -Scenario "Selected MSIX lane returned '$result'" + Assert-GateFails -Overrides @{ + X64ReleaseRequired = "true" + X64ReleaseResult = "success" + MetadataResult = "success" + MsixResult = "success" + MsixBundleResult = $result + } -Scenario "Selected MSIX bundle lane returned '$result'" } $arm64Arguments = New-GateArguments $arm64Arguments.Arm64ReleaseRequired = "true" $arm64Arguments.Arm64ReleaseResult = "success" $arm64Arguments.MetadataResult = "success" $arm64Arguments.MsixResult = "success" +$arm64Arguments.MsixBundleResult = "success" if ((Invoke-Gate $arm64Arguments) -ne "targeted") { throw "Expected ARM64 release selection to require successful MSIX artifacts." } diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 6fa4b573c..3ed6ae191 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -692,6 +692,28 @@ foreach ($token in @('if: false', "`n continue-on-error: true", 'Set-Content Assert-NotContains -Text $buildMsixJob -Unexpected $token -Message "MSIX artifacts must not contain '$token'." } +$buildMsixBundleJob = Get-JobBlock 'build-msix-bundle' +foreach ($token in @( + 'name: Multi-architecture Store MSIX bundle', + 'needs: [change-classification, metadata, reserve-msix-version, build-msix]', + "needs.build-msix.result == 'success'", + 'name: openclaw-msix-store-unsigned-x64', + 'name: openclaw-msix-store-unsigned-arm64', + '.\scripts\Build-StoreMsixBundle.ps1', + '-PackageVersion ''${{ steps.version.outputs.packageVersion }}''', + '-OutputPath artifacts\msix\bundle\OpenClaw.msixbundle', + 'name: openclaw-msix-store-unsigned-bundle', + 'path: artifacts/msix/bundle/OpenClaw.msixbundle', + 'Assert-MsixVersionInfo', + '-SourceCommit $env:GITHUB_SHA -SourceVersion $env:MSIX_SOURCE_VERSION', + 'refusing to bundle with a fallback version' +)) { + Assert-Contains -Text $buildMsixBundleJob -Expected $token -Message "MSIX bundle lane is missing '$token'." +} +foreach ($token in @('secrets.', 'id-token: write', 'contents: write', '-Reserve')) { + Assert-NotContains -Text $buildMsixBundleJob -Unexpected $token -Message "MSIX bundle lane must not contain '$token'." +} + $reserveMsixJob = Get-JobBlock 'reserve-msix-version' foreach ($token in @( 'needs: [change-classification, metadata]', @@ -757,7 +779,7 @@ $ciGateJob = Get-JobBlock "ci-gate" foreach ($token in @( "name: CI Gate", "if: `${{ always() }}", - "needs: [change-classification, fast-validation, proof-pool-contracts, metadata, core-tests, tray-tests, ui-tests, setup-e2e, revocation-e2e, network-e2e, build-x64, build-arm64, build-msix]", + "needs: [change-classification, fast-validation, proof-pool-contracts, metadata, core-tests, tray-tests, ui-tests, setup-e2e, revocation-e2e, network-e2e, build-x64, build-arm64, build-msix, build-msix-bundle]", "./scripts/Assert-CiGateResults.ps1", "-FullRequired `$env:FULL_REQUIRED", "-CoreRequired `$env:CORE_REQUIRED", @@ -770,15 +792,18 @@ foreach ($token in @( "-Arm64ReleaseRequired `$env:ARM64_RELEASE_REQUIRED", "-MetadataResult `$env:METADATA_RESULT", "MSIX_RESULT: `${{ needs.build-msix.result }}", - "-MsixResult `$env:MSIX_RESULT" + "-MsixResult `$env:MSIX_RESULT", + "MSIX_BUNDLE_RESULT: `${{ needs.build-msix-bundle.result }}", + "-MsixBundleResult `$env:MSIX_BUNDLE_RESULT" )) { Assert-Contains -Text $ciGateJob -Expected $token -Message "Stable CI Gate is missing '$token'." } $releaseJob = Get-JobBlock "release" foreach ($token in @( - "needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, ci-gate]", + "needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, build-msix-bundle, ci-gate]", "needs.reserve-msix-version.result == 'success'", + "needs.build-msix-bundle.result == 'success'", "needs.ci-gate.result == 'success'", "needs.metadata.outputs.semVer", "needs.metadata.outputs.isPrerelease", diff --git a/scripts/test-msix-alpha-release.ps1 b/scripts/test-msix-alpha-release.ps1 index fd46efa44..be3f5ee1f 100644 --- a/scripts/test-msix-alpha-release.ps1 +++ b/scripts/test-msix-alpha-release.ps1 @@ -68,6 +68,32 @@ function New-Fixture { msixVersionAllocation = $allocation } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $directory 'msix-metadata.json') } + $bundleDirectory = Join-Path $inputPath 'openclaw-msix-store-unsigned-bundle' + New-Item -ItemType Directory -Path $bundleDirectory -Force | Out-Null + $bundlePath = Join-Path $bundleDirectory 'OpenClaw.msixbundle' + $bundle = [IO.Compression.ZipFile]::Open($bundlePath, [IO.Compression.ZipArchiveMode]::Create) + try { + $manifestEntry = $bundle.CreateEntry('AppxMetadata/AppxBundleManifest.xml') + $writer = [IO.StreamWriter]::new($manifestEntry.Open()) + try { + $writer.Write( + '' + + '' + + '', + [string]$manifest.Package.Identity.Name, + [Security.SecurityElement]::Escape([string]$manifest.Package.Identity.Publisher)) + } + finally { $writer.Dispose() } + foreach ($architecture in @('x64', 'arm64')) { + $packagePath = Join-Path $inputPath "openclaw-msix-store-unsigned-$architecture\OpenClaw-$architecture.msix" + $entry = $bundle.CreateEntry("OpenClaw-$architecture.msix") + $source = [IO.File]::OpenRead($packagePath) + $destination = $entry.Open() + try { $source.CopyTo($destination) } + finally { $destination.Dispose(); $source.Dispose() } + } + } + finally { $bundle.Dispose() } @{ ArtifactDirectory = $inputPath OutputDirectory = Join-Path $temporaryRoot "output-$scenario" @@ -82,17 +108,19 @@ $oldEvent = $env:EVENT_NAME $oldSchedule = $env:SCHEDULE $oldOutput = $env:GITHUB_OUTPUT try { + Add-Type -AssemblyName System.IO.Compression.FileSystem $arguments = New-Fixture $assets = & $stager @arguments $names = @($assets.Files | ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object) $expected = @( + 'OpenClaw.msixbundle', 'OpenClaw-arm64.msix', 'OpenClaw-arm64.msix-metadata.json', 'OpenClaw-x64.msix', 'OpenClaw-x64.msix-metadata.json' ) if (@(Compare-Object $expected $names).Count -gt 0 -or - @(Get-ChildItem -LiteralPath $arguments.OutputDirectory).Count -ne 4) { + @(Get-ChildItem -LiteralPath $arguments.OutputDirectory).Count -ne 5) { throw 'Release assets did not match the exact public Store allowlist.' } foreach ($architecture in @('x64', 'arm64')) { @@ -107,7 +135,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.202.0', 'reuse its reservation', 'Dev-signed tester downloads remain in Actions')) { + foreach ($warning in @('OpenClaw.msixbundle', 'recommended', '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' @@ -146,6 +174,23 @@ try { Remove-Item -LiteralPath (Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-arm64\OpenClaw-arm64.msix') Assert-Fails { & $stager @arguments } 'exactly' $arguments = New-Fixture + Remove-Item -LiteralPath (Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-bundle\OpenClaw.msixbundle') + Assert-Fails { & $stager @arguments } 'exactly the unsigned multi-architecture' + $arguments = New-Fixture + $bundlePath = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-bundle\OpenClaw.msixbundle' + $bundle = [IO.Compression.ZipFile]::Open($bundlePath, [IO.Compression.ZipArchiveMode]::Update) + try { + $entry = $bundle.GetEntry('OpenClaw-arm64.msix') + $entry.Delete() + $entry = $bundle.CreateEntry('OpenClaw-arm64.msix') + $writer = [IO.StreamWriter]::new($entry.Open()) + try { $writer.Write('changed package bytes') } + finally { $writer.Dispose() } + } + finally { $bundle.Dispose() } + Assert-Fails { & $stager @arguments } 'changed the arm64 package bytes' + if (Test-Path -LiteralPath $arguments.OutputDirectory) { throw 'Rejected bundle left partial release assets.' } + $arguments = New-Fixture $arguments.Version = '2026.7.3-alpha.4' Assert-Fails { & $stager @arguments } 'source version' diff --git a/scripts/test-msix-bundle.ps1 b/scripts/test-msix-bundle.ps1 new file mode 100644 index 000000000..cc1a50f8b --- /dev/null +++ b/scripts/test-msix-bundle.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + Exercises multi-architecture Store MSIX bundle construction contracts. +#> +[CmdletBinding()] +param([string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$builder = Join-Path $RepoRoot 'scripts\Build-StoreMsixBundle.ps1' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-msixbundle-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $temporaryRoot -ItemType Directory -Force | Out-Null + +function Assert-Fails { + param([scriptblock]$Action, [string]$Expected) + try { + & $Action + throw 'The operation unexpectedly succeeded.' + } + catch { + if (-not $_.Exception.Message.Contains($Expected, [StringComparison]::OrdinalIgnoreCase)) { + throw "Expected '$Expected', received: $($_.Exception.Message)" + } + } +} + +try { + $x64Package = Join-Path $temporaryRoot 'x64.msix' + $arm64Package = Join-Path $temporaryRoot 'arm64.msix' + Set-Content -LiteralPath $x64Package -Value 'x64 package' + Set-Content -LiteralPath $arm64Package -Value 'arm64 package' + + $argumentsPath = Join-Path $temporaryRoot 'makeappx-arguments.txt' + $env:OPENCLAW_BUNDLE_TEST_ARGUMENTS = $argumentsPath + $fakeMakeAppx = Join-Path $temporaryRoot 'MakeAppx.ps1' + @' +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) +$Arguments -join ' ' | Set-Content -LiteralPath $env:OPENCLAW_BUNDLE_TEST_ARGUMENTS +$outputIndex = [Array]::IndexOf($Arguments, '/p') +if ($outputIndex -lt 0 -or $outputIndex + 1 -ge $Arguments.Count) { exit 2 } +New-Item -ItemType File -Path $Arguments[$outputIndex + 1] -Force | Out-Null +exit 0 +'@ | Set-Content -LiteralPath $fakeMakeAppx + + $bundle = Join-Path $temporaryRoot 'OpenClaw.msixbundle' + & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.401.0' -OutputPath $bundle -MakeAppxPath $fakeMakeAppx + if (-not (Test-Path -LiteralPath $bundle -PathType Leaf)) { + throw 'The bundle builder did not preserve the MakeAppx output.' + } + $arguments = Get-Content -LiteralPath $argumentsPath -Raw + foreach ($token in @('bundle', '/bv 2026.9.401.0', 'OpenClaw.msixbundle')) { + if (-not $arguments.Contains($token, [StringComparison]::OrdinalIgnoreCase)) { + throw "MakeAppx arguments are missing '$token': $arguments" + } + } + + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $x64Package ` + -PackageVersion '2026.9.401.0' -OutputPath (Join-Path $temporaryRoot 'duplicate.msixbundle') ` + -MakeAppxPath $fakeMakeAppx } 'must be different' + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.401' -OutputPath (Join-Path $temporaryRoot 'short.msixbundle') ` + -MakeAppxPath $fakeMakeAppx } 'four numeric components' + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.401.1' -OutputPath (Join-Path $temporaryRoot 'revision.msixbundle') ` + -MakeAppxPath $fakeMakeAppx } 'must end in .0' + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.401.0' -OutputPath $bundle -MakeAppxPath $fakeMakeAppx } 'already exists' + + Write-Host 'Store MSIX bundle tests passed.' +} +finally { + Remove-Item Env:OPENCLAW_BUNDLE_TEST_ARGUMENTS -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($temporaryRoot)) { + [IO.Directory]::Delete($temporaryRoot, $true) + } +} diff --git a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs index 6ad7b880a..f6dfa55f6 100644 --- a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs +++ b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs @@ -126,7 +126,11 @@ 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, reserve-msix-version, build-x64, build-arm64, ci-gate]", workflow); + Assert.Contains("MSIX_BUNDLE_RESULT: ${{ needs.build-msix-bundle.result }}", workflow); + Assert.Contains(@".\scripts\Build-StoreMsixBundle.ps1", workflow); + Assert.Contains("name: openclaw-msix-store-unsigned-bundle", workflow); + Assert.Contains("needs: [change-classification, metadata, reserve-msix-version, build-x64, build-arm64, build-msix-bundle, ci-gate]", workflow); + Assert.Contains("needs.build-msix-bundle.result == 'success'", workflow); Assert.DoesNotContain("Download win-x64 MSIX artifact", workflow); Assert.DoesNotContain("Download win-arm64 MSIX artifact", workflow); Assert.DoesNotContain("Sign Release MSIX Packages", workflow); From 49d3a72beb5159f735a890dc10651663ae79832d Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 19 Sep 2026 19:51:19 -0700 Subject: [PATCH 2/2] fix(msix): accept maximum bundle version --- scripts/Build-StoreMsixBundle.ps1 | 2 +- scripts/test-msix-bundle.ps1 | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/Build-StoreMsixBundle.ps1 b/scripts/Build-StoreMsixBundle.ps1 index fce7734b6..8f2ddd9f8 100644 --- a/scripts/Build-StoreMsixBundle.ps1 +++ b/scripts/Build-StoreMsixBundle.ps1 @@ -56,7 +56,7 @@ if ($segments.Count -ne 4) { } foreach ($segment in $segments) { [uint16]$value = 0 - if (-not [uint16]::TryParse($segment, [ref]$value) -or $value -gt 65534) { + if (-not [uint16]::TryParse($segment, [ref]$value)) { throw "Invalid MSIX bundle version component: $segment" } } diff --git a/scripts/test-msix-bundle.ps1 b/scripts/test-msix-bundle.ps1 index cc1a50f8b..311dc5ec7 100644 --- a/scripts/test-msix-bundle.ps1 +++ b/scripts/test-msix-bundle.ps1 @@ -55,6 +55,15 @@ exit 0 } } + $maximumBundle = Join-Path $temporaryRoot 'OpenClaw-maximum.msixbundle' + & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.65535.0' -OutputPath $maximumBundle -MakeAppxPath $fakeMakeAppx + $arguments = Get-Content -LiteralPath $argumentsPath -Raw + if (-not $arguments.Contains('/bv 2026.9.65535.0', [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $maximumBundle -PathType Leaf)) { + throw 'The bundle builder did not accept the allocator maximum 2026.9.65535.0.' + } + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $x64Package ` -PackageVersion '2026.9.401.0' -OutputPath (Join-Path $temporaryRoot 'duplicate.msixbundle') ` -MakeAppxPath $fakeMakeAppx } 'must be different' @@ -64,6 +73,9 @@ exit 0 Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` -PackageVersion '2026.9.401.1' -OutputPath (Join-Path $temporaryRoot 'revision.msixbundle') ` -MakeAppxPath $fakeMakeAppx } 'must end in .0' + Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` + -PackageVersion '2026.9.65536.0' -OutputPath (Join-Path $temporaryRoot 'overflow.msixbundle') ` + -MakeAppxPath $fakeMakeAppx } 'invalid msix bundle version component' Assert-Fails { & $builder -X64Package $x64Package -Arm64Package $arm64Package ` -PackageVersion '2026.9.401.0' -OutputPath $bundle -MakeAppxPath $fakeMakeAppx } 'already exists'