From b604c41999b73fac859eb5abb7ba5c0f3e249749 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 11 Sep 2026 18:09:03 -0700 Subject: [PATCH 1/4] feat(ci): publish durable signed MSIX releases --- .github/copilot-instructions.md | 4 +- .github/workflows/gateway-msix.yml | 90 ++++++++++++++++++- README.md | 23 ++++- scripts/Build-MSIX.ps1 | 2 + scripts/Get-WorkflowPackageVersion.ps1 | 39 +++++++- scripts/Test-SigningInputs.Tests.ps1 | 27 +++++- scripts/Test-SigningInputs.ps1 | 10 +++ scripts/Test-WorkflowPackageVersion.Tests.ps1 | 34 +++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 6 ++ 9 files changed, 226 insertions(+), 9 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3790db31..a39f613c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,7 +125,9 @@ and ARM64 separately. 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..b17e02f4 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -259,10 +259,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.ReleaseTag = [string]$policy.releaseTag + } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` - -RunNumber '${{ github.run_number }}' ` - -RunAttempt '${{ github.run_attempt }}' + @versionParameters .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` @@ -348,6 +358,10 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} needs: build-msix 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 +383,22 @@ jobs: name: openclaw-gateway-msix-unsigned-arm64 path: artifacts\arm64 + - 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 }}' ` + -ReleaseTag $releaseTag + + "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: @@ -475,3 +505,59 @@ jobs: path: artifacts\arm64\ 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: 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" + + - 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 + 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 + - **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..9c1e3583 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ 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 +official signing and its corresponding stable Gateway tag. Updating that +policy requires a reviewed repository change. Official signing runs only from +`main` and verifies the workflow input, release-derived package version, both architecture metadata files, both MSIX hashes, the embedded manifests, and every file against the embedded application inventory before requesting Azure credentials. @@ -162,7 +163,9 @@ 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 for the approved + Gateway tag. Official signing uses the protected `release-signing` environment, Azure OIDC, and the existing OpenClaw Artifact Signing account and certificate profile. @@ -170,6 +173,20 @@ 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 Gateway tag from `release-policy.json` for the +GitHub Release tag and MSIX identity version. Stable tags map directly to a +four-part Windows version, and numbered corrections use the fourth component: + +- `v2026.9.4` becomes `2026.9.4.0`; +- `v2026.7.1-2` becomes `2026.7.1.2`. + +Prerelease tags such as `v2026.9.1-beta.1` are not eligible for official MSIX +publication because mapping them directly would make Windows upgrade ordering +incorrect when the stable package is installed. The release contains durable +`OpenClawGateway--x64.msix` and +`OpenClawGateway--arm64.msix` assets. The duplicate GitHub Actions +artifacts remain short-lived transport and diagnostic copies. + ### Official signing setup The `release-signing` GitHub environment must define these environment 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/Get-WorkflowPackageVersion.ps1 b/scripts/Get-WorkflowPackageVersion.ps1 index 11376077..b961d452 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]$ReleaseTag ) Set-StrictMode -Version Latest @@ -13,6 +15,41 @@ $ErrorActionPreference = 'Stop' $maximumComponent = 65534L $componentBase = $maximumComponent + 1L +if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { + $releaseMatch = [regex]::Match( + $ReleaseTag.Trim(), + '^v(?\d+)\.(?\d+)\.(?\d+)(?:-(?[1-9]\d*))?$' + ) + if (-not $releaseMatch.Success) { + throw ( + "ReleaseTag '$ReleaseTag' must be a stable Gateway tag such as " + + 'v2026.9.4 or a correction tag such as v2026.7.1-2.' + ) + } + + $components = @( + [long]::Parse($releaseMatch.Groups['major'].Value), + [long]::Parse($releaseMatch.Groups['minor'].Value), + [long]::Parse($releaseMatch.Groups['build'].Value), + $(if ($releaseMatch.Groups['revision'].Success) { + [long]::Parse($releaseMatch.Groups['revision'].Value) + } + else { + 0L + }) + ) + foreach ($component in $components) { + if ($component -gt $maximumComponent) { + throw ( + "ReleaseTag '$ReleaseTag' 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-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..b93895bb 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 ` + -ReleaseTag ([string]$policy.releaseTag) +$approvedPayloadVersion = ([string]$policy.releaseTag).Substring(1) -replace '-\d+$', '' $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 | @@ -275,6 +285,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 diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9b9cd52a..64366646 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -117,12 +117,20 @@ $policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | if ( $policy.repository -ne 'https://github.com/openclaw/openclaw' -or + [string]::IsNullOrWhiteSpace([string]$policy.releaseTag) -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 ` + -ReleaseTag ([string]$policy.releaseTag) +$approvedPayloadVersion = ([string]$policy.releaseTag).Substring(1) -replace '-\d+$', '' $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() if ( @@ -163,6 +171,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 +179,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." diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index 36091276..1262a61d 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]$ReleaseTag, + + [Parameter(Mandatory)] + [string]$Expected + ) + + $actual = & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseTag $ReleaseTag + if ($actual -ne $Expected) { + throw "Expected $ReleaseTag 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 -ReleaseTag 'v2026.9.4' -Expected '2026.9.4.0' +Assert-ReleaseVersion -ReleaseTag 'v2026.7.1-2' -Expected '2026.7.1.2' + +Assert-Fails -MessagePattern 'must be a stable Gateway tag' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseTag 'v2026.9.1-beta.1' +} +Assert-Fails -MessagePattern 'greater than 65534' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseTag 'v2026.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..a2a7fcd7 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -22,6 +22,12 @@ $requiredFragments = @( '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 }}' + 'overwrite_files: false' + 'fail_on_unmatched_files: true' ) foreach ($fragment in $requiredFragments) { From 212c395cf22d7fd3627cf88b050ad78810408802 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 11 Sep 2026 18:43:42 -0700 Subject: [PATCH 2/4] feat(ci): publish multi-architecture MSIX bundle --- .github/copilot-instructions.md | 4 + .github/workflows/gateway-msix.yml | 117 +++++++++++++++- README.md | 16 ++- scripts/Build-MSIXBundle.ps1 | 126 ++++++++++++++++++ scripts/Test-Build-MSIXBundle.Tests.ps1 | 110 +++++++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 7 + 6 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 scripts/Build-MSIXBundle.ps1 create mode 100644 scripts/Test-Build-MSIXBundle.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a39f613c..851251ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -120,6 +120,10 @@ 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. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index b17e02f4..93cff8c5 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: > @@ -340,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.ReleaseTag = [string]$policy.releaseTag + } + $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' }} @@ -416,6 +474,7 @@ jobs: if: ${{ needs.authorize-signing.result == 'success' }} needs: - build-msix + - build-msix-bundle - authorize-signing runs-on: windows-latest environment: release-signing @@ -436,6 +495,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: @@ -443,7 +508,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/ @@ -457,6 +522,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: | @@ -490,6 +566,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: @@ -506,6 +600,14 @@ jobs: 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' }} @@ -528,6 +630,12 @@ jobs: 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: @@ -539,6 +647,8 @@ jobs: "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 @@ -551,12 +661,15 @@ jobs: make_latest: true overwrite_files: false fail_on_unmatched_files: true - files: release-assets/*.msix + 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` diff --git a/README.md b/README.md index 9c1e3583..7616e133 100644 --- a/README.md +++ b/README.md @@ -182,10 +182,20 @@ four-part Windows version, and numbered corrections use the fourth component: Prerelease tags such as `v2026.9.1-beta.1` are not eligible for official MSIX publication because mapping them directly would make Windows upgrade ordering -incorrect when the stable package is installed. The release contains durable +incorrect when the stable package is installed. The workflow creates the +Gateway 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` assets. The duplicate GitHub Actions -artifacts remain short-lived transport and diagnostic copies. +`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 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/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-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index a2a7fcd7..0551438f 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -18,7 +18,11 @@ $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' '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' @@ -26,8 +30,11 @@ $requiredFragments = @( '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) { From 1e6d6147fed5c7880eac68c4f7878c8c4717495b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 11 Sep 2026 18:50:45 -0700 Subject: [PATCH 3/4] fix(ci): authorize exact MSIX bundle contents --- .github/workflows/gateway-msix.yml | 11 ++- README.md | 5 +- scripts/Test-SigningInputs.Tests.ps1 | 87 ++++++++++++++++++- scripts/Test-SigningInputs.ps1 | 86 ++++++++++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 1 + 5 files changed, 186 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 93cff8c5..3701ae89 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -414,7 +414,9 @@ 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 }} @@ -441,6 +443,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: Resolve official release metadata id: release shell: pwsh @@ -466,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 diff --git a/README.md b/README.md index 7616e133..e602ac33 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,9 @@ official signing and its corresponding stable Gateway tag. Updating that policy requires a reviewed repository change. Official signing runs only from `main` and verifies the workflow input, release-derived 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 diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index b93895bb..03077a36 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -171,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)] @@ -475,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 64366646..96863ef6 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, @@ -145,6 +148,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' @@ -198,6 +202,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 { @@ -331,6 +340,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-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 0551438f..d1f553d4 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -20,6 +20,7 @@ $requiredFragments = @( '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' From 97a4922c03b620ae52c3f92ec86345abb12a2e2d Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 12 Sep 2026 00:32:04 -0700 Subject: [PATCH 4/4] fix(ci): use explicit zero-version signing proof --- .github/workflows/gateway-msix.yml | 6 ++-- README.md | 28 ++++++++----------- release-policy.json | 4 ++- scripts/Get-WorkflowPackageVersion.ps1 | 21 ++++++-------- scripts/Test-SigningInputs.Tests.ps1 | 4 +-- scripts/Test-SigningInputs.ps1 | 7 +++-- scripts/Test-WorkflowPackageVersion.Tests.ps1 | 16 +++++------ 7 files changed, 40 insertions(+), 46 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 3701ae89..3b6e1760 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -274,7 +274,7 @@ jobs: if ($env:SIGNING_MODE -eq 'official') { $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json - $versionParameters.ReleaseTag = [string]$policy.releaseTag + $versionParameters.ReleaseVersion = [string]$policy.packageVersion } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` @versionParameters @@ -379,7 +379,7 @@ jobs: if ($env:SIGNING_MODE -eq 'official') { $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json - $versionParameters.ReleaseTag = [string]$policy.releaseTag + $versionParameters.ReleaseVersion = [string]$policy.packageVersion } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` @versionParameters @@ -459,7 +459,7 @@ jobs: $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` -RunNumber '${{ github.run_number }}' ` -RunAttempt '${{ github.run_attempt }}' ` - -ReleaseTag $releaseTag + -ReleaseVersion ([string]$policy.packageVersion) "package_version=$packageVersion" >> $env:GITHUB_OUTPUT "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT diff --git a/README.md b/README.md index e602ac33..20205914 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,11 @@ 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 and its corresponding stable Gateway tag. Updating that +`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, release-derived package version, both +`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. It also byte-compares the bundle's embedded packages with those authorized standalone packages before @@ -165,8 +166,7 @@ validation. Manual runs support three signing modes: installation; - `official` requires the approved immutable commit from `release-policy.json`, may run only from `main`, and publishes the signed - packages as permanent assets on a GitHub Release named for the approved - Gateway tag. + 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. @@ -174,18 +174,12 @@ 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 Gateway tag from `release-policy.json` for the -GitHub Release tag and MSIX identity version. Stable tags map directly to a -four-part Windows version, and numbered corrections use the fourth component: - -- `v2026.9.4` becomes `2026.9.4.0`; -- `v2026.7.1-2` becomes `2026.7.1.2`. - -Prerelease tags such as `v2026.9.1-beta.1` are not eligible for official MSIX -publication because mapping them directly would make Windows upgrade ordering -incorrect when the stable package is installed. The workflow creates the -Gateway tag in this repository and a GitHub Release with generated release -notes. Each release contains a signed, multi-architecture +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 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/Get-WorkflowPackageVersion.ps1 b/scripts/Get-WorkflowPackageVersion.ps1 index b961d452..9fb31fa4 100644 --- a/scripts/Get-WorkflowPackageVersion.ps1 +++ b/scripts/Get-WorkflowPackageVersion.ps1 @@ -6,7 +6,7 @@ param( [Parameter(Mandatory)] [long]$RunAttempt, - [string]$ReleaseTag + [string]$ReleaseVersion ) Set-StrictMode -Version Latest @@ -15,15 +15,15 @@ $ErrorActionPreference = 'Stop' $maximumComponent = 65534L $componentBase = $maximumComponent + 1L -if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { +if (-not [string]::IsNullOrWhiteSpace($ReleaseVersion)) { $releaseMatch = [regex]::Match( - $ReleaseTag.Trim(), - '^v(?\d+)\.(?\d+)\.(?\d+)(?:-(?[1-9]\d*))?$' + $ReleaseVersion.Trim(), + '^(?\d+)\.(?\d+)\.(?\d+)\.(?\d+)$' ) if (-not $releaseMatch.Success) { throw ( - "ReleaseTag '$ReleaseTag' must be a stable Gateway tag such as " + - 'v2026.9.4 or a correction tag such as v2026.7.1-2.' + "ReleaseVersion '$ReleaseVersion' must contain exactly four " + + 'numeric components, such as 0.0.0.0.' ) } @@ -31,17 +31,12 @@ if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { [long]::Parse($releaseMatch.Groups['major'].Value), [long]::Parse($releaseMatch.Groups['minor'].Value), [long]::Parse($releaseMatch.Groups['build'].Value), - $(if ($releaseMatch.Groups['revision'].Success) { - [long]::Parse($releaseMatch.Groups['revision'].Value) - } - else { - 0L - }) + [long]::Parse($releaseMatch.Groups['revision'].Value) ) foreach ($component in $components) { if ($component -gt $maximumComponent) { throw ( - "ReleaseTag '$ReleaseTag' contains a component greater than " + + "ReleaseVersion '$ReleaseVersion' contains a component greater than " + "$maximumComponent." ) } diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 03077a36..4a8f09f2 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -13,8 +13,8 @@ $approvedPackageVersion = & ( ) ` -RunNumber 1 ` -RunAttempt 1 ` - -ReleaseTag ([string]$policy.releaseTag) -$approvedPayloadVersion = ([string]$policy.releaseTag).Substring(1) -replace '-\d+$', '' + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 96863ef6..3ca2f047 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -121,6 +121,9 @@ $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) ) { @@ -132,8 +135,8 @@ $approvedPackageVersion = & ( ) ` -RunNumber 1 ` -RunAttempt 1 ` - -ReleaseTag ([string]$policy.releaseTag) -$approvedPayloadVersion = ([string]$policy.releaseTag).Substring(1) -replace '-\d+$', '' + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() if ( diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index 1262a61d..d9393762 100644 --- a/scripts/Test-WorkflowPackageVersion.Tests.ps1 +++ b/scripts/Test-WorkflowPackageVersion.Tests.ps1 @@ -57,7 +57,7 @@ function Assert-Fails { function Assert-ReleaseVersion { param( [Parameter(Mandatory)] - [string]$ReleaseTag, + [string]$ReleaseVersion, [Parameter(Mandatory)] [string]$Expected @@ -66,9 +66,9 @@ function Assert-ReleaseVersion { $actual = & $scriptPath ` -RunNumber 1 ` -RunAttempt 1 ` - -ReleaseTag $ReleaseTag + -ReleaseVersion $ReleaseVersion if ($actual -ne $Expected) { - throw "Expected $ReleaseTag to produce $Expected; received $actual." + throw "Expected $ReleaseVersion to produce $Expected; received $actual." } } @@ -81,20 +81,20 @@ 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 -ReleaseTag 'v2026.9.4' -Expected '2026.9.4.0' -Assert-ReleaseVersion -ReleaseTag 'v2026.7.1-2' -Expected '2026.7.1.2' +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 'must be a stable Gateway tag' -Action { +Assert-Fails -MessagePattern 'exactly four numeric components' -Action { & $scriptPath ` -RunNumber 1 ` -RunAttempt 1 ` - -ReleaseTag 'v2026.9.1-beta.1' + -ReleaseVersion '2026.9.1-beta.1' } Assert-Fails -MessagePattern 'greater than 65534' -Action { & $scriptPath ` -RunNumber 1 ` -RunAttempt 1 ` - -ReleaseTag 'v2026.9.4-65535' + -ReleaseVersion '2026.9.4.65535' } $maximumRunNumber = (65534L * 65535L) - 1L