diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3790db31..851251ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -120,12 +120,18 @@ and ARM64 separately. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, payload metadata, and signing validation. +- Official releases combine the x64 and ARM64 packages into one signed + `.msixbundle` while retaining signed standalone packages for explicit + architecture-specific deployment. Compose the bundle before signing; bundle + signing recursively covers its contained packages. - Metadata files are part of the release trust chain, not incidental build output. Changes to their fields must be coordinated across payload creation, MSIX creation, signing validation, workflow artifacts, and tests. - Keep the workflow's manual `openclaw_ref` default and automatic `env.OPENCLAW_REF` fallback identical. Official-release changes also update - the reviewed immutable commit in `release-policy.json`. + the reviewed immutable commit and stable or correction tag in + `release-policy.json`. The tag determines the four-part MSIX identity + version and the permanent GitHub Release tag. - The launcher is NativeAOT. `dotnet build` and the xUnit suite exercise a JIT build, so run the NativeAOT publish path when changing reflection, interop, or trimming-sensitive code. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 2aff1cb8..3b6e1760 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -94,6 +94,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test MSIX bundle build + shell: pwsh + run: > + .\scripts\Test-Build-MSIXBundle.Tests.ps1 + - name: Test Git hooks shell: pwsh run: > @@ -259,10 +264,20 @@ jobs: - name: Compose unsigned MSIX shell: pwsh + env: + SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} run: | + $versionParameters = @{ + RunNumber = '${{ github.run_number }}' + RunAttempt = '${{ github.run_attempt }}' + } + if ($env:SIGNING_MODE -eq 'official') { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $versionParameters.ReleaseVersion = [string]$policy.packageVersion + } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` - -RunNumber '${{ github.run_number }}' ` - -RunAttempt '${{ github.run_attempt }}' + @versionParameters .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` @@ -330,6 +345,59 @@ jobs: if-no-files-found: error retention-days: 7 + build-msix-bundle: + name: Build unsigned multi-architecture Gateway MSIX bundle + needs: build-msix + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download unsigned x64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-x64 + path: artifacts\x64 + + - name: Download unsigned ARM64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-arm64 + path: artifacts\arm64 + + - name: Compose unsigned multi-architecture MSIX bundle + shell: pwsh + env: + SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} + run: | + $versionParameters = @{ + RunNumber = '${{ github.run_number }}' + RunAttempt = '${{ github.run_attempt }}' + } + if ($env:SIGNING_MODE -eq 'official') { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $versionParameters.ReleaseVersion = [string]$policy.packageVersion + } + $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` + @versionParameters + + .\scripts\Build-MSIXBundle.ps1 ` + -X64Package artifacts\x64\OpenClawGateway-x64.msix ` + -Arm64Package artifacts\arm64\OpenClawGateway-arm64.msix ` + -PackageVersion $packageVersion ` + -OutputPath artifacts\bundle\OpenClawGateway.msixbundle + + - name: Upload unsigned multi-architecture MSIX bundle + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle\OpenClawGateway.msixbundle + if-no-files-found: error + retention-days: 7 + reject-untrusted-official-signing: name: Reject official signing outside main if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref != 'refs/heads/main' }} @@ -346,8 +414,14 @@ jobs: authorize-signing: name: Authorize official Gateway MSIX signing if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} - needs: build-msix + needs: + - build-msix + - build-msix-bundle runs-on: windows-latest + outputs: + package_version: ${{ steps.release.outputs.package_version }} + release_tag: ${{ steps.release.outputs.release_tag }} + release_version: ${{ steps.release.outputs.release_version }} permissions: actions: read contents: read @@ -369,6 +443,28 @@ jobs: name: openclaw-gateway-msix-unsigned-arm64 path: artifacts\arm64 + - name: Download unsigned multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle + + - name: Resolve official release metadata + id: release + shell: pwsh + run: | + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $releaseTag = ([string]$policy.releaseTag).Trim() + $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` + -RunNumber '${{ github.run_number }}' ` + -RunAttempt '${{ github.run_attempt }}' ` + -ReleaseVersion ([string]$policy.packageVersion) + + "package_version=$packageVersion" >> $env:GITHUB_OUTPUT + "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT + "release_version=$($releaseTag.Substring(1))" >> $env:GITHUB_OUTPUT + - name: Enforce official signing policy shell: pwsh env: @@ -378,6 +474,7 @@ jobs: .\scripts\Test-SigningInputs.ps1 ` -ArtifactsDirectory artifacts ` -PolicyPath .\release-policy.json ` + -BundlePath artifacts\bundle\OpenClawGateway.msixbundle ` -RequestedRef $env:OPENCLAW_REF ` -PackagingCommit $env:PACKAGING_COMMIT @@ -386,6 +483,7 @@ jobs: if: ${{ needs.authorize-signing.result == 'success' }} needs: - build-msix + - build-msix-bundle - authorize-signing runs-on: windows-latest environment: release-signing @@ -406,6 +504,12 @@ jobs: name: openclaw-gateway-msix-unsigned-arm64 path: artifacts\arm64 + - name: Download unsigned multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle + - name: Azure login uses: azure/login@v3 with: @@ -413,7 +517,7 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - name: Sign MSIX packages + - name: Sign standalone MSIX packages uses: azure/artifact-signing-action@v2 with: endpoint: https://eus.codesigning.azure.net/ @@ -427,6 +531,17 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Sign multi-architecture MSIX bundle + uses: azure/artifact-signing-action@v2 + with: + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw + files: ${{ github.workspace }}\artifacts\bundle\OpenClawGateway.msixbundle + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + - name: Verify signatures and refresh metadata shell: pwsh run: | @@ -460,6 +575,24 @@ jobs: Set-Content -LiteralPath $metadataPath -Encoding utf8 } + $bundle = Get-Item ` + -LiteralPath artifacts\bundle\OpenClawGateway.msixbundle + $bundleSignature = Get-AuthenticodeSignature ` + -LiteralPath $bundle.FullName + if ($bundleSignature.Status -ne 'Valid') { + throw "$($bundle.Name) signature status was $($bundleSignature.Status)." + } + if (-not [string]::Equals( + $bundleSignature.SignerCertificate.Subject, + $expectedSubject, + [StringComparison]::OrdinalIgnoreCase + )) { + throw ( + "$($bundle.Name) signer was unexpected: " + + $bundleSignature.SignerCertificate.Subject + ) + } + - name: Upload signed x64 MSIX uses: actions/upload-artifact@v7 with: @@ -475,3 +608,78 @@ jobs: path: artifacts\arm64\ if-no-files-found: error retention-days: 7 + + - name: Upload signed multi-architecture MSIX bundle + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-bundle + path: artifacts\bundle\OpenClawGateway.msixbundle + if-no-files-found: error + retention-days: 7 + + publish-release: + name: Publish signed Gateway MSIX release + if: ${{ needs.sign-msix.result == 'success' }} + needs: + - authorize-signing + - sign-msix + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download signed x64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-x64 + path: signed/x64 + + - name: Download signed ARM64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-arm64 + path: signed/arm64 + + - name: Download signed multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-bundle + path: signed/bundle + + - name: Stage versioned release assets + shell: bash + env: + RELEASE_VERSION: ${{ needs.authorize-signing.outputs.release_version }} + run: | + set -euo pipefail + mkdir release-assets + cp signed/x64/OpenClawGateway-x64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-x64.msix" + cp signed/arm64/OpenClawGateway-arm64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-arm64.msix" + cp signed/bundle/OpenClawGateway.msixbundle \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}.msixbundle" + + - name: Create permanent GitHub release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.authorize-signing.outputs.release_tag }} + target_commitish: ${{ github.sha }} + name: OpenClaw Gateway MSIX ${{ needs.authorize-signing.outputs.release_tag }} + generate_release_notes: true + prerelease: false + make_latest: true + overwrite_files: false + fail_on_unmatched_files: true + files: | + release-assets/*.msix + release-assets/*.msixbundle + body: | + Packages OpenClaw Gateway `${{ needs.authorize-signing.outputs.release_tag }}` + from [`openclaw/openclaw@${{ inputs.openclaw_ref }}`](https://github.com/openclaw/openclaw/commit/${{ inputs.openclaw_ref }}). + + ### Downloads + - **Recommended:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}.msixbundle` + - **x64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-x64.msix` + - **ARM64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-arm64.msix` + + The packages are signed by OpenClaw Foundation through Azure Artifact Signing. diff --git a/README.md b/README.md index b874ae82..20205914 100644 --- a/README.md +++ b/README.md @@ -129,12 +129,15 @@ The payload artifact records the requested ref and resolved upstream commit in OpenClaw commit, while embedded `payload-files.json` records every packaged application file's path, length, and SHA-256. -`release-policy.json` records the immutable OpenClaw commit approved for -official signing. Updating that policy requires a reviewed repository change. -Official signing runs only from `main` and verifies the workflow input, both +`release-policy.json` records the immutable OpenClaw commit and payload version +approved for official signing, plus the independent MSIX package version and +release tag. Updating that +policy requires a reviewed repository change. Official signing runs only from +`main` and verifies the workflow input, policy-approved package version, both architecture metadata files, both MSIX hashes, the embedded manifests, and -every file against the embedded application inventory before requesting Azure -credentials. +every file against the embedded application inventory. It also byte-compares +the bundle's embedded packages with those authorized standalone packages before +requesting Azure credentials. ## Build and test @@ -162,7 +165,8 @@ validation. Manual runs support three signing modes: temporary self-signed certificate plus the public `.cer` needed for local installation; - `official` requires the approved immutable commit from - `release-policy.json` and may run only from `main`. + `release-policy.json`, may run only from `main`, and publishes the signed + packages as permanent assets on a GitHub Release named by the policy. Official signing uses the protected `release-signing` environment, Azure OIDC, and the existing OpenClaw Artifact Signing account and certificate profile. @@ -170,6 +174,24 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. +Official releases use the independent four-part numeric `packageVersion` and +`releaseTag` from `release-policy.json`. The initial signing proof uses package +version `0.0.0.0` and tag `v0.0.0.0`; a later policy change can establish the +long-term Gateway-to-MSIX version mapping. The workflow creates the tag in this +repository and a GitHub Release with generated release notes. Each release +contains a signed, multi-architecture +`OpenClawGateway-.msixbundle` as the recommended download, plus signed +`OpenClawGateway--x64.msix` and +`OpenClawGateway--arm64.msix` packages for architecture-specific +deployment. The duplicate GitHub Actions artifacts remain short-lived transport +and diagnostic copies. + +An `.msixbundle` is a single installable container for the x64 and ARM64 MSIX +packages; Windows selects the package appropriate for the device. An +`.appinstaller` file is separate update-channel metadata rather than an +alternative package format. This repository does not publish one yet, so GitHub +Release installs do not opt devices into automatic update checks. + ### Official signing setup The `release-signing` GitHub environment must define these environment diff --git a/release-policy.json b/release-policy.json index daef22a3..6bc2f234 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,6 +1,8 @@ { "repository": "https://github.com/openclaw/openclaw", - "releaseTag": "v2026.8.2", + "releaseTag": "v0.0.0.0", + "packageVersion": "0.0.0.0", + "payloadPackageVersion": "2026.8.2", "approvedCommit": "0965053fe6b9341776df147a6934b7485c60b5ca", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" } diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index d690f864..6bab046d 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -166,6 +166,7 @@ if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or $payloadInfo.layout -ne 'expanded-directory' -or + [string]::IsNullOrWhiteSpace([string]$payloadInfo.packageVersion) -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or $payloadInfo.resolvedCommit -notmatch '^[0-9a-fA-F]{40}$' @@ -461,6 +462,7 @@ try { payloadRepository = $payloadInfo.repository payloadRequestedRef = $payloadInfo.requestedRef payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() + payloadPackageVersion = [string]$payloadInfo.packageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count architecture = $Architecture diff --git a/scripts/Build-MSIXBundle.ps1 b/scripts/Build-MSIXBundle.ps1 new file mode 100644 index 00000000..a1a9727a --- /dev/null +++ b/scripts/Build-MSIXBundle.ps1 @@ -0,0 +1,126 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$X64Package, + + [Parameter(Mandatory)] + [string]$Arm64Package, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [string]$MakeAppxPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-MakeAppx { + if (-not [string]::IsNullOrWhiteSpace($MakeAppxPath)) { + return (Resolve-Path -LiteralPath $MakeAppxPath).Path + } + + $command = Get-Command ` + MakeAppx.exe ` + -CommandType Application ` + -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $windowsKits = Join-Path ` + ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\bin' + $candidate = Get-ChildItem ` + -LiteralPath $windowsKits ` + -Filter MakeAppx.exe ` + -File ` + -Recurse ` + -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'x64' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $candidate) { + throw 'MakeAppx.exe was not found in PATH or the Windows 10 SDK.' + } + + $candidate.FullName +} + +foreach ($package in @($X64Package, $Arm64Package)) { + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "Required MSIX package was not found: $package" + } + if ([IO.Path]::GetExtension($package) -ine '.msix') { + throw "Bundle input must be an MSIX package: $package" + } +} + +$version = $null +if (-not [version]::TryParse($PackageVersion, [ref]$version)) { + throw "PackageVersion is not a valid four-part version: $PackageVersion" +} +$versionSegments = @($PackageVersion.Split('.')) +if ($versionSegments.Count -ne 4) { + throw 'PackageVersion must contain four numeric components.' +} +foreach ($segment in $versionSegments) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($segment, [ref]$value) -or $value -gt 65534) { + throw "Invalid MSIX bundle version component: $segment" + } +} + +$resolvedX64Package = (Resolve-Path -LiteralPath $X64Package).Path +$resolvedArm64Package = (Resolve-Path -LiteralPath $Arm64Package).Path +if ($resolvedX64Package -eq $resolvedArm64Package) { + throw 'The x64 and ARM64 bundle inputs must be different packages.' +} + +$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath) +if ([IO.Path]::GetExtension($resolvedOutputPath) -ine '.msixbundle') { + throw 'OutputPath must use the .msixbundle extension.' +} +if (Test-Path -LiteralPath $resolvedOutputPath) { + throw "MSIX bundle output already exists: $resolvedOutputPath" +} + +$outputDirectory = Split-Path $resolvedOutputPath -Parent +New-Item -Path $outputDirectory -ItemType Directory -Force | Out-Null +$workRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + "openclaw-msixbundle-$([guid]::NewGuid().ToString('N'))" +$bundleInput = Join-Path $workRoot 'packages' +New-Item -Path $bundleInput -ItemType Directory -Force | Out-Null + +try { + Copy-Item ` + -LiteralPath $resolvedX64Package ` + -Destination (Join-Path $bundleInput 'OpenClawGateway-x64.msix') + Copy-Item ` + -LiteralPath $resolvedArm64Package ` + -Destination (Join-Path $bundleInput 'OpenClawGateway-arm64.msix') + + $resolvedMakeAppx = Resolve-MakeAppx + & $resolvedMakeAppx bundle ` + /v ` + /bv $PackageVersion ` + /d $bundleInput ` + /p $resolvedOutputPath + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx.exe failed to build the MSIX bundle. Exit code: $LASTEXITCODE." + } + if (-not (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf)) { + throw 'MakeAppx.exe completed without producing the requested bundle.' + } + + Write-Host "Unsigned MSIX bundle is ready: $resolvedOutputPath" +} +finally { + if ([IO.Directory]::Exists($workRoot)) { + [IO.Directory]::Delete($workRoot, $true) + } +} diff --git a/scripts/Get-WorkflowPackageVersion.ps1 b/scripts/Get-WorkflowPackageVersion.ps1 index 11376077..9fb31fa4 100644 --- a/scripts/Get-WorkflowPackageVersion.ps1 +++ b/scripts/Get-WorkflowPackageVersion.ps1 @@ -4,7 +4,9 @@ param( [long]$RunNumber, [Parameter(Mandatory)] - [long]$RunAttempt + [long]$RunAttempt, + + [string]$ReleaseVersion ) Set-StrictMode -Version Latest @@ -13,6 +15,36 @@ $ErrorActionPreference = 'Stop' $maximumComponent = 65534L $componentBase = $maximumComponent + 1L +if (-not [string]::IsNullOrWhiteSpace($ReleaseVersion)) { + $releaseMatch = [regex]::Match( + $ReleaseVersion.Trim(), + '^(?\d+)\.(?\d+)\.(?\d+)\.(?\d+)$' + ) + if (-not $releaseMatch.Success) { + throw ( + "ReleaseVersion '$ReleaseVersion' must contain exactly four " + + 'numeric components, such as 0.0.0.0.' + ) + } + + $components = @( + [long]::Parse($releaseMatch.Groups['major'].Value), + [long]::Parse($releaseMatch.Groups['minor'].Value), + [long]::Parse($releaseMatch.Groups['build'].Value), + [long]::Parse($releaseMatch.Groups['revision'].Value) + ) + foreach ($component in $components) { + if ($component -gt $maximumComponent) { + throw ( + "ReleaseVersion '$ReleaseVersion' contains a component greater than " + + "$maximumComponent." + ) + } + } + + return ($components -join '.') +} + if ($RunNumber -lt 1) { throw 'RunNumber must be greater than zero.' } diff --git a/scripts/Test-Build-MSIXBundle.Tests.ps1 b/scripts/Test-Build-MSIXBundle.Tests.ps1 new file mode 100644 index 00000000..4cc51698 --- /dev/null +++ b/scripts/Test-Build-MSIXBundle.Tests.ps1 @@ -0,0 +1,110 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Build-MSIXBundle.ps1' +$testRoot = Join-Path $env:TEMP ` + "openclaw-bundle-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +try { + $x64Package = Join-Path $testRoot 'x64.msix' + $arm64Package = Join-Path $testRoot 'arm64.msix' + Set-Content -LiteralPath $x64Package -Value 'x64' -Encoding ascii + Set-Content -LiteralPath $arm64Package -Value 'arm64' -Encoding ascii + + $fakeMakeAppx = Join-Path $testRoot 'MakeAppx.cmd' + $makeAppxArguments = Join-Path $testRoot 'makeappx-arguments.txt' + $env:OPENCLAW_BUNDLE_TEST_ARGUMENTS = $makeAppxArguments + @' +@echo off +echo %* > "%OPENCLAW_BUNDLE_TEST_ARGUMENTS%" +set output= +:parse +if "%~1"=="" goto done +if /I "%~1"=="/p" ( + set output=%~2 + shift +) +shift +goto parse +:done +if "%output%"=="" exit /b 2 +type nul > "%output%" +'@ | Set-Content -LiteralPath $fakeMakeAppx -Encoding ascii + + $bundle = Join-Path $testRoot 'OpenClawGateway.msixbundle' + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath $bundle ` + -MakeAppxPath $fakeMakeAppx + if (-not (Test-Path -LiteralPath $bundle -PathType Leaf)) { + throw 'The bundle builder did not preserve the MakeAppx output.' + } + $arguments = Get-Content -LiteralPath $makeAppxArguments -Raw + if ($arguments -notmatch '/bv 2026\.9\.4\.0') { + throw "MakeAppx did not receive the expected bundle version: $arguments" + } + + Assert-Fails -MessagePattern 'must be different' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $x64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath (Join-Path $testRoot 'duplicate.msixbundle') ` + -MakeAppxPath $fakeMakeAppx + } + Assert-Fails -MessagePattern 'four numeric components' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4' ` + -OutputPath (Join-Path $testRoot 'bad-version.msixbundle') ` + -MakeAppxPath $fakeMakeAppx + } + Assert-Fails -MessagePattern 'already exists' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath $bundle ` + -MakeAppxPath $fakeMakeAppx + } + + Write-Host 'MSIX bundle build tests passed.' +} +finally { + Remove-Item Env:OPENCLAW_BUNDLE_TEST_ARGUMENTS -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($testRoot)) { + [IO.Directory]::Delete($testRoot, $true) + } +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..4a8f09f2 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -8,6 +8,13 @@ $repositoryRoot = Split-Path $PSScriptRoot -Parent $policyPath = Join-Path $repositoryRoot 'release-policy.json' $policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json $approvedCommit = [string]$policy.approvedCommit +$approvedPackageVersion = & ( + Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +) ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" @@ -24,6 +31,8 @@ function New-TestArtifact { [string]$PayloadCommit = $approvedCommit, + [string]$PayloadPackageVersion = $approvedPayloadVersion, + [bool]$SourceTreeDirty = $false, [bool]$IncludeBundledNode = $false, @@ -110,7 +119,7 @@ function New-TestArtifact { "@ | Set-Content ` @@ -141,13 +150,14 @@ function New-TestArtifact { payloadRepository = $policy.repository payloadRequestedRef = $PayloadCommit payloadResolvedCommit = $PayloadCommit + payloadPackageVersion = $PayloadPackageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count architecture = $Architecture archive = $msixName sha256 = $msixHash signed = $false - packageVersion = '0.1.1.0' + packageVersion = $approvedPackageVersion publisher = $policy.publisher } | ConvertTo-Json | @@ -161,16 +171,86 @@ function Invoke-PolicyValidation { [Parameter(Mandatory)] [string]$Root, - [string]$RequestedRef = $approvedCommit + [string]$RequestedRef = $approvedCommit, + + [switch]$PreserveBundle ) + if (-not $PreserveBundle) { + New-TestBundle -Root $Root + } + & (Join-Path $PSScriptRoot 'Test-SigningInputs.ps1') ` -ArtifactsDirectory $Root ` -PolicyPath $policyPath ` + -BundlePath (Join-Path $Root 'bundle\OpenClawGateway.msixbundle') ` -RequestedRef $RequestedRef ` -PackagingCommit $packagingCommit } +function New-TestBundle { + param( + [Parameter(Mandatory)] + [string]$Root, + + [string]$X64Package = (Join-Path ` + $Root ` + 'x64\OpenClawGateway-x64.msix') + ) + + $bundleDirectory = Join-Path $Root 'bundle' + $bundleStaging = Join-Path $Root '.bundle-package' + Remove-Item ` + -LiteralPath $bundleDirectory, $bundleStaging ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + $bundleMetadata = Join-Path $bundleStaging 'AppxMetadata' + New-Item ` + -Path $bundleDirectory, $bundleMetadata ` + -ItemType Directory ` + -Force | + Out-Null + + Copy-Item ` + -LiteralPath $X64Package ` + -Destination (Join-Path $bundleStaging 'OpenClawGateway-x64.msix') + Copy-Item ` + -LiteralPath (Join-Path ` + $Root ` + 'arm64\OpenClawGateway-arm64.msix') ` + -Destination (Join-Path $bundleStaging 'OpenClawGateway-arm64.msix') + + @" + + + + + + + + +"@ | Set-Content ` + -LiteralPath (Join-Path ` + $bundleMetadata ` + 'AppxBundleManifest.xml') ` + -Encoding utf8 + + [IO.Compression.ZipFile]::CreateFromDirectory( + $bundleStaging, + (Join-Path $bundleDirectory 'OpenClawGateway.msixbundle') + ) + Remove-Item -LiteralPath $bundleStaging -Recurse -Force +} + function Assert-Fails { param( [Parameter(Mandatory)] @@ -275,6 +355,19 @@ try { Invoke-PolicyValidation -Root $testRoot } + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact ` + -Root $testRoot ` + -Architecture x64 ` + -PayloadPackageVersion '2026.9.4' + New-TestArtifact -Root $testRoot -Architecture arm64 + Assert-Fails ` + -MessagePattern 'metadata is not eligible' ` + -Action { + Invoke-PolicyValidation -Root $testRoot + } + Remove-Item -LiteralPath $testRoot -Recurse -Force New-Item -Path $testRoot -ItemType Directory | Out-Null New-TestArtifact -Root $testRoot -Architecture x64 @@ -452,6 +545,21 @@ try { Invoke-PolicyValidation -Root $testRoot } + Reset-TestArtifacts + $substitutedX64 = Join-Path $testRoot 'substituted-x64.msix' + Copy-Item ` + -LiteralPath (Join-Path ` + $testRoot ` + 'x64\OpenClawGateway-x64.msix') ` + -Destination $substitutedX64 + Add-Content -LiteralPath $substitutedX64 -Value 'substituted' + New-TestBundle -Root $testRoot -X64Package $substitutedX64 + Assert-Fails ` + -MessagePattern 'does not match the authorized standalone package' ` + -Action { + Invoke-PolicyValidation -Root $testRoot -PreserveBundle + } + Write-Host 'Gateway MSIX signing policy tests passed.' } finally { diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9b9cd52a..3ca2f047 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -6,6 +6,9 @@ param( [Parameter(Mandatory)] [string]$PolicyPath, + [Parameter(Mandatory)] + [string]$BundlePath, + [Parameter(Mandatory)] [string]$RequestedRef, @@ -117,12 +120,23 @@ $policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | if ( $policy.repository -ne 'https://github.com/openclaw/openclaw' -or + [string]::IsNullOrWhiteSpace([string]$policy.releaseTag) -or + $policy.packageVersion -notmatch '^\d+\.\d+\.\d+\.\d+$' -or + $policy.releaseTag -ne "v$($policy.packageVersion)" -or + [string]::IsNullOrWhiteSpace([string]$policy.payloadPackageVersion) -or $policy.approvedCommit -notmatch '^[0-9a-fA-F]{40}$' -or [string]::IsNullOrWhiteSpace([string]$policy.publisher) ) { throw 'The Gateway MSIX release policy is invalid.' } +$approvedPackageVersion = & ( + Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +) ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() if ( @@ -137,6 +151,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture $metadataPath = Join-Path $directory 'msix-metadata.json' @@ -163,6 +178,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.payloadRepository -ne $policy.repository -or $metadata.payloadRequestedRef -ine $approvedCommit -or $metadata.payloadResolvedCommit -ine $approvedCommit -or + $metadata.payloadPackageVersion -ne $approvedPayloadVersion -or $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or @@ -170,6 +186,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or $metadata.signed -ne $false -or + $metadata.packageVersion -ne $approvedPackageVersion -or $metadata.publisher -ne $policy.publisher ) { throw "The $architecture MSIX metadata is not eligible for signing." @@ -188,6 +205,11 @@ foreach ($architecture in @('x64', 'arm64')) { if ($actualMsixHash -ne ([string]$metadata.sha256).ToLowerInvariant()) { throw "The $architecture MSIX hash does not match its metadata." } + $expectedPackages[$architecture] = @{ + Name = $msix.Name + Path = $msix.FullName + Sha256 = $actualMsixHash + } $packageArchive = [IO.Compression.ZipFile]::OpenRead($msix.FullName) try { @@ -321,6 +343,83 @@ foreach ($architecture in @('x64', 'arm64')) { } } +$resolvedBundlePath = (Resolve-Path -LiteralPath $BundlePath).Path +if ([IO.Path]::GetExtension($resolvedBundlePath) -ine '.msixbundle') { + throw 'The official signing bundle must use the .msixbundle extension.' +} + +$bundleArchive = [IO.Compression.ZipFile]::OpenRead($resolvedBundlePath) +try { + $bundleEntries = New-PackageEntryIndex -Archive $bundleArchive + [xml]$bundleManifest = Read-ZipEntryText ` + -EntriesByPath $bundleEntries ` + -Path 'AppxMetadata/AppxBundleManifest.xml' + $bundleIdentity = $bundleManifest.SelectSingleNode( + "/*[local-name()='Bundle']/*[local-name()='Identity']" + ) + if ( + $null -eq $bundleIdentity -or + $bundleIdentity.Name -ne 'OpenClaw.Gateway' -or + $bundleIdentity.Publisher -ne $policy.publisher -or + $bundleIdentity.Version -ne $expectedPackageVersion + ) { + throw 'The MSIX bundle manifest identity is unexpected.' + } + + $bundlePackages = @( + $bundleManifest.SelectNodes( + "/*[local-name()='Bundle']/*[local-name()='Packages']/*[local-name()='Package']" + ) + ) + if ($bundlePackages.Count -ne 2) { + throw 'The MSIX bundle must contain exactly two application packages.' + } + + $seenArchitectures = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($bundlePackage in $bundlePackages) { + $architecture = [string]$bundlePackage.Architecture + if ( + -not $expectedPackages.ContainsKey($architecture) -or + -not $seenArchitectures.Add($architecture) -or + $bundlePackage.Type -ne 'application' -or + $bundlePackage.Version -ne $expectedPackageVersion + ) { + throw 'The MSIX bundle package manifest is unexpected.' + } + + $expectedPackage = $expectedPackages[$architecture] + $fileName = [string]$bundlePackage.FileName + if ($fileName -ne $expectedPackage.Name) { + throw "The bundled $architecture MSIX filename is unexpected." + } + + $bundleEntry = Get-PackageEntry ` + -EntriesByPath $bundleEntries ` + -Path $fileName + $bundledHash = Get-PackageEntrySha256 -Entry $bundleEntry + if ($bundledHash -ne $expectedPackage.Sha256) { + throw ( + "The bundled $architecture MSIX does not match the " + + 'authorized standalone package.' + ) + } + } + + $embeddedMsixEntries = @( + $bundleEntries.Keys | + Where-Object { [IO.Path]::GetExtension($_) -ieq '.msix' } + ) + if ($embeddedMsixEntries.Count -ne 2) { + throw 'The MSIX bundle contains an unexpected package file set.' + } +} +finally { + $bundleArchive.Dispose() +} + Write-Host ( "Authorized official signing for OpenClaw commit $approvedCommit " + "and Gateway MSIX version $expectedPackageVersion." diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index 36091276..d9393762 100644 --- a/scripts/Test-WorkflowPackageVersion.Tests.ps1 +++ b/scripts/Test-WorkflowPackageVersion.Tests.ps1 @@ -54,6 +54,24 @@ function Assert-Fails { throw "Expected failure matching '$MessagePattern', but the action succeeded." } +function Assert-ReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$ReleaseVersion, + + [Parameter(Mandatory)] + [string]$Expected + ) + + $actual = & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion $ReleaseVersion + if ($actual -ne $Expected) { + throw "Expected $ReleaseVersion to produce $Expected; received $actual." + } +} + Assert-Version -RunNumber 1 -RunAttempt 1 -Expected '0.1.1.1' Assert-Version -RunNumber 65534 -RunAttempt 1 -Expected '0.1.65534.1' Assert-Version -RunNumber 65535 -RunAttempt 1 -Expected '0.2.0.1' @@ -63,6 +81,22 @@ Assert-Version -RunNumber 131069 -RunAttempt 1 -Expected '0.2.65534.1' Assert-Version -RunNumber 131070 -RunAttempt 1 -Expected '0.3.0.1' Assert-Version -RunNumber 1 -RunAttempt 65534 -Expected '0.1.1.65534' +Assert-ReleaseVersion -ReleaseVersion '0.0.0.0' -Expected '0.0.0.0' +Assert-ReleaseVersion -ReleaseVersion '2026.9.4.0' -Expected '2026.9.4.0' + +Assert-Fails -MessagePattern 'exactly four numeric components' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion '2026.9.1-beta.1' +} +Assert-Fails -MessagePattern 'greater than 65534' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion '2026.9.4.65535' +} + $maximumRunNumber = (65534L * 65535L) - 1L Assert-Version ` -RunNumber $maximumRunNumber ` diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index b47146f9..d1f553d4 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -18,10 +18,24 @@ $requiredFragments = @( 'tenant-id: ${{ vars.AZURE_TENANT_ID }}' 'subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}' 'uses: azure/artifact-signing-action@v2' + 'name: Compose unsigned multi-architecture MSIX bundle' + 'name: Upload unsigned multi-architecture MSIX bundle' + '-BundlePath artifacts\bundle\OpenClawGateway.msixbundle' 'files-folder-recurse: true' + 'files: ${{ github.workspace }}\artifacts\bundle\OpenClawGateway.msixbundle' + 'name: Upload signed multi-architecture MSIX bundle' 'endpoint: https://eus.codesigning.azure.net/' 'signing-account-name: openclaw' 'certificate-profile-name: openclaw' + 'name: Publish signed Gateway MSIX release' + 'contents: write' + 'uses: softprops/action-gh-release@v3' + 'tag_name: ${{ needs.authorize-signing.outputs.release_tag }}' + 'target_commitish: ${{ github.sha }}' + 'generate_release_notes: true' + 'overwrite_files: false' + 'fail_on_unmatched_files: true' + 'release-assets/*.msixbundle' ) foreach ($fragment in $requiredFragments) {