diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 396b4c3df..7b0395a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,14 @@ 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 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 @@ -154,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: @@ -212,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 @@ -808,37 +819,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') }} + # Unsigned Store packages may publish to alpha releases only. Dev packages stay workflow-only. + runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest' }} + env: + OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} + DEV_MSIX_REVISION: ${{ github.run_number }} 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 +850,98 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v3 + - name: Select MSIX artifact version + id: msix_version + shell: pwsh + env: + BUILD_EVENT: ${{ github.event_name }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_BRANCH: ${{ github.head_ref }} + run: | + $baseVersionOverride = '' + # Temporary first-Store-submission exception for this PR branch. Remove before merge. + if ($env:BUILD_EVENT -eq 'pull_request' -and + $env:PR_HEAD_REPOSITORY -eq 'natalie-aguinaldo/openclaw-windows-node' -and + $env:PR_HEAD_BRANCH -eq 'user/natalie-aguinaldo/msix-ci-artifacts-versioning') { + $baseVersionOverride = '2026.9.4' + } + $expectedDevVersion = if ($baseVersionOverride) { $baseVersionOverride } else { $env:OPENCLAW_BUILD_VERSION } + "baseVersionOverride=$baseVersionOverride" >> $env:GITHUB_OUTPUT + "expectedDevVersion=$expectedDevVersion" >> $env:GITHUB_OUTPUT - - name: Restore - run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} + - name: Build and validate unsigned Store MSIX + shell: pwsh + env: + BUILD_ARCHITECTURE: ${{ matrix.architecture }} + MSIX_BASE_VERSION_OVERRIDE: ${{ steps.msix_version.outputs.baseVersionOverride }} + run: | + $buildArguments = @{ Architecture = $env:BUILD_ARCHITECTURE } + if ($env:MSIX_BASE_VERSION_OVERRIDE) { + $buildArguments.StorePackageVersion = "$($env:MSIX_BASE_VERSION_OVERRIDE).0" + } + .\scripts\Build-StoreMsix.ps1 @buildArguments - - name: Build MSIX Package - 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 + - name: Upload unsigned Store submission artifact + uses: actions/upload-artifact@v7 + with: + name: openclaw-msix-store-unsigned-${{ matrix.architecture }} + path: | + artifacts/msix/${{ matrix.architecture }}/OpenClaw-${{ 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 signed Dev MSIX shell: pwsh + env: + MSIX_BASE_VERSION_OVERRIDE: ${{ steps.msix_version.outputs.baseVersionOverride }} 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 + $versionArguments = @{} + if ($env:MSIX_BASE_VERSION_OVERRIDE) { + $versionArguments.MsixBaseVersion = $env:MSIX_BASE_VERSION_OVERRIDE } - Write-Host "Found: $($msix.FullName)" - echo "msix_path=$($msix.FullName)" >> $env:GITHUB_OUTPUT - echo "msix_name=$($msix.Name)" >> $env:GITHUB_OUTPUT + .\build.ps1 -Project WinUI -Configuration Release -Msix Dev ` + -MsixRevision $env:DEV_MSIX_REVISION ` + -MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx" ` + @versionArguments - - name: Upload MSIX Artifact + - name: Validate and stage Dev tester artifact + shell: pwsh + env: + EXPECTED_DEV_VERSION: ${{ steps.msix_version.outputs.expectedDevVersion }} + run: | + $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:EXPECTED_DEV_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 }}/OpenClaw-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 +971,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 +995,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: @@ -1150,6 +1204,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: @@ -1159,6 +1235,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: | @@ -1170,6 +1248,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 e0549ed58..424a726c5 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 @@ -291,10 +291,23 @@ Partner Center rejects any submission whose revision is non-zero. `-Msix Store` forces `-Configuration Release`, refuses to combine with `-DevBuild`, and delegates to `scripts\Build-StoreMsix.ps1` once per architecture. Each run produces one unsigned self-contained package at -`artifacts\msix\\OpenClawCompanion-.msix` alongside an +`artifacts\msix\\OpenClaw-.msix` alongside an `msix-metadata.json` provenance sidecar recording the source commit, whether the tree was dirty, the package version, publisher, and the package SHA-256. +For a one-off Store submission version, invoke the validated builder directly: + +```powershell +.\scripts\Build-StoreMsix.ps1 -Architecture x64 -StorePackageVersion 2026.9.4.0 ` + -OutputDirectory 'artifacts\store-submission\x64' +.\scripts\Build-StoreMsix.ps1 -Architecture arm64 -StorePackageVersion 2026.9.4.0 ` + -OutputDirectory 'artifacts\store-submission\arm64' +``` + +Use fresh output directories. The override changes the numeric package and +assembly versions for that invocation and verifies the packaged version before +writing metadata. It does not create a tag or change the normal GitVersion policy. + `scripts\Build-StoreMsix.ps1` fails the build when the produced package drifts from `Package.appxmanifest`: the identity name, publisher, and processor architecture must match, the version must be four `uint16` components ending in @@ -318,6 +331,87 @@ 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**: + +| Artifact | Contents and purpose | +|---|---| +| `openclaw-msix-dev-x64` / `openclaw-msix-dev-arm64` | Signed `OpenClaw-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 `OpenClaw-.msix` and the validated provenance sidecar from `Build-StoreMsix.ps1`. Submission inputs, not directly installable tester packages. | + +These MSIX filenames use `OpenClaw`, not the previous `OpenClawCompanion` +prefix. Only the download filenames changed: package identities, versions, +and EXE installer filenames are unchanged. Existing downloads are not renamed. + +**Temporary submission-build exception:** PR runs whose head is +`natalie-aguinaldo/openclaw-windows-node:user/natalie-aguinaldo/msix-ci-artifacts-versioning` +build the unsigned Store artifacts as `2026.9.4.0` and the Dev tester artifacts +as `2026.9.4.`. Both use the same base without changing their +identities or signing. Other PRs, pushes, tags, manual runs, EXE/ZIP artifacts, +and GitHub release tags still use their normal version policy. +Remove this temporary workflow exception and this note before merging. +This exception does not publish an alpha release or change its version checks. + +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. + +For a one-off local Dev build, `-MsixBaseVersion 2026.9.4` overrides only the +base while retaining the selected revision. It requires `-Msix Dev`; omit it +for the GitVersion base. The CI exporter checks the package against the +selected base plus the run-number revision. A `2026.9.4.*` Dev package is older +than an installed `2026.9.5.*` package regardless of its revision. + +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. + +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 The Store package and the Inno installer produce the same application. Both can diff --git a/build.ps1 b/build.ps1 index 60eaeda38..6977af9b2 100644 --- a/build.ps1 +++ b/build.ps1 @@ -41,6 +41,21 @@ 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. + +.PARAMETER MsixBaseVersion + Optional numeric X.Y.Z base for a one-off Dev MSIX build. Keeps the explicit + or installed-package-derived revision. Only valid with -Msix Dev. + Omit to retain the GitVersion-calculated base. + .EXAMPLE .\build.ps1 .\build.ps1 -Project WinUI -Configuration Release @@ -67,6 +82,22 @@ param( [ValidateSet("Dev", "Store")] [string]$Msix, + [ValidateRange(1, 65535)] + [int]$MsixRevision, + + [ValidateNotNullOrEmpty()] + [string]$MsixOutputDirectory, + + [ValidatePattern('^[1-9]\d*\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$')] + [ValidateScript({ + foreach ($part in $_.Split('.')) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($part, [ref]$value)) { return $false } + } + return $true + })] + [string]$MsixBaseVersion, + [switch]$NoTrustRepository ) @@ -78,6 +109,23 @@ 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 ($PSBoundParameters.ContainsKey("MsixBaseVersion") -and -not $buildDevMsix) { + throw "-MsixBaseVersion requires -Msix Dev." +} +if ($MsixOutputDirectory) { + $MsixOutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repoRoot, $MsixOutputDirectory)) + if ((Test-Path -LiteralPath $MsixOutputDirectory) -and + (-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 +497,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 +534,30 @@ 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" + ) + } + if ($MsixBaseVersion) { + $assemblyRevision = if ($packageMsix) { $msixRevision } else { 0 } + $assemblyVersion = "$MsixBaseVersion.$assemblyRevision" + $dotnetArgs += @( + "-p:Version=$MsixBaseVersion", + "-p:UpdateVersionProperties=false", + "-p:UpdateAssemblyInfo=false", + "-p:AssemblyVersion=$assemblyVersion", + "-p:FileVersion=$assemblyVersion", + "-p:InformationalVersion=$assemblyVersion" ) } $result = Invoke-DotNetCaptured $dotnetArgs @@ -558,7 +627,7 @@ if ($buildStoreMsix) { Write-Error "Store MSIX ($storeArchitecture) packaging failed: $($_.Exception.Message)" exit 1 } - $storePackages += Join-Path $repoRoot "artifacts\msix\$storeArchitecture\OpenClawCompanion-$storeArchitecture.msix" + $storePackages += Join-Path $repoRoot "artifacts\msix\$storeArchitecture\OpenClaw-$storeArchitecture.msix" } Write-Header "Store MSIX Packages" @@ -621,14 +690,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..e5b81aa06 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -10,7 +10,12 @@ 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. 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 @@ -31,7 +36,8 @@ 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" + "isMsixAlpha:", ` + "Stage alpha Store MSIX release assets" ``` 3. Create a new stable, stable correction, or prerelease tag from `origin/main`. @@ -94,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 @@ -164,10 +173,60 @@ 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. +Canonical alpha releases additionally contain: + +- `OpenClaw-x64.msix` and `OpenClaw-arm64.msix` +- `OpenClaw-x64.msix-metadata.json` and + `OpenClaw-arm64.msix-metadata.json` + +These are **unsigned Store submission inputs, not installers**. Download the +MSIX files and upload them manually to Partner Center. Microsoft signs accepted +Store submissions. The alpha release step checks both architectures' clean +source provenance, identity, version, and package hashes before staging the +unchanged bytes built by `Build-StoreMsix.ps1`. It fails rather than publishing +a partial or mismatched set. + +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 +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 @@ -270,8 +329,10 @@ 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. 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: @@ -282,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/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/Build-StoreMsix.ps1 b/scripts/Build-StoreMsix.ps1 index adbd8d06d..89e736a46 100644 --- a/scripts/Build-StoreMsix.ps1 +++ b/scripts/Build-StoreMsix.ps1 @@ -36,6 +36,11 @@ which is cleaned on each run. A caller-supplied directory is never deleted; the build fails if it already exists and is not empty. +.PARAMETER StorePackageVersion + Optional numeric X.Y.Z.0 version for a one-off Store submission build. + Overrides GitVersion for package and assembly versions only for this build. + Omitting it preserves normal GitVersion behavior. + .EXAMPLE .\scripts\Build-StoreMsix.ps1 -Architecture x64 .\scripts\Build-StoreMsix.ps1 -Architecture arm64 @@ -53,7 +58,10 @@ param( [ValidateSet('Release')] [string]$Configuration = 'Release', - [string]$OutputDirectory + [string]$OutputDirectory, + + [ValidatePattern('^[1-9]\d*\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.0$')] + [string]$StorePackageVersion ) Set-StrictMode -Version Latest @@ -127,6 +135,20 @@ function Test-PackageVersion { } } +$versionArguments = @() +if ($PSBoundParameters.ContainsKey('StorePackageVersion')) { + Test-PackageVersion -Version $StorePackageVersion + # GitVersion rewrites Version and assembly attributes unless both updates are disabled. + $versionArguments = @( + "-p:Version=$StorePackageVersion", + '-p:UpdateVersionProperties=false', + '-p:UpdateAssemblyInfo=false', + "-p:AssemblyVersion=$StorePackageVersion", + "-p:FileVersion=$StorePackageVersion", + "-p:InformationalVersion=$StorePackageVersion" + ) +} + # The tracked manifest is the single source of truth for the release identity. # A packaged build that drifts from it is a packaging bug, not a new identity. [xml]$sourceManifest = Get-Content -LiteralPath $sourceManifestPath -Raw @@ -191,6 +213,7 @@ try { -p:UapAppxPackageBuildMode=SideloadOnly ` -p:AppxPackageSigningEnabled=false ` "-p:AppxPackageDir=$appxOutput" ` + @versionArguments ` --nologo } @@ -208,7 +231,7 @@ try { ) } - $msixName = "OpenClawCompanion-$Architecture.msix" + $msixName = "OpenClaw-$Architecture.msix" $msixPath = Join-Path $OutputDirectory $msixName Copy-Item -LiteralPath $builtPackages[0].FullName -Destination $msixPath -Force @@ -281,6 +304,9 @@ try { $packagedIdentity = $packagedManifest.Package.Identity $packageVersion = [string]$packagedIdentity.Version Test-PackageVersion -Version $packageVersion + if ($StorePackageVersion -and $packageVersion -ne $StorePackageVersion) { + throw "Expected Store package version $StorePackageVersion, found $packageVersion." + } if ([string]$packagedIdentity.Name -ne $expectedIdentityName) { throw ( diff --git a/scripts/Export-DevMsixArtifact.ps1 b/scripts/Export-DevMsixArtifact.ps1 new file mode 100644 index 000000000..6366dddcf --- /dev/null +++ b/scripts/Export-DevMsixArtifact.ps1 @@ -0,0 +1,152 @@ +<# +.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 = "OpenClaw-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. +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..2e749bdbe --- /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 = "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 ($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) + +OpenClaw-x64.msix and OpenClaw-arm64.msix are unsigned +Partner Center submission inputs, not installers. Their architecture-specific +metadata files record the source commit, package version, and SHA-256. +Upload the MSIX files manually to Partner Center; Microsoft signs accepted +Store submissions. This workflow does not submit or retrieve Store packages. + +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-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..cc7dae8f2 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -648,16 +648,51 @@ 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 @buildArguments', + 'BUILD_ARCHITECTURE: ${{ matrix.architecture }}', + 'BUILD_EVENT: ${{ github.event_name }}', + 'PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}', + 'PR_HEAD_BRANCH: ${{ github.head_ref }}', + 'MSIX_BASE_VERSION_OVERRIDE: ${{ steps.msix_version.outputs.baseVersionOverride }}', + 'EXPECTED_DEV_VERSION: ${{ steps.msix_version.outputs.expectedDevVersion }}', + '.\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:EXPECTED_DEV_VERSION', + '-CertificateThumbprint $thumbprint', + 'name: openclaw-msix-store-unsigned-${{ matrix.architecture }}', + 'name: openclaw-msix-dev-${{ matrix.architecture }}', + 'artifacts/msix/${{ matrix.architecture }}/OpenClaw-${{ matrix.architecture }}.msix', + 'artifacts/msix-dev/${{ matrix.architecture }}/OpenClaw-Dev-${{ matrix.architecture }}.msix', + '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 +703,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 +720,24 @@ foreach ($token in @( )) { Assert-Contains -Text $releaseJob -Expected $token -Message "Tag release is missing '$token'." } +$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 'OpenClaw-x64.msix' -Message "MSIX must not be an unconditional stable release asset." +Assert-NotContains -Text $createRelease -Unexpected 'OpenClaw-arm64.msix' -Message "MSIX must not be an unconditional stable release asset." +Assert-Contains -Text $workflow -Expected "./scripts/test-msix-ci-artifacts.ps1" -Message "Fast validation must exercise the Dev artifact contracts." +Assert-Contains -Text $workflow -Expected "./scripts/test-msix-alpha-release.ps1" -Message "Fast validation must exercise alpha release staging." $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..9f8d042a0 --- /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 = "OpenClaw-$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 = @( + 'OpenClaw-arm64.msix', + 'OpenClaw-arm64.msix-metadata.json', + 'OpenClaw-x64.msix', + 'OpenClaw-x64.msix-metadata.json' + ) + if (@(Compare-Object $expected $names).Count -gt 0 -or + @(Get-ChildItem -LiteralPath $arguments.OutputDirectory).Count -ne 4) { + 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\OpenClaw-$architecture.msix" + $copy = Join-Path $arguments.OutputDirectory "OpenClaw-$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 @('OpenClaw-x64.msix', 'OpenClaw-arm64.msix', '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\OpenClaw-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(?