From 693d2f5bb5162b7c890045f9e8679d0ee1a2c184 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 11 Sep 2026 13:13:24 -0700 Subject: [PATCH 1/2] ci: enable Dev and unsigned Store MSIX workflow artifacts Build verified x64 and ARM64 workflow downloads through the existing packaging scripts. Bound Dev CI revisions, stage only public signing material, and gate selected MSIX jobs without enabling MSIX release publishing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb0e5a80-d2cf-41b0-9fb0-21eb32623526 --- .github/workflows/ci.yml | 115 ++++++------ DEVELOPMENT.md | 51 +++++- build.ps1 | 62 +++++-- docs/RELEASING.md | 34 +++- scripts/Assert-CiGateResults.ps1 | 4 +- scripts/Export-DevMsixArtifact.ps1 | 151 ++++++++++++++++ scripts/test-ci-gate-results.ps1 | 20 +++ scripts/test-ci-workflow-contract.ps1 | 43 ++++- scripts/test-msix-ci-artifacts.ps1 | 170 ++++++++++++++++++ scripts/validate-msix-storage-paths.ps1 | 9 +- .../MsixDevelopmentSigningTests.cs | 5 + .../ReleaseSigningWorkflowTests.cs | 10 +- 12 files changed, 591 insertions(+), 83 deletions(-) create mode 100644 scripts/Export-DevMsixArtifact.ps1 create mode 100644 scripts/test-msix-ci-artifacts.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 396b4c3df..30e1ca23b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,10 @@ jobs: shell: pwsh run: ./scripts/test-ci-workflow-contract.ps1 + - name: Validate MSIX CI artifacts + shell: pwsh + run: ./scripts/test-msix-ci-artifacts.ps1 + - name: Validate stable correction release ordering regressions shell: pwsh run: ./scripts/test-stable-correction-release-validator.ps1 @@ -808,37 +812,28 @@ jobs: path: publish/ build-msix: - needs: [metadata] - if: false # MSIX distribution is paused; ship Inno setup and portable ZIP artifacts only. - runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} - continue-on-error: true + 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') }} + # Workflow downloads only. MSIX release publishing remains paused. + runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest' }} + env: + OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} + DEV_MSIX_REVISION: ${{ github.run_number }} strategy: fail-fast: false matrix: - rid: [win-x64, win-arm64] - include: - - rid: win-x64 - platform: x64 - - rid: win-arm64 - platform: ARM64 + architecture: [x64, arm64] steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Setup .NET 10 for VS MSBuild + - name: Setup .NET from global.json uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.100 - - - name: Pin .NET SDK for MSIX packaging - shell: pwsh - run: | - $globalJson = Get-Content global.json -Raw | ConvertFrom-Json - $globalJson.sdk.rollForward = "disable" - $globalJson | ConvertTo-Json -Depth 5 | Set-Content global.json - dotnet --version + global-json-file: global.json - name: Cache NuGet packages continue-on-error: true @@ -848,48 +843,62 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v3 + - name: Build and validate unsigned Store MSIX + shell: pwsh + run: .\scripts\Build-StoreMsix.ps1 -Architecture ${{ matrix.architecture }} - - name: Restore - run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} + - name: Upload unsigned Store submission artifact + uses: actions/upload-artifact@v7 + with: + name: openclaw-msix-store-unsigned-${{ matrix.architecture }} + path: | + artifacts/msix/${{ matrix.architecture }}/OpenClawCompanion-${{ matrix.architecture }}.msix + artifacts/msix/${{ matrix.architecture }}/msix-metadata.json + if-no-files-found: error + + - name: Provision disposable Dev MSIX certificate + shell: pwsh + run: .\scripts\setup-dev-msix-cert.ps1 - - name: Build MSIX Package + - name: Build signed Dev MSIX + shell: pwsh run: > - msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj - /p:Configuration=Release - /p:RuntimeIdentifier=${{ matrix.rid }} - /p:Platform=${{ matrix.platform }} - /p:PackageMsix=true - /p:GenerateAppxPackageOnBuild=true - /p:AppxPackageSigningEnabled=false - /p:AppxBundle=Never - /p:UapAppxPackageBuildMode=SideloadOnly - /p:AppxPackageDir=AppPackages\ - - - name: Find MSIX Package - id: find-msix + .\build.ps1 -Project WinUI -Configuration Release -Msix Dev + -MsixRevision $env:DEV_MSIX_REVISION + -MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx" + + - name: Validate and stage Dev tester artifact shell: pwsh run: | - $msix = Get-ChildItem -Path src/OpenClaw.Tray.WinUI/AppPackages -Recurse -Filter "*.msix" -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $msix) { - Write-Error "No MSIX package found in AppPackages directory" - exit 1 - } - Write-Host "Found: $($msix.FullName)" - echo "msix_path=$($msix.FullName)" >> $env:GITHUB_OUTPUT - echo "msix_name=$($msix.Name)" >> $env:GITHUB_OUTPUT - - - name: Upload MSIX Artifact + $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 ` + -CertificateThumbprint $thumbprint ` + -OutputDirectory "artifacts\msix-dev\${{ matrix.architecture }}" + + - name: Upload Dev tester artifact uses: actions/upload-artifact@v7 with: - name: openclaw-msix-${{ matrix.rid }} - path: ${{ steps.find-msix.outputs.msix_path }} + name: openclaw-msix-dev-${{ matrix.architecture }} + path: | + artifacts/msix-dev/${{ matrix.architecture }}/OpenClawCompanion-Dev-${{ matrix.architecture }}.msix + artifacts/msix-dev/${{ matrix.architecture }}/OpenClaw-Dev.cer + artifacts/msix-dev/${{ matrix.architecture }}/msix-metadata.json + artifacts/msix-dev/${{ matrix.architecture }}/INSTALL.txt + if-no-files-found: error + + - name: Remove disposable Dev MSIX certificate + if: ${{ always() }} + shell: pwsh + run: .\scripts\setup-dev-msix-cert.ps1 -Remove 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] + 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] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -919,6 +928,7 @@ jobs: ARM64_RELEASE_REQUIRED: ${{ needs.change-classification.outputs.arm64_release }} ARM64_RELEASE_RESULT: ${{ needs.build-arm64.result }} METADATA_RESULT: ${{ needs.metadata.result }} + MSIX_RESULT: ${{ needs.build-msix.result }} run: | $validatedMode = ./scripts/Assert-CiGateResults.ps1 ` -ClassificationResult $env:CLASSIFICATION_RESULT ` @@ -942,7 +952,8 @@ jobs: -X64ReleaseResult $env:X64_RELEASE_RESULT ` -Arm64ReleaseRequired $env:ARM64_RELEASE_REQUIRED ` -Arm64ReleaseResult $env:ARM64_RELEASE_RESULT ` - -MetadataResult $env:METADATA_RESULT + -MetadataResult $env:METADATA_RESULT ` + -MsixResult $env:MSIX_RESULT "CI Gate passed $validatedMode validation." >> $env:GITHUB_STEP_SUMMARY release: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e0549ed58..607475f59 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -276,7 +276,7 @@ is what lets a packaged smoke test run without disturbing a working install: | Publisher | local development certificate | Partner Center | | Protocol | `openclaw-dev` | `openclaw` | | Signing | signed locally | unsigned; the Store signs | -| Version revision | installed revision + 1 | pinned to `0` | +| Version revision | installed revision + 1 locally; explicit CI run number | pinned to `0` | | Architectures | host only | x64 and ARM64 | The revision field is the clearest reason the modes cannot merge, because each @@ -318,6 +318,55 @@ Generating the optional `.appxsym` symbol package additionally requires `mspdbcmf.exe` from the Visual Studio **Desktop development with C++** workload; 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 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. +MSIX failures block **CI Gate** when selected; a skipped unselected job is valid. + +Download the desired ZIP from the workflow run's **Artifacts**, not from GitHub +Releases: + +| Artifact | Contents and purpose | +|---|---| +| `openclaw-msix-dev-x64` / `openclaw-msix-dev-arm64` | Signed Dev `.msix`, public `OpenClaw-Dev.cer`, `msix-metadata.json`, and `INSTALL.txt` for opt-in tester installation. | +| `openclaw-msix-store-unsigned-x64` / `openclaw-msix-store-unsigned-arm64` | Unsigned Store `.msix` and the validated provenance sidecar from `Build-StoreMsix.ps1`. Submission inputs, not directly installable tester packages. | + +Each disposable runner uses `setup-dev-msix-cert.ps1` to generate and trust a +non-exportable Dev certificate. Only its public `.cer` is included. The key and +runner trust are removed in an always-run cleanup step. No repository signing +secret or production release-signing environment is used. Each architecture +and later workflow run can have a different certificate; testers must trust +the matching signer explicitly. Only install packages from a workflow/source +you trust, especially when testing unreviewed PR code. + +Extract the Dev artifact and follow `INSTALL.txt`: verify package/certificate +hashes, install the architecture-matched VCLibs dependency described above, +import the public certificate into `LocalMachine\TrustedPeople` from elevated +PowerShell, then install the package as the intended user. This uses the +existing **OpenClaw (Dev)** identity and can upgrade a locally installed Dev +package; it is not a new independent test identity. + +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 +resolve a version conflict without considering its settings and data. + +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. + +MSIX release publishing remains paused. This workflow neither submits to +Partner Center nor retrieves Store-signed packages nor adds MSIX assets to +GitHub Releases. Existing EXE/ZIP releases are unchanged. + #### The Store package alongside an existing Inno install The Store package and the Inno installer produce the same application. Both can diff --git a/build.ps1 b/build.ps1 index 60eaeda38..562ac40ba 100644 --- a/build.ps1 +++ b/build.ps1 @@ -41,6 +41,16 @@ cannot read a repo owned by a different Windows account/group. The script will print the manual command instead. +.PARAMETER MsixRevision + Explicit Dev package revision (1-65535), for example a CI workflow run + number. Omit for the existing installed-revision-plus-one behavior. + Only valid with -Msix Dev. + +.PARAMETER MsixOutputDirectory + Empty directory for Dev packaging output. Relative paths resolve against + the repository root. The directory is never cleared automatically. + Only valid with -Msix Dev; omission preserves the local AppPackages path. + .EXAMPLE .\build.ps1 .\build.ps1 -Project WinUI -Configuration Release @@ -67,6 +77,12 @@ param( [ValidateSet("Dev", "Store")] [string]$Msix, + [ValidateRange(1, 65535)] + [int]$MsixRevision, + + [ValidateNotNullOrEmpty()] + [string]$MsixOutputDirectory, + [switch]$NoTrustRepository ) @@ -78,6 +94,20 @@ Set-Location $repoRoot $buildDevMsix = ($Msix -eq "Dev") $buildStoreMsix = ($Msix -eq "Store") +if (($PSBoundParameters.ContainsKey("MsixRevision") -or + $PSBoundParameters.ContainsKey("MsixOutputDirectory")) -and -not $buildDevMsix) { + throw "-MsixRevision and -MsixOutputDirectory require -Msix Dev." +} +$explicitMsixRevision = $PSBoundParameters.ContainsKey("MsixRevision") +if ($MsixOutputDirectory) { + $MsixOutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repoRoot, $MsixOutputDirectory)) + if ((Test-Path -LiteralPath $MsixOutputDirectory) -and + (-not (Test-Path -LiteralPath $MsixOutputDirectory -PathType Container) -or + @(Get-ChildItem -LiteralPath $MsixOutputDirectory -Force).Count -gt 0)) { + throw "The Dev MSIX output directory must be absent or empty: $MsixOutputDirectory" + } +} + if ($buildDevMsix) { $DevBuild = $true } @@ -449,14 +479,18 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { } if ($packageMsix) { - $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 - $msixRevision = if ($installedDevPackage) { - ([version]$installedDevPackage.Version.ToString()).Revision + 1 + $msixRevision = if ($explicitMsixRevision) { + $MsixRevision } else { - 1 + $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 { + 1 + } } if ($msixRevision -gt 65535) { Write-Error "The installed development MSIX revision is already 65535. Remove the installed OpenClawFoundation.OpenClaw.Dev package before rebuilding." @@ -482,13 +516,18 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { } if ($packageMsix) { $platform = if ($rid -eq "win-arm64") { "ARM64" } else { "x64" } + $appxOutput = if ($MsixOutputDirectory) { + $MsixOutputDirectory.TrimEnd('\') + '\' + } else { + "AppPackages\" + } $dotnetArgs += @( "-p:Platform=$platform", "-p:PackageMsix=true", "-p:GenerateAppxPackageOnBuild=true", "-p:AppxBundle=Never", "-p:UapAppxPackageBuildMode=SideloadOnly", - "-p:AppxPackageDir=AppPackages\" + "-p:AppxPackageDir=$appxOutput" ) } $result = Invoke-DotNetCaptured $dotnetArgs @@ -621,14 +660,17 @@ if ($failCount -eq 0) { $winUIProjectDirectory = (Split-Path -Parent $winUIProjectPath).Replace("/", "\") if ($buildDevMsix) { - $devMsixPackage = Get-ChildItem (Join-Path $repoRoot "$winUIProjectDirectory\AppPackages") -Recurse -Filter "*.msix" -ErrorAction SilentlyContinue | + $packageDirectory = if ($MsixOutputDirectory) { $MsixOutputDirectory } else { + Join-Path $repoRoot "$winUIProjectDirectory\AppPackages" + } + $devMsixPackage = Get-ChildItem $packageDirectory -Recurse -Filter "*.msix" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($devMsixPackage) { Write-Host " MSIX: $($devMsixPackage.FullName)" -ForegroundColor White Write-Host " Install: Add-AppxPackage -Path `"$($devMsixPackage.FullName)`" -ForceApplicationShutdown" -ForegroundColor White } else { - Write-Warning "MSIX packaging succeeded but no .msix was found under $winUIProjectDirectory\AppPackages." + Write-Warning "MSIX packaging succeeded but no .msix was found under $packageDirectory." } } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 24aa8167c..f6363ffd0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -10,7 +10,10 @@ metadata in parallel with tests and E2E, and the stable **CI Gate** requires all selected lanes before a tag can publish. Pull requests do not produce release artifacts unless packaging, build, installer, release, workflow, or classifier infrastructure changes. Those fail-closed pull requests run the x64 publish -smoke only; ARM64 publish remains required on `main` and tags. +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, but no MSIX is attached to GitHub Releases. ## Release checklist @@ -31,7 +34,7 @@ smoke only; ARM64 publish remains required on `main` and tags. "Verify Release Binary Signing Policy", ` "OpenClaw.Tray.WinUI.exe", ` "build-msix:", ` - "MSIX distribution is paused" + "MSIX release publishing remains paused" ``` 3. Create a new stable, stable correction, or prerelease tag from `origin/main`. @@ -164,10 +167,24 @@ Current release artifacts are: - `OpenClawTray--win-x64.zip` - `OpenClawTray--win-arm64.zip` -MSIX artifacts remain paused while the supported distribution path uses Inno -installers and signed portable update payloads. This pause is independent of -whether a tag is stable or alpha. Re-enable MSIX only with packaged -camera/microphone consent validation and release coverage. +MSIX release publishing remains paused while the supported release downloads +use Inno installers and signed portable update payloads. CI workflow downloads +now include Dev-signed tester MSIX packages (with public certificates and +instructions) and validated unsigned Store submission packages with provenance. +They are not official Store-signed release assets. + +The pause is independent of whether a tag is stable or alpha. This workflow +does not submit to Partner Center, retrieve Store-signed packages, or attach +MSIX assets to GitHub Releases. Those stages remain follow-up work in #1375, +including packaged consent, native ARM64 and signed lifecycle proof, maintainer +approval, Store availability, and a verified signed-package retrieval path. +Existing EXE/ZIP publishing remains unchanged. + +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. ## Binary signing policy @@ -270,8 +287,9 @@ 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 is disabled with `if: false` while MSIX -distribution is paused, so it should not appear in the required run list. +succeeded. The `build-msix` job must also succeed whenever release metadata is +required. It builds workflow artifacts only; MSIX release publishing remains +paused and the release job does not download or attach those artifacts. The release job should: diff --git a/scripts/Assert-CiGateResults.ps1 b/scripts/Assert-CiGateResults.ps1 index b22ef5c7e..167bfc90b 100644 --- a/scripts/Assert-CiGateResults.ps1 +++ b/scripts/Assert-CiGateResults.ps1 @@ -26,7 +26,8 @@ param( [Parameter(Mandatory)][string]$X64ReleaseResult, [Parameter(Mandatory)][string]$Arm64ReleaseRequired, [Parameter(Mandatory)][string]$Arm64ReleaseResult, - [Parameter(Mandatory)][string]$MetadataResult + [Parameter(Mandatory)][string]$MetadataResult, + [Parameter(Mandatory)][string]$MsixResult ) Set-StrictMode -Version Latest @@ -125,5 +126,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 $Classification diff --git a/scripts/Export-DevMsixArtifact.ps1 b/scripts/Export-DevMsixArtifact.ps1 new file mode 100644 index 000000000..e8cd0b43c --- /dev/null +++ b/scripts/Export-DevMsixArtifact.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS + Validates a signed Dev MSIX and stages a public-only CI tester download. +.DESCRIPTION + Requires exactly one package from the current build. Verifies its Dev + identity, architecture, version, and trusted signature before exporting + only the public certificate, package, provenance, and install instructions. + The output directory must be absent or empty and is never deleted. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('x64', 'arm64')][string]$Architecture, + [Parameter(Mandatory)][string]$PackageDirectory, + [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 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$PackageDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $PackageDirectory)) +$OutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $OutputDirectory)) +if ((Test-Path -LiteralPath $OutputDirectory) -and + (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container) -or + @(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0)) { + throw "The Dev artifact output directory must be absent or empty: $OutputDirectory" +} + +$baseVersion = $ExpectedVersion -replace '[-+].*$', '' +if ($baseVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "Expected a three-part base version: $ExpectedVersion" +} +$expectedPackageVersion = "$baseVersion.$ExpectedRevision" +$packages = @(Get-ChildItem -LiteralPath $PackageDirectory -Filter '*.msix' -File -Recurse) +if ($packages.Count -ne 1) { + throw "Expected one Dev MSIX in '$PackageDirectory'; found $($packages.Count)." +} +$package = $packages[0] + +[xml]$project = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj') -Raw +$expectedIdentity = $project.SelectSingleNode('/Project/Target/GenerateOpenClawAppxManifest').IdentityName +$expectedPublisher = $project.SelectSingleNode('/Project/PropertyGroup/OpenClawDevMsixPublisher').InnerText +$signature = Get-AuthenticodeSignature -LiteralPath $package.FullName +if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { + throw "The Dev package signature is not trusted and valid: $($signature.Status)" +} +$certificate = $signature.SignerCertificate +if ($certificate.Thumbprint -ne $CertificateThumbprint -or $certificate.Subject -ne $expectedPublisher) { + throw 'The Dev package signer does not match the provisioned development certificate.' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + foreach ($entry in @('AppxManifest.xml', 'AppxSignature.p7x', 'OpenClaw.Tray.WinUI.exe', 'OpenClaw.Tray.WinUI.dll', 'coreclr.dll')) { + if ($null -eq $archive.GetEntry($entry)) { throw "The Dev package is missing $entry." } + } + $reader = [IO.StreamReader]::new($archive.GetEntry('AppxManifest.xml').Open()) + try { [xml]$manifest = $reader.ReadToEnd() } + finally { $reader.Dispose() } +} +finally { $archive.Dispose() } + +$identity = $manifest.Package.Identity +if ($identity.Name -ne $expectedIdentity -or $identity.Publisher -ne $expectedPublisher) { + throw 'The package does not have the expected side-by-side Dev identity.' +} +if ($identity.ProcessorArchitecture -ne $Architecture -or $identity.Version -ne $expectedPackageVersion) { + throw "Expected Dev package $expectedPackageVersion for $Architecture; found $($identity.Version) for $($identity.ProcessorArchitecture)." +} +$sourceCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' +if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Unable to resolve the current source commit.' +} +$sourceTreeDirty = [bool](& git -C $repositoryRoot status --porcelain) +if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect the current source tree.' } + +$packageName = "OpenClawCompanion-Dev-$Architecture.msix" +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +Copy-Item -LiteralPath $package.FullName -Destination (Join-Path $OutputDirectory $packageName) +$certificatePath = Join-Path $OutputDirectory 'OpenClaw-Dev.cer' +Export-Certificate -Cert $certificate -FilePath $certificatePath -Type CERT | Out-Null +$packageHash = (Get-FileHash -LiteralPath (Join-Path $OutputDirectory $packageName) -Algorithm SHA256).Hash.ToLowerInvariant() +$certificateHash = (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256).Hash.ToLowerInvariant() +[ordered]@{ + repository = 'https://github.com/openclaw/openclaw-windows-node' + sourceCommit = $sourceCommit.ToLowerInvariant() + sourceTreeDirty = $sourceTreeDirty + architecture = $Architecture + archive = $packageName + sha256 = $packageHash + signed = $true + signing = 'development-only' + identityName = [string]$identity.Name + packageVersion = [string]$identity.Version + publisher = [string]$identity.Publisher + certificate = 'OpenClaw-Dev.cer' + certificateThumbprint = $certificate.Thumbprint + certificateSha256 = $certificateHash + 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 + +@" +OpenClaw (Dev) CI tester package ($Architecture) + +This is NOT a Microsoft Store-signed release. Install only from a workflow +and source revision you trust. Pull request builds can contain unreviewed code. +No private key is included. The public certificate is unique to this runner; +later runs and the other architecture may have different certificates. + +Source: $sourceCommit +Package version: $($identity.Version) +Package SHA-256: $packageHash +Certificate thumbprint: $($certificate.Thumbprint) +Certificate SHA-256: $certificateHash + +1. Extract this download. Compare its package and certificate hashes with + msix-metadata.json using Get-FileHash -Algorithm SHA256. +2. Install Microsoft.VCLibs.140.00.UWPDesktop 14.0.33728.0 or newer for + $Architecture if absent. Obtain it from Microsoft's documented VC++ runtime + packages for Desktop Bridge apps, not an untrusted mirror: + https://learn.microsoft.com/troubleshoot/developer/visualstudio/cpp/libraries/c-runtime-packages-desktop-bridge + Check installed versions with: + Get-AppxPackage Microsoft.VCLibs.140.00.UWPDesktop | Select-Object Version, Architecture +3. From this directory, in elevated PowerShell, explicitly trust the public + development certificate (this changes machine trust): + Import-Certificate -FilePath .\OpenClaw-Dev.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople +4. As the intended Windows user, close the existing Dev app and install: + Add-AppxPackage -Path .\$packageName +5. Launch OpenClaw (Dev) from Start. This uses the existing Dev identity, so an + installed Dev package may be upgraded and its settings retained. It is not + 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. +Do not uninstall or downgrade an existing Dev installation merely to bypass a +version error without first considering its retained settings and data. + +When finished, remove this certificate from elevated PowerShell only if no +installed package still relies on it: +Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)' + +Store submission artifacts are separate unsigned CI downloads, not installers. +No MSIX package is published to GitHub Releases by this workflow. +"@ | Set-Content -LiteralPath (Join-Path $OutputDirectory 'INSTALL.txt') -Encoding utf8 +Write-Host "Staged signed Dev tester artifact: $OutputDirectory" diff --git a/scripts/test-ci-gate-results.ps1 b/scripts/test-ci-gate-results.ps1 index ebdb5e29c..2060c7c72 100644 --- a/scripts/test-ci-gate-results.ps1 +++ b/scripts/test-ci-gate-results.ps1 @@ -41,6 +41,7 @@ function New-GateArguments { Arm64ReleaseRequired = "false" Arm64ReleaseResult = "skipped" MetadataResult = "skipped" + MsixResult = "skipped" } } @@ -112,6 +113,7 @@ foreach ($prefix in @( $fullArguments["${prefix}Result"] = "success" } $fullArguments.MetadataResult = "success" +$fullArguments.MsixResult = "success" $full = Invoke-Gate $fullArguments if ($full -ne "full") { throw "Expected the full gate to pass." @@ -132,6 +134,7 @@ foreach ($prefix in @( $fullPrArguments["${prefix}Result"] = "success" } $fullPrArguments.MetadataResult = "success" +$fullPrArguments.MsixResult = "success" $fullPr = Invoke-Gate $fullPrArguments if ($fullPr -ne "full") { throw "Expected full pull request validation without ARM64 publish to pass." @@ -144,6 +147,23 @@ Assert-GateFails -Overrides @{ CoreRequired = "" } -Scenario "Missing classifier Assert-GateFails -Overrides @{ CoreResult = "skipped" } -Scenario "Required lane skipped" 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" +foreach ($result in @("failure", "cancelled", "skipped", "")) { + Assert-GateFails -Overrides @{ + X64ReleaseRequired = "true" + X64ReleaseResult = "success" + MetadataResult = "success" + MsixResult = $result + } -Scenario "Selected MSIX lane returned '$result'" +} +$arm64Arguments = New-GateArguments +$arm64Arguments.Arm64ReleaseRequired = "true" +$arm64Arguments.Arm64ReleaseResult = "success" +$arm64Arguments.MetadataResult = "success" +$arm64Arguments.MsixResult = "success" +if ((Invoke-Gate $arm64Arguments) -ne "targeted") { + throw "Expected ARM64 release selection to require successful MSIX artifacts." +} Assert-GateFails ` -Overrides @{ CoreRequired = "false"; CoreResult = "success" } ` -Scenario "Unrequired lane ran" diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 9c0e79a08..79a984a21 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -648,16 +648,43 @@ foreach ($build in $releaseBuilds.GetEnumerator()) { } $buildMsixJob = Get-JobBlock "build-msix" -Assert-Contains ` - -Text $buildMsixJob ` - -Expected "fetch-depth: 0" ` - -Message "The paused MSIX build must retain full history before it can be re-enabled." +foreach ($token in @( + "needs: [change-classification, metadata]", + "needs.metadata.result == 'success'", + "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'", + "fetch-depth: 0", + "global-json-file: global.json", + "OPENCLAW_BUILD_VERSION: `${{ needs.metadata.outputs.semVer }}", + "DEV_MSIX_REVISION: `${{ github.run_number }}", + '.\scripts\Build-StoreMsix.ps1 -Architecture', + '.\scripts\setup-dev-msix-cert.ps1', + '.\build.ps1 -Project WinUI -Configuration Release -Msix Dev', + '-MsixRevision $env:DEV_MSIX_REVISION', + '-MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx"', + '.\scripts\Export-DevMsixArtifact.ps1', + '-ExpectedVersion $env:OPENCLAW_BUILD_VERSION', + '-CertificateThumbprint $thumbprint', + 'name: openclaw-msix-store-unsigned-${{ matrix.architecture }}', + 'name: openclaw-msix-dev-${{ matrix.architecture }}', + 'msix-metadata.json', + 'OpenClaw-Dev.cer', + 'INSTALL.txt', + 'if-no-files-found: error', + '.\scripts\setup-dev-msix-cert.ps1 -Remove' + )) { + 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')) { + Assert-NotContains -Text $buildMsixJob -Unexpected $token -Message "MSIX artifacts must not contain '$token'." +} $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]", + "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]", "./scripts/Assert-CiGateResults.ps1", "-FullRequired `$env:FULL_REQUIRED", "-CoreRequired `$env:CORE_REQUIRED", @@ -668,7 +695,9 @@ foreach ($token in @( "-NetworkE2eRequired `$env:NETWORK_E2E_REQUIRED", "-X64ReleaseRequired `$env:X64_RELEASE_REQUIRED", "-Arm64ReleaseRequired `$env:ARM64_RELEASE_REQUIRED", - "-MetadataResult `$env:METADATA_RESULT" + "-MetadataResult `$env:METADATA_RESULT", + "MSIX_RESULT: `${{ needs.build-msix.result }}", + "-MsixResult `$env:MSIX_RESULT" )) { Assert-Contains -Text $ciGateJob -Expected $token -Message "Stable CI Gate is missing '$token'." } @@ -683,6 +712,8 @@ foreach ($token in @( )) { Assert-Contains -Text $releaseJob -Expected $token -Message "Tag release is missing '$token'." } +Assert-NotContains -Text $releaseJob -Unexpected ".msix" -Message "MSIX release publishing must remain paused." +Assert-Contains -Text $workflow -Expected "./scripts/test-msix-ci-artifacts.ps1" -Message "Fast validation must exercise the Dev artifact contracts." $triggerPaths = @( ".github/workflows/ci.yml", diff --git a/scripts/test-msix-ci-artifacts.ps1 b/scripts/test-msix-ci-artifacts.ps1 new file mode 100644 index 000000000..36f805f49 --- /dev/null +++ b/scripts/test-msix-ci-artifacts.ps1 @@ -0,0 +1,170 @@ +<# +.SYNOPSIS + Exercises Dev CI artifact validation and build argument contracts. +.DESCRIPTION + Uses synthetic ZIPs and an in-memory signer. Authenticode is stubbed here; + the packaging job separately verifies the actual Windows signature. No + certificate is installed or trusted and no product build is launched. +#> +[CmdletBinding()] +param([string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$exporter = Join-Path $RepoRoot 'scripts\Export-DevMsixArtifact.ps1' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-msix-ci-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +$rsa = [Security.Cryptography.RSA]::Create(2048) +$request = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=OpenClaw Local Development', $rsa, + [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) +$certificate = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddDays(1)) +$signatureStatus = 'Valid' +$scenarioNumber = 0 + +function Get-AuthenticodeSignature { + param([string]$LiteralPath) + if (-not (Test-Path -LiteralPath $LiteralPath)) { throw 'Signature probe received a missing package.' } + [pscustomobject]@{ Status = $signatureStatus; SignerCertificate = $certificate } +} + +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 failure containing '$Expected', received: $($_.Exception.Message)" + } + } +} + +function New-Package { + param( + [string]$Directory, + [string]$Name = 'Dev.msix', + [string]$Identity = 'OpenClawFoundation.OpenClaw.Dev', + [string]$Publisher = 'CN=OpenClaw Local Development', + [string]$Architecture = 'x64', + [string]$Version = '2026.7.2.123', + [string]$Omit = '' + ) + 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')) { + if ($name -eq $Omit) { continue } + $writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open()) + try { + $content = if ($name -eq 'AppxManifest.xml') { + "" + } else { 'Synthetic contract-test content, not executable.' } + $writer.Write($content) + } + finally { $writer.Dispose() } + } + } + finally { $zip.Dispose() } +} + +function New-Arguments { + $script:scenarioNumber++ + @{ + Architecture = 'x64' + PackageDirectory = Join-Path $temporaryRoot "input-$scenarioNumber" + ExpectedRevision = 123 + ExpectedVersion = '2026.7.2-alpha.4' + CertificateThumbprint = $certificate.Thumbprint + OutputDirectory = Join-Path $temporaryRoot "output-$scenarioNumber" + } +} + +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + # Unrelated build output must not be uploaded, even if it contains a key. + Set-Content (Join-Path $arguments.PackageDirectory 'do-not-publish.pfx') 'not a real key' + & $exporter @arguments + $files = @(Get-ChildItem -LiteralPath $arguments.OutputDirectory -File | Sort-Object Name | Select-Object -ExpandProperty Name) + if (($files -join ',') -ne 'INSTALL.txt,msix-metadata.json,OpenClaw-Dev.cer,OpenClawCompanion-Dev-x64.msix') { + throw "Unexpected Dev artifact contents: $($files -join ',')" + } + $metadata = Get-Content (Join-Path $arguments.OutputDirectory 'msix-metadata.json') -Raw | ConvertFrom-Json + $publicCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Join-Path $arguments.OutputDirectory 'OpenClaw-Dev.cer')) + try { + if ($publicCertificate.HasPrivateKey -or $publicCertificate.Thumbprint -ne $certificate.Thumbprint) { + throw 'The exported certificate is not the expected public-only signer.' + } + } + finally { $publicCertificate.Dispose() } + $actualHash = (Get-FileHash (Join-Path $arguments.OutputDirectory $metadata.archive) -Algorithm SHA256).Hash + if (-not $metadata.signed -or $metadata.signing -ne 'development-only' -or + $metadata.packageVersion -ne '2026.7.2.123' -or $metadata.sha256 -ne $actualHash -or + $metadata.certificateThumbprint -ne $certificate.Thumbprint -or $metadata.sourceCommit -notmatch '^[0-9a-f]{40}$') { + throw 'Dev package provenance did not match its inputs.' + } + Assert-Fails { & $exporter @arguments } 'must be absent or empty' + + $arguments = New-Arguments + New-Item -ItemType Directory -Path $arguments.PackageDirectory | Out-Null + Assert-Fails { & $exporter @arguments } 'found 0' + New-Package -Directory $arguments.PackageDirectory + New-Package -Directory $arguments.PackageDirectory -Name Other.msix + Assert-Fails { & $exporter @arguments } 'found 2' + + foreach ($status in @('NotSigned', 'HashMismatch', 'NotTrusted')) { + $signatureStatus = $status + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + Assert-Fails { & $exporter @arguments } 'signature is not trusted and valid' + if (Test-Path $arguments.OutputDirectory) { throw 'Failed verification published output.' } + } + $signatureStatus = 'Valid' + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + $arguments.CertificateThumbprint = '0' * 40 + Assert-Fails { & $exporter @arguments } 'signer does not match' + + foreach ($mismatch in @( + @{ Identity = 'OpenClawFoundation.OpenClaw'; Error = 'side-by-side Dev identity' }, + @{ Publisher = 'CN=Wrong'; Error = 'side-by-side Dev identity' }, + @{ Architecture = 'arm64'; Error = 'Expected Dev package' }, + @{ Version = '2026.7.2.122'; Error = 'Expected Dev package' }, + @{ Version = '2026.7.1.123'; Error = 'Expected Dev package' }, + @{ Omit = 'AppxSignature.p7x'; Error = 'missing AppxSignature.p7x' }, + @{ Omit = 'coreclr.dll'; Error = 'missing coreclr.dll' } + )) { + $arguments = New-Arguments + $packageArguments = @{ Directory = $arguments.PackageDirectory } + foreach ($key in $mismatch.Keys) { if ($key -ne 'Error') { $packageArguments[$key] = $mismatch[$key] } } + New-Package @packageArguments + Assert-Fails { & $exporter @arguments } $mismatch.Error + } + + # Exercise the real parameter binder without executing build.ps1's body. + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $RepoRoot 'build.ps1'), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'build.ps1 has syntax errors.' } + $attributes = ($ast.ParamBlock.Attributes | ForEach-Object { $_.Extent.Text }) -join "`n" + $bind = [scriptblock]::Create($attributes + "`n" + $ast.ParamBlock.Extent.Text + "`n`$MsixRevision") + foreach ($revision in @(1, 65535)) { + if ((& $bind -Msix Dev -MsixRevision $revision) -ne $revision) { throw 'A valid CI revision was rejected.' } + } + foreach ($revision in @(0, -1, 65536)) { + Assert-Fails { & $bind -Msix Dev -MsixRevision $revision } 'cannot validate argument' + $arguments.ExpectedRevision = $revision + Assert-Fails { & $exporter @arguments } 'cannot validate argument' + } + Assert-Fails { & $bind -PackageMsix } 'parameter cannot be found' + Write-Host 'MSIX CI artifact contracts passed: version bounds, identity, architecture, signature rejection, exact package selection, provenance, and public-only exports.' +} +finally { + $certificate.Dispose() + $rsa.Dispose() + [IO.Directory]::Delete($temporaryRoot, $true) +} diff --git a/scripts/validate-msix-storage-paths.ps1 b/scripts/validate-msix-storage-paths.ps1 index 4a2d62924..23c75ef68 100644 --- a/scripts/validate-msix-storage-paths.ps1 +++ b/scripts/validate-msix-storage-paths.ps1 @@ -75,9 +75,12 @@ CI ARTIFACT ----------- - The MSIX is produced by the build-msix CI job. Download artifact: - gh run download --name openclaw-msix-win-x64 --dir ./msix-drop/ - Then pass the .msix file path to -MsixPath. + The build-msix job now produces openclaw-msix-store-unsigned-x64 and + openclaw-msix-dev-x64 workflow artifacts. The Store artifact is unsigned + and cannot be installed directly. The Dev artifact has a different + identity and is not a substitute for this production-identity storage test. + Obtain an appropriately signed production-identity MSIX before passing + its path to -MsixPath. MSIX release publishing remains paused. .PARAMETER MsixPath Absolute path to the OpenClawTray MSIX file (e.g. OpenClawTray_1.2.3.0_x64.msix) diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 4262634b2..2ec9421e3 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -50,6 +50,11 @@ public void BuildScript_DevelopmentMsixPathStaysLocallySigned() Assert.Contains("\"publish\", $path", buildScript); Assert.Contains("\"--self-contained\"", buildScript); Assert.Contains("-p:MsixRevision=$msixRevision", buildScript); + Assert.Contains("[ValidateRange(1, 65535)]", buildScript); + Assert.Contains("$explicitMsixRevision", buildScript); + Assert.Contains("-MsixRevision and -MsixOutputDirectory require -Msix Dev.", 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); Assert.DoesNotContain("ReleaseChannel", buildScript); Assert.DoesNotContain("AppInstaller", buildScript, StringComparison.OrdinalIgnoreCase); diff --git a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs index 58dc8ca54..090f97596 100644 --- a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs +++ b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs @@ -115,11 +115,17 @@ public void ReleaseWorkflow_BundlesAndVerifiesNativeRuntimeDependencies() } [Fact] - public void ReleaseWorkflow_PausesMsixDistribution() + public void ReleaseWorkflow_EnablesMsixArtifactsButKeepsReleasePublishingPaused() { var workflow = File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - Assert.Contains("if: false # MSIX distribution is paused; ship Inno setup and portable ZIP artifacts only.", workflow); + Assert.Contains("MSIX release publishing remains paused.", workflow); + Assert.Contains("global-json-file: global.json", workflow); + Assert.Contains(@".\scripts\Build-StoreMsix.ps1 -Architecture", workflow); + Assert.Contains(@".\scripts\Export-DevMsixArtifact.ps1", workflow); + 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.DoesNotContain("Download win-x64 MSIX artifact", workflow); Assert.DoesNotContain("Download win-arm64 MSIX artifact", workflow); From e348ef7d9ffdef4b76f438d60484ffd2d6529bcc Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 15 Sep 2026 15:27:31 -0700 Subject: [PATCH 2/2] ci: publish unsigned Store MSIX assets on alpha releases Keep Dev-signed packages as workflow artifacts and stable assets unchanged. Add manual default-branch alpha dispatch with existing tag gates, validate Store staging provenance, and document submission/version constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb0e5a80-d2cf-41b0-9fb0-21eb32623526 --- .github/workflows/ci.yml | 35 +++- .github/workflows/daily-alpha-release.yml | 11 + DEVELOPMENT.md | 23 ++- docs/RELEASING.md | 79 +++++-- scripts/Export-DevMsixArtifact.ps1 | 3 +- scripts/Stage-StoreMsixReleaseAssets.ps1 | 94 +++++++++ scripts/test-ci-workflow-contract.ps1 | 18 +- scripts/test-msix-alpha-release.ps1 | 195 ++++++++++++++++++ scripts/validate-msix-storage-paths.ps1 | 3 +- .../ReleaseSigningWorkflowTests.cs | 15 +- .../VersioningContractTests.cs | 10 +- 11 files changed, 456 insertions(+), 30 deletions(-) create mode 100644 scripts/Stage-StoreMsixReleaseAssets.ps1 create mode 100644 scripts/test-msix-alpha-release.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30e1ca23b..5915ab931 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 alpha release assets + shell: pwsh + run: ./scripts/test-msix-alpha-release.ps1 + - name: Validate stable correction release ordering regressions shell: pwsh run: ./scripts/test-stable-correction-release-validator.ps1 @@ -158,6 +162,7 @@ jobs: majorMinorPatch: ${{ steps.release_version.outputs.majorMinorPatch }} isPrerelease: ${{ steps.release_version.outputs.isPrerelease }} isStableCorrection: ${{ steps.release_version.outputs.isStableCorrection }} + isMsixAlpha: ${{ steps.release_version.outputs.isMsixAlpha }} steps: - uses: actions/checkout@v7 with: @@ -216,6 +221,8 @@ jobs: "majorMinorPatch=$majorMinorPatch" >> $env:GITHUB_OUTPUT "isPrerelease=$($isPrerelease.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT "isStableCorrection=$($isStableCorrection.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + $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 core-tests: name: Core and CLI tests @@ -815,7 +822,7 @@ jobs: 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') }} - # Workflow downloads only. MSIX release publishing remains paused. + # 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 }} @@ -1161,6 +1168,28 @@ jobs: -Tag $env:RELEASE_TAG -GitHubToken $env:GH_TOKEN + - name: Download alpha Store MSIX artifacts + if: needs.metadata.outputs.isMsixAlpha == 'true' + uses: actions/download-artifact@v8 + with: + pattern: openclaw-msix-store-unsigned-* + path: artifacts/msix-alpha + + - name: Stage alpha Store MSIX release assets + if: needs.metadata.outputs.isMsixAlpha == 'true' + id: msix_alpha + shell: pwsh + env: + RELEASE_VERSION: ${{ needs.metadata.outputs.semVer }} + run: | + $assets = .\scripts\Stage-StoreMsixReleaseAssets.ps1 ` + -ArtifactDirectory 'artifacts\msix-alpha' ` + -OutputDirectory 'msix-alpha-release' ` + -Version $env:RELEASE_VERSION ` + -ExpectedSourceCommit $env:GITHUB_SHA + @('files<> $env:GITHUB_OUTPUT + @('notes<> $env:GITHUB_OUTPUT + - name: Create Release uses: softprops/action-gh-release@v3 with: @@ -1170,6 +1199,8 @@ jobs: Output/OpenClawCompanion-Setup-arm64.exe OpenClawTray-${{ needs.metadata.outputs.semVer }}-win-x64.zip OpenClawTray-${{ needs.metadata.outputs.semVer }}-win-arm64.zip + ${{ steps.msix_alpha.outputs.files }} + fail_on_unmatched_files: true prerelease: ${{ needs.metadata.outputs.isPrerelease }} make_latest: ${{ needs.metadata.outputs.isPrerelease == 'true' && 'false' || 'true' }} body: | @@ -1181,6 +1212,8 @@ jobs: - **Portable x64**: `OpenClawTray-${{ needs.metadata.outputs.semVer }}-win-x64.zip` - **Portable ARM64**: `OpenClawTray-${{ needs.metadata.outputs.semVer }}-win-arm64.zip` + ${{ steps.msix_alpha.outputs.notes }} + ### Features - 🦞 System tray integration with gateway status - 🔄 Auto-updates from GitHub Releases diff --git a/.github/workflows/daily-alpha-release.yml b/.github/workflows/daily-alpha-release.yml index 458d04c5b..404ca18e6 100644 --- a/.github/workflows/daily-alpha-release.yml +++ b/.github/workflows/daily-alpha-release.yml @@ -1,6 +1,7 @@ name: Daily Alpha Release on: + workflow_dispatch: schedule: - cron: '0 21 * * *' - cron: '0 22 * * *' @@ -21,10 +22,17 @@ jobs: id: pacific_schedule shell: bash env: + EVENT_NAME: ${{ github.event_name }} SCHEDULE: ${{ github.event.schedule }} run: | set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + echo "Manually checking the default branch for a new alpha release." + exit 0 + fi + pacific_offset="$(TZ=America/Los_Angeles date +%z)" case "$pacific_offset" in -0700) expected_schedule='0 21 * * *' ;; @@ -117,6 +125,9 @@ jobs: if: steps.pacific_schedule.outputs.run == 'true' && steps.previous_release.outputs.changed == 'true' id: gitversion uses: gittools/actions/gitversion/execute@7417b1089e2c7de93510f1901d656ddf60bb024f # v4.7.0 + with: + # Checkout already selected the full default branch. Ignore a manual dispatch's source ref. + disableNormalization: true - name: Create or reuse alpha tag if: steps.pacific_schedule.outputs.run == 'true' && steps.previous_release.outputs.changed == 'true' diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 607475f59..0c09c2b83 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -326,8 +326,7 @@ build, and workflow PRs, pushes to `main`/`master`, tags, and manual workflow dispatches. Ordinary targeted or documentation-only PRs intentionally skip them. MSIX failures block **CI Gate** when selected; a skipped unselected job is valid. -Download the desired ZIP from the workflow run's **Artifacts**, not from GitHub -Releases: +Download the desired ZIP from the workflow run's **Artifacts**: | Artifact | Contents and purpose | |---|---| @@ -363,9 +362,23 @@ 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. -MSIX release publishing remains paused. This workflow neither submits to -Partner Center nor retrieves Store-signed packages nor adds MSIX assets to -GitHub Releases. Existing EXE/ZIP releases are unchanged. +Canonical `vX.Y.Z-alpha.N` releases also attach the **unsigned Store** MSIX +files and architecture-specific metadata, for manual upload to Partner Center. +They do not attach the Dev-signed packages or certificates. These public +pre-releases are not Latest and are not hidden from GitHub's Releases list. +Stable releases retain only the existing EXE/ZIP downloads and do not mention +MSIX submission assets in their generated download notes. + +To request a new alpha from current `main`, manually run **Daily Alpha +Release**. Its existing checks choose the GitVersion alpha tag, skip a commit +that already has a published release, and dispatch **Build and Test** on the +tag. It does not release the feature branch selected in the UI. Running +**Build and Test** directly on a branch still produces workflow artifacts +only. See [manual alpha releases](docs/RELEASING.md#manual-alpha-releases). + +Store distribution remains paused. This workflow neither submits to Partner +Center nor retrieves or publishes Store-signed packages. An alpha release +label does not change the Store package version or make the package installable. #### The Store package alongside an existing Inno install diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f6363ffd0..e5953d87b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -13,7 +13,9 @@ infrastructure changes. Those fail-closed pull requests run the x64 publish 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, but no MSIX is attached to GitHub Releases. +that MSIX job to succeed. Canonical alpha releases also attach the unsigned +Store MSIX packages and metadata for manual Partner Center submission. +Stable releases do not include MSIX assets; Dev-signed packages stay in Actions. ## Release checklist @@ -34,7 +36,8 @@ that MSIX job to succeed, but no MSIX is attached to GitHub Releases. "Verify Release Binary Signing Policy", ` "OpenClaw.Tray.WinUI.exe", ` "build-msix:", ` - "MSIX release publishing remains paused" + "isMsixAlpha:", ` + "Stage alpha Store MSIX release assets" ``` 3. Create a new stable, stable correction, or prerelease tag from `origin/main`. @@ -97,6 +100,9 @@ Stable, stable-correction, and alpha tags use the same signed CI release pipelin numeric-suffix tag, including malformed ones such as `-0` and `-03`, is routed through the validator rather than silently classified by GitVersion. - `vX.Y.Z-alpha.N` creates a prerelease that stable updater checks do not offer. + It also includes unsigned x64/ARM64 Store submission MSIX files and their + metadata, not Dev-signed installers. The pre-release is visible on GitHub's + Releases page but is not promoted as Latest. The daily workflow evaluates the default branch at 2:00 PM Pacific, skips a head already represented by a published release, and defers while an unpublished non-alpha tag points at the head. After each successful alpha @@ -167,18 +173,28 @@ Current release artifacts are: - `OpenClawTray--win-x64.zip` - `OpenClawTray--win-arm64.zip` -MSIX release publishing remains paused while the supported release downloads -use Inno installers and signed portable update payloads. CI workflow downloads -now include Dev-signed tester MSIX packages (with public certificates and -instructions) and validated unsigned Store submission packages with provenance. -They are not official Store-signed release assets. +Canonical alpha releases additionally contain: -The pause is independent of whether a tag is stable or alpha. This workflow -does not submit to Partner Center, retrieve Store-signed packages, or attach -MSIX assets to GitHub Releases. Those stages remain follow-up work in #1375, -including packaged consent, native ARM64 and signed lifecycle proof, maintainer -approval, Store availability, and a verified signed-package retrieval path. -Existing EXE/ZIP publishing remains unchanged. +- `OpenClawCompanion-x64.msix` and `OpenClawCompanion-arm64.msix` +- `OpenClawCompanion-x64.msix-metadata.json` and + `OpenClawCompanion-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. + +Stable, stable-correction, and non-alpha prereleases retain the existing +EXE/ZIP asset set and do not receive MSIX download notes. Dev-signed tester +MSIX packages, public certificates, and instructions remain Actions artifacts +only. No production signing step is applied to the unsigned Store packages. + +Store distribution remains paused: automatic Partner Center submission, +Store-signed retrieval and publication, and official lifecycle acceptance +remain follow-up work in #1375. 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 @@ -186,6 +202,32 @@ 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. +## Manual alpha releases + +After the workflow change is on the default branch, a maintainer with Actions +write access can use **Actions > Daily Alpha Release > Run workflow**, or: + +```powershell +gh workflow run daily-alpha-release.yml ` + --repo openclaw/openclaw-windows-node --ref main +``` + +This is a request to release the **current default branch**, not the selected +feature branch. It bypasses only the scheduled time-of-day check. All existing +change, published-head, pending non-alpha tag, canonical GitVersion, and tag +ownership checks remain active. If the head is already published, it skips; +it does not replace the release, move the tag, or force a new version. + +When there is an eligible new head, the workflow creates or reuses its +unpublished `vX.Y.Z-alpha.N` tag and dispatches **Build and Test** on that tag. +The full CI Gate and release-signing environment still gate publication. +The release stays a public pre-release with `make_latest: false`. +Existing 30-day alpha retention applies to its submission assets too. + +Running **Build and Test** manually on a branch is still build-only. Running +it on an eligible alpha tag uses the same tagged release path. No new +unreviewed-branch or MSIX-only version allocator is introduced. + ## Binary signing policy Only OpenClaw-owned binaries should be signed by the OpenClaw release signing @@ -288,8 +330,9 @@ proofs as skipped when the host is not MXC-capable; use 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. It builds workflow artifacts only; MSIX release publishing remains -paused and the release job does not download or attach those artifacts. +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. The release job should: @@ -300,8 +343,10 @@ The release job should: 5. Create the portable x64 and ARM64 ZIPs. 6. Build Inno installers. 7. Sign installers. -8. Create a GitHub release whose prerelease flag matches the tag, with installer - and portable ZIP assets. +8. For canonical alpha tags only, stage the validated unsigned Store MSIX + 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. ## Post-release verification diff --git a/scripts/Export-DevMsixArtifact.ps1 b/scripts/Export-DevMsixArtifact.ps1 index e8cd0b43c..53811659b 100644 --- a/scripts/Export-DevMsixArtifact.ps1 +++ b/scripts/Export-DevMsixArtifact.ps1 @@ -146,6 +146,7 @@ installed package still relies on it: Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)' Store submission artifacts are separate unsigned CI downloads, not installers. -No MSIX package is published to GitHub Releases by this workflow. +Unsigned Store packages may also appear on alpha GitHub pre-releases for +Partner Center submission. This signed Dev tester package stays workflow-only. "@ | Set-Content -LiteralPath (Join-Path $OutputDirectory 'INSTALL.txt') -Encoding utf8 Write-Host "Staged signed Dev tester artifact: $OutputDirectory" diff --git a/scripts/Stage-StoreMsixReleaseAssets.ps1 b/scripts/Stage-StoreMsixReleaseAssets.ps1 new file mode 100644 index 000000000..1e49c1f40 --- /dev/null +++ b/scripts/Stage-StoreMsixReleaseAssets.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Stages validated unsigned Store MSIX packages for an alpha GitHub release. +.DESCRIPTION + Checks both architectures' provenance and hashes before copying any 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. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ArtifactDirectory, + [Parameter(Mandatory)][string]$OutputDirectory, + [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 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$ArtifactDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $ArtifactDirectory)) +$OutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $OutputDirectory)) +if ((Test-Path -LiteralPath $OutputDirectory) -and + (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container) -or + @(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0)) { + throw "The alpha release output directory must be absent or empty: $OutputDirectory" +} + +[xml]$manifest = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw +$expectedVersion = ($Version -replace '-alpha\.\d+$', '') + '.0' +$packages = foreach ($architecture in @('x64', 'arm64')) { + $directory = Join-Path $ArtifactDirectory "openclaw-msix-store-unsigned-$architecture" + $packageName = "OpenClawCompanion-$architecture.msix" + $expectedFiles = @($packageName, 'msix-metadata.json') | Sort-Object + $entries = @(Get-ChildItem -LiteralPath $directory -Force) + if ($entries.Count -ne 2 -or + @($entries | Where-Object { $_.PSIsContainer -or $_.LinkType }).Count -gt 0 -or + @(Compare-Object $expectedFiles @($entries.Name | Sort-Object)).Count -gt 0) { + throw "Expected exactly the unsigned Store package and metadata for $architecture." + } + + $metadataPath = Join-Path $directory 'msix-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + 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." + } + if ($metadata.signed -isnot [bool] -or $metadata.signed -or + $metadata.configuration -ne 'Release' -or + $metadata.identityName -ne [string]$manifest.Package.Identity.Name -or + $metadata.publisher -ne [string]$manifest.Package.Identity.Publisher -or + $metadata.architecture -ne $architecture -or + $metadata.packageVersion -ne $expectedVersion -or + $metadata.archive -ne $packageName) { + throw "The $architecture Store artifact identity, version, or unsigned metadata is invalid." + } + $packagePath = Join-Path $directory $packageName + if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $metadata.sha256) { + throw "The $architecture Store package hash does not match its metadata." + } + + [pscustomobject]@{ Path = $packagePath; Name = $packageName; Metadata = $metadataPath } +} + +# A bad second architecture must not leave a publishable partial set. +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$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 + Copy-Item -LiteralPath $package.Metadata -Destination $metadataDestination + $packageDestination + $metadataDestination +} + +[pscustomobject]@{ + Files = @($files) + Notes = @" +### Unsigned Store submission packages (alpha only) + +OpenClawCompanion-x64.msix and OpenClawCompanion-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. + +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 +experimental submission assets. Dev-signed tester downloads remain in Actions. +"@ +} diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 79a984a21..fa62adac7 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -712,8 +712,24 @@ foreach ($token in @( )) { Assert-Contains -Text $releaseJob -Expected $token -Message "Tag release is missing '$token'." } -Assert-NotContains -Text $releaseJob -Unexpected ".msix" -Message "MSIX release publishing must remain paused." +$alphaDownload = Get-StepBlock -Text $releaseJob -Name 'Download alpha Store MSIX artifacts' +$alphaStage = Get-StepBlock -Text $releaseJob -Name 'Stage alpha Store MSIX release assets' +foreach ($step in @($alphaDownload, $alphaStage)) { + Assert-Contains -Text $step -Expected "if: needs.metadata.outputs.isMsixAlpha == 'true'" -Message "Store release assets must be alpha-only." +} +Assert-Contains -Text $alphaDownload -Expected 'pattern: openclaw-msix-store-unsigned-*' -Message "Alpha releases must use unsigned Store inputs." +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." +$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." +Assert-Contains -Text $createRelease -Expected 'fail_on_unmatched_files: true' -Message "Missing release files must fail publication." +Assert-Contains -Text $createRelease -Expected "make_latest: `${{ needs.metadata.outputs.isPrerelease == 'true' && 'false' || 'true' }}" -Message "Alpha releases must not become Latest." +Assert-NotContains -Text $createRelease -Unexpected 'OpenClawCompanion-x64.msix' -Message "MSIX must not be an unconditional stable release asset." +Assert-NotContains -Text $createRelease -Unexpected 'OpenClawCompanion-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." $triggerPaths = @( ".github/workflows/ci.yml", diff --git a/scripts/test-msix-alpha-release.ps1 b/scripts/test-msix-alpha-release.ps1 new file mode 100644 index 000000000..660aca423 --- /dev/null +++ b/scripts/test-msix-alpha-release.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Exercises alpha-only Store asset staging and workflow release selection. +.DESCRIPTION + Uses synthetic package bytes and metadata. Real package-content validation + remains in Build-StoreMsix.ps1; no build, signing, or release API is invoked. +#> +[CmdletBinding()] +param([string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$stager = Join-Path $RepoRoot 'scripts\Stage-StoreMsixReleaseAssets.ps1' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-msix-alpha-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +$sourceCommit = 'a' * 40 +$scenario = 0 +[xml]$manifest = Get-Content -LiteralPath (Join-Path $RepoRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw + +function Assert-Fails { + param([scriptblock]$Action, [string]$Expected) + try { + & $Action | Out-Null + throw 'The operation unexpectedly succeeded.' + } + catch { + if (-not $_.Exception.Message.Contains($Expected, [StringComparison]::OrdinalIgnoreCase)) { + throw "Expected '$Expected', received: $($_.Exception.Message)" + } + } +} + +function New-Fixture { + $script:scenario++ + $inputPath = Join-Path $temporaryRoot "input-$scenario" + foreach ($architecture in @('x64', 'arm64')) { + $directory = Join-Path $inputPath "openclaw-msix-store-unsigned-$architecture" + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $packageName = "OpenClawCompanion-$architecture.msix" + $packagePath = Join-Path $directory $packageName + Set-Content -LiteralPath $packagePath -Value "Synthetic $architecture package fixture." + [ordered]@{ + sourceCommit = $sourceCommit + sourceTreeDirty = $false + signed = $false + configuration = 'Release' + identityName = [string]$manifest.Package.Identity.Name + publisher = [string]$manifest.Package.Identity.Publisher + architecture = $architecture + packageVersion = '2026.7.2.0' + archive = $packageName + sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash + } | ConvertTo-Json | 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 + } +} + +$oldRef = $env:GITHUB_REF +$oldEvent = $env:EVENT_NAME +$oldSchedule = $env:SCHEDULE +$oldOutput = $env:GITHUB_OUTPUT +try { + $arguments = New-Fixture + $assets = & $stager @arguments + $names = @($assets.Files | ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object) + $expected = @( + 'OpenClawCompanion-arm64.msix', + 'OpenClawCompanion-arm64.msix-metadata.json', + 'OpenClawCompanion-x64.msix', + 'OpenClawCompanion-x64.msix-metadata.json' + ) + if (@(Compare-Object $expected $names).Count -gt 0 -or + @(Get-ChildItem -LiteralPath $arguments.OutputDirectory).Count -ne 4) { + throw 'Release assets did not match the exact public Store allowlist.' + } + foreach ($architecture in @('x64', 'arm64')) { + $original = Join-Path $arguments.ArtifactDirectory "openclaw-msix-store-unsigned-$architecture\OpenClawCompanion-$architecture.msix" + $copy = Join-Path $arguments.OutputDirectory "OpenClawCompanion-$architecture.msix" + if ((Get-FileHash -LiteralPath $original).Hash -ne (Get-FileHash -LiteralPath $copy).Hash) { + throw 'Staging changed the validated package bytes.' + } + $metadata = Get-Content -LiteralPath "$copy-metadata.json" -Raw | ConvertFrom-Json + if ($metadata.archive -ne [IO.Path]::GetFileName($copy) -or + $metadata.sha256 -ne (Get-FileHash -LiteralPath $copy).Hash) { + throw 'Published metadata did not describe the released file.' + } + } + foreach ($warning in @('unsigned', 'not installers', '2026.7.2.0', 'same Store version', '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' + + foreach ($version in @('2026.7.2', '2026.7.2-3', '2026.7.2-beta.1', '2026.7.2-alpha', '2026.7.2-alpha.01', '2026.7.2-alpha.1+meta')) { + $arguments = New-Fixture + $arguments.Version = $version + Assert-Fails { & $stager @arguments } 'cannot validate argument' + } + foreach ($mutation in @( + @{ Field = 'sourceCommit'; Value = ('b' * 40); Error = 'expected clean source' }, + @{ Field = 'sourceTreeDirty'; Value = $true; Error = 'expected clean source' }, + @{ Field = 'sourceTreeDirty'; Value = 'false'; Error = 'expected clean source' }, + @{ Field = 'signed'; Value = $true; Error = 'unsigned metadata' }, + @{ Field = 'signed'; Value = 'false'; Error = 'unsigned metadata' }, + @{ Field = 'configuration'; Value = 'Debug'; Error = 'unsigned metadata' }, + @{ Field = 'identityName'; Value = 'OpenClawFoundation.OpenClaw.Dev'; Error = 'unsigned metadata' }, + @{ Field = 'publisher'; Value = 'CN=OpenClaw Local Development'; Error = 'unsigned metadata' }, + @{ Field = 'architecture'; Value = 'x64'; Error = 'unsigned metadata' }, + @{ Field = 'packageVersion'; Value = '2026.7.2.123'; Error = 'unsigned metadata' }, + @{ Field = 'archive'; Value = '..\other.msix'; Error = 'unsigned metadata' }, + @{ Field = 'sha256'; Value = ('0' * 64); Error = 'hash does not match' } + )) { + $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.($mutation.Field) = $mutation.Value + $metadata | ConvertTo-Json | Set-Content -LiteralPath $path + Assert-Fails { & $stager @arguments } $mutation.Error + if (Test-Path -LiteralPath $arguments.OutputDirectory) { throw 'Rejected ARM64 input left partial release assets.' } + } + $arguments = New-Fixture + Set-Content -LiteralPath (Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-x64\extra.pfx') 'not a real key' + Assert-Fails { & $stager @arguments } 'exactly' + $arguments = New-Fixture + Remove-Item -LiteralPath (Join-Path $arguments.ArtifactDirectory 'openclaw-msix-store-unsigned-arm64\OpenClawCompanion-arm64.msix') + Assert-Fails { & $stager @arguments } 'exactly' + $arguments = New-Fixture + $arguments.Version = '2026.7.3-alpha.4' + Assert-Fails { & $stager @arguments } 'unsigned metadata' + + # 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 + $selectorLine = [regex]::Match($workflow, '(?m)^\s*\$isMsixAlpha = .+$') + if (-not $selectorLine.Success) { throw 'The workflow is missing its alpha-only selector.' } + $selector = [scriptblock]::Create($selectorLine.Value + "`n`$isMsixAlpha") + foreach ($case in @( + @{ Ref = 'refs/tags/v2026.7.2-alpha.4'; Prerelease = $true; Expected = $true }, + @{ Ref = 'refs/tags/v2026.7.2-alpha.0'; Prerelease = $true; Expected = $true }, + @{ Ref = 'refs/tags/v2026.7.2-alpha.4'; Prerelease = $false; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2'; Prerelease = $false; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2-3'; Prerelease = $false; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2-beta.1'; Prerelease = $true; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2-alpha.04'; Prerelease = $true; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2-Alpha.4'; Prerelease = $true; Expected = $false }, + @{ Ref = 'refs/tags/v2026.7.2-alpha.4+build'; Prerelease = $true; Expected = $false }, + @{ Ref = 'refs/heads/v2026.7.2-alpha.4'; Prerelease = $true; Expected = $false }, + @{ Ref = 'refs/pull/1403/merge'; Prerelease = $true; Expected = $false } + )) { + $env:GITHUB_REF = $case.Ref + $isPrerelease = $case.Prerelease + if ((& $selector) -ne $case.Expected) { throw "Incorrect alpha selection for $($case.Ref)." } + } + $daily = Get-Content -LiteralPath (Join-Path $RepoRoot '.github\workflows\daily-alpha-release.yml') -Raw + $scheduleBlock = [regex]::Match($daily, '(?ms)^ run: \|\r?\n(?