From 39f9be36bd36ea2775d6f975683e2b7e2d89a4fe Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 2 Oct 2026 02:56:39 -0700 Subject: [PATCH 1/2] fix(release): publish signed MSIX downloads --- .github/workflows/ci.yml | 25 +- DEVELOPMENT.md | 16 +- docs/RELEASING.md | 62 ++-- scripts/Export-DevMsixArtifact.ps1 | 9 +- scripts/Stage-DevMsixReleaseAssets.ps1 | 164 +++++++++++ scripts/Stage-StoreMsixReleaseAssets.ps1 | 178 ------------ scripts/test-ci-workflow-contract.ps1 | 22 +- scripts/test-dev-msix-release.ps1 | 267 +++++++++++++++++ scripts/test-msix-alpha-release.ps1 | 268 ------------------ .../ReleaseSigningWorkflowTests.cs | 16 +- 10 files changed, 517 insertions(+), 510 deletions(-) create mode 100644 scripts/Stage-DevMsixReleaseAssets.ps1 delete mode 100644 scripts/Stage-StoreMsixReleaseAssets.ps1 create mode 100644 scripts/test-dev-msix-release.ps1 delete mode 100644 scripts/test-msix-alpha-release.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f6cd7707..35fa2d746 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,9 +144,9 @@ jobs: shell: pwsh run: ./scripts/test-msix-preview-source-version.ps1 - - name: Validate Store MSIX alpha release assets + - name: Validate signed Dev MSIX release assets shell: pwsh - run: ./scripts/test-msix-alpha-release.ps1 + run: ./scripts/test-dev-msix-release.ps1 - name: Validate stable correction release ordering regressions shell: pwsh @@ -899,7 +899,7 @@ jobs: name: MSIX artifacts (${{ matrix.architecture }}) needs: [change-classification, metadata, reserve-msix-version] if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped') && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} - # Unsigned Store packages may publish to alpha releases only. Dev packages stay workflow-only. + # Dev-signed packages publish on tag releases. Unsigned Store packages stay workflow-only. runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest' }} env: OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} @@ -1363,27 +1363,36 @@ jobs: -Tag $env:RELEASE_TAG -GitHubToken $env:GH_TOKEN - - name: Download Store MSIX release artifacts + - name: Download x64 signed Dev MSIX release artifact uses: actions/download-artifact@v8 with: - pattern: openclaw-msix-store-unsigned-* - path: artifacts/msix-release + name: openclaw-msix-dev-x64 + path: artifacts/msix-release/openclaw-msix-dev-x64 - - name: Stage Store MSIX release assets + - name: Download ARM64 signed Dev MSIX release artifact + uses: actions/download-artifact@v8 + with: + name: openclaw-msix-dev-arm64 + path: artifacts/msix-release/openclaw-msix-dev-arm64 + + - name: Stage signed Dev MSIX release assets id: msix_release shell: pwsh env: RELEASE_VERSION: ${{ needs.metadata.outputs.semVer }} MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo }} + DEV_MSIX_REVISION: ${{ github.run_number }} run: | if ([string]::IsNullOrWhiteSpace($env:MSIX_VERSION_INFO)) { throw 'Missing official MSIX reservation.' } $env:MSIX_VERSION_INFO | Set-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Encoding utf8 - $assets = .\scripts\Stage-StoreMsixReleaseAssets.ps1 ` + $assets = .\scripts\Stage-DevMsixReleaseAssets.ps1 ` -ArtifactDirectory 'artifacts\msix-release' ` -OutputDirectory 'msix-release' ` -Version $env:RELEASE_VERSION ` + -ExpectedRevision $env:DEV_MSIX_REVISION ` + -ExpectedWorkflowRunId $env:GITHUB_RUN_ID ` -VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json" ` -ExpectedSourceCommit $env:GITHUB_SHA @('files<> $env:GITHUB_OUTPUT diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e335a55f0..744645bf9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -442,12 +442,12 @@ Ordinary local builds that omit these options preserve their previous unallocated version calculation. Store distribution remains gated by #1375; this allocator does not submit packages to Partner Center. -Canonical `vX.Y.Z-alpha.N` releases also attach the **unsigned Store** MSIX -files and architecture-specific metadata, for manual upload to Partner Center. -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. +Every canonical stable, correction, and prerelease tag attaches +`OpenClaw-Dev-x64.zip` and `OpenClaw-Dev-arm64.zip`. Each archive contains the +signed Dev MSIX, matching public certificate, provenance metadata, and +installation instructions. Unsigned Store MSIX packages and the bundle remain +Actions artifacts only for manual upload to Partner Center. Public +prereleases are not Latest and are not hidden from GitHub's Releases list. To request a new alpha from current `main`, manually run **Daily Alpha Release**. Its existing checks choose the GitVersion alpha tag, skip a commit @@ -457,8 +457,8 @@ tag. It does not release the feature branch selected in the UI. Running 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. +Center nor retrieves or publishes Store-signed packages. The published Dev +packages require explicit trust of their included development certificate. #### The Store package alongside an existing Inno install diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 74c6236bf..254ffd33e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -13,9 +13,10 @@ 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. Every tag release also attaches the unsigned Store -MSIX bundle, standalone packages, and metadata for manual Partner Center -submission. Dev-signed packages stay in Actions. +that MSIX job to succeed. Every tag release also attaches the signed Dev MSIX +packages with their matching public certificates, provenance metadata, and +installation instructions. Unsigned Store packages stay in Actions for manual +Partner Center submission. ## Inno-to-Store migration foundation @@ -395,7 +396,7 @@ migration proof. "Verify Release Binary Signing Policy", ` "OpenClaw.Tray.WinUI.exe", ` "build-msix:", ` - "Stage Store MSIX release assets" + "Stage signed Dev MSIX release assets" ``` 3. Create a new stable, stable correction, or prerelease tag from `origin/main`. @@ -458,9 +459,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. + Like every canonical tag, it includes signed x64/ARM64 Dev MSIX ZIPs with + their matching public certificates and instructions. 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 @@ -533,22 +534,19 @@ Current release artifacts are: Every stable, correction, and prerelease additionally contains: -- `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` +- `OpenClaw-Dev-x64.zip` +- `OpenClaw-Dev-arm64.zip` -These are **unsigned Store submission inputs, not installers**. Upload the -bundle to Partner Center for one architecture-selecting submission. The -standalone packages remain available for inspection or fallback. Microsoft -signs accepted Store submissions. The release step checks both -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. +Each archive contains the signed Dev-identity MSIX, its matching public +certificate, `msix-metadata.json`, and `INSTALL.txt`. These packages are +development-signed for sideloading, not Microsoft Store-signed. Testers must +extract the matching architecture archive and follow its instructions, +including explicitly trusting the included public certificate. -Dev-signed tester MSIX packages, public certificates, and instructions remain -Actions artifacts only. No production signing step is applied to the unsigned -Store packages. +Unsigned Store submission packages, the multi-architecture bundle, and their +metadata remain Actions artifacts only. Upload the unsigned bundle to Partner +Center for one architecture-selecting submission. No production signing step +is applied to those Store inputs. Store distribution remains paused: automatic Partner Center submission, Store-signed retrieval and publication, and official lifecycle acceptance @@ -625,14 +623,16 @@ Preview candidates never reserve a number and can change between reruns; they must not be treated as official Store submissions. The release stager requires `-VersionInfoPath` for the exact reserved result. It -checks the app version, source commit, reserved allocation, package -version, and both architectures' metadata before copying any assets: +checks the app version, source commit, workflow run, reserved allocation, +package and certificate hashes, Dev identity, signature presence, version, and +both architectures' metadata before creating any release ZIPs: ```powershell -.\scripts\Stage-StoreMsixReleaseAssets.ps1 ` +.\scripts\Stage-DevMsixReleaseAssets.ps1 ` -ArtifactDirectory 'artifacts\msix-release' ` -OutputDirectory 'msix-release' ` -Version $appVersion -ExpectedSourceCommit $sourceCommit ` + -ExpectedRevision $runNumber -ExpectedWorkflowRunId $runId ` -VersionInfoPath $reservedVersionInfoPath ``` @@ -768,8 +768,9 @@ 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` 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 for every -canonical tag. Dev tester distribution stays workflow-only. +its signed x64 and ARM64 Dev packages, public certificates, metadata, and +instructions for every canonical tag. Unsigned Store distribution stays +workflow-only. The release job should: @@ -780,10 +781,10 @@ The release job should: 5. Create the portable x64 and ARM64 ZIPs. 6. Build Inno installers. 7. Sign installers. -8. Stage the validated unsigned Store MSIX - bundle, standalone packages, and metadata. +8. Stage the validated signed Dev MSIX packages and their public-only support + files into architecture-specific ZIPs. 9. Create a GitHub release whose prerelease flag matches the tag, with installer - and portable ZIP assets plus the Store submission assets. + and portable ZIP assets plus the signed Dev MSIX ZIPs. ## Post-release verification @@ -801,6 +802,9 @@ Expected: - Stable tags: `isPrerelease` is `false`. - Alpha tags: `isPrerelease` is `true` and `isLatest` is `false`. - Installer EXEs are signed. +- `OpenClaw-Dev-x64.zip` and `OpenClaw-Dev-arm64.zip` are present, and each + contains the signed MSIX, matching public certificate, metadata, and + `INSTALL.txt`. - In ZIP payload: - `OpenClaw.Tray.WinUI.exe` is OpenClaw-signed. - All listed OpenClaw-owned DLLs are OpenClaw-signed. diff --git a/scripts/Export-DevMsixArtifact.ps1 b/scripts/Export-DevMsixArtifact.ps1 index 55aad554d..7c3d8e83b 100644 --- a/scripts/Export-DevMsixArtifact.ps1 +++ b/scripts/Export-DevMsixArtifact.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Validates a signed Dev MSIX and stages a public-only CI tester download. + Validates a signed Dev MSIX and stages a public tester download. .DESCRIPTION Requires exactly one package from the current build. Verifies its Dev identity, architecture, version, and trusted signature before exporting @@ -124,7 +124,7 @@ $certificateHash = (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256 $allocationNote = if ($versionInfo) { $versionInfo.allocation } else { 'unallocated local build' } @" -OpenClaw (Dev) CI tester package ($Architecture) +OpenClaw (Dev) signed 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. @@ -168,7 +168,8 @@ installed package still relies on it: Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)' Store submission artifacts are separate unsigned CI downloads, not installers. -Unsigned Store packages may also appear on alpha GitHub pre-releases for -Partner Center submission. This signed Dev tester package stays workflow-only. +Tagged releases publish this signed Dev tester package with its matching public +certificate and instructions. Unsigned Store packages stay workflow-only for +Partner Center submission. "@ | Set-Content -LiteralPath (Join-Path $OutputDirectory 'INSTALL.txt') -Encoding utf8 Write-Host "Staged signed Dev tester artifact: $OutputDirectory" diff --git a/scripts/Stage-DevMsixReleaseAssets.ps1 b/scripts/Stage-DevMsixReleaseAssets.ps1 new file mode 100644 index 000000000..39eca97bc --- /dev/null +++ b/scripts/Stage-DevMsixReleaseAssets.ps1 @@ -0,0 +1,164 @@ +<# +.SYNOPSIS + Stages validated signed Dev MSIX packages for a GitHub release. +.DESCRIPTION + Checks both architectures' provenance, identity, package and certificate + hashes, and signature presence before creating architecture-specific ZIPs. + Unsigned Store packages remain workflow artifacts for Partner Center. + Returns Files and Notes for the existing release publisher. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ArtifactDirectory, + [Parameter(Mandatory)][string]$OutputDirectory, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Version, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$ExpectedSourceCommit, + [Parameter(Mandatory)][ValidateRange(1, 65535)][int]$ExpectedRevision, + [Parameter(Mandatory)][ValidateRange(1, [long]::MaxValue)][long]$ExpectedWorkflowRunId, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$VersionInfoPath +) + +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 release output directory must be absent or empty: $OutputDirectory" +} + +. (Join-Path $PSScriptRoot 'MsixVersioning.ps1') +$versionInfo = Read-MsixVersionInfo ` + -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) ` + -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved +$expectedPackageVersion = "$($versionInfo.packageBaseVersion).$ExpectedRevision" +[xml]$project = Get-Content -LiteralPath ( + Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj') -Raw +$expectedIdentity = [string]$project.SelectSingleNode( + '/Project/Target/GenerateOpenClawAppxManifest').IdentityName +$expectedPublisher = [string]$project.SelectSingleNode( + '/Project/PropertyGroup/OpenClawDevMsixPublisher').InnerText + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$packages = foreach ($architecture in @('x64', 'arm64')) { + $directory = Join-Path $ArtifactDirectory "openclaw-msix-dev-$architecture" + $packageName = "OpenClaw-Dev-$architecture.msix" + $expectedFiles = @('INSTALL.txt', 'msix-metadata.json', 'OpenClaw-Dev.cer', $packageName) | Sort-Object + $entries = @(Get-ChildItem -LiteralPath $directory -Force) + if ($entries.Count -ne 4 -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 signed Dev package, certificate, metadata, and instructions for $architecture." + } + + $metadataPath = Join-Path $directory 'msix-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + if (-not $metadata.PSObject.Properties['msixVersionAllocation']) { + throw "The $architecture Dev artifact is missing its release allocation." + } + $packageAllocation = Assert-MsixVersionInfo -VersionInfo $metadata.msixVersionAllocation ` + -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved + foreach ($property in $versionInfo.PSObject.Properties) { + if ($packageAllocation.($property.Name) -cne $property.Value) { + throw "The $architecture Dev artifact does not match the expected release allocation." + } + } + if ($metadata.sourceTreeDirty -isnot [bool] -or $metadata.sourceTreeDirty -or + $metadata.sourceCommit -ine $ExpectedSourceCommit) { + throw "The $architecture Dev artifact does not belong to the expected clean source commit." + } + if ($metadata.signed -isnot [bool] -or -not $metadata.signed -or + $metadata.signing -ne 'development-only' -or + $metadata.identityName -ne $expectedIdentity -or + $metadata.publisher -ne $expectedPublisher -or + $metadata.architecture -ne $architecture -or + $metadata.packageVersion -ne $expectedPackageVersion -or + $metadata.archive -ne $packageName -or + $metadata.certificate -ne 'OpenClaw-Dev.cer' -or + [long]$metadata.workflowRunId -ne $ExpectedWorkflowRunId) { + throw "The $architecture Dev artifact identity, version, run, or signed metadata is invalid." + } + + $packagePath = Join-Path $directory $packageName + $certificatePath = Join-Path $directory 'OpenClaw-Dev.cer' + if (-not (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.Equals( + [string]$metadata.sha256, [StringComparison]::OrdinalIgnoreCase)) { + throw "The $architecture Dev package hash does not match its metadata." + } + if (-not (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256).Hash.Equals( + [string]$metadata.certificateSha256, [StringComparison]::OrdinalIgnoreCase)) { + throw "The $architecture Dev certificate hash does not match its metadata." + } + + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($certificatePath) + try { + if ($certificate.HasPrivateKey -or $certificate.Subject -ne $expectedPublisher -or + -not $certificate.Thumbprint.Equals( + [string]$metadata.certificateThumbprint, [StringComparison]::OrdinalIgnoreCase)) { + throw "The $architecture Dev certificate does not match its public signer metadata." + } + } + finally { $certificate.Dispose() } + + $archive = [IO.Compression.ZipFile]::OpenRead($packagePath) + try { + foreach ($requiredEntry in @('AppxManifest.xml', 'AppxSignature.p7x')) { + if ($null -eq $archive.GetEntry($requiredEntry)) { + throw "The $architecture Dev package is missing $requiredEntry." + } + } + $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 ([string]$identity.Name -ne $expectedIdentity -or + [string]$identity.Publisher -ne $expectedPublisher -or + [string]$identity.ProcessorArchitecture -ne $architecture -or + [string]$identity.Version -ne $expectedPackageVersion) { + throw "The $architecture Dev package manifest does not match its release metadata." + } + + [pscustomobject]@{ + Architecture = $architecture + Directory = $directory + Files = $entries + PackageVersion = [string]$identity.Version + } +} + +# Validate both architectures before creating any publishable output. +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$files = foreach ($package in $packages) { + $zipPath = Join-Path $OutputDirectory "OpenClaw-Dev-$($package.Architecture).zip" + $zip = [IO.Compression.ZipFile]::Open($zipPath, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in $package.Files | Sort-Object Name) { + [IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, $file.FullName, $file.Name, [IO.Compression.CompressionLevel]::Optimal) | Out-Null + } + } + finally { $zip.Dispose() } + $zipPath +} + +[pscustomobject]@{ + Files = @($files) + Notes = @" +### Signed Dev MSIX packages + +- **Dev MSIX (x64)**: ``OpenClaw-Dev-x64.zip`` +- **Dev MSIX (ARM64)**: ``OpenClaw-Dev-arm64.zip`` + +Each ZIP contains a signed Dev-identity MSIX, its matching public certificate, +provenance metadata, and ``INSTALL.txt``. Extract it and follow the included +instructions. These packages are development-signed for sideloading, not +Microsoft Store-signed. + +Unsigned Store submission packages remain available only as workflow artifacts +in the corresponding [Actions run](https://github.com/openclaw/openclaw-windows-node/actions/runs/$ExpectedWorkflowRunId). +"@ +} diff --git a/scripts/Stage-StoreMsixReleaseAssets.ps1 b/scripts/Stage-StoreMsixReleaseAssets.ps1 deleted file mode 100644 index d9a2b19d3..000000000 --- a/scripts/Stage-StoreMsixReleaseAssets.ps1 +++ /dev/null @@ -1,178 +0,0 @@ -<# -.SYNOPSIS - Stages validated unsigned Store MSIX packages for a GitHub release. -.DESCRIPTION - 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. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)][string]$ArtifactDirectory, - [Parameter(Mandatory)][string]$OutputDirectory, - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()][string]$Version, - [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$ExpectedSourceCommit, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$VersionInfoPath -) - -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 release output directory must be absent or empty: $OutputDirectory" -} - -[xml]$manifest = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw -. (Join-Path $PSScriptRoot 'MsixVersioning.ps1') -$versionInfo = Read-MsixVersionInfo ` - -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) ` - -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved -$expectedVersion = $versionInfo.storePackageVersion -$packages = foreach ($architecture in @('x64', 'arm64')) { - $directory = Join-Path $ArtifactDirectory "openclaw-msix-store-unsigned-$architecture" - $packageName = "OpenClaw-$architecture.msix" - $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 (-not $metadata.PSObject.Properties['msixVersionAllocation']) { - throw "The $architecture Store artifact is missing its release allocation." - } - $packageAllocation = Assert-MsixVersionInfo -VersionInfo $metadata.msixVersionAllocation ` - -SourceCommit $ExpectedSourceCommit -SourceVersion $Version -RequireReserved - foreach ($property in $versionInfo.PSObject.Properties) { - if ($packageAllocation.($property.Name) -cne $property.Value) { - throw "The $architecture Store artifact does not match the expected release allocation." - } - } - if ($metadata.sourceTreeDirty -isnot [bool] -or $metadata.sourceTreeDirty -or - $metadata.sourceCommit -ne $ExpectedSourceCommit) { - throw "The $architecture Store artifact does not belong to the expected clean source commit." - } - 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]@{ - 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 architecture or bundle must not leave a publishable partial set. -New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null -$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 - Copy-Item -LiteralPath $package.Metadata -Destination $metadataDestination - $packageDestination - $metadataDestination -} - -[pscustomobject]@{ - Files = @($files) - Notes = @" -### Unsigned Store submission 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 -this tag reuse its reservation. Dev-signed tester downloads remain in Actions. -"@ -} diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 7f20c66b2..150b30207 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -830,24 +830,30 @@ foreach ($token in @( )) { Assert-Contains -Text $releaseJob -Expected $token -Message "Tag release is missing '$token'." } -$msixDownload = Get-StepBlock -Text $releaseJob -Name 'Download Store MSIX release artifacts' -$msixStage = Get-StepBlock -Text $releaseJob -Name 'Stage Store MSIX release assets' -foreach ($step in @($msixDownload, $msixStage)) { - Assert-NotContains -Text $step -Unexpected 'if:' -Message "Every tag release must publish Store MSIX assets." +$msixX64Download = Get-StepBlock -Text $releaseJob -Name 'Download x64 signed Dev MSIX release artifact' +$msixArm64Download = Get-StepBlock -Text $releaseJob -Name 'Download ARM64 signed Dev MSIX release artifact' +$msixStage = Get-StepBlock -Text $releaseJob -Name 'Stage signed Dev MSIX release assets' +foreach ($step in @($msixX64Download, $msixArm64Download, $msixStage)) { + Assert-NotContains -Text $step -Unexpected 'if:' -Message "Every tag release must publish signed Dev MSIX assets." +} +Assert-Contains -Text $msixX64Download -Expected 'name: openclaw-msix-dev-x64' -Message 'Tag releases must use the signed x64 Dev artifact.' +Assert-Contains -Text $msixArm64Download -Expected 'name: openclaw-msix-dev-arm64' -Message 'Tag releases must use the signed ARM64 Dev artifact.' +foreach ($step in @($msixX64Download, $msixArm64Download)) { + Assert-NotContains -Text $step -Unexpected 'openclaw-msix-store-unsigned-' -Message 'Unsigned Store packages must stay workflow-only.' } -Assert-Contains -Text $msixDownload -Expected 'pattern: openclaw-msix-store-unsigned-*' -Message "Tag releases must use unsigned Store inputs." -Assert-NotContains -Text $msixDownload -Unexpected 'openclaw-msix-dev-' -Message "Dev packages must stay workflow-only." Assert-Contains -Text $msixStage -Expected '-ExpectedSourceCommit $env:GITHUB_SHA' -Message "Release staging must bind artifacts to the tag's source." Assert-Contains -Text $msixStage -Expected '-Version $env:RELEASE_VERSION' -Message "Release staging must validate the release version." +Assert-Contains -Text $msixStage -Expected '-ExpectedRevision $env:DEV_MSIX_REVISION' -Message 'Release staging must bind the Dev package revision.' +Assert-Contains -Text $msixStage -Expected '-ExpectedWorkflowRunId $env:GITHUB_RUN_ID' -Message 'Release staging must bind artifacts to the publishing run.' Assert-Contains -Text $msixStage -Expected '-VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json"' -Message 'Release staging must require its exact reserved MSIX version.' Assert-Contains -Text $msixStage -Expected 'MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo }}' -Message 'Release staging must not accept a preview.' $createRelease = Get-StepBlock -Text $releaseJob -Name 'Create Release' Assert-Contains -Text $createRelease -Expected '${{ steps.msix_release.outputs.files }}' -Message "Every tag release must add validated MSIX release files." -Assert-Contains -Text $createRelease -Expected '${{ steps.msix_release.outputs.notes }}' -Message "Every tag release must include MSIX submission notes." +Assert-Contains -Text $createRelease -Expected '${{ steps.msix_release.outputs.notes }}' -Message "Every tag release must include signed Dev MSIX notes." Assert-Contains -Text $createRelease -Expected 'fail_on_unmatched_files: true' -Message "Missing release files must fail publication." Assert-Contains -Text $createRelease -Expected "make_latest: `${{ needs.metadata.outputs.isPrerelease == 'true' && 'false' || 'true' }}" -Message "Alpha releases must not become Latest." Assert-Contains -Text $workflow -Expected "./scripts/test-msix-ci-artifacts.ps1" -Message "Fast validation must exercise the Dev artifact contracts." -Assert-Contains -Text $workflow -Expected "./scripts/test-msix-alpha-release.ps1" -Message "Fast validation must exercise Store release staging." +Assert-Contains -Text $workflow -Expected "./scripts/test-dev-msix-release.ps1" -Message "Fast validation must exercise signed Dev release staging." Assert-Contains -Text $workflow -Expected "./scripts/test-msix-versioning.ps1" -Message 'Fast validation must exercise allocation races and boundaries.' Assert-Contains -Text $workflow -Expected "./scripts/test-msix-preview-source-version.ps1" -Message 'Fast validation must exercise latest-stable MSIX preview selection.' diff --git a/scripts/test-dev-msix-release.ps1 b/scripts/test-dev-msix-release.ps1 new file mode 100644 index 000000000..cccfbad33 --- /dev/null +++ b/scripts/test-dev-msix-release.ps1 @@ -0,0 +1,267 @@ +<# +.SYNOPSIS + Exercises signed Dev MSIX release staging and alpha scheduling. +.DESCRIPTION + Uses synthetic signed-package archives, public certificates, and metadata. + No product build, release upload, or signing service is invoked. +#> +[CmdletBinding()] +param([string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$stager = Join-Path $RepoRoot 'scripts\Stage-DevMsixReleaseAssets.ps1' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-dev-msix-release-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +$sourceCommit = (& git -C $RepoRoot rev-parse HEAD) -join '' +if ($LASTEXITCODE -ne 0) { throw 'Could not resolve fixture source commit.' } +$scenario = 0 +$expectedIdentity = 'OpenClawFoundation.OpenClaw.Dev' +$expectedPublisher = 'CN=OpenClaw Local Development' +$expectedRunId = 456 +$expectedRevision = 123 +$rsa = [Security.Cryptography.RSA]::Create(2048) +$request = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + $expectedPublisher, $rsa, [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) +$certificate = $request.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddDays(30)) + +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-Package { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Architecture, + [string]$Version = '2026.7.202.123', + [string]$Identity = $expectedIdentity, + [string]$Publisher = $expectedPublisher, + [switch]$OmitSignature + ) + $zip = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create) + try { + $manifest = $zip.CreateEntry('AppxManifest.xml') + $writer = [IO.StreamWriter]::new($manifest.Open()) + try { + $writer.Write( + '', + $Identity, $Publisher, $Architecture, $Version) + } + finally { $writer.Dispose() } + if (-not $OmitSignature) { + $writer = [IO.StreamWriter]::new($zip.CreateEntry('AppxSignature.p7x').Open()) + try { $writer.Write('Synthetic signature marker.') } + finally { $writer.Dispose() } + } + } + finally { $zip.Dispose() } +} + +function New-Fixture { + param([string]$Version = '2026.7.2-alpha.4') + + $script:scenario++ + $inputPath = Join-Path $temporaryRoot "input-$scenario" + $allocation = [ordered]@{ + schemaVersion = 1 + sourceVersion = $Version + sourceCommit = $sourceCommit + sourceRef = "refs/tags/v$Version" + repository = 'openclaw/openclaw-windows-node' + baseVersion = '2026.7.2' + packageBaseVersion = '2026.7.202' + storePackageVersion = '2026.7.202.0' + packagingRevision = 2 + allocation = 'reserved' + reservationRef = 'refs/tags/msix-package/2026.7.2/202' + } + $allocationPath = Join-Path $temporaryRoot "allocation-$scenario.json" + $allocation | ConvertTo-Json | Set-Content -LiteralPath $allocationPath + foreach ($architecture in @('x64', 'arm64')) { + $directory = Join-Path $inputPath "openclaw-msix-dev-$architecture" + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $packageName = "OpenClaw-Dev-$architecture.msix" + $packagePath = Join-Path $directory $packageName + New-Package -Path $packagePath -Architecture $architecture + $certificatePath = Join-Path $directory 'OpenClaw-Dev.cer' + [IO.File]::WriteAllBytes( + $certificatePath, + $certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + Set-Content -LiteralPath (Join-Path $directory 'INSTALL.txt') -Value "Install $packageName" + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw-windows-node' + sourceCommit = $sourceCommit + sourceTreeDirty = $false + architecture = $architecture + archive = $packageName + sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + signed = $true + signing = 'development-only' + identityName = $expectedIdentity + packageVersion = '2026.7.202.123' + publisher = $expectedPublisher + certificate = 'OpenClaw-Dev.cer' + certificateThumbprint = $certificate.Thumbprint + certificateSha256 = (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256).Hash.ToLowerInvariant() + certificateExpiresUtc = $certificate.NotAfter.ToUniversalTime().ToString('O') + workflowRunId = $expectedRunId + workflowRunAttempt = 1 + msixVersionAllocation = $allocation + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $directory 'msix-metadata.json') + } + @{ + ArtifactDirectory = $inputPath + OutputDirectory = Join-Path $temporaryRoot "output-$scenario" + Version = $Version + ExpectedSourceCommit = $sourceCommit + ExpectedRevision = $expectedRevision + ExpectedWorkflowRunId = $expectedRunId + VersionInfoPath = $allocationPath + } +} + +function Get-MetadataPath { + param([hashtable]$Arguments, [string]$Architecture = 'arm64') + Join-Path $Arguments.ArtifactDirectory "openclaw-msix-dev-$Architecture\msix-metadata.json" +} + +$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-Dev-arm64.zip', 'OpenClaw-Dev-x64.zip') + if (@(Compare-Object $expected $names).Count -gt 0 -or + @(Get-ChildItem -LiteralPath $arguments.OutputDirectory).Count -ne 2) { + throw 'Release assets did not match the exact signed Dev allowlist.' + } + foreach ($architecture in @('x64', 'arm64')) { + $zipPath = Join-Path $arguments.OutputDirectory "OpenClaw-Dev-$architecture.zip" + $zip = [IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $actualEntries = @($zip.Entries.FullName | Sort-Object) + $expectedEntries = @( + 'INSTALL.txt', 'msix-metadata.json', 'OpenClaw-Dev.cer', + "OpenClaw-Dev-$architecture.msix") | Sort-Object + if (@(Compare-Object $expectedEntries $actualEntries).Count -gt 0) { + throw "The $architecture release ZIP did not preserve the exact Dev artifact." + } + } + finally { $zip.Dispose() } + } + foreach ($token in @( + 'Signed Dev MSIX packages', 'OpenClaw-Dev-x64.zip', 'OpenClaw-Dev-arm64.zip', + 'development-signed', 'Microsoft Store-signed', 'workflow artifacts', + "actions/runs/$expectedRunId")) { + if (-not $assets.Notes.Contains($token)) { throw "Release notes are missing '$token'." } + } + Assert-Fails { & $stager @arguments } 'absent or empty' + + foreach ($version in @('2026.7.2', '2026.7.2-3', '2026.7.2-beta.1')) { + $arguments = New-Fixture -Version $version + & $stager @arguments | Out-Null + } + + foreach ($mutation in @( + @{ Field = 'sourceCommit'; Value = ('b' * 40); Error = 'expected clean source' }, + @{ Field = 'sourceTreeDirty'; Value = $true; Error = 'expected clean source' }, + @{ Field = 'signed'; Value = $false; Error = 'signed metadata' }, + @{ Field = 'signing'; Value = 'store'; Error = 'signed metadata' }, + @{ Field = 'identityName'; Value = 'OpenClawFoundation.OpenClaw'; Error = 'signed metadata' }, + @{ Field = 'publisher'; Value = 'CN=Wrong'; Error = 'signed metadata' }, + @{ Field = 'architecture'; Value = 'x64'; Error = 'signed metadata' }, + @{ Field = 'packageVersion'; Value = '2026.7.202.122'; Error = 'signed metadata' }, + @{ Field = 'workflowRunId'; Value = 999; Error = 'signed metadata' }, + @{ Field = 'sha256'; Value = ('0' * 64); Error = 'package hash' }, + @{ Field = 'certificateSha256'; Value = ('0' * 64); Error = 'certificate hash' }, + @{ Field = 'certificateThumbprint'; Value = ('0' * 40); Error = 'public signer metadata' } + )) { + $arguments = New-Fixture + $path = Get-MetadataPath $arguments + $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $metadata.($mutation.Field) = $mutation.Value + $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path + Assert-Fails { & $stager @arguments } $mutation.Error + if (Test-Path -LiteralPath $arguments.OutputDirectory) { + throw 'Rejected ARM64 input left partial release assets.' + } + } + + $arguments = New-Fixture + Set-Content -LiteralPath ( + Join-Path $arguments.ArtifactDirectory 'openclaw-msix-dev-x64\extra.pfx') -Value 'not a key' + Assert-Fails { & $stager @arguments } 'exactly' + $arguments = New-Fixture + $packagePath = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-dev-arm64\OpenClaw-Dev-arm64.msix' + [IO.File]::AppendAllText($packagePath, 'tampered') + Assert-Fails { & $stager @arguments } 'package hash' + $arguments = New-Fixture + $packagePath = Join-Path $arguments.ArtifactDirectory 'openclaw-msix-dev-arm64\OpenClaw-Dev-arm64.msix' + [IO.File]::Delete($packagePath) + New-Package -Path $packagePath -Architecture arm64 -OmitSignature + $path = Get-MetadataPath $arguments + $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $metadata.sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path + Assert-Fails { & $stager @arguments } 'missing AppxSignature.p7x' + $arguments = New-Fixture + $path = Get-MetadataPath $arguments + $metadata = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $metadata.msixVersionAllocation.allocation = 'preview' + $metadata.msixVersionAllocation.reservationRef = $null + $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path + Assert-Fails { & $stager @arguments } 'MSIX' + + $daily = Get-Content -LiteralPath (Join-Path $RepoRoot '.github\workflows\daily-alpha-release.yml') -Raw + $scheduleBlock = [regex]::Match($daily, '(?ms)^ run: \|\r?\n(?