From f40b678b4cc68519301f3aa1fff7a1d06153480b Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Tue, 15 Sep 2026 00:02:56 -0700 Subject: [PATCH 01/10] feat: follow upstream extended stable for MSIX builds Resolve the public channel to a verified immutable source snapshot, carry its identity through packaging, and authorize official releases against the channel policy. Preserve snapshots on retries, derive release versions, reject duplicate or older publications, and update contributor documentation and coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 103 +++- CONTRIBUTING.md | 16 + scripts/Build-MSIX.ps1 | 14 + scripts/Build-Payload.ps1 | 13 + scripts/Get-WorkflowSource.ps1 | 96 +++ scripts/OpenClawSource.ps1 | 428 +++++++++++++ scripts/Test-NodeRuntimeInputs.Tests.ps1 | 21 + scripts/Test-OfficialReleaseVersion.ps1 | 38 ++ scripts/Test-OpenClawSource.Tests.ps1 | 583 ++++++++++++++++++ scripts/Test-SigningInputs.Tests.ps1 | 91 ++- scripts/Test-SigningInputs.ps1 | 36 +- scripts/Test-WorkflowPackageVersion.Tests.ps1 | 16 + scripts/Test-WorkflowSigningConfiguration.ps1 | 31 + scripts/Test-WorkflowSource.Tests.ps1 | 159 +++++ 14 files changed, 1629 insertions(+), 16 deletions(-) create mode 100644 scripts/Get-WorkflowSource.ps1 create mode 100644 scripts/OpenClawSource.ps1 create mode 100644 scripts/Test-OfficialReleaseVersion.ps1 create mode 100644 scripts/Test-OpenClawSource.Tests.ps1 create mode 100644 scripts/Test-WorkflowSource.Tests.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 7598d91a..286c88be 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -30,6 +30,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: gateway-msix-${{ inputs.signing_mode == 'official' && 'official' || github.run_id }} + cancel-in-progress: false + env: OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '3a9d69db306cd7f081e06254cb89c4bcc14a7107' }} PACKAGING_ROOT: . @@ -286,6 +290,12 @@ jobs: ./scripts/Test-OpenClawPackage.ps1 ` $env:RUNNER_TEMP/Test-OpenClawPackage.ps1 + - name: Download immutable source snapshot + uses: actions/download-artifact@v8 + with: + name: openclaw-source-resolution + path: ${{ runner.temp }}/openclaw-source + - name: Check out OpenClaw source if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} uses: actions/checkout@v7 @@ -339,7 +349,15 @@ jobs: "nodeVersion": "${node_version}", "packageSha256": "${package_sha256}" } - EOF + $source = Get-Content -LiteralPath $snapshotPath -Raw | ConvertFrom-Json + if ($source.resolvedCommit -cne $env:BUILT_SHA -or + $source.packageVersion -cne $env:BUILT_VERSION -or + $env:NODE_VERSION -notmatch '^\d+\.\d+\.\d+$') { + throw 'The source build does not match the resolved OpenClaw identity.' + } + $source | Add-Member -NotePropertyName nodeVersion -NotePropertyValue $env:NODE_VERSION + $source | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath (Join-Path $env:RUNNER_TEMP 'openclaw-package/source.json') -Encoding utf8 - name: Verify OpenClaw package id: package @@ -369,6 +387,7 @@ jobs: with: name: openclaw-gateway-npm-package path: ${{ runner.temp }}/openclaw-package/ + overwrite: true if-no-files-found: error retention-days: 7 @@ -447,6 +466,7 @@ jobs: with: name: openclaw-gateway-payload-${{ matrix.architecture }} path: ${{ runner.temp }}\openclaw-payload\ + overwrite: true if-no-files-found: error retention-days: ${{ github.event_name == 'pull_request' && 1 || 7 }} @@ -499,7 +519,7 @@ jobs: .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` -Architecture '${{ matrix.architecture }}' ` - -PackageVersion $packageVersion ` + -PackageVersion $env:PACKAGE_VERSION ` -SourceCommit '${{ github.sha }}' ` -OutputDirectory '${{ runner.temp }}\openclaw-msix' @@ -507,6 +527,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: openclaw-gateway-msix-unsigned-${{ matrix.architecture }} + overwrite: true path: | ${{ runner.temp }}\openclaw-msix\OpenClawGateway-${{ matrix.architecture }}.msix ${{ runner.temp }}\openclaw-msix\msix-metadata.json @@ -551,6 +572,7 @@ jobs: with: name: openclaw-gateway-msix-test-signed-x64 path: test-signed\x64\ + overwrite: true if-no-files-found: error retention-days: 7 @@ -559,6 +581,7 @@ jobs: with: name: openclaw-gateway-msix-test-signed-arm64 path: test-signed\arm64\ + overwrite: true if-no-files-found: error retention-days: 7 @@ -613,7 +636,7 @@ jobs: .\scripts\Build-MSIXBundle.ps1 ` -X64Package artifacts\x64\OpenClawGateway-x64.msix ` -Arm64Package artifacts\arm64\OpenClawGateway-arm64.msix ` - -PackageVersion $packageVersion ` + -PackageVersion $env:PACKAGE_VERSION ` -OutputPath artifacts\bundle\OpenClawGateway.msixbundle - name: Upload unsigned multi-architecture MSIX bundle @@ -621,6 +644,7 @@ jobs: with: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle\OpenClawGateway.msixbundle + overwrite: true if-no-files-found: error retention-days: 7 @@ -721,6 +745,7 @@ jobs: name: Authorize official Gateway MSIX signing if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} needs: + - resolve-source - build-msix - build-msix-bundle runs-on: windows-latest @@ -755,9 +780,18 @@ jobs: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle + - name: Download immutable source snapshot + uses: actions/download-artifact@v8 + with: + name: openclaw-source-resolution + path: artifacts\source + - name: Resolve official release metadata id: release shell: pwsh + env: + PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }} + RELEASE_TAG: ${{ needs.resolve-source.outputs.release_tag }} run: | $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json @@ -776,16 +810,27 @@ jobs: - name: Enforce official signing policy shell: pwsh env: - OPENCLAW_REF: ${{ inputs.openclaw_ref }} PACKAGING_COMMIT: ${{ github.sha }} + SNAPSHOT_SHA256: ${{ needs.resolve-source.outputs.snapshot_sha256 }} run: | .\scripts\Test-SigningInputs.ps1 ` -ArtifactsDirectory artifacts ` -PolicyPath .\release-policy.json ` -BundlePath artifacts\bundle\OpenClawGateway.msixbundle ` - -RequestedRef $env:OPENCLAW_REF ` + -SourceResolutionPath artifacts\source\source-resolution.json ` + -SourceResolutionSha256 $env:SNAPSHOT_SHA256 ` + -WorkflowRunId $env:GITHUB_RUN_ID ` -PackagingCommit $env:PACKAGING_COMMIT + - name: Reject duplicate or older official releases + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }} + run: | + .\scripts\Test-OfficialReleaseVersion.ps1 ` + -PackageVersion $env:PACKAGE_VERSION + sign-msix: name: Officially sign Gateway MSIX packages if: ${{ needs.authorize-signing.result == 'success' }} @@ -800,6 +845,11 @@ jobs: contents: read id-token: write steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download unsigned x64 package uses: actions/download-artifact@v8 with: @@ -818,6 +868,15 @@ jobs: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle + - name: Recheck official release version before signing + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_VERSION: ${{ needs.authorize-signing.outputs.package_version }} + run: | + .\scripts\Test-OfficialReleaseVersion.ps1 ` + -PackageVersion $env:PACKAGE_VERSION + - name: Azure login uses: azure/login@v3 with: @@ -906,6 +965,7 @@ jobs: with: name: openclaw-gateway-msix-x64 path: artifacts\x64\ + overwrite: true if-no-files-found: error retention-days: 7 @@ -914,6 +974,7 @@ jobs: with: name: openclaw-gateway-msix-arm64 path: artifacts\arm64\ + overwrite: true if-no-files-found: error retention-days: 7 @@ -922,6 +983,7 @@ jobs: with: name: openclaw-gateway-msix-bundle path: artifacts\bundle\OpenClawGateway.msixbundle + overwrite: true if-no-files-found: error retention-days: 7 @@ -929,12 +991,18 @@ jobs: name: Publish signed Gateway MSIX release if: ${{ needs.sign-msix.result == 'success' }} needs: + - resolve-source - authorize-signing - sign-msix runs-on: ubuntu-latest permissions: contents: write steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download signed x64 package uses: actions/download-artifact@v8 with: @@ -953,6 +1021,12 @@ jobs: name: openclaw-gateway-msix-bundle path: signed/bundle + - name: Download immutable source snapshot + uses: actions/download-artifact@v8 + with: + name: openclaw-source-resolution + path: signed/source + - name: Stage versioned release assets shell: bash env: @@ -966,6 +1040,18 @@ jobs: "release-assets/OpenClawGateway-${RELEASE_VERSION}-arm64.msix" cp signed/bundle/OpenClawGateway.msixbundle \ "release-assets/OpenClawGateway-${RELEASE_VERSION}.msixbundle" + cp signed/source/source-resolution.json release-assets/source-resolution.json + cp signed/x64/msix-metadata.json release-assets/msix-metadata-x64.json + cp signed/arm64/msix-metadata.json release-assets/msix-metadata-arm64.json + + - name: Recheck official release version before publication + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_VERSION: ${{ needs.authorize-signing.outputs.package_version }} + run: | + ./scripts/Test-OfficialReleaseVersion.ps1 ` + -PackageVersion $env:PACKAGE_VERSION - name: Create permanent GitHub release uses: softprops/action-gh-release@v3 @@ -981,9 +1067,12 @@ jobs: files: | release-assets/*.msix release-assets/*.msixbundle + release-assets/*.json 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 }}). + Packages OpenClaw `${{ needs.resolve-source.outputs.source_version }}` selected + from the upstream `extended-stable` channel for this workflow run, + from [`openclaw/openclaw@${{ needs.resolve-source.outputs.source_sha }}`](https://github.com/openclaw/openclaw/commit/${{ needs.resolve-source.outputs.source_sha }}). + The source snapshot and architecture metadata are included as release assets. ### Downloads - **Recommended:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}.msixbundle` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128f5a20..1d489e21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,12 +56,28 @@ or package version logic: .\scripts\Test-OpenClawPackage.Tests.ps1 .\scripts\Test-MSIXReleaseIdentity.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 +.\scripts\Test-OpenClawSource.Tests.ps1 +.\scripts\Test-WorkflowSource.Tests.ps1 +.\scripts\Test-WorkflowSigningConfiguration.ps1 +.\scripts\Test-Build-MSIXBundle.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` The Node.js input suite requires Node.js and npm. It builds a dependency-free local fixture; it does not download or build OpenClaw. +The channel resolver tests use offline registry and GitHub fixtures. Keep +source selection separate from the source build: new workflow runs resolve +`release-policy.json`'s `extended-stable` channel once, and retries reuse the +saved snapshot. Never replace the published channel selection with a +maintenance branch head or re-resolve it independently for each architecture. +Changes to source metadata must stay synchronized across resolution, payload +creation, MSIX composition, signing authorization, and release assets. +Official signing trusts the verified channel snapshot rather than a reviewed +per-release commit allowlist. It still requires `main`, the protected signing +environment, and all artifact checks. For packaging-only official corrections, +increase the policy's `packageRevision`; do not overwrite an existing release. + Run the NativeAOT publish when you change host JSON, reflection, interop, or anything else that is trimming-sensitive. A JIT `dotnet build` does not exercise that path: diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 2b93289e..42a041c0 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -193,6 +193,8 @@ if (-not (Test-Path -LiteralPath $payloadMetadata -PathType Leaf)) { } $payloadInfo = Get-Content -LiteralPath $payloadMetadata -Raw | ConvertFrom-Json +$payloadSelection = $payloadInfo | + Select-Object channel, releaseTag, tagObject, resolvedAt, registryIntegrity if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or @@ -207,6 +209,13 @@ if ( throw 'Payload metadata is not valid for this MSIX package.' } +if ($null -ne $payloadSelection.channel) { + . (Join-Path $PSScriptRoot 'OpenClawSource.ps1') + $policy = Read-OpenClawReleasePolicy -Path ( + Join-Path $repositoryRoot 'release-policy.json') + Assert-OpenClawSource -Source $payloadInfo -Policy $policy +} + $nodeVersion = $payloadInfo.nodeVersion.TrimStart('v') $expectedNodeArchiveName = "node-v$nodeVersion-win-$Architecture.zip" if ($NodeArchivePath) { @@ -639,6 +648,11 @@ try { payloadRequestedRef = $payloadInfo.requestedRef payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadPackageVersion = [string]$payloadInfo.packageVersion + payloadChannel = $payloadSelection.channel + payloadReleaseTag = $payloadSelection.releaseTag + payloadTagObject = $payloadSelection.tagObject + payloadResolvedAt = $payloadSelection.resolvedAt + payloadRegistryIntegrity = $payloadSelection.registryIntegrity payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count nodeRuntimeVersion = $nodeVersion diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 1e768ebb..f5c48695 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -27,6 +27,14 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { } $sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json +$sourceSelection = $sourceMetadata | + Select-Object channel, releaseTag, tagObject, resolvedAt, registryIntegrity +if ($null -ne $sourceMetadata.PSObject.Properties['channel']) { + . (Join-Path $PSScriptRoot 'OpenClawSource.ps1') + $policy = Read-OpenClawReleasePolicy -Path ( + Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') + Assert-OpenClawSource -Source $sourceMetadata -Policy $policy +} $nodeVersion = & node -p 'process.versions.node' if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Unable to determine the payload build Node.js version.' @@ -362,6 +370,11 @@ if ($Architecture -eq 'x64') { requestedRef = $sourceMetadata.requestedRef resolvedCommit = $sourceMetadata.resolvedCommit packageVersion = $sourceMetadata.packageVersion + channel = $sourceSelection.channel + releaseTag = $sourceSelection.releaseTag + tagObject = $sourceSelection.tagObject + resolvedAt = $sourceSelection.resolvedAt + registryIntegrity = $sourceSelection.registryIntegrity architecture = $Architecture layout = 'expanded-directory' nodeVersion = $nodeVersion diff --git a/scripts/Get-WorkflowSource.ps1 b/scripts/Get-WorkflowSource.ps1 new file mode 100644 index 00000000..94a14ec4 --- /dev/null +++ b/scripts/Get-WorkflowSource.ps1 @@ -0,0 +1,96 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PolicyPath, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [ValidateSet('unsigned', 'test', 'official')] + [string]$SigningMode = 'unsigned', + + [string]$Ref = '', + + [Parameter(Mandatory)] + [long]$RunNumber, + + [Parameter(Mandatory)] + [ValidatePattern('^[1-9][0-9]*$')] + [string]$WorkflowRunId, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{40}$')] + [string]$PackagingCommit, + + [switch]$ReuseSnapshot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') + +$policy = Read-OpenClawReleasePolicy -Path $PolicyPath +if ($SigningMode -eq 'official' -and $Ref -ne '') { + throw 'Official signing requires the policy channel, not an explicit source override.' +} + +if ($ReuseSnapshot) { + if (-not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { + throw 'The source snapshot is unavailable. Start a new workflow run; do not re-resolve a retry.' + } + $source = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json +} +else { + if (Test-Path -LiteralPath $OutputPath) { + throw "The source snapshot already exists: $OutputPath" + } + $source = Resolve-OpenClawSource -Policy $policy -Ref $Ref +} + +Assert-OpenClawSource -Source $source -Policy $policy ` + -RequireChannel:($SigningMode -eq 'official') +$expectedRef = if ($Ref -eq '') { $policy.channel } else { $Ref } +if ($source.requestedRef -cne $expectedRef) { + throw 'The source snapshot does not match the requested selector.' +} + +$versionParameters = @{ + RunNumber = $RunNumber + RunAttempt = 1 +} +if ($SigningMode -eq 'official') { + $versionParameters.ReleaseVersion = + "$($source.packageVersion).$($policy.packageRevision)" +} +$packageVersion = & (Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1') ` + @versionParameters +$context = [ordered]@{ + packagingCommit = $PackagingCommit.ToLowerInvariant() + workflowRunId = $WorkflowRunId + workflowRunNumber = $RunNumber + signingMode = $SigningMode + msixPackageVersion = $packageVersion + msixReleaseTag = "v$packageVersion" +} + +foreach ($field in $context.Keys) { + if ($ReuseSnapshot) { + if ($source.$field -cne $context[$field]) { + throw "The source snapshot has an unexpected workflow identity: $field" + } + } + else { + $source | Add-Member -NotePropertyName $field -NotePropertyValue $context[$field] + } +} + +if (-not $ReuseSnapshot) { + $parent = Split-Path ([IO.Path]::GetFullPath($OutputPath)) -Parent + New-Item -ItemType Directory -Path $parent -Force | Out-Null + [IO.File]::WriteAllText( + [IO.Path]::GetFullPath($OutputPath), + ($source | ConvertTo-Json -Depth 4) + "`n", + [Text.UTF8Encoding]::new($false)) +} + +return $source diff --git a/scripts/OpenClawSource.ps1 b/scripts/OpenClawSource.ps1 new file mode 100644 index 00000000..f0b0cd96 --- /dev/null +++ b/scripts/OpenClawSource.ps1 @@ -0,0 +1,428 @@ +function Get-OpenClawSourceField { + param( + [AllowNull()] + [object]$InputObject, + [string]$Name, + [switch]$Optional + ) + + if ($InputObject -is [System.Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + $value = $InputObject[$Name] + } + elseif ($InputObject -is [System.Management.Automation.PSCustomObject]) { + $property = $InputObject.PSObject.Properties[$Name] + $exists = $null -ne $property + $value = $null + if ($exists) { + $value = $property.Value + } + } + else { + throw "Expected an object containing '$Name'." + } + + if (-not $exists -and $Optional) { + return $null + } + if (-not $exists -or $null -eq $value) { + throw "Missing required field '$Name'." + } + + return ,$value +} + +function Assert-OpenClawSourceText { + param( + [AllowNull()] + [object]$Value, + [string]$Name, + [string]$Pattern = '', + [switch]$AllowEmpty + ) + + if ($Value -isnot [string] -or + $Value -match '[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]' -or + ([string]::IsNullOrWhiteSpace($Value) -and + -not ($AllowEmpty -and $Value.Length -eq 0))) { + throw "'$Name' must be a string without control characters or blank whitespace." + } + if ($Pattern -and $Value -cnotmatch $Pattern) { + throw "'$Name' has an invalid format." + } +} + +function Assert-OpenClawSourceVersion { + param( + [AllowNull()] + [object]$Version, + [switch]$Final + ) + + $number = '(?:0|[1-9][0-9]*)' + if ($Final) { + $pattern = "\A[1-9][0-9]{3}\.$number\.$number\z" + } + else { + $identifier = "(?:$number|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + $pattern = "\A$number\.$number\.$number" + + "(?:-$identifier(?:\.$identifier)*)?" + + '(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z' + } + Assert-OpenClawSourceText -Value $Version -Name 'packageVersion' -Pattern $pattern +} + +function Assert-OpenClawRegistryIntegrity { + param( + [AllowNull()] + [object]$Integrity + ) + + Assert-OpenClawSourceText ` + -Value $Integrity ` + -Name 'registryIntegrity' ` + -Pattern '\Asha512-[A-Za-z0-9+/]{86}==\z' + $encoded = $Integrity.Substring(7) + $bytes = [Convert]::FromBase64String($encoded) + if ($bytes.Length -ne 64 -or + [Convert]::ToBase64String($bytes) -cne $encoded) { + throw 'registryIntegrity must contain a canonical SHA-512 digest.' + } +} + +function Assert-OpenClawReleasePolicy { + param( + [AllowNull()] + [object]$Policy + ) + + $repository = Get-OpenClawSourceField $Policy 'repository' + $channel = Get-OpenClawSourceField $Policy 'channel' + $revision = Get-OpenClawSourceField $Policy 'packageRevision' + $publisher = Get-OpenClawSourceField $Policy 'publisher' + Assert-OpenClawSourceText $repository 'repository' + Assert-OpenClawSourceText $channel 'channel' + Assert-OpenClawSourceText $publisher 'publisher' + if ($repository -cne 'https://github.com/openclaw/openclaw') { + throw 'The release policy repository must be https://github.com/openclaw/openclaw.' + } + if ($channel -cne 'extended-stable') { + throw 'The release policy channel must be extended-stable.' + } + if (($revision -isnot [long] -and $revision -isnot [int]) -or + $revision -lt 0 -or $revision -gt 65534) { + throw 'packageRevision must be a JSON integer between 0 and 65534.' + } +} + +function Read-OpenClawReleasePolicy { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $policy = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 32 -NoEnumerate + Assert-OpenClawReleasePolicy -Policy $policy + return $policy +} + +function Get-OpenClawRequestOptions { + $options = @{ + TimeoutSec = 30 + MaximumRedirection = 0 + ErrorAction = 'Stop' + } + # Since PowerShell 7.4, TimeoutSec only bounds connection establishment. + if ($PSVersionTable.PSVersion -ge [version]'7.4') { + $options.OperationTimeoutSeconds = 30 + } + return $options +} + +function Invoke-OpenClawGitHubRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $segment = '(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+' + $allowedPath = '\A(?:commits/' + $segment + + '|git/ref/tags/v[1-9][0-9]{3}\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)' + + '|git/tags/[0-9a-f]{40}|contents/package\.json\?ref=[0-9a-f]{40})\z' + Assert-OpenClawSourceText $Path 'GitHub API path' -Pattern $allowedPath + $headers = @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'OpenClaw-Gateway-MSIX' + 'X-GitHub-Api-Version' = '2022-11-28' + } + if (-not [string]::IsNullOrEmpty($env:GH_TOKEN)) { + Assert-OpenClawSourceText $env:GH_TOKEN 'GH_TOKEN' + $headers.Authorization = "Bearer $env:GH_TOKEN" + } + + # Refuse redirects so credentials never leave the fixed GitHub API origin. + $requestOptions = Get-OpenClawRequestOptions + return Invoke-RestMethod ` + -Uri "https://api.github.com/repos/openclaw/openclaw/$Path" ` + -Headers $headers ` + @requestOptions +} + +function Invoke-OpenClawRegistryRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Selector + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + if ($Selector -cne 'extended-stable') { + Assert-OpenClawSourceVersion -Version $Selector -Final + } + $encodedSelector = [Uri]::EscapeDataString($Selector) + $requestOptions = Get-OpenClawRequestOptions + return Invoke-RestMethod ` + -Uri "https://registry.npmjs.org/openclaw/$encodedSelector" ` + -Headers @{ Accept = 'application/json' } ` + @requestOptions +} + +function Get-OpenClawCommitPackageVersion { + param( + [string]$Commit + ) + + Assert-OpenClawSourceText $Commit 'commit' -Pattern '\A[0-9a-f]{40}\z' + $file = Invoke-OpenClawGitHubRequest -Path "contents/package.json?ref=$Commit" + $fileType = Get-OpenClawSourceField $file 'type' + $encoding = Get-OpenClawSourceField $file 'encoding' + Assert-OpenClawSourceText $fileType 'package.json type' + Assert-OpenClawSourceText $encoding 'package.json encoding' + if ($fileType -cne 'file' -or $encoding -cne 'base64') { + throw 'GitHub must return package.json as a base64-encoded file.' + } + $content = Get-OpenClawSourceField $file 'content' + if ($content -isnot [string]) { + throw 'The package.json content must be base64 text.' + } + $utf8 = [System.Text.UTF8Encoding]::new($false, $true) + $json = $utf8.GetString([Convert]::FromBase64String($content)) + $package = ConvertFrom-Json -InputObject $json -Depth 32 -NoEnumerate + $name = Get-OpenClawSourceField $package 'name' + Assert-OpenClawSourceText $name 'package name' + if ($name -cne 'openclaw') { + throw 'The source package name must be openclaw.' + } + $version = Get-OpenClawSourceField $package 'version' + Assert-OpenClawSourceVersion -Version $version + return $version +} + +function Resolve-OpenClawSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Policy, + [AllowEmptyString()] + [string]$Ref = '' + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + Assert-OpenClawReleasePolicy -Policy $Policy + Assert-OpenClawSourceText $Ref 'Ref' -AllowEmpty + $channel = '' + $releaseTag = '' + $tagObject = '' + $integrity = '' + + if ($Ref.Length -gt 0) { + Assert-OpenClawSourceText $Ref 'Ref' -Pattern '\A\S+\z' + $requestedRef = $Ref + $escapedRef = [Uri]::EscapeDataString($Ref) + $commitResponse = Invoke-OpenClawGitHubRequest -Path "commits/$escapedRef" + $commit = Get-OpenClawSourceField $commitResponse 'sha' + Assert-OpenClawSourceText $commit 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' + $commit = $commit.ToLowerInvariant() + $version = Get-OpenClawCommitPackageVersion -Commit $commit + } + else { + $channel = Get-OpenClawSourceField $Policy 'channel' + $requestedRef = $channel + $selection = Invoke-OpenClawRegistryRequest -Selector $channel + $name = Get-OpenClawSourceField $selection 'name' + Assert-OpenClawSourceText $name 'registry package name' + if ($name -cne 'openclaw') { + throw 'The registry channel must resolve to the openclaw package.' + } + $version = Get-OpenClawSourceField $selection 'version' + Assert-OpenClawSourceVersion -Version $version -Final + $releaseTag = "v$version" + $tagRef = Invoke-OpenClawGitHubRequest -Path "git/ref/tags/$releaseTag" + $refLabel = Get-OpenClawSourceField $tagRef 'ref' + Assert-OpenClawSourceText $refLabel 'tag ref' + $refObject = Get-OpenClawSourceField $tagRef 'object' + $refType = Get-OpenClawSourceField $refObject 'type' + Assert-OpenClawSourceText $refType 'tag ref type' + if ($refLabel -cne "refs/tags/$releaseTag" -or $refType -cne 'tag') { + throw 'The selected release must have an exact annotated tag ref.' + } + $tagObject = Get-OpenClawSourceField $refObject 'sha' + Assert-OpenClawSourceText $tagObject 'tag object' -Pattern '\A[0-9a-fA-F]{40}\z' + $tagObject = $tagObject.ToLowerInvariant() + $tag = Invoke-OpenClawGitHubRequest -Path "git/tags/$tagObject" + $tagSha = Get-OpenClawSourceField $tag 'sha' + Assert-OpenClawSourceText $tagSha 'tag SHA' -Pattern '\A[0-9a-fA-F]{40}\z' + $tagLabel = Get-OpenClawSourceField $tag 'tag' + Assert-OpenClawSourceText $tagLabel 'tag label' + $verification = Get-OpenClawSourceField $tag 'verification' + $verified = Get-OpenClawSourceField $verification 'verified' + $reason = Get-OpenClawSourceField $verification 'reason' + Assert-OpenClawSourceText $reason 'tag verification reason' + if ($tagSha.ToLowerInvariant() -cne $tagObject -or + $tagLabel -cne $releaseTag -or + $verified -isnot [bool] -or -not $verified -or $reason -cne 'valid') { + throw 'The release tag must match and have a valid GitHub-verified signature.' + } + $target = Get-OpenClawSourceField $tag 'object' + $targetType = Get-OpenClawSourceField $target 'type' + Assert-OpenClawSourceText $targetType 'tag target type' + if ($targetType -cne 'commit') { + throw 'The annotated release tag must point directly to a commit.' + } + $commit = Get-OpenClawSourceField $target 'sha' + Assert-OpenClawSourceText $commit 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' + $commit = $commit.ToLowerInvariant() + if ((Get-OpenClawCommitPackageVersion -Commit $commit) -cne $version) { + throw 'The immutable source package version does not match the selected release.' + } + + # Re-read the exact version, not the mutable selector, for the release evidence. + $manifest = Invoke-OpenClawRegistryRequest -Selector $version + $manifestName = Get-OpenClawSourceField $manifest 'name' + $manifestVersion = Get-OpenClawSourceField $manifest 'version' + Assert-OpenClawSourceText $manifestName 'registry package name' + Assert-OpenClawSourceVersion -Version $manifestVersion -Final + if ($manifestName -cne 'openclaw' -or $manifestVersion -cne $version) { + throw 'The exact registry manifest does not match the selected package version.' + } + $repository = Get-OpenClawSourceField $manifest 'repository' + if ($repository -isnot [string]) { + $repository = Get-OpenClawSourceField $repository 'url' + } + Assert-OpenClawSourceText $repository 'registry repository' + if ($repository -cnotin @( + 'https://github.com/openclaw/openclaw', + 'git+https://github.com/openclaw/openclaw.git' + )) { + throw 'The registry package repository does not match the release policy.' + } + $dist = Get-OpenClawSourceField $manifest 'dist' + $integrity = Get-OpenClawSourceField $dist 'integrity' + Assert-OpenClawRegistryIntegrity -Integrity $integrity + $gitHead = Get-OpenClawSourceField $manifest 'gitHead' -Optional + if ($null -ne $gitHead) { + Assert-OpenClawSourceText $gitHead 'registry gitHead' -Pattern '\A[0-9a-fA-F]{40}\z' + if ($gitHead.ToLowerInvariant() -cne $commit) { + throw 'The registry gitHead does not match the release tag commit.' + } + } + } + + $source = [pscustomobject][ordered]@{ + repository = Get-OpenClawSourceField $Policy 'repository' + requestedRef = $requestedRef + resolvedCommit = $commit + packageVersion = $version + channel = $channel + releaseTag = $releaseTag + tagObject = $tagObject + resolvedAt = [DateTime]::UtcNow.ToString('o', [Globalization.CultureInfo]::InvariantCulture) + registryIntegrity = $integrity + } + Assert-OpenClawSource -Source $source -Policy $Policy + return $source +} + +function Assert-OpenClawSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Source, + [Parameter(Mandatory)] + [object]$Policy, + [switch]$RequireChannel + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + Assert-OpenClawReleasePolicy -Policy $Policy + $values = @{} + foreach ($field in @( + 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', + 'channel', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity' + )) { + $values[$field] = Get-OpenClawSourceField $Source $field + # ConvertFrom-Json automatically materializes Z timestamps as UTC DateTime. + if ($field -eq 'resolvedAt' -and $values[$field] -is [DateTime]) { + if ($values[$field].Kind -ne [DateTimeKind]::Utc) { + throw 'resolvedAt must be a UTC DateTime or UTC RFC3339 string.' + } + $values[$field] = $values[$field].ToString('o', [Globalization.CultureInfo]::InvariantCulture) + } + $allowEmpty = $field -in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity') + Assert-OpenClawSourceText $values[$field] $field -AllowEmpty:$allowEmpty + } + if ($values.repository -cne (Get-OpenClawSourceField $Policy 'repository')) { + throw 'The source repository does not match the release policy.' + } + Assert-OpenClawSourceText $values.requestedRef 'requestedRef' -Pattern '\A\S+\z' + Assert-OpenClawSourceText $values.resolvedCommit 'resolvedCommit' -Pattern '\A[0-9a-f]{40}\z' + Assert-OpenClawSourceText ` + $values.resolvedAt 'resolvedAt' ` + -Pattern '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|\+00:00)\z' + $timestamp = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParse( + $values.resolvedAt, + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref]$timestamp + )) { + throw 'resolvedAt must be a valid UTC RFC3339 timestamp.' + } + + if ($values.channel.Length -gt 0) { + $policyChannel = Get-OpenClawSourceField $Policy 'channel' + if ($values.channel -cne $policyChannel -or $values.requestedRef -cne $policyChannel) { + throw 'The source channel and requestedRef must match the release policy channel.' + } + Assert-OpenClawSourceVersion $values.packageVersion -Final + if ($values.releaseTag -cne "v$($values.packageVersion)") { + throw 'The releaseTag must match the source package version.' + } + Assert-OpenClawSourceText $values.tagObject 'tagObject' -Pattern '\A[0-9a-f]{40}\z' + Assert-OpenClawRegistryIntegrity $values.registryIntegrity + } + else { + if ($RequireChannel) { + throw 'Official signing requires a channel-resolved source, not a ref override.' + } + Assert-OpenClawSourceVersion $values.packageVersion + if ($values.releaseTag.Length -ne 0 -or + $values.tagObject.Length -ne 0 -or $values.registryIntegrity.Length -ne 0) { + throw 'A ref override must have empty releaseTag, tagObject, and registryIntegrity fields.' + } + } +} diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 490822c8..5edc6f99 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -113,6 +113,11 @@ console.log(JSON.stringify({ requestedRef = '1' * 40 resolvedCommit = '1' * 40 packageVersion = '0.0.0' + channel = '' + releaseTag = '' + tagObject = '' + resolvedAt = '2026-09-15T00:00:00.0000000Z' + registryIntegrity = '' nodeVersion = $nodeVersion } $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" @@ -124,6 +129,22 @@ console.log(JSON.stringify({ if ($metadata.nodeVersion -cne $nodeVersion) { throw 'The payload did not preserve the exact source build Node.js version.' } + foreach ($field in @( + 'requestedRef', 'resolvedCommit', 'packageVersion', 'channel', + 'releaseTag', 'tagObject', 'registryIntegrity' + )) { + if ($metadata.$field -cne $sourceMetadata[$field]) { + throw "The payload did not preserve the source selection field: $field" + } + } + + $sourceMetadata.packageVersion = '0.0.1' + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + Assert-Fails -MessagePattern 'does not match the source identity' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + } + $sourceMetadata.packageVersion = '0.0.0' $reusedPayload = Join-Path $testRoot 'payload-reused' & "$PSScriptRoot\Build-Payload.ps1" ` diff --git a/scripts/Test-OfficialReleaseVersion.ps1 b/scripts/Test-OfficialReleaseVersion.ps1 new file mode 100644 index 00000000..ca79fb1f --- /dev/null +++ b/scripts/Test-OfficialReleaseVersion.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding(DefaultParameterSetName = 'GitHub')] +param( + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory, ParameterSetName = 'Tags')] + [AllowEmptyCollection()] + [string[]]$ExistingTags, + + [Parameter(ParameterSetName = 'GitHub')] + [ValidateSet('openclaw/openclaw-windows-packaging')] + [string]$Repository = 'openclaw/openclaw-windows-packaging' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$version = [version](& (Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1') ` + -RunNumber 1 -RunAttempt 1 -ReleaseVersion $PackageVersion) +if ($PSCmdlet.ParameterSetName -eq 'GitHub') { + $ExistingTags = @(& gh api "repos/$Repository/git/matching-refs/tags/v" ` + --paginate --jq '.[].ref') + if ($LASTEXITCODE -ne 0) { + throw 'Unable to check existing official release versions.' + } +} +foreach ($tag in $ExistingTags) { + if ($tag -cmatch '^refs/tags/v(?\d+\.\d+\.\d+\.\d+)$') { + $existingVersion = [version]$Matches.version + if ($version -le $existingVersion) { + throw ( + "MSIX version $version is not newer than existing official tag $tag. " + + 'Do not overwrite or roll back releases; use a newer upstream version ' + + 'or a reviewed packageRevision increase for a packaging-only correction.' + ) + } + } +} diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 new file mode 100644 index 00000000..b749ed3a --- /dev/null +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -0,0 +1,583 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$sourcePath = Join-Path $PSScriptRoot 'OpenClawSource.ps1' +$tokens = $null +$parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0 -or $ast.BeginBlock -or $ast.ProcessBlock -or + @($ast.EndBlock.Statements | Where-Object { + $_ -isnot [Management.Automation.Language.FunctionDefinitionAst] + }).Count -ne 0) { + throw 'OpenClawSource.ps1 must contain only valid function definitions.' +} +$definitionOutput = @(. $sourcePath) +if ($definitionOutput.Count -ne 0) { + throw 'Dot-sourcing OpenClawSource.ps1 must not produce output.' +} +$githubTransport = ${function:Invoke-OpenClawGitHubRequest} +$registryTransport = ${function:Invoke-OpenClawRegistryRequest} +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-source-tests-$([guid]::NewGuid().ToString('N'))") +$commit = 'c283867d7cdd1a93cfc58f829c849834c4426d3b' +$tagObject = '3f0cb2ac4b8222e5d7fe9930f3aa693b2d68ac87' +$version = '2026.6.35' +$integrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) +$testCount = 0 + +function Assert-TestEqual { + param($Actual, $Expected) + + if ($Actual -cne $Expected) { + throw "Expected '$Expected', got '$Actual'." + } +} + +function Assert-TestThrows { + param([scriptblock]$Action, [string]$Pattern) + + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Expected failure matching '$Pattern', got: $($_.Exception.Message)" + } + return + } + throw "Expected failure matching '$Pattern', but the action succeeded." +} + +function Read-TestPolicy { + param([string]$Json) + + $path = Join-Path $testRoot 'policy.json' + [IO.File]::WriteAllText($path, $Json, [Text.UTF8Encoding]::new($false)) + return Read-OpenClawReleasePolicy -Path $path +} + +function New-TestPolicy { + return [pscustomobject]@{ + repository = 'https://github.com/openclaw/openclaw' + channel = 'extended-stable' + packageRevision = 0 + publisher = 'CN=OpenClaw Test Publisher' + } +} + +function Set-TestPackage { + param( + [string]$Commit = $script:commit, + [object]$Version = $script:version, + [string]$Name = 'openclaw' + ) + + $json = @{ name = $Name; version = $Version } | ConvertTo-Json -Compress + $script:responses["github:contents/package.json?ref=$Commit"] = [pscustomobject]@{ + type = 'file' + encoding = 'base64' + content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) + } +} + +function Add-TestRelease { + param( + [string]$Version = $script:version, + [string]$Commit = $script:commit, + [string]$TagObject = $script:tagObject + ) + + $script:responses["github:git/ref/tags/v$Version"] = [pscustomobject]@{ + ref = "refs/tags/v$Version" + object = [pscustomobject]@{ type = 'tag'; sha = $TagObject } + } + $script:responses["github:git/tags/$TagObject"] = [pscustomobject]@{ + sha = $TagObject + tag = "v$Version" + verification = [pscustomobject]@{ verified = $true; reason = 'valid' } + object = [pscustomobject]@{ type = 'commit'; sha = $Commit } + url = 'https://untrusted.example.invalid/ignored-tag-url' + } + Set-TestPackage -Commit $Commit -Version $Version + $script:responses["registry:$Version"] = [pscustomobject]@{ + name = 'openclaw' + version = $Version + repository = [pscustomobject]@{ + type = 'git' + url = 'git+https://github.com/openclaw/openclaw.git' + } + dist = [pscustomobject]@{ + integrity = $script:integrity + tarball = 'https://untrusted.example.invalid/never-download' + } + gitHead = $Commit + } +} + +function Reset-TestFixture { + $script:requests = [Collections.Generic.List[string]]::new() + $script:httpCalls = [Collections.Generic.List[object]]::new() + $script:failures = @{} + $script:responses = @{ + 'registry:extended-stable' = [pscustomobject]@{ + name = 'openclaw' + version = $script:version + } + } + $script:policy = Read-TestPolicy (New-TestPolicy | ConvertTo-Json) + Add-TestRelease +} + +function Get-TestResponse { + param([string]$Key) + + $script:requests.Add($Key) + if ($script:failures.ContainsKey($Key)) { + throw $script:failures[$Key] + } + if (-not $script:responses.ContainsKey($Key)) { + throw "No offline fixture for $Key." + } + return $script:responses[$Key] +} + +# Both service seams and the underlying transport are replaced: no test can use the network. +function Invoke-OpenClawGitHubRequest { + param([string]$Path) + return Get-TestResponse "github:$Path" +} + +function Invoke-OpenClawRegistryRequest { + param([string]$Selector) + return Get-TestResponse "registry:$Selector" +} + +function Invoke-RestMethod { + param($Uri, $Headers, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection, $ErrorAction) + + $script:httpCalls.Add([pscustomobject]@{ + Uri = $Uri + Headers = $Headers + TimeoutSec = $TimeoutSec + OperationTimeoutSeconds = $OperationTimeoutSeconds + MaximumRedirection = $MaximumRedirection + ErrorAction = $ErrorAction + }) + return [pscustomobject]@{ offline = $true } +} + +function Invoke-Test { + param([string]$Name, [scriptblock]$Body) + + Reset-TestFixture + & $Body + $script:testCount++ + Write-Host "PASS: $Name" +} + +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + Invoke-Test 'signed annotated final release resolves to its immutable source' { + $source = Resolve-OpenClawSource -Policy $policy + Assert-TestEqual $source.repository $policy.repository + Assert-TestEqual $source.requestedRef 'extended-stable' + Assert-TestEqual $source.channel 'extended-stable' + Assert-TestEqual $source.packageVersion $version + Assert-TestEqual $source.releaseTag "v$version" + Assert-TestEqual $source.tagObject $tagObject + Assert-TestEqual $source.resolvedCommit $commit + Assert-TestEqual $source.registryIntegrity $integrity + Assert-TestEqual ($source.resolvedAt.EndsWith('Z')) $true + Assert-TestEqual ($source.PSObject.Properties.Name -join ',') ( + 'repository,requestedRef,resolvedCommit,packageVersion,channel,' + + 'releaseTag,tagObject,resolvedAt,registryIntegrity' + ) + foreach ($property in $source.PSObject.Properties) { + Assert-TestEqual ($property.Value -is [string]) $true + } + Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 + Assert-TestEqual ($requests -join '|') ( + "registry:extended-stable|github:git/ref/tags/v$version|" + + "github:git/tags/$tagObject|github:contents/package.json?ref=$commit|" + + "registry:$version" + ) + } + + Invoke-Test 'a new call follows an advancing selector without caching' { + $first = Resolve-OpenClawSource $policy + $nextCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + Add-TestRelease -Version '2026.6.36' -Commit $nextCommit -TagObject ('b' * 40) + $responses['registry:extended-stable'].version = '2026.6.36' + $second = Resolve-OpenClawSource $policy + Assert-TestEqual $first.resolvedCommit $commit + Assert-TestEqual $second.resolvedCommit $nextCommit + Assert-TestEqual $second.packageVersion '2026.6.36' + } + + foreach ($endpoint in @( + 'registry:extended-stable', "github:git/ref/tags/v$version", + "github:git/tags/$tagObject", "github:contents/package.json?ref=$commit", + "registry:$version" + )) { + Invoke-Test "API failure is terminal at $endpoint" { + $failures[$endpoint] = 'Offline fixture API failure.' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'Offline fixture API failure' + Assert-TestEqual $requests[$requests.Count - 1] $endpoint + } + } + + Invoke-Test 'missing channel has no fallback' { + $responses.Remove('registry:extended-stable') + Assert-TestThrows { Resolve-OpenClawSource $policy } 'No offline fixture' + Assert-TestEqual $requests.Count 1 + } + + foreach ($badVersion in @( + '2026.06.35', '2026.6.35-beta.1', '2026.6.35+build.1', 'v2026.6.35', + '2026.6.35.1', '1.2.3', '2026.6.35?redirect=evil', "2026.6.35`nextra=bad", + @('2026.6.35'), 2026 + )) { + Invoke-Test 'channel rejects invalid final versions before GitHub requests' { + $responses['registry:extended-stable'].version = $badVersion + Assert-TestThrows { Resolve-OpenClawSource $policy } 'packageVersion' + Assert-TestEqual $requests.Count 1 + } + } + + Invoke-Test 'channel package identity must match' { + $responses['registry:extended-stable'].name = 'other' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'openclaw package' + } + + foreach ($case in @( + @{ Field = 'ref'; Value = 'refs/tags/v2026.6.34'; Error = 'exact annotated' }, + @{ Field = 'type'; Value = 'commit'; Error = 'exact annotated' }, + @{ Field = 'sha'; Value = '../../other'; Error = 'tag object' } + )) { + Invoke-Test "malformed tag ref rejects $($case.Field)" { + $tagRef = $responses["github:git/ref/tags/v$version"] + if ($case.Field -eq 'ref') { + $tagRef.ref = $case.Value + } + else { + $tagRef.object.($case.Field) = $case.Value + } + Assert-TestThrows { Resolve-OpenClawSource $policy } $case.Error + Assert-TestEqual $requests.Count 2 + } + } + + foreach ($case in @( + @{ Field = 'sha'; Value = ('f' * 40) }, + @{ Field = 'tag'; Value = 'v2026.6.34' }, + @{ Field = 'verified'; Value = $false }, + @{ Field = 'verified'; Value = 'true' }, + @{ Field = 'verified'; Value = @($true) }, + @{ Field = 'reason'; Value = 'unsigned' } + )) { + Invoke-Test "unverified or mismatched annotated tag rejects $($case.Field)" { + $tag = $responses["github:git/tags/$tagObject"] + if ($case.Field -in @('verified', 'reason')) { + $tag.verification.($case.Field) = $case.Value + } + else { + $tag.($case.Field) = $case.Value + } + Assert-TestThrows { Resolve-OpenClawSource $policy } 'GitHub-verified signature' + } + } + + Invoke-Test 'nested annotated tags are not commit targets' { + $responses["github:git/tags/$tagObject"].object.type = 'tag' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'directly to a commit' + } + + Invoke-Test 'invalid commit is rejected before package request' { + $responses["github:git/tags/$tagObject"].object.sha = 'not-a-sha' + Assert-TestThrows { Resolve-OpenClawSource $policy } "'commit'" + Assert-TestEqual $requests.Count 3 + } + + Invoke-Test 'GitHub SHA casing is normalized' { + $responses["github:git/ref/tags/v$version"].object.sha = $tagObject.ToUpperInvariant() + $responses["github:git/tags/$tagObject"].sha = $tagObject.ToUpperInvariant() + $responses["github:git/tags/$tagObject"].object.sha = $commit.ToUpperInvariant() + $responses["registry:$version"].gitHead = $commit.ToUpperInvariant() + $source = Resolve-OpenClawSource $policy + Assert-TestEqual $source.resolvedCommit $commit + Assert-TestEqual $source.tagObject $tagObject + } + + Invoke-Test 'immutable package version must match the selection' { + Set-TestPackage -Version '2026.6.34' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'source package version' + } + + Invoke-Test 'exact registry version must match the selection' { + $responses["registry:$version"].version = '2026.6.36' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'exact registry manifest' + } + + Invoke-Test 'exact registry package name must match' { + $responses["registry:$version"].name = 'other' + Assert-TestThrows { Resolve-OpenClawSource $policy } 'exact registry manifest' + } + + foreach ($repository in @( + 'https://github.com/other/openclaw', + 'git+https://github.com/openclaw/openclaw.git#main', + 'https://github.com/openclaw/openclaw/extra', + 'git@github.com:openclaw/openclaw.git' + )) { + Invoke-Test "unexpected npm repository is rejected: $repository" { + $responses["registry:$version"].repository.url = $repository + Assert-TestThrows { Resolve-OpenClawSource $policy } 'repository' + } + } + + Invoke-Test 'canonical npm repository string is accepted' { + $responses["registry:$version"].repository = $policy.repository + $source = Resolve-OpenClawSource $policy + Assert-TestEqual $source.resolvedCommit $commit + } + + Invoke-Test 'absent optional gitHead is accepted' { + $responses["registry:$version"].PSObject.Properties.Remove('gitHead') + $source = Resolve-OpenClawSource $policy + Assert-TestEqual $source.resolvedCommit $commit + } + + foreach ($gitHead in @(('f' * 40), 'bad', $null, @($commit))) { + Invoke-Test "present gitHead must match: $($gitHead -join ',')" { + $responses["registry:$version"].gitHead = $gitHead + Assert-TestThrows { Resolve-OpenClawSource $policy } 'gitHead' + } + } + + foreach ($badIntegrity in @( + '', ('sha256-' + [Convert]::ToBase64String([byte[]]::new(32))), + ('sha512-' + [Convert]::ToBase64String([byte[]]::new(63))), + ('sha512-' + ('A' * 85) + 'B=='), "$integrity`n", @($integrity) + )) { + Invoke-Test 'invalid or noncanonical SHA-512 integrity is rejected' { + $responses["registry:$version"].dist.integrity = $badIntegrity + Assert-TestThrows { Resolve-OpenClawSource $policy } 'registryIntegrity' + } + } + + foreach ($ref in @('feature/source', "v$version", $commit, 'extended-stable')) { + Invoke-Test "explicit override resolves only GitHub commit and source: $ref" { + $escapedRef = [Uri]::EscapeDataString($ref) + $responses["github:commits/$escapedRef"] = [pscustomobject]@{ + sha = $commit.ToUpperInvariant() + } + Set-TestPackage -Version '2026.9.1-beta.2+build.3' + $source = Resolve-OpenClawSource $policy -Ref $ref + Assert-TestEqual $source.requestedRef $ref + Assert-TestEqual $source.resolvedCommit $commit + Assert-TestEqual $source.packageVersion '2026.9.1-beta.2+build.3' + foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { + Assert-TestEqual $source.$field '' + } + Assert-TestEqual ($requests -join '|') ( + "github:commits/$escapedRef|github:contents/package.json?ref=$commit" + ) + Assert-TestEqual @(Assert-OpenClawSource $source $policy).Count 0 + Assert-TestThrows { + Assert-OpenClawSource $source $policy -RequireChannel + } 'channel-resolved source' + $replayed = $source | ConvertTo-Json | ConvertFrom-Json + Assert-TestEqual @(Assert-OpenClawSource $replayed $policy).Count 0 + Assert-TestThrows { + Assert-OpenClawSource $replayed $policy -RequireChannel + } 'channel-resolved source' + } + } + + foreach ($ref in @(' ', "`t", 'branch name', "main`nINJECT=value", "main$([char]0)", "main$([char]0x2028)")) { + Invoke-Test 'whitespace and control characters in overrides are rejected before HTTP' { + Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $ref } "'Ref'" + Assert-TestEqual $requests.Count 0 + } + } + + foreach ($badVersion in @('01.2.3', '1.2.3-01', '1.2.3-beta..1', "1.2.3`n", @('1.2.3'))) { + Invoke-Test 'override source version must be valid semver' { + $responses['github:commits/main'] = [pscustomobject]@{ sha = $commit } + Set-TestPackage -Version $badVersion + Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'packageVersion' + } + } + + Invoke-Test 'override source package name must be openclaw' { + $responses['github:commits/main'] = [pscustomobject]@{ sha = $commit } + Set-TestPackage -Name 'other' + Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'source package name' + } + + Invoke-Test 'snapshot timestamp can be old and extra build metadata is allowed' { + $source = Resolve-OpenClawSource $policy + $source.resolvedAt = '2000-01-01T00:00:00Z' + $source | Add-Member -NotePropertyName nodeVersion -NotePropertyValue '24.16.0' + Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 + } + + Invoke-Test 'snapshot survives default JSON timestamp conversion without mutation' { + $source = Resolve-OpenClawSource $policy + $replayed = $source | ConvertTo-Json | ConvertFrom-Json + Assert-TestEqual ($replayed.resolvedAt -is [DateTime]) $true + Assert-TestEqual $replayed.resolvedAt.Kind ([DateTimeKind]::Utc) + Assert-TestEqual $replayed.resolvedAt.ToString('o') $source.resolvedAt + Assert-TestEqual @(Assert-OpenClawSource $replayed $policy -RequireChannel).Count 0 + Assert-TestEqual ($replayed.resolvedAt -is [DateTime]) $true + $replayedAgain = $replayed | ConvertTo-Json | ConvertFrom-Json + Assert-TestEqual ( + $replayedAgain.resolvedAt | ConvertTo-Json -Compress + ) ($replayed.resolvedAt | ConvertTo-Json -Compress) + } + + Invoke-Test 'UTC DateTime timestamps may be old' { + $source = Resolve-OpenClawSource $policy + $source.resolvedAt = [DateTime]::new(2000, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 + } + + foreach ($kind in @([DateTimeKind]::Unspecified, [DateTimeKind]::Local)) { + Invoke-Test "non-UTC DateTime timestamp is rejected: $kind" { + $source = Resolve-OpenClawSource $policy + $source.resolvedAt = [DateTime]::new(2000, 1, 1, 0, 0, 0, $kind) + Assert-TestThrows { Assert-OpenClawSource $source $policy } 'resolvedAt' + } + } + + foreach ($case in @( + @{ Field = 'repository'; Value = 'https://github.com/other/openclaw'; Error = 'repository' }, + @{ Field = 'requestedRef'; Value = 'main'; Error = 'requestedRef' }, + @{ Field = 'requestedRef'; Value = "extended-stable`r`nevil=value"; Error = 'requestedRef' }, + @{ Field = 'resolvedCommit'; Value = $commit.ToUpperInvariant(); Error = 'resolvedCommit' }, + @{ Field = 'resolvedCommit'; Value = @($commit); Error = 'resolvedCommit' }, + @{ Field = 'packageVersion'; Value = '2026.6.35-beta.1'; Error = 'packageVersion' }, + @{ Field = 'channel'; Value = 'latest'; Error = 'channel' }, + @{ Field = 'channel'; Value = ''; Error = 'ref override' }, + @{ Field = 'releaseTag'; Value = 'v2026.6.34'; Error = 'releaseTag' }, + @{ Field = 'tagObject'; Value = ''; Error = 'tagObject' }, + @{ Field = 'tagObject'; Value = $tagObject.ToUpperInvariant(); Error = 'tagObject' }, + @{ Field = 'registryIntegrity'; Value = 'sha512-invalid'; Error = 'registryIntegrity' }, + @{ Field = 'resolvedAt'; Value = '2026-02-30T12:00:00Z'; Error = 'resolvedAt' }, + @{ Field = 'resolvedAt'; Value = 'not-a-date'; Error = 'resolvedAt' }, + @{ Field = 'resolvedAt'; Value = '2026-06-01'; Error = 'resolvedAt' }, + @{ Field = 'resolvedAt'; Value = '2026-06-01T00:00:00'; Error = 'resolvedAt' }, + @{ Field = 'resolvedAt'; Value = "2026-06-01T00:00:00Z`n"; Error = 'resolvedAt' } + )) { + Invoke-Test "snapshot identity rejects inconsistent $($case.Field)" { + $source = Resolve-OpenClawSource $policy + $source.($case.Field) = $case.Value + Assert-TestThrows { Assert-OpenClawSource $source $policy } $case.Error + } + } + + foreach ($field in @( + 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', + 'channel', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity' + )) { + Invoke-Test "snapshot requires $field" { + $source = Resolve-OpenClawSource $policy + $source.PSObject.Properties.Remove($field) + Assert-TestThrows { Assert-OpenClawSource $source $policy } $field + } + } + + foreach ($revision in @(0, 65534)) { + Invoke-Test "policy accepts boundary packageRevision $revision" { + $candidate = New-TestPolicy + $candidate.packageRevision = $revision + $read = Read-TestPolicy ($candidate | ConvertTo-Json) + Assert-TestEqual $read.packageRevision $revision + } + } + + foreach ($case in @( + @{ Field = 'repository'; Value = 'https://github.com/other/openclaw' }, + @{ Field = 'repository'; Value = @('https://github.com/openclaw/openclaw') }, + @{ Field = 'channel'; Value = 'latest' }, + @{ Field = 'channel'; Value = 'Extended-Stable' }, + @{ Field = 'packageRevision'; Value = -1 }, + @{ Field = 'packageRevision'; Value = 65535 }, + @{ Field = 'packageRevision'; Value = '1' }, + @{ Field = 'packageRevision'; Value = 1.0 }, + @{ Field = 'packageRevision'; Value = $true }, + @{ Field = 'packageRevision'; Value = $null }, + @{ Field = 'publisher'; Value = '' }, + @{ Field = 'publisher'; Value = ' ' }, + @{ Field = 'publisher'; Value = "CN=Test`nINJECT=value" } + )) { + Invoke-Test "policy rejects malformed $($case.Field)" { + $candidate = New-TestPolicy + $candidate.($case.Field) = $case.Value + Assert-TestThrows { + Read-TestPolicy ($candidate | ConvertTo-Json -Depth 8) + } $case.Field + } + } + + foreach ($field in @('repository', 'channel', 'packageRevision', 'publisher')) { + Invoke-Test "policy requires $field" { + $candidate = New-TestPolicy + $candidate.PSObject.Properties.Remove($field) + Assert-TestThrows { Read-TestPolicy ($candidate | ConvertTo-Json) } $field + } + } + + foreach ($json in @('[]', '[{"repository":"ignored"}]', 'null', '"string"', '{')) { + Invoke-Test 'policy rejects nonobjects and invalid JSON' { + Assert-TestThrows { Read-TestPolicy $json } 'object|JSON' + } + } + + Invoke-Test 'HTTP transports pin origins, bound timeouts, and isolate GitHub credentials' { + $originalToken = $env:GH_TOKEN + try { + $env:GH_TOKEN = 'offline-test-token' + & $githubTransport -Path "git/tags/$tagObject" | Out-Null + & $registryTransport -Selector 'extended-stable' | Out-Null + & $registryTransport -Selector $version | Out-Null + Assert-TestEqual $httpCalls[0].Uri "https://api.github.com/repos/openclaw/openclaw/git/tags/$tagObject" + Assert-TestEqual $httpCalls[0].Headers.Authorization 'Bearer offline-test-token' + Assert-TestEqual $httpCalls[1].Uri 'https://registry.npmjs.org/openclaw/extended-stable' + Assert-TestEqual $httpCalls[2].Uri "https://registry.npmjs.org/openclaw/$version" + foreach ($call in $httpCalls) { + Assert-TestEqual $call.TimeoutSec 30 + if ($PSVersionTable.PSVersion -ge [version]'7.4') { + Assert-TestEqual $call.OperationTimeoutSeconds 30 + } + Assert-TestEqual $call.MaximumRedirection 0 + Assert-TestEqual $call.ErrorAction 'Stop' + if ($call.Uri.StartsWith('https://registry.npmjs.org/')) { + Assert-TestEqual $call.Headers.ContainsKey('Authorization') $false + } + } + $env:GH_TOKEN = $null + & $githubTransport -Path "git/tags/$tagObject" | Out-Null + Assert-TestEqual $httpCalls[3].Headers.ContainsKey('Authorization') $false + } + finally { + $env:GH_TOKEN = $originalToken + } + } + + Invoke-Test 'service helpers reject arbitrary URLs and invalid selectors before transport' { + Assert-TestThrows { + & $githubTransport -Path 'https://untrusted.example.invalid' + } 'GitHub API path' + Assert-TestThrows { & $registryTransport -Selector '../../other' } 'packageVersion' + Assert-TestEqual $httpCalls.Count 0 + } + + Write-Host "Passed $testCount OpenClaw source resolver tests (offline)." +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index c52c6ba3..b2e193fc 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -16,6 +16,23 @@ $releaseIdentity = & ( $approvedPackageVersion = $releaseIdentity.PackageVersion $approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' +$sourceResolution = [ordered]@{ + repository = $policy.repository + requestedRef = $policy.channel + resolvedCommit = $approvedCommit + packageVersion = $approvedPayloadVersion + channel = $policy.channel + releaseTag = "v$approvedPayloadVersion" + tagObject = '4' * 40 + resolvedAt = '2026-09-15T00:00:00.0000000Z' + registryIntegrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) + packagingCommit = $packagingCommit + workflowRunId = '12345' + workflowRunNumber = 1 + signingMode = 'official' + msixPackageVersion = $approvedPackageVersion + msixReleaseTag = "v$approvedPackageVersion" +} $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" ) @@ -59,6 +76,9 @@ function New-TestArtifact { Set-Content ` -LiteralPath (Join-Path $applicationDirectory 'openclaw.mjs') ` -Value "payload-$Architecture" + @{ name = 'openclaw'; version = $PayloadPackageVersion } | + ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $applicationDirectory 'package.json') if ($IncludeApplicationBundledNode) { Set-Content ` -LiteralPath (Join-Path $applicationDirectory 'node.exe') ` @@ -242,9 +262,14 @@ function New-TestArtifact { packagingCommit = $packagingCommit sourceTreeDirty = $SourceTreeDirty payloadRepository = $policy.repository - payloadRequestedRef = $PayloadCommit + payloadRequestedRef = $policy.channel payloadResolvedCommit = $PayloadCommit payloadPackageVersion = $PayloadPackageVersion + payloadChannel = $sourceResolution.channel + payloadReleaseTag = $sourceResolution.releaseTag + payloadTagObject = $sourceResolution.tagObject + payloadResolvedAt = $sourceResolution.resolvedAt + payloadRegistryIntegrity = $sourceResolution.registryIntegrity payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count nodeRuntimeVersion = $nodeRuntimeVersion @@ -271,7 +296,7 @@ function Invoke-PolicyValidation { [Parameter(Mandatory)] [string]$Root, - [string]$RequestedRef = $approvedCommit, + [string]$SnapshotHash = '', [switch]$PreserveBundle ) @@ -280,11 +305,22 @@ function Invoke-PolicyValidation { New-TestBundle -Root $Root } + $snapshotPath = Join-Path $Root 'source-resolution.json' + if (-not (Test-Path -LiteralPath $snapshotPath)) { + $sourceResolution | ConvertTo-Json | + Set-Content -LiteralPath $snapshotPath -Encoding utf8 + } + if ($SnapshotHash -eq '') { + $SnapshotHash = (Get-FileHash -LiteralPath $snapshotPath -Algorithm SHA256).Hash + } + & (Join-Path $PSScriptRoot 'Test-SigningInputs.ps1') ` -ArtifactsDirectory $Root ` -PolicyPath $policyPath ` -BundlePath (Join-Path $Root 'bundle\OpenClawGateway.msixbundle') ` - -RequestedRef $RequestedRef ` + -SourceResolutionPath $snapshotPath ` + -SourceResolutionSha256 $SnapshotHash ` + -WorkflowRunId '12345' ` -PackagingCommit $packagingCommit } @@ -483,11 +519,11 @@ try { Reset-TestArtifacts Assert-Fails ` - -MessagePattern 'approved immutable OpenClaw commit' ` + -MessagePattern 'trusted resolver output' ` -Action { Invoke-PolicyValidation ` -Root $testRoot ` - -RequestedRef 'v2026.8.2' + -SnapshotHash ('0' * 64) } Reset-TestArtifacts @@ -669,7 +705,7 @@ try { $x64MetadataPath = Join-Path $testRoot 'x64\msix-metadata.json' $x64Metadata = Get-Content -LiteralPath $x64MetadataPath -Raw | ConvertFrom-Json - $x64Metadata.payloadFileCount = 2 + $x64Metadata.payloadFileCount = 4 $x64Metadata | ConvertTo-Json | Set-Content -LiteralPath $x64MetadataPath -Encoding utf8 @@ -722,6 +758,49 @@ try { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } + foreach ($field in @( + 'payloadChannel', 'payloadReleaseTag', 'payloadTagObject', + 'payloadRegistryIntegrity', 'payloadResolvedAt' + )) { + Reset-TestArtifacts + $metadataPath = Join-Path $testRoot 'x64\msix-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + $metadata.$field = 'unexpected' + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + Assert-Fails -MessagePattern 'metadata is not eligible' -Action { + Invoke-PolicyValidation -Root $testRoot + } + } + + Reset-TestArtifacts + $override = $sourceResolution | ConvertTo-Json | ConvertFrom-Json + $override.requestedRef = $approvedCommit + $override.channel = '' + $override.releaseTag = '' + $override.tagObject = '' + $override.registryIntegrity = '' + $override | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $testRoot 'source-resolution.json') + Assert-Fails -MessagePattern 'channel' -Action { + Invoke-PolicyValidation -Root $testRoot + } + + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + '{"name":"openclaw","version":"2026.6.34"}' | + Set-Content -LiteralPath (Join-Path $Expanded 'app\package.json') + } + Assert-Fails -MessagePattern 'OpenClaw package version is unexpected' -Action { + Invoke-PolicyValidation -Root $testRoot + } + + Reset-TestArtifacts + New-TestBundle -Root $testRoot -BundleVersion '2026.6.34.0' + Assert-Fails -MessagePattern 'bundle manifest identity is unexpected' -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 62c9b4b1..8115919c 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -10,7 +10,15 @@ param( [string]$BundlePath, [Parameter(Mandatory)] - [string]$RequestedRef, + [string]$SourceResolutionPath, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$SourceResolutionSha256, + + [Parameter(Mandatory)] + [ValidatePattern('^[1-9][0-9]*$')] + [string]$WorkflowRunId, [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{40}$')] @@ -20,6 +28,7 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem +. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') function New-PackageEntryIndex { param( @@ -148,7 +157,13 @@ $resolvedArtifactsDirectory = ( Resolve-Path -LiteralPath $ArtifactsDirectory ).Path $resolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path -$policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | +$policy = Read-OpenClawReleasePolicy -Path $resolvedPolicyPath +$snapshotHash = (Get-FileHash ` + -LiteralPath $SourceResolutionPath -Algorithm SHA256).Hash +if ($snapshotHash -ine $SourceResolutionSha256) { + throw 'The source snapshot does not match the trusted resolver output.' +} +$snapshot = Get-Content -LiteralPath $SourceResolutionPath -Raw | ConvertFrom-Json if ( @@ -211,9 +226,14 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.packagingCommit -ine $expectedPackagingCommit -or $metadata.sourceTreeDirty -ne $false -or $metadata.payloadRepository -ne $policy.repository -or - $metadata.payloadRequestedRef -ine $approvedCommit -or + $metadata.payloadRequestedRef -cne $source.requestedRef -or $metadata.payloadResolvedCommit -ine $approvedCommit -or $metadata.payloadPackageVersion -ne $approvedPayloadVersion -or + $metadata.payloadChannel -cne $source.channel -or + $metadata.payloadReleaseTag -cne $source.releaseTag -or + $metadata.payloadTagObject -cne $source.tagObject -or + $metadata.payloadResolvedAt -ne $source.resolvedAt -or + $metadata.payloadRegistryIntegrity -cne $source.registryIntegrity -or $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or @@ -459,6 +479,7 @@ foreach ($architecture in @('x64', 'arm64')) { ) if ( $null -eq $identity -or + $identity.Name -ne 'OpenClaw.Gateway' -or $identity.Publisher -ne $policy.publisher -or $identity.ProcessorArchitecture -ne $architecture -or $identity.Version -ne $metadata.packageVersion @@ -466,6 +487,15 @@ foreach ($architecture in @('x64', 'arm64')) { throw "The $architecture MSIX manifest identity is unexpected." } + $applicationManifest = Read-ZipEntryText ` + -EntriesByPath $entriesByPath ` + -Path 'app/package.json' | + ConvertFrom-Json + if ($applicationManifest.name -cne 'openclaw' -or + $applicationManifest.version -cne $approvedPayloadVersion) { + throw "The embedded $architecture OpenClaw package version is unexpected." + } + $payloadFiles = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'payload/payload-files.json' | diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index d9393762..81966880 100644 --- a/scripts/Test-WorkflowPackageVersion.Tests.ps1 +++ b/scripts/Test-WorkflowPackageVersion.Tests.ps1 @@ -123,4 +123,20 @@ if ($secondBoundaryVersion -le $firstBoundaryVersion) { throw 'The package version did not increase across the rollover boundary.' } +$officialVersionScript = Join-Path $PSScriptRoot 'Test-OfficialReleaseVersion.ps1' +& $officialVersionScript -PackageVersion '2026.6.35.0' -ExistingTags @() +& $officialVersionScript -PackageVersion '2026.6.35.1' -ExistingTags @( + 'refs/tags/v0.0.0.0', 'refs/tags/v2026.6.35.0', 'refs/tags/unrelated' +) +& $officialVersionScript -PackageVersion '2026.7.33.0' -ExistingTags @( + 'refs/tags/v2026.6.35.9' +) +foreach ($version in @('2026.6.35.0', '2026.6.34.9')) { + Assert-Fails -MessagePattern 'not newer than existing official tag' -Action { + & $officialVersionScript -PackageVersion $version -ExistingTags @( + 'refs/tags/v2026.6.35.0' + ) + } +} + Write-Host 'Workflow package-version tests passed.' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index aae8fced..d2b24b6c 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -69,6 +69,21 @@ $requiredFragments = @( 'overwrite_files: false' 'fail_on_unmatched_files: true' 'release-assets/*.msixbundle' + 'release-assets/*.json' + 'name: Resolve immutable OpenClaw source' + 'name: Restore source snapshot for a retry' + 'name: Save source snapshot' + 'retention-days: 90' + 'ref: ${{ needs.resolve-source.outputs.source_sha }}' + 'EXPECTED_SNAPSHOT_HASH: ${{ needs.resolve-source.outputs.snapshot_sha256 }}' + 'PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }}' + '-SourceResolutionPath artifacts\source\source-resolution.json' + '-SourceResolutionSha256 $env:SNAPSHOT_SHA256' + '-WorkflowRunId $env:GITHUB_RUN_ID' + 'name: Reject duplicate or older official releases' + 'name: Recheck official release version before signing' + 'name: Recheck official release version before publication' + "group: gateway-msix-`${{ inputs.signing_mode == 'official' && 'official' || github.run_id }}" ) foreach ($fragment in $requiredFragments) { @@ -125,4 +140,20 @@ if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { throw 'Signing workflow must use OIDC, not an Azure client secret.' } +if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or + $workflow -match 'default:\s+[0-9a-f]{40}' -or + $workflow.Contains('-RequestedRef ', [StringComparison]::Ordinal)) { + throw 'The workflow must resolve the policy channel, not retain a second default pin or signing ref.' +} +if ($workflow.IndexOf('name: Enforce official signing policy', [StringComparison]::Ordinal) -gt + $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal)) { + throw 'Source and package authorization must precede Azure credentials.' +} +if ($workflow.IndexOf('name: Recheck official release version before signing', [StringComparison]::Ordinal) -gt + $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal) -or + $workflow.IndexOf('name: Recheck official release version before publication', [StringComparison]::Ordinal) -gt + $workflow.IndexOf('name: Create permanent GitHub release', [StringComparison]::Ordinal)) { + throw 'Signing and publication retries must recheck duplicate/downgrade protection.' +} + Write-Host 'Gateway MSIX signing workflow configuration passed.' diff --git a/scripts/Test-WorkflowSource.Tests.ps1 b/scripts/Test-WorkflowSource.Tests.ps1 new file mode 100644 index 00000000..dcd7fda5 --- /dev/null +++ b/scripts/Test-WorkflowSource.Tests.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptPath = Join-Path $PSScriptRoot 'Get-WorkflowSource.ps1' +$policyPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json' +$policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-workflow-source-$([guid]::NewGuid().ToString('N'))") +$snapshotPath = Join-Path $testRoot 'source-resolution.json' +$packagingCommit = '1' * 40 +$source = [ordered]@{ + repository = $policy.repository + requestedRef = $policy.channel + resolvedCommit = '2' * 40 + packageVersion = '2026.6.35' + channel = $policy.channel + releaseTag = 'v2026.6.35' + tagObject = '3' * 40 + resolvedAt = '2026-09-15T00:00:00.0000000Z' + registryIntegrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) + packagingCommit = $packagingCommit + workflowRunId = '12345' + workflowRunNumber = 42 + signingMode = 'official' + msixPackageVersion = "2026.6.35.$($policy.packageRevision)" + msixReleaseTag = "v2026.6.35.$($policy.packageRevision)" +} +$parameters = @{ + PolicyPath = $policyPath + OutputPath = $snapshotPath + SigningMode = 'official' + RunNumber = 42 + WorkflowRunId = '12345' + PackagingCommit = $packagingCommit +} + +$requests = [Collections.Generic.List[string]]::new() +$responses = @{} +function Invoke-RestMethod { + param($Uri, $Headers, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection, $ErrorAction) + if (-not $responses.ContainsKey([string]$Uri)) { + throw "Unexpected network request in workflow snapshot test: $Uri" + } + $requests.Add([string]$Uri) + return $responses[[string]$Uri] +} + +function Assert-Fails { + param([scriptblock]$Action, [string]$MessagePattern) + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw + } + return + } + throw "Expected failure matching '$MessagePattern'." +} + +try { + New-Item -ItemType Directory -Path $testRoot | Out-Null + Assert-Fails -MessagePattern 'snapshot is unavailable' -Action { + & $scriptPath @parameters -ReuseSnapshot + } + Assert-Fails -MessagePattern 'not an explicit source override' -Action { + & $scriptPath @parameters -Ref ('4' * 40) + } + + $source | ConvertTo-Json | Set-Content -LiteralPath $snapshotPath -Encoding utf8 + $hash = (Get-FileHash -LiteralPath $snapshotPath).Hash + $restored = & $scriptPath @parameters -ReuseSnapshot + if ($restored.resolvedCommit -cne $source.resolvedCommit -or + $restored.msixPackageVersion -cne $source.msixPackageVersion -or + (Get-FileHash -LiteralPath $snapshotPath).Hash -cne $hash) { + throw 'Reusing a snapshot changed its immutable identity or bytes.' + } + Assert-Fails -MessagePattern 'snapshot already exists' -Action { + & $scriptPath @parameters + } + + foreach ($field in @( + 'packagingCommit', 'workflowRunId', 'workflowRunNumber', + 'signingMode', 'msixPackageVersion', 'msixReleaseTag' + )) { + $mutated = $source | ConvertTo-Json | ConvertFrom-Json + $mutated.$field = 'unexpected' + $mutated | ConvertTo-Json | + Set-Content -LiteralPath $snapshotPath -Encoding utf8 + Assert-Fails -MessagePattern "unexpected workflow identity: $field" -Action { + & $scriptPath @parameters -ReuseSnapshot + } + } + + $source.signingMode = 'unsigned' + $source.msixPackageVersion = '0.1.42.1' + $source.msixReleaseTag = 'v0.1.42.1' + $parameters.SigningMode = 'unsigned' + $source | ConvertTo-Json | Set-Content -LiteralPath $snapshotPath -Encoding utf8 + $restored = & $scriptPath @parameters -ReuseSnapshot + if ($restored.msixPackageVersion -cne '0.1.42.1') { + throw 'An unsigned retry did not retain the original run-based MSIX version.' + } + Assert-Fails -MessagePattern 'requested selector' -Action { + & $scriptPath @parameters -Ref 'main' -ReuseSnapshot + } + + $baseUri = 'https://api.github.com/repos/openclaw/openclaw' + $registryUri = 'https://registry.npmjs.org/openclaw' + $responses["$registryUri/extended-stable"] = @{ + name = 'openclaw'; version = '2026.6.35' + } + $responses["$baseUri/git/ref/tags/v2026.6.35"] = @{ + ref = 'refs/tags/v2026.6.35' + object = @{ type = 'tag'; sha = '3' * 40 } + } + $responses["$baseUri/git/tags/$('3' * 40)"] = @{ + sha = '3' * 40 + tag = 'v2026.6.35' + verification = @{ verified = $true; reason = 'valid' } + object = @{ type = 'commit'; sha = '2' * 40 } + } + $responses["$baseUri/contents/package.json?ref=$('2' * 40)"] = @{ + type = 'file' + encoding = 'base64' + content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + '{"name":"openclaw","version":"2026.6.35"}')) + } + $responses["$registryUri/2026.6.35"] = @{ + name = 'openclaw'; version = '2026.6.35' + repository = $policy.repository + dist = @{ integrity = $source.registryIntegrity } + } + $freshParameters = $parameters.Clone() + $freshParameters.OutputPath = Join-Path $testRoot 'fresh\source-resolution.json' + $freshParameters.SigningMode = 'official' + $fresh = & $scriptPath @freshParameters + if ($fresh -isnot [pscustomobject] -or + $fresh.msixPackageVersion -cne "2026.6.35.$($policy.packageRevision)" -or + $fresh.resolvedCommit -cne ('2' * 40) -or $requests.Count -ne 5) { + throw 'A new workflow did not save the resolved source and derived release identity.' + } + $hash = (Get-FileHash -LiteralPath $freshParameters.OutputPath).Hash + $responses.Clear() + $replayed = & $scriptPath @freshParameters -ReuseSnapshot + if ($requests.Count -ne 5 -or $replayed.resolvedCommit -cne $fresh.resolvedCommit -or + (Get-FileHash -LiteralPath $freshParameters.OutputPath).Hash -cne $hash) { + throw 'A retry queried the channel or changed the original source snapshot.' + } + Write-Host 'Workflow source snapshot tests passed.' +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} From 0555fe7d32aed5d21042d8336afa703f41ab335e Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Tue, 15 Sep 2026 00:42:55 -0700 Subject: [PATCH 02/10] fix: build extended-stable sources without cache authority Validate legacy version/commit-derived Control UI identities as well as modern build IDs, bind both to the resolved source, and deny workflow cache access with native cache-mode none. Preserve the manual entry point and document the current CodeQL modeling gap for review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 10 ++- CONTRIBUTING.md | 11 +++ scripts/Test-OpenClawBuildIdentity.Tests.ps1 | 83 +++++++++++++++++-- scripts/Test-OpenClawBuildIdentity.ps1 | 57 +++++++++++-- scripts/Test-WorkflowSigningConfiguration.ps1 | 13 +++ 5 files changed, 158 insertions(+), 16 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 286c88be..aec0194c 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -30,6 +30,10 @@ permissions: contents: read pull-requests: read +# Upstream build/install scripts must never receive cache read/write authority, +# including manually selected refs running in the default-branch context. +cache-mode: none + concurrency: group: gateway-msix-${{ inputs.signing_mode == 'official' && 'official' || github.run_id }} cancel-in-progress: false @@ -123,8 +127,6 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json - cache: true - cache-dependency-path: Directory.Packages.props - name: Set up Node.js uses: actions/setup-node@v6 @@ -311,6 +313,8 @@ jobs: with: install-bun: "false" install-deps: "false" + use-actions-cache: "false" + save-actions-cache: "false" - name: Install dependencies if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} @@ -474,8 +478,6 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json - cache: true - cache-dependency-path: Directory.Packages.props - name: Restore NativeAOT and MSIX dependencies shell: pwsh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d489e21..ec3d31a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,7 @@ or package version logic: .\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-OpenClawSource.Tests.ps1 .\scripts\Test-WorkflowSource.Tests.ps1 +.\scripts\Test-OpenClawBuildIdentity.Tests.ps1 .\scripts\Test-WorkflowSigningConfiguration.ps1 .\scripts\Test-Build-MSIXBundle.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 @@ -73,6 +74,16 @@ saved snapshot. Never replace the published channel selection with a maintenance branch head or re-resolve it independently for each architecture. Changes to source metadata must stay synchronized across resolution, payload creation, MSIX composition, signing authorization, and release assets. +Keep build-identity coverage for both modern explicit Gateway/UI `buildId` +values and older extended-stable version/commit-derived UI identities. Both +must verify the generated Gateway provenance against the resolved source; +never skip identity validation merely because an older build lacks `buildId`. +The workflow denies cache access with native `cache-mode: none`; do not add +job-level overrides or re-enable cache actions. Read-only `GITHUB_TOKEN` +permissions and disabling an action's cache input alone are not sufficient +protection from cache poisoning by upstream scripts. CodeQL currently does not +model this native cache restriction; keep the query enabled and review the +documented false positives rather than hiding checkout or execution. Official signing trusts the verified channel snapshot rather than a reviewed per-release commit allowlist. It still requires `main`, the protected signing environment, and all artifact checks. For packaging-only official corrections, diff --git a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 index ede7de61..312ff9e8 100644 --- a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -8,6 +8,12 @@ $scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1' $testRoot = Join-Path ` ([IO.Path]::GetTempPath()) ` "openclaw-build-identity-$([guid]::NewGuid().ToString('N'))" +$expectedVersion = '2026.6.35' +$expectedCommit = 'c283867d7cdd1a93cfc58f829c849834c4426d3b' +$validationParameters = @{ + ExpectedPackageVersion = $expectedVersion + ExpectedSourceCommit = $expectedCommit +} function New-BuildFixture { param( @@ -19,7 +25,9 @@ function New-BuildFixture { [string]$ControlUiBuildId, - [switch]$OmitControlUiBuildId + [switch]$OmitControlUiBuildId, + + [switch]$Legacy ) $root = Join-Path $testRoot $Name @@ -28,9 +36,20 @@ function New-BuildFixture { $assetsDirectory = Join-Path $controlUiDirectory 'assets' New-Item -Path $assetsDirectory -ItemType Directory -Force | Out-Null - @{ buildId = $GatewayBuildId } | + $buildInfo = @{ + version = $expectedVersion + commit = $expectedCommit + builtAt = '2026-09-15T07:00:00.000Z' + } + if (-not $Legacy) { + $buildInfo.buildId = $GatewayBuildId + } + $buildInfo | ConvertTo-Json | Set-Content (Join-Path $distDirectory 'build-info.json') -Encoding utf8 + @{ name = 'openclaw'; version = $expectedVersion } | + ConvertTo-Json | + Set-Content (Join-Path $root 'package.json') -Encoding utf8 if ($OmitControlUiBuildId) { 'const CACHE_NAME = "openclaw-control-ui";' | @@ -86,7 +105,7 @@ try { -Name 'matching' ` -GatewayBuildId 'release-build-a' ` -ControlUiBuildId 'release-build-a' - & $scriptPath -OpenClawDirectory $matching + & $scriptPath -OpenClawDirectory $matching @validationParameters # Missing UI identity is unsafe because packaging could not prove which # dashboard build will connect to the Gateway. @@ -95,7 +114,7 @@ try { -GatewayBuildId 'release-build-a' ` -OmitControlUiBuildId Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { - & $scriptPath -OpenClawDirectory $missing + & $scriptPath -OpenClawDirectory $missing @validationParameters } # A separately rebuilt dashboard must fail even when both artifacts are @@ -105,7 +124,61 @@ try { -GatewayBuildId 'release-build-a' ` -ControlUiBuildId 'release-build-b' Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { - & $scriptPath -OpenClawDirectory $mismatched + & $scriptPath -OpenClawDirectory $mismatched @validationParameters + } + + $legacyId = "$expectedVersion-$($expectedCommit.Substring(0, 12))" + $legacy = New-BuildFixture -Name 'legacy' ` + -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy + & $scriptPath -OpenClawDirectory $legacy @validationParameters + $legacyMismatch = New-BuildFixture -Name 'legacy-mismatch' ` + -GatewayBuildId 'unused' -ControlUiBuildId "$expectedVersion-aaaaaaaaaaaa" -Legacy + Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { + & $scriptPath -OpenClawDirectory $legacyMismatch @validationParameters + } + $legacyMissingUi = New-BuildFixture -Name 'legacy-missing-ui' ` + -GatewayBuildId 'unused' -OmitControlUiBuildId -Legacy + Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { + & $scriptPath -OpenClawDirectory $legacyMissingUi @validationParameters + } + + foreach ($field in @('version', 'commit')) { + $fixture = New-BuildFixture -Name "wrong-$field" ` + -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy + $path = Join-Path $fixture 'dist\build-info.json' + $info = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $info.$field = 'unexpected' + $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 + Assert-Fails -MessagePattern 'does not match the resolved OpenClaw source' -Action { + & $scriptPath -OpenClawDirectory $fixture @validationParameters + } + $info.PSObject.Properties.Remove($field) + $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 + Assert-Fails -MessagePattern "provenance is missing '$field'" -Action { + & $scriptPath -OpenClawDirectory $fixture @validationParameters + } + } + + $manifestMismatch = New-BuildFixture -Name 'manifest-mismatch' ` + -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy + '{"name":"openclaw","version":"2026.6.34"}' | + Set-Content -LiteralPath (Join-Path $manifestMismatch 'package.json') + Assert-Fails -MessagePattern 'does not match the resolved OpenClaw source' -Action { + & $scriptPath -OpenClawDirectory $manifestMismatch @validationParameters + } + + foreach ($invalidId in @('', $null, 123)) { + $path = Join-Path $legacy 'dist\build-info.json' + $info = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $info | Add-Member -NotePropertyName buildId -NotePropertyValue $invalidId -Force + $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 + Assert-Fails -MessagePattern 'Gateway build identity is missing or invalid' -Action { + & $scriptPath -OpenClawDirectory $legacy @validationParameters + } + } + Remove-Item -LiteralPath (Join-Path $matching 'dist\control-ui\assets\app.js') + Assert-Fails -MessagePattern 'client bundle does not contain Gateway build identity' -Action { + & $scriptPath -OpenClawDirectory $matching @validationParameters } } finally { diff --git a/scripts/Test-OpenClawBuildIdentity.ps1 b/scripts/Test-OpenClawBuildIdentity.ps1 index db56d5f5..8398d3f0 100644 --- a/scripts/Test-OpenClawBuildIdentity.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.ps1 @@ -1,21 +1,31 @@ [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$OpenClawDirectory + [string]$OpenClawDirectory, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{40}$')] + [string]$ExpectedSourceCommit, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ExpectedPackageVersion ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# A release is safe to package only when the Gateway and its same-origin -# dashboard were emitted by the same OpenClaw build lifecycle. +# Verify both upstream identity formats without treating missing provenance +# or a mismatched dashboard as a successful legacy build. $distDirectory = Join-Path $OpenClawDirectory 'dist' $buildInfoPath = Join-Path $distDirectory 'build-info.json' $controlUiDirectory = Join-Path $distDirectory 'control-ui' $serviceWorkerPath = Join-Path $controlUiDirectory 'sw.js' $assetsDirectory = Join-Path $controlUiDirectory 'assets' +$packageManifestPath = Join-Path $OpenClawDirectory 'package.json' foreach ($requiredPath in @( + $packageManifestPath $buildInfoPath $serviceWorkerPath $assetsDirectory @@ -27,9 +37,37 @@ foreach ($requiredPath in @( $buildInfo = Get-Content -LiteralPath $buildInfoPath -Raw | ConvertFrom-Json -$gatewayBuildId = [string]$buildInfo.buildId -if ([string]::IsNullOrWhiteSpace($gatewayBuildId)) { - throw "Gateway build identity is missing from '$buildInfoPath'." +$packageManifest = Get-Content -LiteralPath $packageManifestPath -Raw | + ConvertFrom-Json +foreach ($field in @('version', 'commit')) { + $property = $buildInfo.PSObject.Properties[$field] + if ($null -eq $property -or $property.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace($property.Value)) { + throw "Gateway build provenance is missing '$field' in '$buildInfoPath'." + } +} +if ($buildInfo.version -cne $ExpectedPackageVersion -or + $buildInfo.commit -ine $ExpectedSourceCommit -or + $packageManifest.name -cne 'openclaw' -or + $packageManifest.version -cne $ExpectedPackageVersion) { + throw 'Gateway build provenance does not match the resolved OpenClaw source.' +} + +$buildIdProperty = $buildInfo.PSObject.Properties['buildId'] +if ($null -ne $buildIdProperty) { + if ($buildIdProperty.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace($buildIdProperty.Value)) { + throw "Gateway build identity is missing or invalid in '$buildInfoPath'." + } + $gatewayBuildId = $buildIdProperty.Value +} +else { + # Older upstream Vite builds use version + 12-character Git SHA rather + # than emitting a shared buildId in dist/build-info.json. + $shortCommit = $ExpectedSourceCommit.ToLowerInvariant().Substring(0, 12) + $gatewayBuildId = [regex]::Replace( + "$ExpectedPackageVersion-$shortCommit", '[^a-zA-Z0-9._-]+', '-') + $gatewayBuildId = $gatewayBuildId.Substring(0, [Math]::Min(96, $gatewayBuildId.Length)) } # Vite writes the dashboard identity into the service worker so stale browser @@ -76,4 +114,9 @@ if (-not $clientBundleContainsBuildId) { ) } -Write-Host "OpenClaw Gateway and Control UI build identity match: $gatewayBuildId" +if ($null -eq $buildIdProperty) { + Write-Host "Legacy Control UI identity matches verified source provenance: $gatewayBuildId" +} +else { + Write-Host "OpenClaw Gateway and Control UI build identity match: $gatewayBuildId" +} diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index d2b24b6c..60923cc0 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -76,6 +76,12 @@ $requiredFragments = @( 'retention-days: 90' 'ref: ${{ needs.resolve-source.outputs.source_sha }}' 'EXPECTED_SNAPSHOT_HASH: ${{ needs.resolve-source.outputs.snapshot_sha256 }}' + 'EXPECTED_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.source_sha }}' + 'EXPECTED_PACKAGE_VERSION: ${{ needs.resolve-source.outputs.source_version }}' + '-ExpectedSourceCommit $env:EXPECTED_SOURCE_COMMIT' + '-ExpectedPackageVersion $env:EXPECTED_PACKAGE_VERSION' + 'use-actions-cache: "false"' + 'save-actions-cache: "false"' 'PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }}' '-SourceResolutionPath artifacts\source\source-resolution.json' '-SourceResolutionSha256 $env:SNAPSHOT_SHA256' @@ -140,6 +146,13 @@ if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { throw 'Signing workflow must use OIDC, not an Azure client secret.' } +$cacheModes = [regex]::Matches($workflow, '(?m)^\s*cache-mode:\s*(?\S+)') +if ($cacheModes.Count -ne 1 -or + $workflow -notmatch '(?m)^cache-mode: none\s*$' -or + $workflow -match '(?m)^\s*cache:\s*true\s*$') { + throw 'Every job must inherit native cache-mode: none, without cache overrides or opt-ins.' +} + if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or $workflow -match 'default:\s+[0-9a-f]{40}' -or $workflow.Contains('-RequestedRef ', [StringComparison]::Ordinal)) { From e161f5e121925f7d1a0a6a81ab9f6e9f6d1d7c12 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Tue, 15 Sep 2026 00:52:08 -0700 Subject: [PATCH 03/10] fix: use packing options supported by extended stable Let the selected upstream packer own its package-manager and changelog defaults. Retain its inventory and tarball validation without passing newer-only switches that older extended-stable releases reject. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 2 -- scripts/Test-WorkflowSigningConfiguration.ps1 | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index aec0194c..5280f326 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -333,9 +333,7 @@ jobs: artifact_dir="${RUNNER_TEMP}/openclaw-package" mkdir -p "${artifact_dir}" node scripts/package-openclaw-for-docker.mjs \ - --allow-unreleased-changelog \ --skip-build \ - --pnpm-pack \ --output-dir "${artifact_dir}" \ --output-name openclaw.tgz diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 60923cc0..ac60ead0 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -158,6 +158,10 @@ if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or $workflow.Contains('-RequestedRef ', [StringComparison]::Ordinal)) { throw 'The workflow must resolve the policy channel, not retain a second default pin or signing ref.' } +if ($workflow.Contains('--allow-unreleased-changelog', [StringComparison]::Ordinal) -or + $workflow.Contains('--pnpm-pack', [StringComparison]::Ordinal)) { + throw 'Use the shared upstream packer options and its defaults, not switches absent from extended stable.' +} if ($workflow.IndexOf('name: Enforce official signing policy', [StringComparison]::Ordinal) -gt $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal)) { throw 'Source and package authorization must precede Azure credentials.' From a715ca834d202b5a2b08fc0fddbf91e47fe963b8 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Tue, 15 Sep 2026 15:41:31 -0700 Subject: [PATCH 04/10] fix: keep MSIX builds on the stable release channel Resolve stable through npm latest and reject extended-stable or prerelease sources, including explicit refs and legacy payload inputs. Allow only a reviewed exact stableVersion compatibility pin, with no automatic fallback. Preserve source/signing checks, map stable numeric corrections safely, and update regression coverage and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 2 +- CONTRIBUTING.md | 11 +- scripts/Build-MSIX.ps1 | 13 +- scripts/Build-Payload.ps1 | 3 +- scripts/Get-WorkflowSource.ps1 | 6 +- scripts/OpenClawSource.ps1 | 166 +++++++-- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 39 +- scripts/Test-OpenClawBuildIdentity.Tests.ps1 | 8 +- scripts/Test-OpenClawSource.Tests.ps1 | 339 ++++++++++++++++-- scripts/Test-SigningInputs.Tests.ps1 | 15 +- scripts/Test-WorkflowSigningConfiguration.ps1 | 11 +- scripts/Test-WorkflowSource.Tests.ps1 | 72 +++- 12 files changed, 582 insertions(+), 103 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 5280f326..c7259c9d 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -1070,7 +1070,7 @@ jobs: release-assets/*.json body: | Packages OpenClaw `${{ needs.resolve-source.outputs.source_version }}` selected - from the upstream `extended-stable` channel for this workflow run, + by the `stable` release policy for this workflow run, from [`openclaw/openclaw@${{ needs.resolve-source.outputs.source_sha }}`](https://github.com/openclaw/openclaw/commit/${{ needs.resolve-source.outputs.source_sha }}). The source snapshot and architecture metadata are included as release assets. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec3d31a8..d1aaab3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,13 +69,18 @@ local fixture; it does not download or build OpenClaw. The channel resolver tests use offline registry and GitHub fixtures. Keep source selection separate from the source build: new workflow runs resolve -`release-policy.json`'s `extended-stable` channel once, and retries reuse the +`release-policy.json`'s `stable` channel (`openclaw@latest`) once, and retries reuse the saved snapshot. Never replace the published channel selection with a maintenance branch head or re-resolve it independently for each architecture. +There is no automatic fallback. An incompatible latest release may be replaced +only by an explicitly reviewed, known-good stable `stableVersion` policy pin. +Never fall back to extended stable. Explicit refs and legacy payload inputs +must also have regular stable versions; numeric stable corrections are +supported, but their published and source package versions must match. Changes to source metadata must stay synchronized across resolution, payload creation, MSIX composition, signing authorization, and release assets. Keep build-identity coverage for both modern explicit Gateway/UI `buildId` -values and older extended-stable version/commit-derived UI identities. Both +values and older stable version/commit-derived UI identities. Both must verify the generated Gateway provenance against the resolved source; never skip identity validation merely because an older build lacks `buildId`. The workflow denies cache access with native `cache-mode: none`; do not add @@ -88,6 +93,8 @@ Official signing trusts the verified channel snapshot rather than a reviewed per-release commit allowlist. It still requires `main`, the protected signing environment, and all artifact checks. For packaging-only official corrections, increase the policy's `packageRevision`; do not overwrite an existing release. +For numeric upstream corrections, add that correction number to the packaging +revision for the fourth MSIX component, and reject overflow or version reuse. Run the NativeAOT publish when you change host JSON, reflection, interop, or anything else that is trimming-sensitive. A JIT `dotnet build` does not diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 42a041c0..31d3bf87 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -209,8 +209,9 @@ if ( throw 'Payload metadata is not valid for this MSIX package.' } +. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') +Assert-OpenClawSourceVersion -Version $payloadInfo.packageVersion -Final if ($null -ne $payloadSelection.channel) { - . (Join-Path $PSScriptRoot 'OpenClawSource.ps1') $policy = Read-OpenClawReleasePolicy -Path ( Join-Path $repositoryRoot 'release-policy.json') Assert-OpenClawSource -Source $payloadInfo -Policy $policy @@ -233,6 +234,16 @@ if (-not (Test-Path ` -PathType Leaf)) { throw 'Expanded payload does not contain openclaw.mjs.' } +$applicationManifestPath = Join-Path $payloadApplication 'package.json' +if (-not (Test-Path -LiteralPath $applicationManifestPath -PathType Leaf)) { + throw 'Expanded payload does not contain package.json.' +} +$applicationManifest = Get-Content -LiteralPath $applicationManifestPath -Raw | + ConvertFrom-Json +if ($applicationManifest.name -cne 'openclaw' -or + $applicationManifest.version -cne $payloadInfo.packageVersion) { + throw 'The expanded application does not match the payload package version.' +} Assert-ApplicationHasNoReparsePoints -Path $payloadApplication Assert-ApplicationDoesNotBundleNode -Path $payloadApplication diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index f5c48695..0d7fd777 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -29,8 +29,9 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { $sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json $sourceSelection = $sourceMetadata | Select-Object channel, releaseTag, tagObject, resolvedAt, registryIntegrity +. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') +Assert-OpenClawSourceVersion -Version $sourceMetadata.packageVersion -Final if ($null -ne $sourceMetadata.PSObject.Properties['channel']) { - . (Join-Path $PSScriptRoot 'OpenClawSource.ps1') $policy = Read-OpenClawReleasePolicy -Path ( Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') Assert-OpenClawSource -Source $sourceMetadata -Policy $policy diff --git a/scripts/Get-WorkflowSource.ps1 b/scripts/Get-WorkflowSource.ps1 index 94a14ec4..b460cd09 100644 --- a/scripts/Get-WorkflowSource.ps1 +++ b/scripts/Get-WorkflowSource.ps1 @@ -49,7 +49,7 @@ else { Assert-OpenClawSource -Source $source -Policy $policy ` -RequireChannel:($SigningMode -eq 'official') -$expectedRef = if ($Ref -eq '') { $policy.channel } else { $Ref } +$expectedRef = if ($Ref -eq '') { Get-OpenClawPolicyRef -Policy $policy } else { $Ref } if ($source.requestedRef -cne $expectedRef) { throw 'The source snapshot does not match the requested selector.' } @@ -59,8 +59,8 @@ $versionParameters = @{ RunAttempt = 1 } if ($SigningMode -eq 'official') { - $versionParameters.ReleaseVersion = - "$($source.packageVersion).$($policy.packageRevision)" + $versionParameters.ReleaseVersion = Get-OpenClawMsixReleaseVersion ` + -Version $source.packageVersion -PackageRevision $policy.packageRevision } $packageVersion = & (Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1') ` @versionParameters diff --git a/scripts/OpenClawSource.ps1 b/scripts/OpenClawSource.ps1 index f0b0cd96..38b2bf57 100644 --- a/scripts/OpenClawSource.ps1 +++ b/scripts/OpenClawSource.ps1 @@ -52,6 +52,26 @@ function Assert-OpenClawSourceText { } } +function Get-OpenClawStableVersionMatch { + param( + [AllowNull()] + [object]$Version, + [string]$Name = 'packageVersion' + ) + + Assert-OpenClawSourceText $Version $Name + # Upstream reserves patch 33+ for extended stable; numeric suffixes are stable corrections. + $match = [regex]::Match( + $Version, + '\A(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.' + + '(?[1-9]|[12][0-9]|3[0-2])(?:-(?[1-9][0-9]*))?\z' + ) + if (-not $match.Success) { + throw "'$Name' must be a regular stable version (YYYY.M.P or YYYY.M.P-C)." + } + return $match +} + function Assert-OpenClawSourceVersion { param( [AllowNull()] @@ -59,17 +79,65 @@ function Assert-OpenClawSourceVersion { [switch]$Final ) - $number = '(?:0|[1-9][0-9]*)' - if ($Final) { - $pattern = "\A[1-9][0-9]{3}\.$number\.$number\z" + # Retain -Final for packaging callers; both modes now require regular stable. + $null = Get-OpenClawStableVersionMatch -Version $Version +} + +function Assert-OpenClawSourceRef { + param( + [AllowNull()] + [object]$Ref, + [string]$Name = 'Ref' + ) + + Assert-OpenClawSourceText $Ref $Name -Pattern '\A\S+\z' + if ($Ref -match '\A(?:refs/(?:heads|tags)/)?extended-stable(?:/|\z)') { + throw "'$Name' cannot select extended-stable." } - else { - $identifier = "(?:$number|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" - $pattern = "\A$number\.$number\.$number" + - "(?:-$identifier(?:\.$identifier)*)?" + - '(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z' +} + +function Assert-OpenClawPackageRevision { + param( + [AllowNull()] + [object]$PackageRevision + ) + + if (($PackageRevision -isnot [long] -and $PackageRevision -isnot [int]) -or + $PackageRevision -lt 0 -or $PackageRevision -gt 65534) { + throw 'packageRevision must be a JSON integer between 0 and 65534.' } - Assert-OpenClawSourceText -Value $Version -Name 'packageVersion' -Pattern $pattern +} + +function Get-OpenClawMsixReleaseVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object]$Version, + [Parameter(Mandatory)] + [AllowNull()] + [object]$PackageRevision + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $match = Get-OpenClawStableVersionMatch -Version $Version + Assert-OpenClawPackageRevision -PackageRevision $PackageRevision + $correction = 0 + if ($match.Groups['correction'].Success -and ( + -not [int]::TryParse( + $match.Groups['correction'].Value, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$correction + ) -or $correction -gt (65534 - $PackageRevision) + )) { + throw 'The stable correction plus packageRevision exceeds 65534.' + } + $revision = $correction + $PackageRevision + return '{0}.{1}.{2}.{3}' -f $match.Groups['year'].Value, + $match.Groups['month'].Value, $match.Groups['patch'].Value, $revision } function Assert-OpenClawRegistryIntegrity { @@ -106,13 +174,32 @@ function Assert-OpenClawReleasePolicy { if ($repository -cne 'https://github.com/openclaw/openclaw') { throw 'The release policy repository must be https://github.com/openclaw/openclaw.' } - if ($channel -cne 'extended-stable') { - throw 'The release policy channel must be extended-stable.' + if ($channel -cne 'stable') { + throw 'The release policy channel must be stable.' } - if (($revision -isnot [long] -and $revision -isnot [int]) -or - $revision -lt 0 -or $revision -gt 65534) { - throw 'packageRevision must be a JSON integer between 0 and 65534.' + Assert-OpenClawPackageRevision -PackageRevision $revision + $pin = Get-OpenClawSourceField $Policy 'stableVersion' -Optional + if ($null -ne $pin) { + $null = Get-OpenClawStableVersionMatch -Version $pin -Name 'stableVersion' + } +} + +function Get-OpenClawPolicyRef { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Policy + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + Assert-OpenClawReleasePolicy -Policy $Policy + $pin = Get-OpenClawSourceField $Policy 'stableVersion' -Optional + if ($null -ne $pin) { + return $pin } + return 'stable' } function Read-OpenClawReleasePolicy { @@ -155,9 +242,15 @@ function Invoke-OpenClawGitHubRequest { $segment = '(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+' $allowedPath = '\A(?:commits/' + $segment + - '|git/ref/tags/v[1-9][0-9]{3}\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)' + '|git/tags/[0-9a-f]{40}|contents/package\.json\?ref=[0-9a-f]{40})\z' - Assert-OpenClawSourceText $Path 'GitHub API path' -Pattern $allowedPath + Assert-OpenClawSourceText $Path 'GitHub API path' + $tagPrefix = 'git/ref/tags/v' + if ($Path.StartsWith($tagPrefix, [StringComparison]::Ordinal)) { + Assert-OpenClawSourceVersion -Version $Path.Substring($tagPrefix.Length) + } + else { + Assert-OpenClawSourceText $Path 'GitHub API path' -Pattern $allowedPath + } $headers = @{ Accept = 'application/vnd.github+json' 'User-Agent' = 'OpenClaw-Gateway-MSIX' @@ -186,8 +279,8 @@ function Invoke-OpenClawRegistryRequest { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - if ($Selector -cne 'extended-stable') { - Assert-OpenClawSourceVersion -Version $Selector -Final + if ($Selector -cne 'latest') { + Assert-OpenClawSourceVersion -Version $Selector } $encodedSelector = [Uri]::EscapeDataString($Selector) $requestOptions = Get-OpenClawRequestOptions @@ -240,7 +333,7 @@ function Resolve-OpenClawSource { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - Assert-OpenClawReleasePolicy -Policy $Policy + $policyRef = Get-OpenClawPolicyRef -Policy $Policy Assert-OpenClawSourceText $Ref 'Ref' -AllowEmpty $channel = '' $releaseTag = '' @@ -248,7 +341,7 @@ function Resolve-OpenClawSource { $integrity = '' if ($Ref.Length -gt 0) { - Assert-OpenClawSourceText $Ref 'Ref' -Pattern '\A\S+\z' + Assert-OpenClawSourceRef -Ref $Ref $requestedRef = $Ref $escapedRef = [Uri]::EscapeDataString($Ref) $commitResponse = Invoke-OpenClawGitHubRequest -Path "commits/$escapedRef" @@ -259,15 +352,19 @@ function Resolve-OpenClawSource { } else { $channel = Get-OpenClawSourceField $Policy 'channel' - $requestedRef = $channel - $selection = Invoke-OpenClawRegistryRequest -Selector $channel + $requestedRef = $policyRef + $selector = if ($policyRef -ceq 'stable') { 'latest' } else { $policyRef } + $selection = Invoke-OpenClawRegistryRequest -Selector $selector $name = Get-OpenClawSourceField $selection 'name' Assert-OpenClawSourceText $name 'registry package name' if ($name -cne 'openclaw') { throw 'The registry channel must resolve to the openclaw package.' } $version = Get-OpenClawSourceField $selection 'version' - Assert-OpenClawSourceVersion -Version $version -Final + Assert-OpenClawSourceVersion -Version $version + if ($policyRef -cne 'stable' -and $version -cne $policyRef) { + throw 'The registry package version does not match the stableVersion pin.' + } $releaseTag = "v$version" $tagRef = Invoke-OpenClawGitHubRequest -Path "git/ref/tags/$releaseTag" $refLabel = Get-OpenClawSourceField $tagRef 'ref' @@ -308,12 +405,15 @@ function Resolve-OpenClawSource { throw 'The immutable source package version does not match the selected release.' } - # Re-read the exact version, not the mutable selector, for the release evidence. - $manifest = Invoke-OpenClawRegistryRequest -Selector $version + # A pin already fetched the exact manifest; latest needs an immutable version lookup. + $manifest = $selection + if ($policyRef -ceq 'stable') { + $manifest = Invoke-OpenClawRegistryRequest -Selector $version + } $manifestName = Get-OpenClawSourceField $manifest 'name' $manifestVersion = Get-OpenClawSourceField $manifest 'version' Assert-OpenClawSourceText $manifestName 'registry package name' - Assert-OpenClawSourceVersion -Version $manifestVersion -Final + Assert-OpenClawSourceVersion -Version $manifestVersion if ($manifestName -cne 'openclaw' -or $manifestVersion -cne $version) { throw 'The exact registry manifest does not match the selected package version.' } @@ -368,7 +468,7 @@ function Assert-OpenClawSource { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - Assert-OpenClawReleasePolicy -Policy $Policy + $policyRef = Get-OpenClawPolicyRef -Policy $Policy $values = @{} foreach ($field in @( 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', @@ -388,8 +488,9 @@ function Assert-OpenClawSource { if ($values.repository -cne (Get-OpenClawSourceField $Policy 'repository')) { throw 'The source repository does not match the release policy.' } - Assert-OpenClawSourceText $values.requestedRef 'requestedRef' -Pattern '\A\S+\z' + Assert-OpenClawSourceRef $values.requestedRef 'requestedRef' Assert-OpenClawSourceText $values.resolvedCommit 'resolvedCommit' -Pattern '\A[0-9a-f]{40}\z' + Assert-OpenClawSourceVersion $values.packageVersion Assert-OpenClawSourceText ` $values.resolvedAt 'resolvedAt' ` -Pattern '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|\+00:00)\z' @@ -405,10 +506,12 @@ function Assert-OpenClawSource { if ($values.channel.Length -gt 0) { $policyChannel = Get-OpenClawSourceField $Policy 'channel' - if ($values.channel -cne $policyChannel -or $values.requestedRef -cne $policyChannel) { - throw 'The source channel and requestedRef must match the release policy channel.' + if ($values.channel -cne $policyChannel -or $values.requestedRef -cne $policyRef) { + throw 'The source channel and requestedRef must match the release policy.' + } + if ($policyRef -cne 'stable' -and $values.packageVersion -cne $policyRef) { + throw 'The source packageVersion must match the stableVersion pin.' } - Assert-OpenClawSourceVersion $values.packageVersion -Final if ($values.releaseTag -cne "v$($values.packageVersion)") { throw 'The releaseTag must match the source package version.' } @@ -419,7 +522,6 @@ function Assert-OpenClawSource { if ($RequireChannel) { throw 'Official signing requires a channel-resolved source, not a ref override.' } - Assert-OpenClawSourceVersion $values.packageVersion if ($values.releaseTag.Length -ne 0 -or $values.tagObject.Length -ne 0 -or $values.registryIntegrity.Length -ne 0) { throw 'A ref override must have empty releaseTag, tagObject, and registryIntegrity fields.' diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 5edc6f99..e5c29d25 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -112,7 +112,7 @@ console.log(JSON.stringify({ repository = 'https://github.com/openclaw/openclaw' requestedRef = '1' * 40 resolvedCommit = '1' * 40 - packageVersion = '0.0.0' + packageVersion = '2026.9.4' channel = '' releaseTag = '' tagObject = '' @@ -138,13 +138,23 @@ console.log(JSON.stringify({ } } - $sourceMetadata.packageVersion = '0.0.1' + $sourceMetadata.packageVersion = '2026.9.3' $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" Assert-Fails -MessagePattern 'does not match the source identity' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package -Architecture x64 -OutputDirectory $payload } - $sourceMetadata.packageVersion = '0.0.0' + $sourceMetadata.packageVersion = '2026.9.4' + + foreach ($rejectedVersion in @('2026.6.35', '2026.9.4-beta.1')) { + $sourceMetadata.packageVersion = $rejectedVersion + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + Assert-Fails -MessagePattern 'packageVersion' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + } + } + $sourceMetadata.packageVersion = '2026.9.4' $reusedPayload = Join-Path $testRoot 'payload-reused' & "$PSScriptRoot\Build-Payload.ps1" ` @@ -271,6 +281,29 @@ console.log(JSON.stringify({ -OutputDirectory "$testRoot\msix" } + $metadata.nodeVersion = '24.20.0' + $metadata.architecture = 'x64' + $matchingArchive = Join-Path $testRoot 'node-v24.20.0-win-x64.zip' + Set-Content -LiteralPath $matchingArchive -Value 'not reached' + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + '{"name":"openclaw","version":"2026.6.35"}' | + Set-Content -LiteralPath (Join-Path $payload 'app\package.json') + Assert-Fails -MessagePattern 'application does not match the payload package version' -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -NodeArchivePath $matchingArchive ` + -Architecture x64 -PackageVersion '0.1.1.0' -SourceCommit ('1' * 40) ` + -OutputDirectory "$testRoot\msix" + } + $legacyMetadata = $metadata | + Select-Object * -ExcludeProperty channel, releaseTag, tagObject, resolvedAt, registryIntegrity + $legacyMetadata.packageVersion = '2026.6.35' + $legacyMetadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + Assert-Fails -MessagePattern 'packageVersion' -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -Architecture x64 -PackageVersion '0.1.1.0' ` + -SourceCommit ('1' * 40) -OutputDirectory "$testRoot\msix" + } + Write-Host 'Node.js source and packaging input tests passed.' } finally { diff --git a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 index 312ff9e8..1a9d2de5 100644 --- a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -8,8 +8,8 @@ $scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1' $testRoot = Join-Path ` ([IO.Path]::GetTempPath()) ` "openclaw-build-identity-$([guid]::NewGuid().ToString('N'))" -$expectedVersion = '2026.6.35' -$expectedCommit = 'c283867d7cdd1a93cfc58f829c849834c4426d3b' +$expectedVersion = '2026.6.5' +$expectedCommit = 'a' * 40 $validationParameters = @{ ExpectedPackageVersion = $expectedVersion ExpectedSourceCommit = $expectedCommit @@ -132,7 +132,7 @@ try { -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy & $scriptPath -OpenClawDirectory $legacy @validationParameters $legacyMismatch = New-BuildFixture -Name 'legacy-mismatch' ` - -GatewayBuildId 'unused' -ControlUiBuildId "$expectedVersion-aaaaaaaaaaaa" -Legacy + -GatewayBuildId 'unused' -ControlUiBuildId "$expectedVersion-bbbbbbbbbbbb" -Legacy Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { & $scriptPath -OpenClawDirectory $legacyMismatch @validationParameters } @@ -161,7 +161,7 @@ try { $manifestMismatch = New-BuildFixture -Name 'manifest-mismatch' ` -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy - '{"name":"openclaw","version":"2026.6.34"}' | + '{"name":"openclaw","version":"2026.6.6"}' | Set-Content -LiteralPath (Join-Path $manifestMismatch 'package.json') Assert-Fails -MessagePattern 'does not match the resolved OpenClaw source' -Action { & $scriptPath -OpenClawDirectory $manifestMismatch @validationParameters diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 index b749ed3a..7865f9d3 100644 --- a/scripts/Test-OpenClawSource.Tests.ps1 +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -20,13 +20,20 @@ if ($definitionOutput.Count -ne 0) { } $githubTransport = ${function:Invoke-OpenClawGitHubRequest} $registryTransport = ${function:Invoke-OpenClawRegistryRequest} -$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( - "openclaw-source-tests-$([guid]::NewGuid().ToString('N'))") +$testRoot = Join-Path (Split-Path $PSScriptRoot -Parent) ( + ".openclaw-source-tests-$([guid]::NewGuid().ToString('N'))") $commit = 'c283867d7cdd1a93cfc58f829c849834c4426d3b' -$tagObject = '3f0cb2ac4b8222e5d7fe9930f3aa693b2d68ac87' -$version = '2026.6.35' +$tagObject = '8bec206f3c1f787e1e9c45cfd34d3de2a78c7b8e' +$version = '2026.9.4' $integrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) $testCount = 0 +$invalidStableVersions = @( + '2026.09.4', '2026.0.4', '2026.13.4', '2026.9.0', '2026.9.33', + '2026.6.35', '2026.9.33-1', '2026.9.999', '2026.9.04', '2026.9.4-0', + '2026.9.4-01', '2026.9.4-beta.1', '2026.9.4-alpha.1', '2026.9.4+build.1', + '2026.9.4-1+build.1', '2026.9.4.1', 'v2026.9.4', '1.2.3', '0.0.0', + '2026.9.4?redirect=evil', "2026.9.4`nextra=bad", @('2026.9.4'), 2026 +) function Assert-TestEqual { param($Actual, $Expected) @@ -62,7 +69,7 @@ function Read-TestPolicy { function New-TestPolicy { return [pscustomobject]@{ repository = 'https://github.com/openclaw/openclaw' - channel = 'extended-stable' + channel = 'stable' packageRevision = 0 publisher = 'CN=OpenClaw Test Publisher' } @@ -122,13 +129,18 @@ function Reset-TestFixture { $script:httpCalls = [Collections.Generic.List[object]]::new() $script:failures = @{} $script:responses = @{ - 'registry:extended-stable' = [pscustomobject]@{ + 'registry:latest' = [pscustomobject]@{ name = 'openclaw' version = $script:version } + 'registry:extended-stable' = [pscustomobject]@{ + name = 'openclaw' + version = '2026.6.35' + } } $script:policy = Read-TestPolicy (New-TestPolicy | ConvertTo-Json) Add-TestRelease + Add-TestRelease -Version '2026.6.35' -Commit ('e' * 40) -TagObject ('f' * 40) } function Get-TestResponse { @@ -180,11 +192,12 @@ function Invoke-Test { New-Item -Path $testRoot -ItemType Directory | Out-Null try { - Invoke-Test 'signed annotated final release resolves to its immutable source' { + Invoke-Test 'npm latest resolves a signed annotated regular stable release' { $source = Resolve-OpenClawSource -Policy $policy + Assert-TestEqual ($source -is [System.Management.Automation.PSCustomObject]) $true Assert-TestEqual $source.repository $policy.repository - Assert-TestEqual $source.requestedRef 'extended-stable' - Assert-TestEqual $source.channel 'extended-stable' + Assert-TestEqual $source.requestedRef 'stable' + Assert-TestEqual $source.channel 'stable' Assert-TestEqual $source.packageVersion $version Assert-TestEqual $source.releaseTag "v$version" Assert-TestEqual $source.tagObject $tagObject @@ -200,7 +213,7 @@ try { } Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 Assert-TestEqual ($requests -join '|') ( - "registry:extended-stable|github:git/ref/tags/v$version|" + + "registry:latest|github:git/ref/tags/v$version|" + "github:git/tags/$tagObject|github:contents/package.json?ref=$commit|" + "registry:$version" ) @@ -209,16 +222,130 @@ try { Invoke-Test 'a new call follows an advancing selector without caching' { $first = Resolve-OpenClawSource $policy $nextCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - Add-TestRelease -Version '2026.6.36' -Commit $nextCommit -TagObject ('b' * 40) - $responses['registry:extended-stable'].version = '2026.6.36' + Add-TestRelease -Version '2026.9.5' -Commit $nextCommit -TagObject ('b' * 40) + $responses['registry:latest'].version = '2026.9.5' $second = Resolve-OpenClawSource $policy Assert-TestEqual $first.resolvedCommit $commit Assert-TestEqual $second.resolvedCommit $nextCommit - Assert-TestEqual $second.packageVersion '2026.6.36' + Assert-TestEqual $second.packageVersion '2026.9.5' + } + + Invoke-Test 'unpinned policy ref is logical stable without any HTTP lookup' { + $refs = @(Get-OpenClawPolicyRef -Policy $policy) + Assert-TestEqual $refs.Count 1 + Assert-TestEqual $refs[0] 'stable' + Assert-TestEqual $requests.Count 0 + } + + foreach ($final in @($false, $true)) { + Invoke-Test 'source version Final compatibility always requires regular stable' { + foreach ($stableVersion in @($version, "$version-1")) { + Assert-TestEqual @( + Assert-OpenClawSourceVersion -Version $stableVersion -Final:$final + ).Count 0 + } + foreach ($badVersion in $invalidStableVersions) { + Assert-TestThrows { + Assert-OpenClawSourceVersion -Version $badVersion -Final:$final + } 'packageVersion' + } + } + } + + foreach ($pin in @('2026.8.31', '2026.8.31-2')) { + Invoke-Test "reviewed older stable pin skips latest: $pin" { + $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin + $policy = Read-TestPolicy ($policy | ConvertTo-Json) + $pinnedCommit = 'a' * 40 + $pinnedTag = 'b' * 40 + Add-TestRelease -Version $pin -Commit $pinnedCommit -TagObject $pinnedTag + $failures['registry:latest'] = 'A reviewed pin must not query latest.' + Assert-TestEqual (Get-OpenClawPolicyRef $policy) $pin + $source = Resolve-OpenClawSource $policy + Assert-TestEqual $source.channel 'stable' + Assert-TestEqual $source.requestedRef $pin + Assert-TestEqual $source.packageVersion $pin + Assert-TestEqual $source.resolvedCommit $pinnedCommit + Assert-TestEqual $source.releaseTag "v$pin" + Assert-TestEqual ($requests -join '|') ( + "registry:$pin|github:git/ref/tags/v$pin|" + + "github:git/tags/$pinnedTag|github:contents/package.json?ref=$pinnedCommit" + ) + $replayed = $source | ConvertTo-Json | ConvertFrom-Json + Assert-TestEqual @(Assert-OpenClawSource $replayed $policy -RequireChannel).Count 0 + } + } + + foreach ($correctionVersion in @('2026.9.4-1', '2026.12.32-42')) { + Invoke-Test "latest accepts a verbatim stable numeric correction: $correctionVersion" { + Add-TestRelease -Version $correctionVersion + $responses['registry:latest'].version = $correctionVersion + $source = Resolve-OpenClawSource $policy + Assert-TestEqual $source.packageVersion $correctionVersion + Assert-TestEqual $source.releaseTag "v$correctionVersion" + Assert-TestEqual $source.requestedRef 'stable' + Assert-TestEqual $source.channel 'stable' + Assert-TestEqual $requests[$requests.Count - 1] "registry:$correctionVersion" + Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 + } + } + + foreach ($pinned in @($false, $true)) { + Invoke-Test 'same-source npm correction cannot silently rewrite the source version' { + $correctionVersion = "$version-1" + Add-TestRelease -Version $correctionVersion + Set-TestPackage -Version $version + if ($pinned) { + $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $correctionVersion + } + else { + $responses['registry:latest'].version = $correctionVersion + } + Assert-TestThrows { Resolve-OpenClawSource $policy } 'source package version' + Assert-TestEqual $requests.Count 4 + Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 + } + } + + Invoke-Test 'a withdrawn registry pin fails without latest or cross-channel fallback' { + $pin = '2026.8.31' + $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin + Assert-TestThrows { Resolve-OpenClawSource $policy } 'No offline fixture' + Assert-TestEqual $requests.Count 1 + Assert-TestEqual $requests[0] "registry:$pin" + } + + Invoke-Test 'an exact pin response cannot select a different version' { + $pin = '2026.8.31' + $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin + Add-TestRelease -Version $pin + $responses["registry:$pin"].version = $version + Assert-TestThrows { Resolve-OpenClawSource $policy } 'stableVersion pin' + Assert-TestEqual $requests.Count 1 + } + + foreach ($withdrawn in @($false, $true)) { + Invoke-Test 'changed or withdrawn policy pins reject old snapshots on replay' { + $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $version + $source = Resolve-OpenClawSource $policy | ConvertTo-Json | ConvertFrom-Json + if ($withdrawn) { + $policy.PSObject.Properties.Remove('stableVersion') + } + else { + $policy.stableVersion = '2026.9.3' + } + $requestCount = $requests.Count + Assert-TestThrows { Assert-OpenClawSource $source $policy -RequireChannel } 'requestedRef' + if (-not $withdrawn) { + $source.requestedRef = $policy.stableVersion + Assert-TestThrows { Assert-OpenClawSource $source $policy -RequireChannel } 'stableVersion pin' + } + Assert-TestEqual $requests.Count $requestCount + } } foreach ($endpoint in @( - 'registry:extended-stable', "github:git/ref/tags/v$version", + 'registry:latest', "github:git/ref/tags/v$version", "github:git/tags/$tagObject", "github:contents/package.json?ref=$commit", "registry:$version" )) { @@ -226,34 +353,33 @@ try { $failures[$endpoint] = 'Offline fixture API failure.' Assert-TestThrows { Resolve-OpenClawSource $policy } 'Offline fixture API failure' Assert-TestEqual $requests[$requests.Count - 1] $endpoint + Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 } } - Invoke-Test 'missing channel has no fallback' { - $responses.Remove('registry:extended-stable') + Invoke-Test 'missing latest never falls back to an available extended-stable release' { + $responses.Remove('registry:latest') Assert-TestThrows { Resolve-OpenClawSource $policy } 'No offline fixture' Assert-TestEqual $requests.Count 1 + Assert-TestEqual $requests[0] 'registry:latest' } - foreach ($badVersion in @( - '2026.06.35', '2026.6.35-beta.1', '2026.6.35+build.1', 'v2026.6.35', - '2026.6.35.1', '1.2.3', '2026.6.35?redirect=evil', "2026.6.35`nextra=bad", - @('2026.6.35'), 2026 - )) { - Invoke-Test 'channel rejects invalid final versions before GitHub requests' { - $responses['registry:extended-stable'].version = $badVersion + foreach ($badVersion in $invalidStableVersions) { + Invoke-Test 'latest rejects invalid or extended versions without fallback' { + $responses['registry:latest'].version = $badVersion Assert-TestThrows { Resolve-OpenClawSource $policy } 'packageVersion' Assert-TestEqual $requests.Count 1 + Assert-TestEqual $requests[0] 'registry:latest' } } Invoke-Test 'channel package identity must match' { - $responses['registry:extended-stable'].name = 'other' + $responses['registry:latest'].name = 'other' Assert-TestThrows { Resolve-OpenClawSource $policy } 'openclaw package' } foreach ($case in @( - @{ Field = 'ref'; Value = 'refs/tags/v2026.6.34'; Error = 'exact annotated' }, + @{ Field = 'ref'; Value = 'refs/tags/v2026.9.3'; Error = 'exact annotated' }, @{ Field = 'type'; Value = 'commit'; Error = 'exact annotated' }, @{ Field = 'sha'; Value = '../../other'; Error = 'tag object' } )) { @@ -272,7 +398,7 @@ try { foreach ($case in @( @{ Field = 'sha'; Value = ('f' * 40) }, - @{ Field = 'tag'; Value = 'v2026.6.34' }, + @{ Field = 'tag'; Value = 'v2026.9.3' }, @{ Field = 'verified'; Value = $false }, @{ Field = 'verified'; Value = 'true' }, @{ Field = 'verified'; Value = @($true) }, @@ -312,13 +438,15 @@ try { } Invoke-Test 'immutable package version must match the selection' { - Set-TestPackage -Version '2026.6.34' + Set-TestPackage -Version '2026.9.3' Assert-TestThrows { Resolve-OpenClawSource $policy } 'source package version' + Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 } Invoke-Test 'exact registry version must match the selection' { - $responses["registry:$version"].version = '2026.6.36' + $responses["registry:$version"].version = '2026.9.5' Assert-TestThrows { Resolve-OpenClawSource $policy } 'exact registry manifest' + Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 } Invoke-Test 'exact registry package name must match' { @@ -368,17 +496,17 @@ try { } } - foreach ($ref in @('feature/source', "v$version", $commit, 'extended-stable')) { + foreach ($ref in @('feature/source', "v$version", $commit, 'stable')) { Invoke-Test "explicit override resolves only GitHub commit and source: $ref" { $escapedRef = [Uri]::EscapeDataString($ref) $responses["github:commits/$escapedRef"] = [pscustomobject]@{ sha = $commit.ToUpperInvariant() } - Set-TestPackage -Version '2026.9.1-beta.2+build.3' + Set-TestPackage -Version '2026.9.4-2' $source = Resolve-OpenClawSource $policy -Ref $ref Assert-TestEqual $source.requestedRef $ref Assert-TestEqual $source.resolvedCommit $commit - Assert-TestEqual $source.packageVersion '2026.9.1-beta.2+build.3' + Assert-TestEqual $source.packageVersion '2026.9.4-2' foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { Assert-TestEqual $source.$field '' } @@ -404,8 +532,24 @@ try { } } - foreach ($badVersion in @('01.2.3', '1.2.3-01', '1.2.3-beta..1', "1.2.3`n", @('1.2.3'))) { - Invoke-Test 'override source version must be valid semver' { + foreach ($ref in @( + 'extended-stable', 'extended-stable/2026.9', 'Extended-Stable', + 'refs/heads/extended-stable/2026.9', 'refs/tags/extended-stable' + )) { + Invoke-Test 'explicit extended-stable selectors are rejected before networking' { + Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $ref } "'Ref'.*extended-stable" + Assert-TestEqual $requests.Count 0 + $source = Resolve-OpenClawSource $policy + foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { + $source.$field = '' + } + $source.requestedRef = $ref + Assert-TestThrows { Assert-OpenClawSource $source $policy } "'requestedRef'.*extended-stable" + } + } + + foreach ($badVersion in $invalidStableVersions) { + Invoke-Test 'override source version must belong to regular stable' { $responses['github:commits/main'] = [pscustomobject]@{ sha = $commit } Set-TestPackage -Version $badVersion Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'packageVersion' @@ -418,6 +562,37 @@ try { Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'source package name' } + Invoke-Test 'an immutable SHA override cannot opt into an extended-stable source version' { + $responses["github:commits/$commit"] = [pscustomobject]@{ sha = $commit } + Set-TestPackage -Version '2026.6.35' + Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $commit } 'packageVersion' + Assert-TestEqual ($requests -join '|') ( + "github:commits/$commit|github:contents/package.json?ref=$commit" + ) + } + + foreach ($badVersion in $invalidStableVersions) { + Invoke-Test 'channel and override snapshots both require regular stable versions' { + $source = Resolve-OpenClawSource $policy + $source.packageVersion = $badVersion + Assert-TestThrows { Assert-OpenClawSource $source $policy } 'packageVersion' + foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { + $source.$field = '' + } + $source.requestedRef = 'main' + Assert-TestThrows { Assert-OpenClawSource $source $policy } 'packageVersion' + } + } + + Invoke-Test 'a previous extended-stable snapshot is rejected' { + $source = Resolve-OpenClawSource $policy + $source.requestedRef = 'extended-stable' + $source.channel = 'extended-stable' + $source.packageVersion = '2026.6.35' + $source.releaseTag = 'v2026.6.35' + Assert-TestThrows { Assert-OpenClawSource $source $policy } 'extended-stable' + } + Invoke-Test 'snapshot timestamp can be old and extra build metadata is allowed' { $source = Resolve-OpenClawSource $policy $source.resolvedAt = '2000-01-01T00:00:00Z' @@ -456,13 +631,13 @@ try { foreach ($case in @( @{ Field = 'repository'; Value = 'https://github.com/other/openclaw'; Error = 'repository' }, @{ Field = 'requestedRef'; Value = 'main'; Error = 'requestedRef' }, - @{ Field = 'requestedRef'; Value = "extended-stable`r`nevil=value"; Error = 'requestedRef' }, + @{ Field = 'requestedRef'; Value = "stable`r`nevil=value"; Error = 'requestedRef' }, @{ Field = 'resolvedCommit'; Value = $commit.ToUpperInvariant(); Error = 'resolvedCommit' }, @{ Field = 'resolvedCommit'; Value = @($commit); Error = 'resolvedCommit' }, - @{ Field = 'packageVersion'; Value = '2026.6.35-beta.1'; Error = 'packageVersion' }, + @{ Field = 'packageVersion'; Value = '2026.9.4-beta.1'; Error = 'packageVersion' }, @{ Field = 'channel'; Value = 'latest'; Error = 'channel' }, @{ Field = 'channel'; Value = ''; Error = 'ref override' }, - @{ Field = 'releaseTag'; Value = 'v2026.6.34'; Error = 'releaseTag' }, + @{ Field = 'releaseTag'; Value = 'v2026.9.3'; Error = 'releaseTag' }, @{ Field = 'tagObject'; Value = ''; Error = 'tagObject' }, @{ Field = 'tagObject'; Value = $tagObject.ToUpperInvariant(); Error = 'tagObject' }, @{ Field = 'registryIntegrity'; Value = 'sha512-invalid'; Error = 'registryIntegrity' }, @@ -499,11 +674,25 @@ try { } } + foreach ($pin in ($invalidStableVersions + @('', $null, 'stable', 'latest', 'extended-stable'))) { + Invoke-Test 'policy rejects invalid, extended, empty, and null stableVersion pins' { + $candidate = New-TestPolicy + $candidate | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin + Assert-TestThrows { + Read-TestPolicy ($candidate | ConvertTo-Json -Depth 8) + } 'stableVersion' + Assert-TestThrows { Get-OpenClawPolicyRef $candidate } 'stableVersion' + Assert-TestThrows { Resolve-OpenClawSource $candidate } 'stableVersion' + Assert-TestEqual $requests.Count 0 + } + } + foreach ($case in @( @{ Field = 'repository'; Value = 'https://github.com/other/openclaw' }, @{ Field = 'repository'; Value = @('https://github.com/openclaw/openclaw') }, @{ Field = 'channel'; Value = 'latest' }, - @{ Field = 'channel'; Value = 'Extended-Stable' }, + @{ Field = 'channel'; Value = 'Stable' }, + @{ Field = 'channel'; Value = 'extended-stable' }, @{ Field = 'packageRevision'; Value = -1 }, @{ Field = 'packageRevision'; Value = 65535 }, @{ Field = 'packageRevision'; Value = '1' }, @@ -537,16 +726,66 @@ try { } } + foreach ($case in @( + @{ Version = '2026.9.4'; Revision = 0; Expected = '2026.9.4.0' }, + @{ Version = '2026.9.4'; Revision = 1; Expected = '2026.9.4.1' }, + @{ Version = '2026.9.4'; Revision = [long]65534; Expected = '2026.9.4.65534' }, + @{ Version = '2026.9.4-1'; Revision = 0; Expected = '2026.9.4.1' }, + @{ Version = '2026.9.4-2'; Revision = 3; Expected = '2026.9.4.5' }, + @{ Version = '2026.9.4-65534'; Revision = 0; Expected = '2026.9.4.65534' }, + @{ Version = '2026.9.4-65533'; Revision = 1; Expected = '2026.9.4.65534' }, + @{ Version = '9999.12.32'; Revision = 0; Expected = '9999.12.32.0' }, + @{ Version = '2026.1.1'; Revision = 0; Expected = '2026.1.1.0' } + )) { + Invoke-Test "MSIX mapping preserves base and adds numeric corrections: $($case.Expected)" { + $results = @(Get-OpenClawMsixReleaseVersion -Version $case.Version -PackageRevision $case.Revision) + Assert-TestEqual $results.Count 1 + Assert-TestEqual ($results[0] -is [string]) $true + Assert-TestEqual $results[0] $case.Expected + } + } + + foreach ($case in @( + @{ Version = '2026.9.4-65535'; Revision = 0 }, + @{ Version = '2026.9.4-65534'; Revision = 1 }, + @{ Version = '2026.9.4-1'; Revision = 65534 }, + @{ Version = '2026.9.4-2147483647'; Revision = 0 }, + @{ Version = '2026.9.4-2147483648'; Revision = 0 }, + @{ Version = ('2026.9.4-' + ('9' * 200)); Revision = 0 } + )) { + Invoke-Test 'MSIX correction overflow is rejected before addition' { + Assert-TestThrows { + Get-OpenClawMsixReleaseVersion -Version $case.Version -PackageRevision $case.Revision + } 'correction.*exceeds 65534' + } + } + + foreach ($revision in @(-1, 65535, '1', 1.0, $true, $null, [long]::MaxValue)) { + Invoke-Test 'MSIX mapping requires an in-range integer package revision' { + Assert-TestThrows { + Get-OpenClawMsixReleaseVersion -Version $version -PackageRevision $revision + } 'packageRevision' + } + } + + foreach ($badVersion in $invalidStableVersions) { + Invoke-Test 'MSIX mapping shares regular stable version validation' { + Assert-TestThrows { + Get-OpenClawMsixReleaseVersion -Version $badVersion -PackageRevision 0 + } 'packageVersion' + } + } + Invoke-Test 'HTTP transports pin origins, bound timeouts, and isolate GitHub credentials' { $originalToken = $env:GH_TOKEN try { $env:GH_TOKEN = 'offline-test-token' & $githubTransport -Path "git/tags/$tagObject" | Out-Null - & $registryTransport -Selector 'extended-stable' | Out-Null + & $registryTransport -Selector 'latest' | Out-Null & $registryTransport -Selector $version | Out-Null Assert-TestEqual $httpCalls[0].Uri "https://api.github.com/repos/openclaw/openclaw/git/tags/$tagObject" Assert-TestEqual $httpCalls[0].Headers.Authorization 'Bearer offline-test-token' - Assert-TestEqual $httpCalls[1].Uri 'https://registry.npmjs.org/openclaw/extended-stable' + Assert-TestEqual $httpCalls[1].Uri 'https://registry.npmjs.org/openclaw/latest' Assert-TestEqual $httpCalls[2].Uri "https://registry.npmjs.org/openclaw/$version" foreach ($call in $httpCalls) { Assert-TestEqual $call.TimeoutSec 30 @@ -576,6 +815,28 @@ try { Assert-TestEqual $httpCalls.Count 0 } + Invoke-Test 'HTTP helpers accept stable numeric correction manifests and annotated tag refs' { + & $registryTransport -Selector '2026.9.4-2' | Out-Null + & $githubTransport -Path 'git/ref/tags/v2026.9.4-2' | Out-Null + Assert-TestEqual $httpCalls[0].Uri 'https://registry.npmjs.org/openclaw/2026.9.4-2' + Assert-TestEqual $httpCalls[1].Uri 'https://api.github.com/repos/openclaw/openclaw/git/ref/tags/v2026.9.4-2' + } + + foreach ($selector in @('stable', 'extended-stable', 'extended-stable/2026.9', 'beta', 'LATEST')) { + Invoke-Test 'registry transport rejects logical and non-stable channel names' { + Assert-TestThrows { & $registryTransport -Selector $selector } 'packageVersion' + Assert-TestEqual $httpCalls.Count 0 + } + } + + foreach ($badVersion in ($invalidStableVersions | Where-Object { $_ -is [string] })) { + Invoke-Test 'HTTP manifest and release tag endpoints reject non-stable versions' { + Assert-TestThrows { & $registryTransport -Selector $badVersion } 'packageVersion' + Assert-TestThrows { & $githubTransport -Path "git/ref/tags/v$badVersion" } 'packageVersion|GitHub API path' + Assert-TestEqual $httpCalls.Count 0 + } + } + Write-Host "Passed $testCount OpenClaw source resolver tests (offline)." } finally { diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index b2e193fc..6cf9f15b 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -262,7 +262,7 @@ function New-TestArtifact { packagingCommit = $packagingCommit sourceTreeDirty = $SourceTreeDirty payloadRepository = $policy.repository - payloadRequestedRef = $policy.channel + payloadRequestedRef = $sourceResolution.requestedRef payloadResolvedCommit = $PayloadCommit payloadPackageVersion = $PayloadPackageVersion payloadChannel = $sourceResolution.channel @@ -788,7 +788,7 @@ try { Reset-TestArtifacts Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { param($Expanded) - '{"name":"openclaw","version":"2026.6.34"}' | + '{"name":"openclaw","version":"2026.9.3"}' | Set-Content -LiteralPath (Join-Path $Expanded 'app\package.json') } Assert-Fails -MessagePattern 'OpenClaw package version is unexpected' -Action { @@ -796,11 +796,20 @@ try { } Reset-TestArtifacts - New-TestBundle -Root $testRoot -BundleVersion '2026.6.34.0' + New-TestBundle -Root $testRoot -BundleVersion '2026.9.3.0' Assert-Fails -MessagePattern 'bundle manifest identity is unexpected' -Action { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } + $approvedPayloadVersion = '2026.9.4-1' + $approvedPackageVersion = "2026.9.4.$(1 + $policy.packageRevision)" + $sourceResolution.packageVersion = $approvedPayloadVersion + $sourceResolution.releaseTag = "v$approvedPayloadVersion" + $sourceResolution.msixPackageVersion = $approvedPackageVersion + $sourceResolution.msixReleaseTag = "v$approvedPackageVersion" + Reset-TestArtifacts + Invoke-PolicyValidation -Root $testRoot + Write-Host 'Gateway MSIX signing policy tests passed.' } finally { diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index ac60ead0..cbe85186 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -160,7 +160,16 @@ if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or } if ($workflow.Contains('--allow-unreleased-changelog', [StringComparison]::Ordinal) -or $workflow.Contains('--pnpm-pack', [StringComparison]::Ordinal)) { - throw 'Use the shared upstream packer options and its defaults, not switches absent from extended stable.' + throw 'Use the shared upstream packer options and its defaults, including for older stable pins.' +} +if ($workflow.Contains('extended-stable', [StringComparison]::Ordinal) -or + -not $workflow.Contains('by the `stable` release policy', [StringComparison]::Ordinal)) { + throw 'Workflow inputs and release notes must describe stable, not extended-stable selection.' +} +$policy = Get-Content -LiteralPath (Join-Path $repositoryRoot 'release-policy.json') -Raw | + ConvertFrom-Json +if ($policy.channel -cne 'stable') { + throw 'MSIX source selection must stay on the stable channel.' } if ($workflow.IndexOf('name: Enforce official signing policy', [StringComparison]::Ordinal) -gt $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal)) { diff --git a/scripts/Test-WorkflowSource.Tests.ps1 b/scripts/Test-WorkflowSource.Tests.ps1 index dcd7fda5..38371ae5 100644 --- a/scripts/Test-WorkflowSource.Tests.ps1 +++ b/scripts/Test-WorkflowSource.Tests.ps1 @@ -10,13 +10,14 @@ $testRoot = Join-Path ([IO.Path]::GetTempPath()) ( "openclaw-workflow-source-$([guid]::NewGuid().ToString('N'))") $snapshotPath = Join-Path $testRoot 'source-resolution.json' $packagingCommit = '1' * 40 +$version = '2026.9.4' $source = [ordered]@{ repository = $policy.repository requestedRef = $policy.channel resolvedCommit = '2' * 40 - packageVersion = '2026.6.35' + packageVersion = $version channel = $policy.channel - releaseTag = 'v2026.6.35' + releaseTag = "v$version" tagObject = '3' * 40 resolvedAt = '2026-09-15T00:00:00.0000000Z' registryIntegrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) @@ -24,8 +25,8 @@ $source = [ordered]@{ workflowRunId = '12345' workflowRunNumber = 42 signingMode = 'official' - msixPackageVersion = "2026.6.35.$($policy.packageRevision)" - msixReleaseTag = "v2026.6.35.$($policy.packageRevision)" + msixPackageVersion = "$version.$($policy.packageRevision)" + msixReleaseTag = "v$version.$($policy.packageRevision)" } $parameters = @{ PolicyPath = $policyPath @@ -110,16 +111,16 @@ try { $baseUri = 'https://api.github.com/repos/openclaw/openclaw' $registryUri = 'https://registry.npmjs.org/openclaw' - $responses["$registryUri/extended-stable"] = @{ - name = 'openclaw'; version = '2026.6.35' + $responses["$registryUri/latest"] = @{ + name = 'openclaw'; version = $version } - $responses["$baseUri/git/ref/tags/v2026.6.35"] = @{ - ref = 'refs/tags/v2026.6.35' + $responses["$baseUri/git/ref/tags/v$version"] = @{ + ref = "refs/tags/v$version" object = @{ type = 'tag'; sha = '3' * 40 } } $responses["$baseUri/git/tags/$('3' * 40)"] = @{ sha = '3' * 40 - tag = 'v2026.6.35' + tag = "v$version" verification = @{ verified = $true; reason = 'valid' } object = @{ type = 'commit'; sha = '2' * 40 } } @@ -127,10 +128,10 @@ try { type = 'file' encoding = 'base64' content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( - '{"name":"openclaw","version":"2026.6.35"}')) + (@{ name = 'openclaw'; version = $version } | ConvertTo-Json))) } - $responses["$registryUri/2026.6.35"] = @{ - name = 'openclaw'; version = '2026.6.35' + $responses["$registryUri/$version"] = @{ + name = 'openclaw'; version = $version repository = $policy.repository dist = @{ integrity = $source.registryIntegrity } } @@ -139,7 +140,7 @@ try { $freshParameters.SigningMode = 'official' $fresh = & $scriptPath @freshParameters if ($fresh -isnot [pscustomobject] -or - $fresh.msixPackageVersion -cne "2026.6.35.$($policy.packageRevision)" -or + $fresh.msixPackageVersion -cne "$version.$($policy.packageRevision)" -or $fresh.resolvedCommit -cne ('2' * 40) -or $requests.Count -ne 5) { throw 'A new workflow did not save the resolved source and derived release identity.' } @@ -150,6 +151,51 @@ try { (Get-FileHash -LiteralPath $freshParameters.OutputPath).Hash -cne $hash) { throw 'A retry queried the channel or changed the original source snapshot.' } + + $corrected = $fresh | ConvertTo-Json | ConvertFrom-Json + $corrected.packageVersion = "$version-1" + $corrected.releaseTag = "v$version-1" + $corrected.msixPackageVersion = "$version.$(1 + $policy.packageRevision)" + $corrected.msixReleaseTag = "v$($corrected.msixPackageVersion)" + $corrected | ConvertTo-Json | + Set-Content -LiteralPath $freshParameters.OutputPath -Encoding utf8 + $replayedCorrection = & $scriptPath @freshParameters -ReuseSnapshot + if ($replayedCorrection.msixPackageVersion -cne $corrected.msixPackageVersion) { + throw 'A stable numeric correction did not retain its numeric MSIX identity.' + } + + $pinnedPolicy = $policy | ConvertTo-Json | ConvertFrom-Json + $pinnedPolicy | Add-Member -NotePropertyName stableVersion -NotePropertyValue '2026.8.2' + $pinnedPolicyPath = Join-Path $testRoot 'pinned-policy.json' + $pinnedPolicy | ConvertTo-Json | Set-Content -LiteralPath $pinnedPolicyPath -Encoding utf8 + $pinnedParameters = $freshParameters.Clone() + $pinnedParameters.PolicyPath = $pinnedPolicyPath + $pinned = $fresh | ConvertTo-Json | ConvertFrom-Json + $pinned.requestedRef = '2026.8.2' + $pinned.packageVersion = '2026.8.2' + $pinned.releaseTag = 'v2026.8.2' + $pinned.msixPackageVersion = "2026.8.2.$($policy.packageRevision)" + $pinned.msixReleaseTag = "v$($pinned.msixPackageVersion)" + $pinned | ConvertTo-Json | Set-Content -LiteralPath $pinnedParameters.OutputPath -Encoding utf8 + $replayedPin = & $scriptPath @pinnedParameters -ReuseSnapshot + if ($replayedPin.channel -cne 'stable' -or $replayedPin.requestedRef -cne '2026.8.2') { + throw 'A reviewed compatibility pin did not remain on stable.' + } + $pinnedPolicy.stableVersion = $version + $pinnedPolicy | ConvertTo-Json | Set-Content -LiteralPath $pinnedPolicyPath -Encoding utf8 + Assert-Fails -MessagePattern 'requestedRef|stableVersion|policy' -Action { + & $scriptPath @pinnedParameters -ReuseSnapshot + } + + $retired = $fresh | ConvertTo-Json | ConvertFrom-Json + $retired.channel = 'extended-stable' + $retired.requestedRef = 'extended-stable' + $retired.packageVersion = '2026.6.35' + $retired.releaseTag = 'v2026.6.35' + $retired | ConvertTo-Json | Set-Content -LiteralPath $freshParameters.OutputPath -Encoding utf8 + Assert-Fails -MessagePattern 'channel|packageVersion|requestedRef' -Action { + & $scriptPath @freshParameters -ReuseSnapshot + } Write-Host 'Workflow source snapshot tests passed.' } finally { From 0886330da1dcc414c391af143137de85eef1a0e5 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Tue, 15 Sep 2026 16:15:29 -0700 Subject: [PATCH 05/10] fix: avoid obsolete upstream cache setup inputs The regular-stable setup action no longer accepts the old cache option names. Remove those ignored inputs while preserving workflow-wide native cache-mode none as the enforced security boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 2 -- scripts/Test-WorkflowSigningConfiguration.ps1 | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index c7259c9d..73b94317 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -313,8 +313,6 @@ jobs: with: install-bun: "false" install-deps: "false" - use-actions-cache: "false" - save-actions-cache: "false" - name: Install dependencies if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index cbe85186..013e4a1c 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -80,8 +80,6 @@ $requiredFragments = @( 'EXPECTED_PACKAGE_VERSION: ${{ needs.resolve-source.outputs.source_version }}' '-ExpectedSourceCommit $env:EXPECTED_SOURCE_COMMIT' '-ExpectedPackageVersion $env:EXPECTED_PACKAGE_VERSION' - 'use-actions-cache: "false"' - 'save-actions-cache: "false"' 'PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }}' '-SourceResolutionPath artifacts\source\source-resolution.json' '-SourceResolutionSha256 $env:SNAPSHOT_SHA256' @@ -152,6 +150,10 @@ if ($cacheModes.Count -ne 1 -or $workflow -match '(?m)^\s*cache:\s*true\s*$') { throw 'Every job must inherit native cache-mode: none, without cache overrides or opt-ins.' } +if ($workflow.Contains('use-actions-cache:', [StringComparison]::Ordinal) -or + $workflow.Contains('save-actions-cache:', [StringComparison]::Ordinal)) { + throw 'Use native cache-mode: none, not legacy cache inputs unsupported by newer upstream setup actions.' +} if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or $workflow -match 'default:\s+[0-9a-f]{40}' -or From 45251d2d511739159369a3acb3e2a2f2f700859b Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Thu, 17 Sep 2026 11:30:24 -0700 Subject: [PATCH 06/10] refactor: keep stable selection on the merged MSIX release basis Favor PR36's existing identity, signing approval, payload/plugin and upgrade paths. Reduce PR42 to verified stable-source selection, immutable per-run source replay and selected-version verification. Remove the competing MSIX mapping, automatic signing authority and duplicate release guards; retain focused source and integration tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 191 ++- CONTRIBUTING.md | 40 +- scripts/Build-MSIX.ps1 | 25 - scripts/Build-Payload.ps1 | 14 - scripts/Get-WorkflowSource.ps1 | 91 +- scripts/OpenClawSource.ps1 | 529 +++------ scripts/Test-NodeRuntimeInputs.Tests.ps1 | 56 +- scripts/Test-OfficialReleaseVersion.ps1 | 38 - scripts/Test-OpenClawBuildIdentity.Tests.ps1 | 83 +- scripts/Test-OpenClawBuildIdentity.ps1 | 57 +- scripts/Test-OpenClawPackage.Tests.ps1 | 10 + scripts/Test-OpenClawPackage.ps1 | 6 + scripts/Test-OpenClawSource.Tests.ps1 | 1044 ++++------------- scripts/Test-SigningInputs.Tests.ps1 | 100 +- scripts/Test-SigningInputs.ps1 | 36 +- scripts/Test-WorkflowPackageVersion.Tests.ps1 | 16 - scripts/Test-WorkflowSigningConfiguration.ps1 | 107 +- scripts/Test-WorkflowSource.Tests.ps1 | 205 ---- 18 files changed, 568 insertions(+), 2080 deletions(-) delete mode 100644 scripts/Test-OfficialReleaseVersion.ps1 delete mode 100644 scripts/Test-WorkflowSource.Tests.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 73b94317..9b386152 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -8,9 +8,9 @@ on: workflow_dispatch: inputs: openclaw_ref: - description: openclaw/openclaw tag, branch, or commit to package - required: true - default: 3a9d69db306cd7f081e06254cb89c4bcc14a7107 + description: Optional stable-source ref; empty follows npm latest (official signing still requires policy approval) + required: false + default: '' type: string signing_mode: description: Package signing mode @@ -30,16 +30,9 @@ permissions: contents: read pull-requests: read -# Upstream build/install scripts must never receive cache read/write authority, -# including manually selected refs running in the default-branch context. cache-mode: none -concurrency: - group: gateway-msix-${{ inputs.signing_mode == 'official' && 'official' || github.run_id }} - cancel-in-progress: false - env: - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '3a9d69db306cd7f081e06254cb89c4bcc14a7107' }} PACKAGING_ROOT: . jobs: @@ -82,6 +75,9 @@ jobs: ConvertFrom-Json $versioningPaths = @( 'release-policy.json' + 'scripts/OpenClawSource.ps1' + 'scripts/Get-WorkflowSource.ps1' + 'scripts/Test-OpenClawSource.Tests.ps1' 'scripts/Get-MSIXReleaseIdentity.ps1' 'scripts/Test-MSIXReleaseIdentity.Tests.ps1' 'scripts/Test-MSIXUpgrade.ps1' @@ -127,6 +123,8 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props - name: Set up Node.js uses: actions/setup-node@v6 @@ -207,6 +205,10 @@ jobs: run: > .\scripts\Test-OpenClawPackage.Tests.ps1 + - name: Test stable source selection + shell: pwsh + run: .\scripts\Test-OpenClawSource.Tests.ps1 + - name: Test local package deployment shell: pwsh run: > @@ -239,6 +241,7 @@ jobs: runs-on: ubuntu-latest outputs: source_sha: ${{ steps.resolve.outputs.sha }} + source_tag: ${{ steps.resolve.outputs.tag }} package_cache_key: ${{ steps.resolve.outputs.package_cache_key }} package_version: ${{ steps.package.outputs.version }} node_version: ${{ steps.package.outputs.node_version }} @@ -249,29 +252,47 @@ jobs: with: persist-credentials: false + - name: Restore source selection for a retry + if: ${{ github.run_attempt != 1 }} + uses: actions/download-artifact@v8 + with: + name: openclaw-source-resolution + path: ${{ runner.temp }}/openclaw-source + - name: Resolve immutable OpenClaw source id: resolve shell: pwsh env: GH_TOKEN: ${{ github.token }} + SOURCE_REF: ${{ inputs.openclaw_ref }} + SIGNING_MODE: ${{ inputs.signing_mode || 'unsigned' }} run: | - $shaLines = @( - gh api ` - "repos/openclaw/openclaw/commits/$env:OPENCLAW_REF" ` - --jq .sha - ) - if ($LASTEXITCODE -ne 0) { - throw "Unable to resolve OpenClaw ref '$env:OPENCLAW_REF'." - } - $sha = [string]::Join('', [string[]]$shaLines).Trim().ToLowerInvariant() - if ($sha -notmatch '^[0-9a-f]{40}$') { - throw "OpenClaw ref resolved to an invalid commit SHA: '$sha'." - } + $snapshotPath = Join-Path $env:RUNNER_TEMP 'openclaw-source/source-resolution.json' + $source = ./scripts/Get-WorkflowSource.ps1 ` + -PolicyPath ./release-policy.json ` + -OutputPath $snapshotPath ` + -Ref $env:SOURCE_REF ` + -SigningMode $env:SIGNING_MODE ` + -WorkflowRunId $env:GITHUB_RUN_ID ` + -PackagingCommit $env:GITHUB_SHA ` + -ReuseSnapshot:($env:GITHUB_RUN_ATTEMPT -ne '1') $cacheKey = .\scripts\Get-OpenClawCacheKey.ps1 ` -Layer package ` - -Commit $sha - "sha=$sha" >> $env:GITHUB_OUTPUT + -Commit $source.resolvedCommit + "sha=$($source.resolvedCommit)" >> $env:GITHUB_OUTPUT + "tag=v$($source.packageVersion)" >> $env:GITHUB_OUTPUT + "version=$($source.packageVersion)" >> $env:GITHUB_OUTPUT "package_cache_key=$cacheKey" >> $env:GITHUB_OUTPUT + "OpenClaw $($source.packageVersion) ($($source.resolvedCommit)), selected by $($source.requestedRef)." >> $env:GITHUB_STEP_SUMMARY + + - name: Save immutable source selection + if: ${{ github.run_attempt == 1 }} + uses: actions/upload-artifact@v7 + with: + name: openclaw-source-resolution + path: ${{ runner.temp }}/openclaw-source/source-resolution.json + if-no-files-found: error + retention-days: 90 - name: Restore cached OpenClaw package id: package-cache @@ -292,12 +313,6 @@ jobs: ./scripts/Test-OpenClawPackage.ps1 ` $env:RUNNER_TEMP/Test-OpenClawPackage.ps1 - - name: Download immutable source snapshot - uses: actions/download-artifact@v8 - with: - name: openclaw-source-resolution - path: ${{ runner.temp }}/openclaw-source - - name: Check out OpenClaw source if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} uses: actions/checkout@v7 @@ -326,12 +341,16 @@ jobs: - name: Pack npm package if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} + env: + OPENCLAW_REF: ${{ steps.resolve.outputs.sha }} run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" mkdir -p "${artifact_dir}" node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ --skip-build \ + --pnpm-pack \ --output-dir "${artifact_dir}" \ --output-name openclaw.tgz @@ -349,15 +368,7 @@ jobs: "nodeVersion": "${node_version}", "packageSha256": "${package_sha256}" } - $source = Get-Content -LiteralPath $snapshotPath -Raw | ConvertFrom-Json - if ($source.resolvedCommit -cne $env:BUILT_SHA -or - $source.packageVersion -cne $env:BUILT_VERSION -or - $env:NODE_VERSION -notmatch '^\d+\.\d+\.\d+$') { - throw 'The source build does not match the resolved OpenClaw identity.' - } - $source | Add-Member -NotePropertyName nodeVersion -NotePropertyValue $env:NODE_VERSION - $source | ConvertTo-Json -Depth 4 | - Set-Content -LiteralPath (Join-Path $env:RUNNER_TEMP 'openclaw-package/source.json') -Encoding utf8 + EOF - name: Verify OpenClaw package id: package @@ -368,7 +379,8 @@ jobs: & "$env:RUNNER_TEMP/Test-OpenClawPackage.ps1" ` -PackageDirectory $packageDirectory ` -ExpectedCommit '${{ steps.resolve.outputs.sha }}' ` - -RequestedRef $env:OPENCLAW_REF + -ExpectedVersion '${{ steps.resolve.outputs.version }}' ` + -RequestedRef '${{ steps.resolve.outputs.sha }}' $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json "version=$([string]$metadata.packageVersion)" >> $env:GITHUB_OUTPUT @@ -387,7 +399,6 @@ jobs: with: name: openclaw-gateway-npm-package path: ${{ runner.temp }}/openclaw-package/ - overwrite: true if-no-files-found: error retention-days: 7 @@ -466,7 +477,6 @@ jobs: with: name: openclaw-gateway-payload-${{ matrix.architecture }} path: ${{ runner.temp }}\openclaw-payload\ - overwrite: true if-no-files-found: error retention-days: ${{ github.event_name == 'pull_request' && 1 || 7 }} @@ -474,6 +484,8 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props - name: Restore NativeAOT and MSIX dependencies shell: pwsh @@ -495,6 +507,7 @@ jobs: env: SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} VERSIONING_CHANGE: ${{ needs.changes.outputs.versioning }} + GATEWAY_TAG: ${{ needs.build-package.outputs.source_tag }} run: | $versionParameters = @{ RunNumber = '${{ github.run_number }}' @@ -507,7 +520,7 @@ jobs: $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` - -GatewayTag ([string]$policy.gatewayTag) ` + -GatewayTag $env:GATEWAY_TAG ` -MSIXRevision ([int]$policy.msixRevision) $versionParameters.ReleaseVersion = $identity.PackageVersion } @@ -517,7 +530,7 @@ jobs: .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` -Architecture '${{ matrix.architecture }}' ` - -PackageVersion $env:PACKAGE_VERSION ` + -PackageVersion $packageVersion ` -SourceCommit '${{ github.sha }}' ` -OutputDirectory '${{ runner.temp }}\openclaw-msix' @@ -525,7 +538,6 @@ jobs: uses: actions/upload-artifact@v7 with: name: openclaw-gateway-msix-unsigned-${{ matrix.architecture }} - overwrite: true path: | ${{ runner.temp }}\openclaw-msix\OpenClawGateway-${{ matrix.architecture }}.msix ${{ runner.temp }}\openclaw-msix\msix-metadata.json @@ -570,7 +582,6 @@ jobs: with: name: openclaw-gateway-msix-test-signed-x64 path: test-signed\x64\ - overwrite: true if-no-files-found: error retention-days: 7 @@ -579,7 +590,6 @@ jobs: with: name: openclaw-gateway-msix-test-signed-arm64 path: test-signed\arm64\ - overwrite: true if-no-files-found: error retention-days: 7 @@ -587,6 +597,7 @@ jobs: name: Build unsigned multi-architecture Gateway MSIX bundle needs: - changes + - build-package - build-msix runs-on: windows-latest steps: @@ -612,6 +623,7 @@ jobs: env: SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} VERSIONING_CHANGE: ${{ needs.changes.outputs.versioning }} + GATEWAY_TAG: ${{ needs.build-package.outputs.source_tag }} run: | $versionParameters = @{ RunNumber = '${{ github.run_number }}' @@ -624,7 +636,7 @@ jobs: $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` - -GatewayTag ([string]$policy.gatewayTag) ` + -GatewayTag $env:GATEWAY_TAG ` -MSIXRevision ([int]$policy.msixRevision) $versionParameters.ReleaseVersion = $identity.PackageVersion } @@ -634,7 +646,7 @@ jobs: .\scripts\Build-MSIXBundle.ps1 ` -X64Package artifacts\x64\OpenClawGateway-x64.msix ` -Arm64Package artifacts\arm64\OpenClawGateway-arm64.msix ` - -PackageVersion $env:PACKAGE_VERSION ` + -PackageVersion $packageVersion ` -OutputPath artifacts\bundle\OpenClawGateway.msixbundle - name: Upload unsigned multi-architecture MSIX bundle @@ -642,7 +654,6 @@ jobs: with: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle\OpenClawGateway.msixbundle - overwrite: true if-no-files-found: error retention-days: 7 @@ -651,6 +662,7 @@ jobs: if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.versioning == 'true' }} needs: - changes + - build-package - build-msix - build-msix-bundle runs-on: windows-latest @@ -703,11 +715,13 @@ jobs: - name: Test installed-package upgrades and retained LocalState shell: pwsh + env: + GATEWAY_TAG: ${{ needs.build-package.outputs.source_tag }} run: | $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` - -GatewayTag ([string]$policy.gatewayTag) ` + -GatewayTag $env:GATEWAY_TAG ` -MSIXRevision ([int]$policy.msixRevision) .\scripts\Test-MSIXUpgrade.ps1 ` -BaselinesPath .\scripts\msix-upgrade-baselines.json ` @@ -743,7 +757,7 @@ jobs: name: Authorize official Gateway MSIX signing if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} needs: - - resolve-source + - build-package - build-msix - build-msix-bundle runs-on: windows-latest @@ -778,18 +792,9 @@ jobs: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle - - name: Download immutable source snapshot - uses: actions/download-artifact@v8 - with: - name: openclaw-source-resolution - path: artifacts\source - - name: Resolve official release metadata id: release shell: pwsh - env: - PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }} - RELEASE_TAG: ${{ needs.resolve-source.outputs.release_tag }} run: | $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json @@ -808,27 +813,16 @@ jobs: - name: Enforce official signing policy shell: pwsh env: + OPENCLAW_REF: ${{ inputs.openclaw_ref || needs.build-package.outputs.source_sha }} PACKAGING_COMMIT: ${{ github.sha }} - SNAPSHOT_SHA256: ${{ needs.resolve-source.outputs.snapshot_sha256 }} run: | .\scripts\Test-SigningInputs.ps1 ` -ArtifactsDirectory artifacts ` -PolicyPath .\release-policy.json ` -BundlePath artifacts\bundle\OpenClawGateway.msixbundle ` - -SourceResolutionPath artifacts\source\source-resolution.json ` - -SourceResolutionSha256 $env:SNAPSHOT_SHA256 ` - -WorkflowRunId $env:GITHUB_RUN_ID ` + -RequestedRef $env:OPENCLAW_REF ` -PackagingCommit $env:PACKAGING_COMMIT - - name: Reject duplicate or older official releases - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }} - run: | - .\scripts\Test-OfficialReleaseVersion.ps1 ` - -PackageVersion $env:PACKAGE_VERSION - sign-msix: name: Officially sign Gateway MSIX packages if: ${{ needs.authorize-signing.result == 'success' }} @@ -843,11 +837,6 @@ jobs: contents: read id-token: write steps: - - name: Check out repository - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Download unsigned x64 package uses: actions/download-artifact@v8 with: @@ -866,15 +855,6 @@ jobs: name: openclaw-gateway-msix-unsigned-bundle path: artifacts\bundle - - name: Recheck official release version before signing - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - PACKAGE_VERSION: ${{ needs.authorize-signing.outputs.package_version }} - run: | - .\scripts\Test-OfficialReleaseVersion.ps1 ` - -PackageVersion $env:PACKAGE_VERSION - - name: Azure login uses: azure/login@v3 with: @@ -963,7 +943,6 @@ jobs: with: name: openclaw-gateway-msix-x64 path: artifacts\x64\ - overwrite: true if-no-files-found: error retention-days: 7 @@ -972,7 +951,6 @@ jobs: with: name: openclaw-gateway-msix-arm64 path: artifacts\arm64\ - overwrite: true if-no-files-found: error retention-days: 7 @@ -981,7 +959,6 @@ jobs: with: name: openclaw-gateway-msix-bundle path: artifacts\bundle\OpenClawGateway.msixbundle - overwrite: true if-no-files-found: error retention-days: 7 @@ -989,18 +966,13 @@ jobs: name: Publish signed Gateway MSIX release if: ${{ needs.sign-msix.result == 'success' }} needs: - - resolve-source + - build-package - authorize-signing - sign-msix runs-on: ubuntu-latest permissions: contents: write steps: - - name: Check out repository - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Download signed x64 package uses: actions/download-artifact@v8 with: @@ -1019,12 +991,6 @@ jobs: name: openclaw-gateway-msix-bundle path: signed/bundle - - name: Download immutable source snapshot - uses: actions/download-artifact@v8 - with: - name: openclaw-source-resolution - path: signed/source - - name: Stage versioned release assets shell: bash env: @@ -1038,18 +1004,6 @@ jobs: "release-assets/OpenClawGateway-${RELEASE_VERSION}-arm64.msix" cp signed/bundle/OpenClawGateway.msixbundle \ "release-assets/OpenClawGateway-${RELEASE_VERSION}.msixbundle" - cp signed/source/source-resolution.json release-assets/source-resolution.json - cp signed/x64/msix-metadata.json release-assets/msix-metadata-x64.json - cp signed/arm64/msix-metadata.json release-assets/msix-metadata-arm64.json - - - name: Recheck official release version before publication - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - PACKAGE_VERSION: ${{ needs.authorize-signing.outputs.package_version }} - run: | - ./scripts/Test-OfficialReleaseVersion.ps1 ` - -PackageVersion $env:PACKAGE_VERSION - name: Create permanent GitHub release uses: softprops/action-gh-release@v3 @@ -1065,12 +1019,9 @@ jobs: files: | release-assets/*.msix release-assets/*.msixbundle - release-assets/*.json body: | - Packages OpenClaw `${{ needs.resolve-source.outputs.source_version }}` selected - by the `stable` release policy for this workflow run, - from [`openclaw/openclaw@${{ needs.resolve-source.outputs.source_sha }}`](https://github.com/openclaw/openclaw/commit/${{ needs.resolve-source.outputs.source_sha }}). - The source snapshot and architecture metadata are included as release assets. + Packages OpenClaw `${{ needs.build-package.outputs.package_version }}` + from [`openclaw/openclaw@${{ needs.build-package.outputs.source_sha }}`](https://github.com/openclaw/openclaw/commit/${{ needs.build-package.outputs.source_sha }}). ### Downloads - **Recommended:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}.msixbundle` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1aaab3a..689e87e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,47 +55,19 @@ or package version logic: .\scripts\Test-OpenClawCacheKey.Tests.ps1 .\scripts\Test-OpenClawPackage.Tests.ps1 .\scripts\Test-MSIXReleaseIdentity.Tests.ps1 -.\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-OpenClawSource.Tests.ps1 -.\scripts\Test-WorkflowSource.Tests.ps1 -.\scripts\Test-OpenClawBuildIdentity.Tests.ps1 -.\scripts\Test-WorkflowSigningConfiguration.ps1 -.\scripts\Test-Build-MSIXBundle.Tests.ps1 +.\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` +Source-selection changes must keep `Get-MSIXReleaseIdentity.ps1` and the +reviewed official-signing policy as the authority for releases. The resolver +tests use offline registry/GitHub fixtures; source-selection changes also run +the existing hosted Windows install/upgrade gate against the selected tag. + The Node.js input suite requires Node.js and npm. It builds a dependency-free local fixture; it does not download or build OpenClaw. -The channel resolver tests use offline registry and GitHub fixtures. Keep -source selection separate from the source build: new workflow runs resolve -`release-policy.json`'s `stable` channel (`openclaw@latest`) once, and retries reuse the -saved snapshot. Never replace the published channel selection with a -maintenance branch head or re-resolve it independently for each architecture. -There is no automatic fallback. An incompatible latest release may be replaced -only by an explicitly reviewed, known-good stable `stableVersion` policy pin. -Never fall back to extended stable. Explicit refs and legacy payload inputs -must also have regular stable versions; numeric stable corrections are -supported, but their published and source package versions must match. -Changes to source metadata must stay synchronized across resolution, payload -creation, MSIX composition, signing authorization, and release assets. -Keep build-identity coverage for both modern explicit Gateway/UI `buildId` -values and older stable version/commit-derived UI identities. Both -must verify the generated Gateway provenance against the resolved source; -never skip identity validation merely because an older build lacks `buildId`. -The workflow denies cache access with native `cache-mode: none`; do not add -job-level overrides or re-enable cache actions. Read-only `GITHUB_TOKEN` -permissions and disabling an action's cache input alone are not sufficient -protection from cache poisoning by upstream scripts. CodeQL currently does not -model this native cache restriction; keep the query enabled and review the -documented false positives rather than hiding checkout or execution. -Official signing trusts the verified channel snapshot rather than a reviewed -per-release commit allowlist. It still requires `main`, the protected signing -environment, and all artifact checks. For packaging-only official corrections, -increase the policy's `packageRevision`; do not overwrite an existing release. -For numeric upstream corrections, add that correction number to the packaging -revision for the fourth MSIX component, and reject overflow or version reuse. - Run the NativeAOT publish when you change host JSON, reflection, interop, or anything else that is trimming-sensitive. A JIT `dotnet build` does not exercise that path: diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 31d3bf87..2b93289e 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -193,8 +193,6 @@ if (-not (Test-Path -LiteralPath $payloadMetadata -PathType Leaf)) { } $payloadInfo = Get-Content -LiteralPath $payloadMetadata -Raw | ConvertFrom-Json -$payloadSelection = $payloadInfo | - Select-Object channel, releaseTag, tagObject, resolvedAt, registryIntegrity if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or @@ -209,14 +207,6 @@ if ( throw 'Payload metadata is not valid for this MSIX package.' } -. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') -Assert-OpenClawSourceVersion -Version $payloadInfo.packageVersion -Final -if ($null -ne $payloadSelection.channel) { - $policy = Read-OpenClawReleasePolicy -Path ( - Join-Path $repositoryRoot 'release-policy.json') - Assert-OpenClawSource -Source $payloadInfo -Policy $policy -} - $nodeVersion = $payloadInfo.nodeVersion.TrimStart('v') $expectedNodeArchiveName = "node-v$nodeVersion-win-$Architecture.zip" if ($NodeArchivePath) { @@ -234,16 +224,6 @@ if (-not (Test-Path ` -PathType Leaf)) { throw 'Expanded payload does not contain openclaw.mjs.' } -$applicationManifestPath = Join-Path $payloadApplication 'package.json' -if (-not (Test-Path -LiteralPath $applicationManifestPath -PathType Leaf)) { - throw 'Expanded payload does not contain package.json.' -} -$applicationManifest = Get-Content -LiteralPath $applicationManifestPath -Raw | - ConvertFrom-Json -if ($applicationManifest.name -cne 'openclaw' -or - $applicationManifest.version -cne $payloadInfo.packageVersion) { - throw 'The expanded application does not match the payload package version.' -} Assert-ApplicationHasNoReparsePoints -Path $payloadApplication Assert-ApplicationDoesNotBundleNode -Path $payloadApplication @@ -659,11 +639,6 @@ try { payloadRequestedRef = $payloadInfo.requestedRef payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadPackageVersion = [string]$payloadInfo.packageVersion - payloadChannel = $payloadSelection.channel - payloadReleaseTag = $payloadSelection.releaseTag - payloadTagObject = $payloadSelection.tagObject - payloadResolvedAt = $payloadSelection.resolvedAt - payloadRegistryIntegrity = $payloadSelection.registryIntegrity payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count nodeRuntimeVersion = $nodeVersion diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 0d7fd777..1e768ebb 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -27,15 +27,6 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { } $sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json -$sourceSelection = $sourceMetadata | - Select-Object channel, releaseTag, tagObject, resolvedAt, registryIntegrity -. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') -Assert-OpenClawSourceVersion -Version $sourceMetadata.packageVersion -Final -if ($null -ne $sourceMetadata.PSObject.Properties['channel']) { - $policy = Read-OpenClawReleasePolicy -Path ( - Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') - Assert-OpenClawSource -Source $sourceMetadata -Policy $policy -} $nodeVersion = & node -p 'process.versions.node' if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Unable to determine the payload build Node.js version.' @@ -371,11 +362,6 @@ if ($Architecture -eq 'x64') { requestedRef = $sourceMetadata.requestedRef resolvedCommit = $sourceMetadata.resolvedCommit packageVersion = $sourceMetadata.packageVersion - channel = $sourceSelection.channel - releaseTag = $sourceSelection.releaseTag - tagObject = $sourceSelection.tagObject - resolvedAt = $sourceSelection.resolvedAt - registryIntegrity = $sourceSelection.registryIntegrity architecture = $Architecture layout = 'expanded-directory' nodeVersion = $nodeVersion diff --git a/scripts/Get-WorkflowSource.ps1 b/scripts/Get-WorkflowSource.ps1 index b460cd09..c602a10e 100644 --- a/scripts/Get-WorkflowSource.ps1 +++ b/scripts/Get-WorkflowSource.ps1 @@ -1,27 +1,11 @@ [CmdletBinding()] param( - [Parameter(Mandatory)] - [string]$PolicyPath, - - [Parameter(Mandatory)] - [string]$OutputPath, - - [ValidateSet('unsigned', 'test', 'official')] - [string]$SigningMode = 'unsigned', - + [Parameter(Mandatory)][string]$PolicyPath, + [Parameter(Mandatory)][string]$OutputPath, [string]$Ref = '', - - [Parameter(Mandatory)] - [long]$RunNumber, - - [Parameter(Mandatory)] - [ValidatePattern('^[1-9][0-9]*$')] - [string]$WorkflowRunId, - - [Parameter(Mandatory)] - [ValidatePattern('^[0-9a-fA-F]{40}$')] - [string]$PackagingCommit, - + [ValidateSet('unsigned', 'test', 'official')][string]$SigningMode = 'unsigned', + [Parameter(Mandatory)][ValidatePattern('\A[1-9][0-9]*\z')][string]$WorkflowRunId, + [Parameter(Mandatory)][ValidatePattern('\A[0-9a-fA-F]{40}\z')][string]$PackagingCommit, [switch]$ReuseSnapshot ) @@ -30,67 +14,56 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'OpenClawSource.ps1') $policy = Read-OpenClawReleasePolicy -Path $PolicyPath -if ($SigningMode -eq 'official' -and $Ref -ne '') { - throw 'Official signing requires the policy channel, not an explicit source override.' +if ($SigningMode -eq 'official' -and $Ref -ne '' -and + ($Ref -cnotmatch '\A[0-9a-fA-F]{40}\z' -or $Ref -ine $policy.approvedCommit)) { + throw 'Official signing requires the full reviewed approvedCommit for an explicit Ref.' } - if ($ReuseSnapshot) { if (-not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { throw 'The source snapshot is unavailable. Start a new workflow run; do not re-resolve a retry.' } - $source = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + $source = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json -Depth 16 -NoEnumerate } else { - if (Test-Path -LiteralPath $OutputPath) { - throw "The source snapshot already exists: $OutputPath" - } + if (Test-Path -LiteralPath $OutputPath) { throw "The source snapshot already exists: $OutputPath" } $source = Resolve-OpenClawSource -Policy $policy -Ref $Ref } - -Assert-OpenClawSource -Source $source -Policy $policy ` - -RequireChannel:($SigningMode -eq 'official') -$expectedRef = if ($Ref -eq '') { Get-OpenClawPolicyRef -Policy $policy } else { $Ref } -if ($source.requestedRef -cne $expectedRef) { +Assert-OpenClawSource -Source $source -Policy $policy +$expectedRef = if ($Ref -eq '') { Get-OpenClawPolicyRef $policy } else { $Ref } +if ($source.requestedRef -cne $expectedRef -or (($Ref -eq '') -ne ($source.channel -ceq 'stable'))) { throw 'The source snapshot does not match the requested selector.' } - -$versionParameters = @{ - RunNumber = $RunNumber - RunAttempt = 1 -} if ($SigningMode -eq 'official') { - $versionParameters.ReleaseVersion = Get-OpenClawMsixReleaseVersion ` - -Version $source.packageVersion -PackageRevision $policy.packageRevision + Assert-OpenClawSourceText $policy.approvedCommit 'approvedCommit' -Pattern '\A[0-9a-fA-F]{40}\z' + Assert-OpenClawSourceText $policy.payloadPackageVersion 'payloadPackageVersion' + Assert-OpenClawSourceText $policy.gatewayTag 'gatewayTag' + if ($source.resolvedCommit -ine $policy.approvedCommit -or + $source.packageVersion -cne $policy.payloadPackageVersion -or + "v$($source.packageVersion)" -cne $policy.gatewayTag) { + throw 'Official signing requires the reviewed approvedCommit, payloadPackageVersion, and gatewayTag.' + } } -$packageVersion = & (Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1') ` - @versionParameters $context = [ordered]@{ - packagingCommit = $PackagingCommit.ToLowerInvariant() workflowRunId = $WorkflowRunId - workflowRunNumber = $RunNumber + packagingCommit = $PackagingCommit.ToLowerInvariant() signingMode = $SigningMode - msixPackageVersion = $packageVersion - msixReleaseTag = "v$packageVersion" } - foreach ($field in $context.Keys) { if ($ReuseSnapshot) { - if ($source.$field -cne $context[$field]) { + $value = Get-OpenClawSourceField $source $field + if ($value -isnot [string] -or $value -cne $context[$field]) { throw "The source snapshot has an unexpected workflow identity: $field" } } - else { - $source | Add-Member -NotePropertyName $field -NotePropertyValue $context[$field] - } + else { $source | Add-Member -NotePropertyName $field -NotePropertyValue $context[$field] } } - if (-not $ReuseSnapshot) { - $parent = Split-Path ([IO.Path]::GetFullPath($OutputPath)) -Parent - New-Item -ItemType Directory -Path $parent -Force | Out-Null - [IO.File]::WriteAllText( - [IO.Path]::GetFullPath($OutputPath), - ($source | ConvertTo-Json -Depth 4) + "`n", - [Text.UTF8Encoding]::new($false)) + $path = [IO.Path]::GetFullPath($OutputPath) + New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null + $bytes = [Text.UTF8Encoding]::new($false).GetBytes( + ($source | ConvertTo-Json -Depth 4).Replace("`r`n", "`n") + "`n") + $stream = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $stream.Write($bytes, 0, $bytes.Length) } + finally { $stream.Dispose() } } - return $source diff --git a/scripts/OpenClawSource.ps1 b/scripts/OpenClawSource.ps1 index 38b2bf57..b10d14a7 100644 --- a/scripts/OpenClawSource.ps1 +++ b/scripts/OpenClawSource.ps1 @@ -1,445 +1,204 @@ function Get-OpenClawSourceField { - param( - [AllowNull()] - [object]$InputObject, - [string]$Name, - [switch]$Optional - ) + param([AllowNull()][object]$InputObject, [string]$Name, [switch]$Optional) - if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject -is [Collections.IDictionary]) { $exists = $InputObject.Contains($Name) $value = $InputObject[$Name] } - elseif ($InputObject -is [System.Management.Automation.PSCustomObject]) { + elseif ($InputObject -is [pscustomobject]) { $property = $InputObject.PSObject.Properties[$Name] $exists = $null -ne $property $value = $null - if ($exists) { - $value = $property.Value - } - } - else { - throw "Expected an object containing '$Name'." - } - - if (-not $exists -and $Optional) { - return $null - } - if (-not $exists -or $null -eq $value) { - throw "Missing required field '$Name'." + if ($exists) { $value = $property.Value } } - + else { throw "Expected an object containing '$Name'." } + if (-not $exists -and $Optional) { return $null } + if (-not $exists -or $null -eq $value) { throw "Missing required field '$Name'." } return ,$value } function Assert-OpenClawSourceText { - param( - [AllowNull()] - [object]$Value, - [string]$Name, - [string]$Pattern = '', - [switch]$AllowEmpty - ) - - if ($Value -isnot [string] -or - $Value -match '[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]' -or - ([string]::IsNullOrWhiteSpace($Value) -and - -not ($AllowEmpty -and $Value.Length -eq 0))) { - throw "'$Name' must be a string without control characters or blank whitespace." - } - if ($Pattern -and $Value -cnotmatch $Pattern) { - throw "'$Name' has an invalid format." - } -} - -function Get-OpenClawStableVersionMatch { - param( - [AllowNull()] - [object]$Version, - [string]$Name = 'packageVersion' - ) + param([AllowNull()][object]$Value, [string]$Name, [string]$Pattern = '', [switch]$AllowEmpty) - Assert-OpenClawSourceText $Version $Name - # Upstream reserves patch 33+ for extended stable; numeric suffixes are stable corrections. - $match = [regex]::Match( - $Version, - '\A(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.' + - '(?[1-9]|[12][0-9]|3[0-2])(?:-(?[1-9][0-9]*))?\z' - ) - if (-not $match.Success) { - throw "'$Name' must be a regular stable version (YYYY.M.P or YYYY.M.P-C)." + if ($Value -isnot [string] -or $Value -match '[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]' -or + ([string]::IsNullOrWhiteSpace($Value) -and -not ($AllowEmpty -and $Value.Length -eq 0))) { + throw "'$Name' must be text without control characters or blank whitespace." } - return $match + if ($Pattern -and $Value -cnotmatch $Pattern) { throw "'$Name' has an invalid format." } } function Assert-OpenClawSourceVersion { - param( - [AllowNull()] - [object]$Version, - [switch]$Final - ) + param([AllowNull()][object]$Version) - # Retain -Final for packaging callers; both modes now require regular stable. - $null = Get-OpenClawStableVersionMatch -Version $Version + # Patch 33+ is extended stable; numeric suffixes are regular stable corrections. + Assert-OpenClawSourceText $Version 'packageVersion' -Pattern ( + '\A[1-9][0-9]{3}\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[0-2])(?:-[1-9][0-9]*)?\z') } function Assert-OpenClawSourceRef { - param( - [AllowNull()] - [object]$Ref, - [string]$Name = 'Ref' - ) - - Assert-OpenClawSourceText $Ref $Name -Pattern '\A\S+\z' - if ($Ref -match '\A(?:refs/(?:heads|tags)/)?extended-stable(?:/|\z)') { - throw "'$Name' cannot select extended-stable." - } -} - -function Assert-OpenClawPackageRevision { - param( - [AllowNull()] - [object]$PackageRevision - ) - - if (($PackageRevision -isnot [long] -and $PackageRevision -isnot [int]) -or - $PackageRevision -lt 0 -or $PackageRevision -gt 65534) { - throw 'packageRevision must be a JSON integer between 0 and 65534.' - } -} - -function Get-OpenClawMsixReleaseVersion { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object]$Version, - [Parameter(Mandatory)] - [AllowNull()] - [object]$PackageRevision - ) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' + param([AllowNull()][object]$Ref) - $match = Get-OpenClawStableVersionMatch -Version $Version - Assert-OpenClawPackageRevision -PackageRevision $PackageRevision - $correction = 0 - if ($match.Groups['correction'].Success -and ( - -not [int]::TryParse( - $match.Groups['correction'].Value, - [Globalization.NumberStyles]::None, - [Globalization.CultureInfo]::InvariantCulture, - [ref]$correction - ) -or $correction -gt (65534 - $PackageRevision) - )) { - throw 'The stable correction plus packageRevision exceeds 65534.' + Assert-OpenClawSourceText $Ref 'requestedRef' -Pattern '\A\S+\z' + if ($Ref -in @('.', '..') -or $Ref -match '\A(?:refs/(?:heads|tags)/)?extended-stable(?:/|\z)') { + throw 'The requestedRef cannot select extended-stable or a relative path.' } - $revision = $correction + $PackageRevision - return '{0}.{1}.{2}.{3}' -f $match.Groups['year'].Value, - $match.Groups['month'].Value, $match.Groups['patch'].Value, $revision } -function Assert-OpenClawRegistryIntegrity { - param( - [AllowNull()] - [object]$Integrity - ) - - Assert-OpenClawSourceText ` - -Value $Integrity ` - -Name 'registryIntegrity' ` - -Pattern '\Asha512-[A-Za-z0-9+/]{86}==\z' - $encoded = $Integrity.Substring(7) - $bytes = [Convert]::FromBase64String($encoded) - if ($bytes.Length -ne 64 -or - [Convert]::ToBase64String($bytes) -cne $encoded) { - throw 'registryIntegrity must contain a canonical SHA-512 digest.' - } -} - -function Assert-OpenClawReleasePolicy { - param( - [AllowNull()] - [object]$Policy - ) +function Get-OpenClawPolicyRef { + param([Parameter(Mandatory)][object]$Policy) $repository = Get-OpenClawSourceField $Policy 'repository' - $channel = Get-OpenClawSourceField $Policy 'channel' - $revision = Get-OpenClawSourceField $Policy 'packageRevision' - $publisher = Get-OpenClawSourceField $Policy 'publisher' Assert-OpenClawSourceText $repository 'repository' - Assert-OpenClawSourceText $channel 'channel' - Assert-OpenClawSourceText $publisher 'publisher' if ($repository -cne 'https://github.com/openclaw/openclaw') { throw 'The release policy repository must be https://github.com/openclaw/openclaw.' } - if ($channel -cne 'stable') { - throw 'The release policy channel must be stable.' - } - Assert-OpenClawPackageRevision -PackageRevision $revision - $pin = Get-OpenClawSourceField $Policy 'stableVersion' -Optional - if ($null -ne $pin) { - $null = Get-OpenClawStableVersionMatch -Version $pin -Name 'stableVersion' + $channel = Get-OpenClawSourceField $Policy 'channel' -Optional + if ($null -ne $channel -and ($channel -isnot [string] -or $channel -cne 'stable')) { + throw 'The release policy channel must be stable when specified.' } -} - -function Get-OpenClawPolicyRef { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [object]$Policy - ) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - - Assert-OpenClawReleasePolicy -Policy $Policy $pin = Get-OpenClawSourceField $Policy 'stableVersion' -Optional if ($null -ne $pin) { + Assert-OpenClawSourceVersion $pin return $pin } return 'stable' } function Read-OpenClawReleasePolicy { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Path - ) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' + param([Parameter(Mandatory)][string]$Path) - $policy = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 32 -NoEnumerate - Assert-OpenClawReleasePolicy -Policy $policy + $policy = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | + ConvertFrom-Json -Depth 16 -NoEnumerate -ErrorAction Stop + $null = Get-OpenClawPolicyRef $policy return $policy } -function Get-OpenClawRequestOptions { - $options = @{ - TimeoutSec = 30 - MaximumRedirection = 0 - ErrorAction = 'Stop' - } - # Since PowerShell 7.4, TimeoutSec only bounds connection establishment. - if ($PSVersionTable.PSVersion -ge [version]'7.4') { - $options.OperationTimeoutSeconds = 30 - } - return $options -} - -function Invoke-OpenClawGitHubRequest { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Path - ) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' +function Assert-OpenClawRegistryIntegrity { + param([AllowNull()][object]$Integrity) - $segment = '(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+' - $allowedPath = '\A(?:commits/' + $segment + - '|git/tags/[0-9a-f]{40}|contents/package\.json\?ref=[0-9a-f]{40})\z' - Assert-OpenClawSourceText $Path 'GitHub API path' - $tagPrefix = 'git/ref/tags/v' - if ($Path.StartsWith($tagPrefix, [StringComparison]::Ordinal)) { - Assert-OpenClawSourceVersion -Version $Path.Substring($tagPrefix.Length) - } - else { - Assert-OpenClawSourceText $Path 'GitHub API path' -Pattern $allowedPath - } - $headers = @{ - Accept = 'application/vnd.github+json' - 'User-Agent' = 'OpenClaw-Gateway-MSIX' - 'X-GitHub-Api-Version' = '2022-11-28' - } - if (-not [string]::IsNullOrEmpty($env:GH_TOKEN)) { - Assert-OpenClawSourceText $env:GH_TOKEN 'GH_TOKEN' - $headers.Authorization = "Bearer $env:GH_TOKEN" + Assert-OpenClawSourceText $Integrity 'registryIntegrity' -Pattern '\Asha512-[A-Za-z0-9+/]{86}==\z' + $encoded = $Integrity.Substring(7) + if ([Convert]::ToBase64String([Convert]::FromBase64String($encoded)) -cne $encoded) { + throw 'registryIntegrity must contain a canonical SHA-512 digest.' } - - # Refuse redirects so credentials never leave the fixed GitHub API origin. - $requestOptions = Get-OpenClawRequestOptions - return Invoke-RestMethod ` - -Uri "https://api.github.com/repos/openclaw/openclaw/$Path" ` - -Headers $headers ` - @requestOptions } -function Invoke-OpenClawRegistryRequest { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Selector - ) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - - if ($Selector -cne 'latest') { - Assert-OpenClawSourceVersion -Version $Selector +function Invoke-OpenClawSourceRequest { + param([ValidateSet('GitHub', 'Registry')][string]$Service, [string]$Path) + + $options = @{ TimeoutSec = 30; MaximumRedirection = 0; ErrorAction = 'Stop' } + # PowerShell 7.4+ separates connection and response timeouts. + if ($PSVersionTable.PSVersion -ge [version]'7.4') { $options.OperationTimeoutSeconds = 30 } + $headers = @{ Accept = 'application/json' } + if ($Service -eq 'GitHub') { + $origin = 'https://api.github.com/repos/openclaw/openclaw/' + $headers.Accept = 'application/vnd.github+json' + $headers['User-Agent'] = 'OpenClaw-Gateway-MSIX' + $headers['X-GitHub-Api-Version'] = '2022-11-28' + if (-not [string]::IsNullOrEmpty($env:GH_TOKEN)) { + Assert-OpenClawSourceText $env:GH_TOKEN 'GH_TOKEN' + $headers.Authorization = "Bearer $env:GH_TOKEN" + } } - $encodedSelector = [Uri]::EscapeDataString($Selector) - $requestOptions = Get-OpenClawRequestOptions - return Invoke-RestMethod ` - -Uri "https://registry.npmjs.org/openclaw/$encodedSelector" ` - -Headers @{ Accept = 'application/json' } ` - @requestOptions + else { $origin = 'https://registry.npmjs.org/openclaw/' } + # Paths are constructed locally; never follow response URLs or HTTP redirects. + return Invoke-RestMethod -Uri "$origin$Path" -Headers $headers @options } function Get-OpenClawCommitPackageVersion { - param( - [string]$Commit - ) + param([string]$Commit) - Assert-OpenClawSourceText $Commit 'commit' -Pattern '\A[0-9a-f]{40}\z' - $file = Invoke-OpenClawGitHubRequest -Path "contents/package.json?ref=$Commit" - $fileType = Get-OpenClawSourceField $file 'type' - $encoding = Get-OpenClawSourceField $file 'encoding' - Assert-OpenClawSourceText $fileType 'package.json type' - Assert-OpenClawSourceText $encoding 'package.json encoding' - if ($fileType -cne 'file' -or $encoding -cne 'base64') { + $file = Invoke-OpenClawSourceRequest GitHub "contents/package.json?ref=$Commit" + if ($file.type -cne 'file' -or $file.encoding -cne 'base64' -or $file.content -isnot [string]) { throw 'GitHub must return package.json as a base64-encoded file.' } - $content = Get-OpenClawSourceField $file 'content' - if ($content -isnot [string]) { - throw 'The package.json content must be base64 text.' - } - $utf8 = [System.Text.UTF8Encoding]::new($false, $true) - $json = $utf8.GetString([Convert]::FromBase64String($content)) - $package = ConvertFrom-Json -InputObject $json -Depth 32 -NoEnumerate - $name = Get-OpenClawSourceField $package 'name' - Assert-OpenClawSourceText $name 'package name' - if ($name -cne 'openclaw') { + $utf8 = [Text.UTF8Encoding]::new($false, $true) + $package = $utf8.GetString([Convert]::FromBase64String($file.content)) | + ConvertFrom-Json -Depth 16 -NoEnumerate -ErrorAction Stop + if ($package.name -isnot [string] -or $package.name -cne 'openclaw') { throw 'The source package name must be openclaw.' } - $version = Get-OpenClawSourceField $package 'version' - Assert-OpenClawSourceVersion -Version $version - return $version + Assert-OpenClawSourceVersion $package.version + return $package.version } function Resolve-OpenClawSource { [CmdletBinding()] - param( - [Parameter(Mandatory)] - [object]$Policy, - [AllowEmptyString()] - [string]$Ref = '' - ) + param([Parameter(Mandatory)][object]$Policy, [AllowEmptyString()][string]$Ref = '') Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - - $policyRef = Get-OpenClawPolicyRef -Policy $Policy + $policyRef = Get-OpenClawPolicyRef $Policy Assert-OpenClawSourceText $Ref 'Ref' -AllowEmpty - $channel = '' - $releaseTag = '' - $tagObject = '' - $integrity = '' - + $channel = $releaseTag = $tagObject = $integrity = '' if ($Ref.Length -gt 0) { - Assert-OpenClawSourceRef -Ref $Ref + Assert-OpenClawSourceRef $Ref $requestedRef = $Ref - $escapedRef = [Uri]::EscapeDataString($Ref) - $commitResponse = Invoke-OpenClawGitHubRequest -Path "commits/$escapedRef" - $commit = Get-OpenClawSourceField $commitResponse 'sha' - Assert-OpenClawSourceText $commit 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' - $commit = $commit.ToLowerInvariant() - $version = Get-OpenClawCommitPackageVersion -Commit $commit + $response = Invoke-OpenClawSourceRequest GitHub "commits/$([Uri]::EscapeDataString($Ref))" + Assert-OpenClawSourceText $response.sha 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' + $commit = $response.sha.ToLowerInvariant() + if ($Ref -match '\A[0-9a-fA-F]{40}\z' -and $Ref -ine $commit) { + throw 'The resolved commit does not match the full SHA override.' + } + $version = Get-OpenClawCommitPackageVersion $commit } else { - $channel = Get-OpenClawSourceField $Policy 'channel' + $channel = 'stable' $requestedRef = $policyRef $selector = if ($policyRef -ceq 'stable') { 'latest' } else { $policyRef } - $selection = Invoke-OpenClawRegistryRequest -Selector $selector - $name = Get-OpenClawSourceField $selection 'name' - Assert-OpenClawSourceText $name 'registry package name' - if ($name -cne 'openclaw') { - throw 'The registry channel must resolve to the openclaw package.' + $selection = Invoke-OpenClawSourceRequest Registry $selector + if ($selection.name -isnot [string] -or $selection.name -cne 'openclaw') { + throw 'The registry selector must resolve to the openclaw package.' } - $version = Get-OpenClawSourceField $selection 'version' - Assert-OpenClawSourceVersion -Version $version + $version = $selection.version + Assert-OpenClawSourceVersion $version if ($policyRef -cne 'stable' -and $version -cne $policyRef) { - throw 'The registry package version does not match the stableVersion pin.' + throw 'The registry version does not match the stableVersion pin.' + } + $manifest = $selection + if ($policyRef -ceq 'stable') { $manifest = Invoke-OpenClawSourceRequest Registry $version } + if ($manifest.name -isnot [string] -or $manifest.name -cne 'openclaw' -or + $manifest.version -isnot [string] -or $manifest.version -cne $version) { + throw 'The exact registry manifest does not match the selected package version.' + } + $repository = $manifest.repository + if ($repository -isnot [string]) { $repository = Get-OpenClawSourceField $repository 'url' } + Assert-OpenClawSourceText $repository 'registry repository' + if ($repository -cnotin @( + 'https://github.com/openclaw/openclaw', 'git+https://github.com/openclaw/openclaw.git')) { + throw 'The registry package repository does not match the release policy.' } + $integrity = $manifest.dist.integrity + Assert-OpenClawRegistryIntegrity $integrity $releaseTag = "v$version" - $tagRef = Invoke-OpenClawGitHubRequest -Path "git/ref/tags/$releaseTag" - $refLabel = Get-OpenClawSourceField $tagRef 'ref' - Assert-OpenClawSourceText $refLabel 'tag ref' - $refObject = Get-OpenClawSourceField $tagRef 'object' - $refType = Get-OpenClawSourceField $refObject 'type' - Assert-OpenClawSourceText $refType 'tag ref type' - if ($refLabel -cne "refs/tags/$releaseTag" -or $refType -cne 'tag') { + $tagRef = Invoke-OpenClawSourceRequest GitHub "git/ref/tags/$releaseTag" + if ($tagRef.ref -isnot [string] -or $tagRef.ref -cne "refs/tags/$releaseTag" -or + $tagRef.object.type -isnot [string] -or $tagRef.object.type -cne 'tag') { throw 'The selected release must have an exact annotated tag ref.' } - $tagObject = Get-OpenClawSourceField $refObject 'sha' - Assert-OpenClawSourceText $tagObject 'tag object' -Pattern '\A[0-9a-fA-F]{40}\z' - $tagObject = $tagObject.ToLowerInvariant() - $tag = Invoke-OpenClawGitHubRequest -Path "git/tags/$tagObject" - $tagSha = Get-OpenClawSourceField $tag 'sha' - Assert-OpenClawSourceText $tagSha 'tag SHA' -Pattern '\A[0-9a-fA-F]{40}\z' - $tagLabel = Get-OpenClawSourceField $tag 'tag' - Assert-OpenClawSourceText $tagLabel 'tag label' - $verification = Get-OpenClawSourceField $tag 'verification' - $verified = Get-OpenClawSourceField $verification 'verified' - $reason = Get-OpenClawSourceField $verification 'reason' - Assert-OpenClawSourceText $reason 'tag verification reason' - if ($tagSha.ToLowerInvariant() -cne $tagObject -or - $tagLabel -cne $releaseTag -or - $verified -isnot [bool] -or -not $verified -or $reason -cne 'valid') { + Assert-OpenClawSourceText $tagRef.object.sha 'tag object' -Pattern '\A[0-9a-fA-F]{40}\z' + $tagObject = $tagRef.object.sha.ToLowerInvariant() + $tag = Invoke-OpenClawSourceRequest GitHub "git/tags/$tagObject" + Assert-OpenClawSourceText $tag.sha 'tag SHA' -Pattern '\A[0-9a-fA-F]{40}\z' + if ($tag.sha -ine $tagObject -or $tag.tag -isnot [string] -or $tag.tag -cne $releaseTag -or + $tag.verification.verified -isnot [bool] -or -not $tag.verification.verified -or + $tag.verification.reason -isnot [string] -or $tag.verification.reason -cne 'valid') { throw 'The release tag must match and have a valid GitHub-verified signature.' } - $target = Get-OpenClawSourceField $tag 'object' - $targetType = Get-OpenClawSourceField $target 'type' - Assert-OpenClawSourceText $targetType 'tag target type' - if ($targetType -cne 'commit') { + if ($tag.object.type -isnot [string] -or $tag.object.type -cne 'commit') { throw 'The annotated release tag must point directly to a commit.' } - $commit = Get-OpenClawSourceField $target 'sha' - Assert-OpenClawSourceText $commit 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' - $commit = $commit.ToLowerInvariant() - if ((Get-OpenClawCommitPackageVersion -Commit $commit) -cne $version) { - throw 'The immutable source package version does not match the selected release.' - } - - # A pin already fetched the exact manifest; latest needs an immutable version lookup. - $manifest = $selection - if ($policyRef -ceq 'stable') { - $manifest = Invoke-OpenClawRegistryRequest -Selector $version + Assert-OpenClawSourceText $tag.object.sha 'commit' -Pattern '\A[0-9a-fA-F]{40}\z' + $commit = $tag.object.sha.ToLowerInvariant() + if ((Get-OpenClawCommitPackageVersion $commit) -cne $version) { + throw 'The source package version does not match the selected registry version.' } - $manifestName = Get-OpenClawSourceField $manifest 'name' - $manifestVersion = Get-OpenClawSourceField $manifest 'version' - Assert-OpenClawSourceText $manifestName 'registry package name' - Assert-OpenClawSourceVersion -Version $manifestVersion - if ($manifestName -cne 'openclaw' -or $manifestVersion -cne $version) { - throw 'The exact registry manifest does not match the selected package version.' - } - $repository = Get-OpenClawSourceField $manifest 'repository' - if ($repository -isnot [string]) { - $repository = Get-OpenClawSourceField $repository 'url' - } - Assert-OpenClawSourceText $repository 'registry repository' - if ($repository -cnotin @( - 'https://github.com/openclaw/openclaw', - 'git+https://github.com/openclaw/openclaw.git' - )) { - throw 'The registry package repository does not match the release policy.' - } - $dist = Get-OpenClawSourceField $manifest 'dist' - $integrity = Get-OpenClawSourceField $dist 'integrity' - Assert-OpenClawRegistryIntegrity -Integrity $integrity $gitHead = Get-OpenClawSourceField $manifest 'gitHead' -Optional if ($null -ne $gitHead) { Assert-OpenClawSourceText $gitHead 'registry gitHead' -Pattern '\A[0-9a-fA-F]{40}\z' - if ($gitHead.ToLowerInvariant() -cne $commit) { - throw 'The registry gitHead does not match the release tag commit.' - } + if ($gitHead -ine $commit) { throw 'The registry gitHead does not match the release tag commit.' } } } - $source = [pscustomobject][ordered]@{ repository = Get-OpenClawSourceField $Policy 'repository' requestedRef = $requestedRef @@ -451,62 +210,45 @@ function Resolve-OpenClawSource { resolvedAt = [DateTime]::UtcNow.ToString('o', [Globalization.CultureInfo]::InvariantCulture) registryIntegrity = $integrity } - Assert-OpenClawSource -Source $source -Policy $Policy + Assert-OpenClawSource $source $Policy return $source } function Assert-OpenClawSource { [CmdletBinding()] - param( - [Parameter(Mandatory)] - [object]$Source, - [Parameter(Mandatory)] - [object]$Policy, - [switch]$RequireChannel - ) + param([Parameter(Mandatory)][object]$Source, [Parameter(Mandatory)][object]$Policy, [switch]$RequireChannel) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - - $policyRef = Get-OpenClawPolicyRef -Policy $Policy + $policyRef = Get-OpenClawPolicyRef $Policy $values = @{} foreach ($field in @( 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', - 'channel', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity' - )) { - $values[$field] = Get-OpenClawSourceField $Source $field - # ConvertFrom-Json automatically materializes Z timestamps as UTC DateTime. - if ($field -eq 'resolvedAt' -and $values[$field] -is [DateTime]) { - if ($values[$field].Kind -ne [DateTimeKind]::Utc) { - throw 'resolvedAt must be a UTC DateTime or UTC RFC3339 string.' - } - $values[$field] = $values[$field].ToString('o', [Globalization.CultureInfo]::InvariantCulture) + 'channel', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity')) { + $value = Get-OpenClawSourceField $Source $field + # ConvertFrom-Json can materialize UTC timestamps as DateTime. + if ($field -eq 'resolvedAt' -and $value -is [DateTime] -and $value.Kind -eq [DateTimeKind]::Utc) { + $value = $value.ToString('o', [Globalization.CultureInfo]::InvariantCulture) } - $allowEmpty = $field -in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity') - Assert-OpenClawSourceText $values[$field] $field -AllowEmpty:$allowEmpty + Assert-OpenClawSourceText $value $field -AllowEmpty:( + $field -in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) + $values[$field] = $value } if ($values.repository -cne (Get-OpenClawSourceField $Policy 'repository')) { throw 'The source repository does not match the release policy.' } - Assert-OpenClawSourceRef $values.requestedRef 'requestedRef' + Assert-OpenClawSourceRef $values.requestedRef Assert-OpenClawSourceText $values.resolvedCommit 'resolvedCommit' -Pattern '\A[0-9a-f]{40}\z' Assert-OpenClawSourceVersion $values.packageVersion - Assert-OpenClawSourceText ` - $values.resolvedAt 'resolvedAt' ` - -Pattern '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|\+00:00)\z' + Assert-OpenClawSourceText $values.resolvedAt 'resolvedAt' -Pattern ( + '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|\+00:00)\z') $timestamp = [DateTimeOffset]::MinValue - if (-not [DateTimeOffset]::TryParse( - $values.resolvedAt, - [Globalization.CultureInfo]::InvariantCulture, - [Globalization.DateTimeStyles]::None, - [ref]$timestamp - )) { + if (-not [DateTimeOffset]::TryParse($values.resolvedAt, [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, [ref]$timestamp)) { throw 'resolvedAt must be a valid UTC RFC3339 timestamp.' } - if ($values.channel.Length -gt 0) { - $policyChannel = Get-OpenClawSourceField $Policy 'channel' - if ($values.channel -cne $policyChannel -or $values.requestedRef -cne $policyRef) { + if ($values.channel -cne 'stable' -or $values.requestedRef -cne $policyRef) { throw 'The source channel and requestedRef must match the release policy.' } if ($policyRef -cne 'stable' -and $values.packageVersion -cne $policyRef) { @@ -519,12 +261,13 @@ function Assert-OpenClawSource { Assert-OpenClawRegistryIntegrity $values.registryIntegrity } else { - if ($RequireChannel) { - throw 'Official signing requires a channel-resolved source, not a ref override.' - } - if ($values.releaseTag.Length -ne 0 -or - $values.tagObject.Length -ne 0 -or $values.registryIntegrity.Length -ne 0) { + if ($RequireChannel) { throw 'A channel-resolved source is required, not a ref override.' } + if ($values.releaseTag -cne '' -or $values.tagObject -cne '' -or $values.registryIntegrity -cne '') { throw 'A ref override must have empty releaseTag, tagObject, and registryIntegrity fields.' } + if ($values.requestedRef -match '\A[0-9a-fA-F]{40}\z' -and + $values.requestedRef -ine $values.resolvedCommit) { + throw 'The resolved commit does not match the full SHA override.' + } } } diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index e5c29d25..490822c8 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -112,12 +112,7 @@ console.log(JSON.stringify({ repository = 'https://github.com/openclaw/openclaw' requestedRef = '1' * 40 resolvedCommit = '1' * 40 - packageVersion = '2026.9.4' - channel = '' - releaseTag = '' - tagObject = '' - resolvedAt = '2026-09-15T00:00:00.0000000Z' - registryIntegrity = '' + packageVersion = '0.0.0' nodeVersion = $nodeVersion } $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" @@ -129,32 +124,6 @@ console.log(JSON.stringify({ if ($metadata.nodeVersion -cne $nodeVersion) { throw 'The payload did not preserve the exact source build Node.js version.' } - foreach ($field in @( - 'requestedRef', 'resolvedCommit', 'packageVersion', 'channel', - 'releaseTag', 'tagObject', 'registryIntegrity' - )) { - if ($metadata.$field -cne $sourceMetadata[$field]) { - throw "The payload did not preserve the source selection field: $field" - } - } - - $sourceMetadata.packageVersion = '2026.9.3' - $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" - Assert-Fails -MessagePattern 'does not match the source identity' -Action { - & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload - } - $sourceMetadata.packageVersion = '2026.9.4' - - foreach ($rejectedVersion in @('2026.6.35', '2026.9.4-beta.1')) { - $sourceMetadata.packageVersion = $rejectedVersion - $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" - Assert-Fails -MessagePattern 'packageVersion' -Action { - & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload - } - } - $sourceMetadata.packageVersion = '2026.9.4' $reusedPayload = Join-Path $testRoot 'payload-reused' & "$PSScriptRoot\Build-Payload.ps1" ` @@ -281,29 +250,6 @@ console.log(JSON.stringify({ -OutputDirectory "$testRoot\msix" } - $metadata.nodeVersion = '24.20.0' - $metadata.architecture = 'x64' - $matchingArchive = Join-Path $testRoot 'node-v24.20.0-win-x64.zip' - Set-Content -LiteralPath $matchingArchive -Value 'not reached' - $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath - '{"name":"openclaw","version":"2026.6.35"}' | - Set-Content -LiteralPath (Join-Path $payload 'app\package.json') - Assert-Fails -MessagePattern 'application does not match the payload package version' -Action { - & "$PSScriptRoot\Build-MSIX.ps1" ` - -PayloadDirectory $payload -NodeArchivePath $matchingArchive ` - -Architecture x64 -PackageVersion '0.1.1.0' -SourceCommit ('1' * 40) ` - -OutputDirectory "$testRoot\msix" - } - $legacyMetadata = $metadata | - Select-Object * -ExcludeProperty channel, releaseTag, tagObject, resolvedAt, registryIntegrity - $legacyMetadata.packageVersion = '2026.6.35' - $legacyMetadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath - Assert-Fails -MessagePattern 'packageVersion' -Action { - & "$PSScriptRoot\Build-MSIX.ps1" ` - -PayloadDirectory $payload -Architecture x64 -PackageVersion '0.1.1.0' ` - -SourceCommit ('1' * 40) -OutputDirectory "$testRoot\msix" - } - Write-Host 'Node.js source and packaging input tests passed.' } finally { diff --git a/scripts/Test-OfficialReleaseVersion.ps1 b/scripts/Test-OfficialReleaseVersion.ps1 deleted file mode 100644 index ca79fb1f..00000000 --- a/scripts/Test-OfficialReleaseVersion.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -[CmdletBinding(DefaultParameterSetName = 'GitHub')] -param( - [Parameter(Mandatory)] - [string]$PackageVersion, - - [Parameter(Mandatory, ParameterSetName = 'Tags')] - [AllowEmptyCollection()] - [string[]]$ExistingTags, - - [Parameter(ParameterSetName = 'GitHub')] - [ValidateSet('openclaw/openclaw-windows-packaging')] - [string]$Repository = 'openclaw/openclaw-windows-packaging' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$version = [version](& (Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1') ` - -RunNumber 1 -RunAttempt 1 -ReleaseVersion $PackageVersion) -if ($PSCmdlet.ParameterSetName -eq 'GitHub') { - $ExistingTags = @(& gh api "repos/$Repository/git/matching-refs/tags/v" ` - --paginate --jq '.[].ref') - if ($LASTEXITCODE -ne 0) { - throw 'Unable to check existing official release versions.' - } -} -foreach ($tag in $ExistingTags) { - if ($tag -cmatch '^refs/tags/v(?\d+\.\d+\.\d+\.\d+)$') { - $existingVersion = [version]$Matches.version - if ($version -le $existingVersion) { - throw ( - "MSIX version $version is not newer than existing official tag $tag. " + - 'Do not overwrite or roll back releases; use a newer upstream version ' + - 'or a reviewed packageRevision increase for a packaging-only correction.' - ) - } - } -} diff --git a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 index 1a9d2de5..ede7de61 100644 --- a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -8,12 +8,6 @@ $scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1' $testRoot = Join-Path ` ([IO.Path]::GetTempPath()) ` "openclaw-build-identity-$([guid]::NewGuid().ToString('N'))" -$expectedVersion = '2026.6.5' -$expectedCommit = 'a' * 40 -$validationParameters = @{ - ExpectedPackageVersion = $expectedVersion - ExpectedSourceCommit = $expectedCommit -} function New-BuildFixture { param( @@ -25,9 +19,7 @@ function New-BuildFixture { [string]$ControlUiBuildId, - [switch]$OmitControlUiBuildId, - - [switch]$Legacy + [switch]$OmitControlUiBuildId ) $root = Join-Path $testRoot $Name @@ -36,20 +28,9 @@ function New-BuildFixture { $assetsDirectory = Join-Path $controlUiDirectory 'assets' New-Item -Path $assetsDirectory -ItemType Directory -Force | Out-Null - $buildInfo = @{ - version = $expectedVersion - commit = $expectedCommit - builtAt = '2026-09-15T07:00:00.000Z' - } - if (-not $Legacy) { - $buildInfo.buildId = $GatewayBuildId - } - $buildInfo | + @{ buildId = $GatewayBuildId } | ConvertTo-Json | Set-Content (Join-Path $distDirectory 'build-info.json') -Encoding utf8 - @{ name = 'openclaw'; version = $expectedVersion } | - ConvertTo-Json | - Set-Content (Join-Path $root 'package.json') -Encoding utf8 if ($OmitControlUiBuildId) { 'const CACHE_NAME = "openclaw-control-ui";' | @@ -105,7 +86,7 @@ try { -Name 'matching' ` -GatewayBuildId 'release-build-a' ` -ControlUiBuildId 'release-build-a' - & $scriptPath -OpenClawDirectory $matching @validationParameters + & $scriptPath -OpenClawDirectory $matching # Missing UI identity is unsafe because packaging could not prove which # dashboard build will connect to the Gateway. @@ -114,7 +95,7 @@ try { -GatewayBuildId 'release-build-a' ` -OmitControlUiBuildId Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { - & $scriptPath -OpenClawDirectory $missing @validationParameters + & $scriptPath -OpenClawDirectory $missing } # A separately rebuilt dashboard must fail even when both artifacts are @@ -124,61 +105,7 @@ try { -GatewayBuildId 'release-build-a' ` -ControlUiBuildId 'release-build-b' Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { - & $scriptPath -OpenClawDirectory $mismatched @validationParameters - } - - $legacyId = "$expectedVersion-$($expectedCommit.Substring(0, 12))" - $legacy = New-BuildFixture -Name 'legacy' ` - -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy - & $scriptPath -OpenClawDirectory $legacy @validationParameters - $legacyMismatch = New-BuildFixture -Name 'legacy-mismatch' ` - -GatewayBuildId 'unused' -ControlUiBuildId "$expectedVersion-bbbbbbbbbbbb" -Legacy - Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { - & $scriptPath -OpenClawDirectory $legacyMismatch @validationParameters - } - $legacyMissingUi = New-BuildFixture -Name 'legacy-missing-ui' ` - -GatewayBuildId 'unused' -OmitControlUiBuildId -Legacy - Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { - & $scriptPath -OpenClawDirectory $legacyMissingUi @validationParameters - } - - foreach ($field in @('version', 'commit')) { - $fixture = New-BuildFixture -Name "wrong-$field" ` - -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy - $path = Join-Path $fixture 'dist\build-info.json' - $info = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json - $info.$field = 'unexpected' - $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 - Assert-Fails -MessagePattern 'does not match the resolved OpenClaw source' -Action { - & $scriptPath -OpenClawDirectory $fixture @validationParameters - } - $info.PSObject.Properties.Remove($field) - $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 - Assert-Fails -MessagePattern "provenance is missing '$field'" -Action { - & $scriptPath -OpenClawDirectory $fixture @validationParameters - } - } - - $manifestMismatch = New-BuildFixture -Name 'manifest-mismatch' ` - -GatewayBuildId 'unused' -ControlUiBuildId $legacyId -Legacy - '{"name":"openclaw","version":"2026.6.6"}' | - Set-Content -LiteralPath (Join-Path $manifestMismatch 'package.json') - Assert-Fails -MessagePattern 'does not match the resolved OpenClaw source' -Action { - & $scriptPath -OpenClawDirectory $manifestMismatch @validationParameters - } - - foreach ($invalidId in @('', $null, 123)) { - $path = Join-Path $legacy 'dist\build-info.json' - $info = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json - $info | Add-Member -NotePropertyName buildId -NotePropertyValue $invalidId -Force - $info | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8 - Assert-Fails -MessagePattern 'Gateway build identity is missing or invalid' -Action { - & $scriptPath -OpenClawDirectory $legacy @validationParameters - } - } - Remove-Item -LiteralPath (Join-Path $matching 'dist\control-ui\assets\app.js') - Assert-Fails -MessagePattern 'client bundle does not contain Gateway build identity' -Action { - & $scriptPath -OpenClawDirectory $matching @validationParameters + & $scriptPath -OpenClawDirectory $mismatched } } finally { diff --git a/scripts/Test-OpenClawBuildIdentity.ps1 b/scripts/Test-OpenClawBuildIdentity.ps1 index 8398d3f0..db56d5f5 100644 --- a/scripts/Test-OpenClawBuildIdentity.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.ps1 @@ -1,31 +1,21 @@ [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$OpenClawDirectory, - - [Parameter(Mandatory)] - [ValidatePattern('^[0-9a-fA-F]{40}$')] - [string]$ExpectedSourceCommit, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string]$ExpectedPackageVersion + [string]$OpenClawDirectory ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# Verify both upstream identity formats without treating missing provenance -# or a mismatched dashboard as a successful legacy build. +# A release is safe to package only when the Gateway and its same-origin +# dashboard were emitted by the same OpenClaw build lifecycle. $distDirectory = Join-Path $OpenClawDirectory 'dist' $buildInfoPath = Join-Path $distDirectory 'build-info.json' $controlUiDirectory = Join-Path $distDirectory 'control-ui' $serviceWorkerPath = Join-Path $controlUiDirectory 'sw.js' $assetsDirectory = Join-Path $controlUiDirectory 'assets' -$packageManifestPath = Join-Path $OpenClawDirectory 'package.json' foreach ($requiredPath in @( - $packageManifestPath $buildInfoPath $serviceWorkerPath $assetsDirectory @@ -37,37 +27,9 @@ foreach ($requiredPath in @( $buildInfo = Get-Content -LiteralPath $buildInfoPath -Raw | ConvertFrom-Json -$packageManifest = Get-Content -LiteralPath $packageManifestPath -Raw | - ConvertFrom-Json -foreach ($field in @('version', 'commit')) { - $property = $buildInfo.PSObject.Properties[$field] - if ($null -eq $property -or $property.Value -isnot [string] -or - [string]::IsNullOrWhiteSpace($property.Value)) { - throw "Gateway build provenance is missing '$field' in '$buildInfoPath'." - } -} -if ($buildInfo.version -cne $ExpectedPackageVersion -or - $buildInfo.commit -ine $ExpectedSourceCommit -or - $packageManifest.name -cne 'openclaw' -or - $packageManifest.version -cne $ExpectedPackageVersion) { - throw 'Gateway build provenance does not match the resolved OpenClaw source.' -} - -$buildIdProperty = $buildInfo.PSObject.Properties['buildId'] -if ($null -ne $buildIdProperty) { - if ($buildIdProperty.Value -isnot [string] -or - [string]::IsNullOrWhiteSpace($buildIdProperty.Value)) { - throw "Gateway build identity is missing or invalid in '$buildInfoPath'." - } - $gatewayBuildId = $buildIdProperty.Value -} -else { - # Older upstream Vite builds use version + 12-character Git SHA rather - # than emitting a shared buildId in dist/build-info.json. - $shortCommit = $ExpectedSourceCommit.ToLowerInvariant().Substring(0, 12) - $gatewayBuildId = [regex]::Replace( - "$ExpectedPackageVersion-$shortCommit", '[^a-zA-Z0-9._-]+', '-') - $gatewayBuildId = $gatewayBuildId.Substring(0, [Math]::Min(96, $gatewayBuildId.Length)) +$gatewayBuildId = [string]$buildInfo.buildId +if ([string]::IsNullOrWhiteSpace($gatewayBuildId)) { + throw "Gateway build identity is missing from '$buildInfoPath'." } # Vite writes the dashboard identity into the service worker so stale browser @@ -114,9 +76,4 @@ if (-not $clientBundleContainsBuildId) { ) } -if ($null -eq $buildIdProperty) { - Write-Host "Legacy Control UI identity matches verified source provenance: $gatewayBuildId" -} -else { - Write-Host "OpenClaw Gateway and Control UI build identity match: $gatewayBuildId" -} +Write-Host "OpenClaw Gateway and Control UI build identity match: $gatewayBuildId" diff --git a/scripts/Test-OpenClawPackage.Tests.ps1 b/scripts/Test-OpenClawPackage.Tests.ps1 index 9e74ea4c..00f98ea6 100644 --- a/scripts/Test-OpenClawPackage.Tests.ps1 +++ b/scripts/Test-OpenClawPackage.Tests.ps1 @@ -57,6 +57,7 @@ try { & $scriptPath ` -PackageDirectory $testRoot ` -ExpectedCommit $commit ` + -ExpectedVersion '1.2.3' ` -RequestedRef 'current-ref' $verified = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json if ($verified.requestedRef -cne 'current-ref') { @@ -68,6 +69,7 @@ try { & $scriptPath ` -PackageDirectory $testRoot ` -ExpectedCommit $commit ` + -ExpectedVersion '1.2.3' ` -RequestedRef 'current-ref' } @@ -82,6 +84,14 @@ try { & $scriptPath ` -PackageDirectory $testRoot ` -ExpectedCommit '2222222222222222222222222222222222222222' ` + -ExpectedVersion '1.2.3' ` + -RequestedRef 'current-ref' + } + Assert-Fails -MessagePattern 'version does not match' -Action { + & $scriptPath ` + -PackageDirectory $testRoot ` + -ExpectedCommit $commit ` + -ExpectedVersion '1.2.4' ` -RequestedRef 'current-ref' } } diff --git a/scripts/Test-OpenClawPackage.ps1 b/scripts/Test-OpenClawPackage.ps1 index 26e1dcf3..8345061c 100644 --- a/scripts/Test-OpenClawPackage.ps1 +++ b/scripts/Test-OpenClawPackage.ps1 @@ -6,6 +6,9 @@ param( [Parameter(Mandatory)] [string]$ExpectedCommit, + [Parameter(Mandatory)] + [string]$ExpectedVersion, + [Parameter(Mandatory)] [string]$RequestedRef ) @@ -32,6 +35,9 @@ if ([string]$metadata.resolvedCommit -cne $normalizedCommit) { "match '$normalizedCommit'." ) } +if ([string]$metadata.packageVersion -cne $ExpectedVersion) { + throw 'The OpenClaw package version does not match the resolved stable source.' +} $actualHash = ( Get-FileHash -LiteralPath $packagePath -Algorithm SHA256 diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 index 7865f9d3..8bb14d8f 100644 --- a/scripts/Test-OpenClawSource.Tests.ps1 +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -3,842 +3,318 @@ param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - $sourcePath = Join-Path $PSScriptRoot 'OpenClawSource.ps1' -$tokens = $null -$parseErrors = $null +$workflowPath = Join-Path $PSScriptRoot 'Get-WorkflowSource.ps1' +$policyPath = Join-Path $PSScriptRoot '..\release-policy.json' +$tokens = $parseErrors = $null $ast = [Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$tokens, [ref]$parseErrors) -if ($parseErrors.Count -gt 0 -or $ast.BeginBlock -or $ast.ProcessBlock -or +if ($parseErrors.Count -ne 0 -or $ast.BeginBlock -or $ast.ProcessBlock -or @($ast.EndBlock.Statements | Where-Object { $_ -isnot [Management.Automation.Language.FunctionDefinitionAst] - }).Count -ne 0) { - throw 'OpenClawSource.ps1 must contain only valid function definitions.' -} -$definitionOutput = @(. $sourcePath) -if ($definitionOutput.Count -ne 0) { - throw 'Dot-sourcing OpenClawSource.ps1 must not produce output.' -} -$githubTransport = ${function:Invoke-OpenClawGitHubRequest} -$registryTransport = ${function:Invoke-OpenClawRegistryRequest} -$testRoot = Join-Path (Split-Path $PSScriptRoot -Parent) ( - ".openclaw-source-tests-$([guid]::NewGuid().ToString('N'))") -$commit = 'c283867d7cdd1a93cfc58f829c849834c4426d3b' + }).Count -ne 0) { throw 'The source helper must contain only valid function definitions.' } +if (@(. $sourcePath).Count -ne 0) { throw 'Dot-sourcing the helper must not produce output.' } +$basePolicy = Read-OpenClawReleasePolicy $policyPath +$commit = $basePolicy.approvedCommit +$version = $basePolicy.payloadPackageVersion $tagObject = '8bec206f3c1f787e1e9c45cfd34d3de2a78c7b8e' -$version = '2026.9.4' $integrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-source-tests-$([guid]::NewGuid().ToString('N'))" $testCount = 0 -$invalidStableVersions = @( - '2026.09.4', '2026.0.4', '2026.13.4', '2026.9.0', '2026.9.33', - '2026.6.35', '2026.9.33-1', '2026.9.999', '2026.9.04', '2026.9.4-0', - '2026.9.4-01', '2026.9.4-beta.1', '2026.9.4-alpha.1', '2026.9.4+build.1', - '2026.9.4-1+build.1', '2026.9.4.1', 'v2026.9.4', '1.2.3', '0.0.0', - '2026.9.4?redirect=evil', "2026.9.4`nextra=bad", @('2026.9.4'), 2026 -) +$http = @{ Calls = [Collections.Generic.List[object]]::new(); Responses = @{} } +$originalToken = $env:GH_TOKEN -function Assert-TestEqual { - param($Actual, $Expected) - - if ($Actual -cne $Expected) { - throw "Expected '$Expected', got '$Actual'." +# Mock the native transport, which survives the workflow script dot-sourcing the helper again. +${function:Invoke-RestMethod} = { + param($Uri, $Headers, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection, $ErrorAction) + $key = if ($Uri.StartsWith('https://api.github.com/repos/openclaw/openclaw/', [StringComparison]::Ordinal)) { + 'GitHub:' + $Uri.Substring('https://api.github.com/repos/openclaw/openclaw/'.Length) + } + elseif ($Uri.StartsWith('https://registry.npmjs.org/openclaw/', [StringComparison]::Ordinal)) { + 'Registry:' + $Uri.Substring('https://registry.npmjs.org/openclaw/'.Length) } + else { throw 'Unexpected HTTP origin.' } + $http.Calls.Add([pscustomobject]@{ + Key = $key; Headers = $Headers; Timeout = $TimeoutSec + OperationTimeout = $OperationTimeoutSeconds; Redirects = $MaximumRedirection; Errors = $ErrorAction + }) + if (-not $http.Responses.ContainsKey($key)) { throw "Missing offline response: $key" } + return $http.Responses[$key] +}.GetNewClosure() + +function Assert-Equal { + param($Actual, $Expected) + if ($Actual -cne $Expected) { throw "Expected '$Expected', got '$Actual'." } } -function Assert-TestThrows { +function Assert-Throws { param([scriptblock]$Action, [string]$Pattern) - - try { - & $Action | Out-Null - } + try { & $Action | Out-Null } catch { - if ($_.Exception.Message -notmatch $Pattern) { - throw "Expected failure matching '$Pattern', got: $($_.Exception.Message)" - } + if ($_.Exception.Message -notmatch $Pattern) { throw "Unexpected failure: $($_.Exception.Message)" } return } - throw "Expected failure matching '$Pattern', but the action succeeded." + throw "Expected failure matching '$Pattern'." } -function Read-TestPolicy { - param([string]$Json) - - $path = Join-Path $testRoot 'policy.json' - [IO.File]::WriteAllText($path, $Json, [Text.UTF8Encoding]::new($false)) - return Read-OpenClawReleasePolicy -Path $path -} - -function New-TestPolicy { - return [pscustomobject]@{ - repository = 'https://github.com/openclaw/openclaw' - channel = 'stable' - packageRevision = 0 - publisher = 'CN=OpenClaw Test Publisher' - } -} - -function Set-TestPackage { - param( - [string]$Commit = $script:commit, - [object]$Version = $script:version, - [string]$Name = 'openclaw' - ) - +function Set-Package { + param([object]$Version = $script:version, [string]$Commit = $script:commit, [string]$Name = 'openclaw') $json = @{ name = $Name; version = $Version } | ConvertTo-Json -Compress - $script:responses["github:contents/package.json?ref=$Commit"] = [pscustomobject]@{ - type = 'file' - encoding = 'base64' + $http.Responses["GitHub:contents/package.json?ref=$Commit"] = [pscustomobject]@{ + type = 'file'; encoding = 'base64' content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) + download_url = 'https://untrusted.invalid/never-follow' } } -function Add-TestRelease { - param( - [string]$Version = $script:version, - [string]$Commit = $script:commit, - [string]$TagObject = $script:tagObject - ) - - $script:responses["github:git/ref/tags/v$Version"] = [pscustomobject]@{ - ref = "refs/tags/v$Version" - object = [pscustomobject]@{ type = 'tag'; sha = $TagObject } - } - $script:responses["github:git/tags/$TagObject"] = [pscustomobject]@{ - sha = $TagObject - tag = "v$Version" - verification = [pscustomobject]@{ verified = $true; reason = 'valid' } - object = [pscustomobject]@{ type = 'commit'; sha = $Commit } - url = 'https://untrusted.example.invalid/ignored-tag-url' +function Add-Release { + param([string]$Version = $script:version, [string]$Commit = $script:commit, [string]$Tag = $script:tagObject) + $http.Responses["Registry:$Version"] = [pscustomobject]@{ + name = 'openclaw'; version = $Version; gitHead = $Commit + repository = [pscustomobject]@{ type = 'git'; url = 'git+https://github.com/openclaw/openclaw.git' } + dist = [pscustomobject]@{ integrity = $script:integrity; tarball = 'https://untrusted.invalid/never-follow' } } - Set-TestPackage -Commit $Commit -Version $Version - $script:responses["registry:$Version"] = [pscustomobject]@{ - name = 'openclaw' - version = $Version - repository = [pscustomobject]@{ - type = 'git' - url = 'git+https://github.com/openclaw/openclaw.git' - } - dist = [pscustomobject]@{ - integrity = $script:integrity - tarball = 'https://untrusted.example.invalid/never-download' - } - gitHead = $Commit + $http.Responses["GitHub:git/ref/tags/v$Version"] = [pscustomobject]@{ + ref = "refs/tags/v$Version"; object = [pscustomobject]@{ type = 'tag'; sha = $Tag } } -} - -function Reset-TestFixture { - $script:requests = [Collections.Generic.List[string]]::new() - $script:httpCalls = [Collections.Generic.List[object]]::new() - $script:failures = @{} - $script:responses = @{ - 'registry:latest' = [pscustomobject]@{ - name = 'openclaw' - version = $script:version - } - 'registry:extended-stable' = [pscustomobject]@{ - name = 'openclaw' - version = '2026.6.35' - } - } - $script:policy = Read-TestPolicy (New-TestPolicy | ConvertTo-Json) - Add-TestRelease - Add-TestRelease -Version '2026.6.35' -Commit ('e' * 40) -TagObject ('f' * 40) -} - -function Get-TestResponse { - param([string]$Key) - - $script:requests.Add($Key) - if ($script:failures.ContainsKey($Key)) { - throw $script:failures[$Key] - } - if (-not $script:responses.ContainsKey($Key)) { - throw "No offline fixture for $Key." + $http.Responses["GitHub:git/tags/$Tag"] = [pscustomobject]@{ + sha = $Tag; tag = "v$Version"; object = [pscustomobject]@{ type = 'commit'; sha = $Commit } + verification = [pscustomobject]@{ verified = $true; reason = 'valid' } + url = 'https://untrusted.invalid/never-follow' } - return $script:responses[$Key] -} - -# Both service seams and the underlying transport are replaced: no test can use the network. -function Invoke-OpenClawGitHubRequest { - param([string]$Path) - return Get-TestResponse "github:$Path" -} - -function Invoke-OpenClawRegistryRequest { - param([string]$Selector) - return Get-TestResponse "registry:$Selector" -} - -function Invoke-RestMethod { - param($Uri, $Headers, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection, $ErrorAction) - - $script:httpCalls.Add([pscustomobject]@{ - Uri = $Uri - Headers = $Headers - TimeoutSec = $TimeoutSec - OperationTimeoutSeconds = $OperationTimeoutSeconds - MaximumRedirection = $MaximumRedirection - ErrorAction = $ErrorAction - }) - return [pscustomobject]@{ offline = $true } + $http.Responses["GitHub:commits/$Commit"] = [pscustomobject]@{ sha = $Commit } + Set-Package -Version $Version -Commit $Commit } function Invoke-Test { param([string]$Name, [scriptblock]$Body) - - Reset-TestFixture + $http.Calls.Clear() + $http.Responses = @{ 'Registry:latest' = [pscustomobject]@{ name = 'openclaw'; version = $version } } + Add-Release + $script:policy = Read-OpenClawReleasePolicy $policyPath + $script:workflow = @{ + PolicyPath = $policyPath; OutputPath = Join-Path $testRoot "source-$testCount.json" + WorkflowRunId = '123456'; PackagingCommit = 'd' * 40 + } & $Body $script:testCount++ Write-Host "PASS: $Name" } -New-Item -Path $testRoot -ItemType Directory | Out-Null -try { - Invoke-Test 'npm latest resolves a signed annotated regular stable release' { - $source = Resolve-OpenClawSource -Policy $policy - Assert-TestEqual ($source -is [System.Management.Automation.PSCustomObject]) $true - Assert-TestEqual $source.repository $policy.repository - Assert-TestEqual $source.requestedRef 'stable' - Assert-TestEqual $source.channel 'stable' - Assert-TestEqual $source.packageVersion $version - Assert-TestEqual $source.releaseTag "v$version" - Assert-TestEqual $source.tagObject $tagObject - Assert-TestEqual $source.resolvedCommit $commit - Assert-TestEqual $source.registryIntegrity $integrity - Assert-TestEqual ($source.resolvedAt.EndsWith('Z')) $true - Assert-TestEqual ($source.PSObject.Properties.Name -join ',') ( - 'repository,requestedRef,resolvedCommit,packageVersion,channel,' + - 'releaseTag,tagObject,resolvedAt,registryIntegrity' - ) - foreach ($property in $source.PSObject.Properties) { - Assert-TestEqual ($property.Value -is [string]) $true - } - Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 - Assert-TestEqual ($requests -join '|') ( - "registry:latest|github:git/ref/tags/v$version|" + - "github:git/tags/$tagObject|github:contents/package.json?ref=$commit|" + - "registry:$version" - ) - } +function Save-Policy { + $workflow.PolicyPath = Join-Path $testRoot "policy-$testCount.json" + [IO.File]::WriteAllText($workflow.PolicyPath, ($policy | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) +} - Invoke-Test 'a new call follows an advancing selector without caching' { +New-Item -ItemType Directory -Path $testRoot | Out-Null +try { + $env:GH_TOKEN = 'offline-test-token' + Invoke-Test 'default policy selects npm latest and verifies the exact signed release' { + Assert-Equal (Get-OpenClawPolicyRef $policy) 'stable' + $source = Resolve-OpenClawSource $policy + Assert-Equal $source.requestedRef 'stable' + Assert-Equal $source.channel 'stable' + Assert-Equal $source.packageVersion $version + Assert-Equal $source.resolvedCommit $commit + Assert-Equal $source.releaseTag "v$version" + Assert-Equal $source.tagObject $tagObject + Assert-Equal $source.registryIntegrity $integrity + Assert-Equal @($source.PSObject.Properties).Count 9 + Assert-Equal @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 + Assert-Equal ($http.Calls.Key -join '|') ( + "Registry:latest|Registry:$version|GitHub:git/ref/tags/v$version|" + + "GitHub:git/tags/$tagObject|GitHub:contents/package.json?ref=$commit") + foreach ($call in $http.Calls) { + Assert-Equal $call.Timeout 30 + if ($PSVersionTable.PSVersion -ge [version]'7.4') { Assert-Equal $call.OperationTimeout 30 } + Assert-Equal $call.Redirects 0 + Assert-Equal $call.Errors 'Stop' + Assert-Equal $call.Headers.ContainsKey('Authorization') ($call.Key.StartsWith('GitHub:')) + } + } + Invoke-Test 'a new resolution follows an advancing stable channel' { $first = Resolve-OpenClawSource $policy - $nextCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - Add-TestRelease -Version '2026.9.5' -Commit $nextCommit -TagObject ('b' * 40) - $responses['registry:latest'].version = '2026.9.5' - $second = Resolve-OpenClawSource $policy - Assert-TestEqual $first.resolvedCommit $commit - Assert-TestEqual $second.resolvedCommit $nextCommit - Assert-TestEqual $second.packageVersion '2026.9.5' - } - - Invoke-Test 'unpinned policy ref is logical stable without any HTTP lookup' { - $refs = @(Get-OpenClawPolicyRef -Policy $policy) - Assert-TestEqual $refs.Count 1 - Assert-TestEqual $refs[0] 'stable' - Assert-TestEqual $requests.Count 0 - } - - foreach ($final in @($false, $true)) { - Invoke-Test 'source version Final compatibility always requires regular stable' { - foreach ($stableVersion in @($version, "$version-1")) { - Assert-TestEqual @( - Assert-OpenClawSourceVersion -Version $stableVersion -Final:$final - ).Count 0 - } - foreach ($badVersion in $invalidStableVersions) { - Assert-TestThrows { - Assert-OpenClawSourceVersion -Version $badVersion -Final:$final - } 'packageVersion' - } - } - } - - foreach ($pin in @('2026.8.31', '2026.8.31-2')) { - Invoke-Test "reviewed older stable pin skips latest: $pin" { - $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin - $policy = Read-TestPolicy ($policy | ConvertTo-Json) - $pinnedCommit = 'a' * 40 - $pinnedTag = 'b' * 40 - Add-TestRelease -Version $pin -Commit $pinnedCommit -TagObject $pinnedTag - $failures['registry:latest'] = 'A reviewed pin must not query latest.' - Assert-TestEqual (Get-OpenClawPolicyRef $policy) $pin - $source = Resolve-OpenClawSource $policy - Assert-TestEqual $source.channel 'stable' - Assert-TestEqual $source.requestedRef $pin - Assert-TestEqual $source.packageVersion $pin - Assert-TestEqual $source.resolvedCommit $pinnedCommit - Assert-TestEqual $source.releaseTag "v$pin" - Assert-TestEqual ($requests -join '|') ( - "registry:$pin|github:git/ref/tags/v$pin|" + - "github:git/tags/$pinnedTag|github:contents/package.json?ref=$pinnedCommit" - ) - $replayed = $source | ConvertTo-Json | ConvertFrom-Json - Assert-TestEqual @(Assert-OpenClawSource $replayed $policy -RequireChannel).Count 0 - } - } - - foreach ($correctionVersion in @('2026.9.4-1', '2026.12.32-42')) { - Invoke-Test "latest accepts a verbatim stable numeric correction: $correctionVersion" { - Add-TestRelease -Version $correctionVersion - $responses['registry:latest'].version = $correctionVersion - $source = Resolve-OpenClawSource $policy - Assert-TestEqual $source.packageVersion $correctionVersion - Assert-TestEqual $source.releaseTag "v$correctionVersion" - Assert-TestEqual $source.requestedRef 'stable' - Assert-TestEqual $source.channel 'stable' - Assert-TestEqual $requests[$requests.Count - 1] "registry:$correctionVersion" - Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 - } - } - - foreach ($pinned in @($false, $true)) { - Invoke-Test 'same-source npm correction cannot silently rewrite the source version' { - $correctionVersion = "$version-1" - Add-TestRelease -Version $correctionVersion - Set-TestPackage -Version $version - if ($pinned) { - $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $correctionVersion - } - else { - $responses['registry:latest'].version = $correctionVersion - } - Assert-TestThrows { Resolve-OpenClawSource $policy } 'source package version' - Assert-TestEqual $requests.Count 4 - Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 + Add-Release '2026.9.5' ('a' * 40) ('b' * 40) + $http.Responses['Registry:latest'].version = '2026.9.5' + $selected = Resolve-OpenClawSource $policy + Assert-Equal $selected.resolvedCommit ('a' * 40) + Assert-Equal $first.resolvedCommit $commit + $identity = & (Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1') ` + -GatewayTag $selected.releaseTag -MSIXRevision 0 + Assert-Equal $identity.PackageVersion '2026.9.5.1000' + Assert-Equal $identity.ReleaseTag 'v2026.9.5-msix.0' + } + foreach ($missing in @('Registry:latest', "Registry:$version", "GitHub:git/ref/tags/v$version")) { + Invoke-Test "missing $missing is terminal, with no fallback" { + Add-Release '2026.6.35' ('e' * 40) ('f' * 40) + $http.Responses.Remove($missing) + Assert-Throws { Resolve-OpenClawSource $policy } 'Missing offline response' + Assert-Equal $http.Calls[-1].Key $missing + Assert-Equal @(@($http.Calls.Key) -match '2026\.6\.35|extended-stable').Count 0 } } - - Invoke-Test 'a withdrawn registry pin fails without latest or cross-channel fallback' { - $pin = '2026.8.31' - $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin - Assert-TestThrows { Resolve-OpenClawSource $policy } 'No offline fixture' - Assert-TestEqual $requests.Count 1 - Assert-TestEqual $requests[0] "registry:$pin" - } - - Invoke-Test 'an exact pin response cannot select a different version' { - $pin = '2026.8.31' - $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin - Add-TestRelease -Version $pin - $responses["registry:$pin"].version = $version - Assert-TestThrows { Resolve-OpenClawSource $policy } 'stableVersion pin' - Assert-TestEqual $requests.Count 1 - } - - foreach ($withdrawn in @($false, $true)) { - Invoke-Test 'changed or withdrawn policy pins reject old snapshots on replay' { - $policy | Add-Member -NotePropertyName stableVersion -NotePropertyValue $version - $source = Resolve-OpenClawSource $policy | ConvertTo-Json | ConvertFrom-Json - if ($withdrawn) { - $policy.PSObject.Properties.Remove('stableVersion') - } - else { - $policy.stableVersion = '2026.9.3' - } - $requestCount = $requests.Count - Assert-TestThrows { Assert-OpenClawSource $source $policy -RequireChannel } 'requestedRef' - if (-not $withdrawn) { - $source.requestedRef = $policy.stableVersion - Assert-TestThrows { Assert-OpenClawSource $source $policy -RequireChannel } 'stableVersion pin' - } - Assert-TestEqual $requests.Count $requestCount - } - } - - foreach ($endpoint in @( - 'registry:latest', "github:git/ref/tags/v$version", - "github:git/tags/$tagObject", "github:contents/package.json?ref=$commit", - "registry:$version" - )) { - Invoke-Test "API failure is terminal at $endpoint" { - $failures[$endpoint] = 'Offline fixture API failure.' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'Offline fixture API failure' - Assert-TestEqual $requests[$requests.Count - 1] $endpoint - Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 - } - } - - Invoke-Test 'missing latest never falls back to an available extended-stable release' { - $responses.Remove('registry:latest') - Assert-TestThrows { Resolve-OpenClawSource $policy } 'No offline fixture' - Assert-TestEqual $requests.Count 1 - Assert-TestEqual $requests[0] 'registry:latest' - } - - foreach ($badVersion in $invalidStableVersions) { - Invoke-Test 'latest rejects invalid or extended versions without fallback' { - $responses['registry:latest'].version = $badVersion - Assert-TestThrows { Resolve-OpenClawSource $policy } 'packageVersion' - Assert-TestEqual $requests.Count 1 - Assert-TestEqual $requests[0] 'registry:latest' - } - } - - Invoke-Test 'channel package identity must match' { - $responses['registry:latest'].name = 'other' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'openclaw package' - } - foreach ($case in @( - @{ Field = 'ref'; Value = 'refs/tags/v2026.9.3'; Error = 'exact annotated' }, - @{ Field = 'type'; Value = 'commit'; Error = 'exact annotated' }, - @{ Field = 'sha'; Value = '../../other'; Error = 'tag object' } + @{ Name = 'registry name'; Edit = { $http.Responses["Registry:$version"].name = 'other' }; Error = 'exact registry' }, + @{ Name = 'registry version'; Edit = { $http.Responses["Registry:$version"].version = '2026.9.5' }; Error = 'exact registry' }, + @{ Name = 'registry repository'; Edit = { $http.Responses["Registry:$version"].repository.url += '/other' }; Error = 'repository' }, + @{ Name = 'registry integrity'; Edit = { $http.Responses["Registry:$version"].dist.integrity = 'sha512-invalid' }; Error = 'registryIntegrity' }, + @{ Name = 'registry gitHead'; Edit = { $http.Responses["Registry:$version"].gitHead = 'a' * 40 }; Error = 'gitHead' }, + @{ Name = 'lightweight tag'; Edit = { $http.Responses["GitHub:git/ref/tags/v$version"].object.type = 'commit' }; Error = 'annotated tag' }, + @{ Name = 'wrong tag ref'; Edit = { $http.Responses["GitHub:git/ref/tags/v$version"].ref = 'refs/tags/other' }; Error = 'annotated tag' }, + @{ Name = 'unsigned tag'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].verification.verified = $false }; Error = 'signature' }, + @{ Name = 'nonboolean verification'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].verification.verified = 'true' }; Error = 'signature' }, + @{ Name = 'wrong tag SHA'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].sha = 'a' * 40 }; Error = 'signature' }, + @{ Name = 'wrong tag label'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].tag = 'v2026.9.5' }; Error = 'signature' }, + @{ Name = 'nested tag target'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].object.type = 'tag' }; Error = 'directly to a commit' }, + @{ Name = 'source name'; Edit = { Set-Package -Name 'other' }; Error = 'source package name' }, + @{ Name = 'source correction mismatch'; Edit = { + Add-Release "$version-1"; $http.Responses['Registry:latest'].version = "$version-1" + Set-Package $version + }; Error = 'source package version' } )) { - Invoke-Test "malformed tag ref rejects $($case.Field)" { - $tagRef = $responses["github:git/ref/tags/v$version"] - if ($case.Field -eq 'ref') { - $tagRef.ref = $case.Value - } - else { - $tagRef.object.($case.Field) = $case.Value - } - Assert-TestThrows { Resolve-OpenClawSource $policy } $case.Error - Assert-TestEqual $requests.Count 2 - } - } - - foreach ($case in @( - @{ Field = 'sha'; Value = ('f' * 40) }, - @{ Field = 'tag'; Value = 'v2026.9.3' }, - @{ Field = 'verified'; Value = $false }, - @{ Field = 'verified'; Value = 'true' }, - @{ Field = 'verified'; Value = @($true) }, - @{ Field = 'reason'; Value = 'unsigned' } - )) { - Invoke-Test "unverified or mismatched annotated tag rejects $($case.Field)" { - $tag = $responses["github:git/tags/$tagObject"] - if ($case.Field -in @('verified', 'reason')) { - $tag.verification.($case.Field) = $case.Value - } - else { - $tag.($case.Field) = $case.Value - } - Assert-TestThrows { Resolve-OpenClawSource $policy } 'GitHub-verified signature' - } - } - - Invoke-Test 'nested annotated tags are not commit targets' { - $responses["github:git/tags/$tagObject"].object.type = 'tag' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'directly to a commit' - } - - Invoke-Test 'invalid commit is rejected before package request' { - $responses["github:git/tags/$tagObject"].object.sha = 'not-a-sha' - Assert-TestThrows { Resolve-OpenClawSource $policy } "'commit'" - Assert-TestEqual $requests.Count 3 - } - - Invoke-Test 'GitHub SHA casing is normalized' { - $responses["github:git/ref/tags/v$version"].object.sha = $tagObject.ToUpperInvariant() - $responses["github:git/tags/$tagObject"].sha = $tagObject.ToUpperInvariant() - $responses["github:git/tags/$tagObject"].object.sha = $commit.ToUpperInvariant() - $responses["registry:$version"].gitHead = $commit.ToUpperInvariant() - $source = Resolve-OpenClawSource $policy - Assert-TestEqual $source.resolvedCommit $commit - Assert-TestEqual $source.tagObject $tagObject - } - - Invoke-Test 'immutable package version must match the selection' { - Set-TestPackage -Version '2026.9.3' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'source package version' - Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 - } - - Invoke-Test 'exact registry version must match the selection' { - $responses["registry:$version"].version = '2026.9.5' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'exact registry manifest' - Assert-TestEqual ($requests -match 'extended-stable|2026\.6\.35').Count 0 - } - - Invoke-Test 'exact registry package name must match' { - $responses["registry:$version"].name = 'other' - Assert-TestThrows { Resolve-OpenClawSource $policy } 'exact registry manifest' - } - - foreach ($repository in @( - 'https://github.com/other/openclaw', - 'git+https://github.com/openclaw/openclaw.git#main', - 'https://github.com/openclaw/openclaw/extra', - 'git@github.com:openclaw/openclaw.git' - )) { - Invoke-Test "unexpected npm repository is rejected: $repository" { - $responses["registry:$version"].repository.url = $repository - Assert-TestThrows { Resolve-OpenClawSource $policy } 'repository' - } - } - - Invoke-Test 'canonical npm repository string is accepted' { - $responses["registry:$version"].repository = $policy.repository - $source = Resolve-OpenClawSource $policy - Assert-TestEqual $source.resolvedCommit $commit - } - - Invoke-Test 'absent optional gitHead is accepted' { - $responses["registry:$version"].PSObject.Properties.Remove('gitHead') - $source = Resolve-OpenClawSource $policy - Assert-TestEqual $source.resolvedCommit $commit - } - - foreach ($gitHead in @(('f' * 40), 'bad', $null, @($commit))) { - Invoke-Test "present gitHead must match: $($gitHead -join ',')" { - $responses["registry:$version"].gitHead = $gitHead - Assert-TestThrows { Resolve-OpenClawSource $policy } 'gitHead' - } - } - - foreach ($badIntegrity in @( - '', ('sha256-' + [Convert]::ToBase64String([byte[]]::new(32))), - ('sha512-' + [Convert]::ToBase64String([byte[]]::new(63))), - ('sha512-' + ('A' * 85) + 'B=='), "$integrity`n", @($integrity) - )) { - Invoke-Test 'invalid or noncanonical SHA-512 integrity is rejected' { - $responses["registry:$version"].dist.integrity = $badIntegrity - Assert-TestThrows { Resolve-OpenClawSource $policy } 'registryIntegrity' - } - } - - foreach ($ref in @('feature/source', "v$version", $commit, 'stable')) { - Invoke-Test "explicit override resolves only GitHub commit and source: $ref" { - $escapedRef = [Uri]::EscapeDataString($ref) - $responses["github:commits/$escapedRef"] = [pscustomobject]@{ - sha = $commit.ToUpperInvariant() - } - Set-TestPackage -Version '2026.9.4-2' + Invoke-Test "rejects $($case.Name)" { & $case.Edit; Assert-Throws { Resolve-OpenClawSource $policy } $case.Error } + } + Invoke-Test 'regular patch 32 and numeric corrections are retained verbatim; gitHead is optional' { + foreach ($stable in @('2026.9.32', '2026.9.4-1', '2026.9.4-64')) { + Add-Release $stable + $http.Responses["Registry:$stable"].PSObject.Properties.Remove('gitHead') + $http.Responses['Registry:latest'].version = $stable + Assert-Equal (Resolve-OpenClawSource $policy).packageVersion $stable + Assert-Equal (Resolve-OpenClawSource $policy -Ref $commit).packageVersion $stable + } + } + foreach ($invalid in @('2026.9.33', '2026.6.35-2', '2026.9.4-beta.1', '2026.09.4', '2026.9.4-0', '2026.9.4-01', '2026.9.4+build')) { + Invoke-Test "rejects $invalid from both latest and a full SHA override" { + $http.Responses['Registry:latest'].version = $invalid + Assert-Throws { Resolve-OpenClawSource $policy } 'packageVersion' + Assert-Equal $http.Calls.Count 1 + Set-Package $invalid + Assert-Throws { Resolve-OpenClawSource $policy -Ref $commit } 'packageVersion' + } + } + Invoke-Test 'a reviewed older stable pin queries only its exact release and remains officially approved' { + Add-Release '2026.9.5' ('a' * 40) ('b' * 40) + $http.Responses['Registry:latest'].version = '2026.9.5' + $policy | Add-Member stableVersion $version + Save-Policy + $source = & $workflowPath @workflow -SigningMode official + Assert-Equal $source.requestedRef $version + Assert-Equal $source.resolvedCommit $commit + Assert-Equal $http.Calls[0].Key "Registry:$version" + Assert-Equal @(@($http.Calls.Key) -match 'Registry:latest').Count 0 + } + Invoke-Test 'a withdrawn or mismatched exact pin never falls back' { + $policy | Add-Member stableVersion '2026.8.31' + Assert-Throws { Resolve-OpenClawSource $policy } 'Missing offline response' + Assert-Equal $http.Calls.Count 1 + $http.Responses['Registry:2026.8.31'] = $http.Responses["Registry:$version"] + Assert-Throws { Resolve-OpenClawSource $policy } 'stableVersion pin' + Assert-Equal $http.Calls.Count 2 + } + Invoke-Test 'SHA, tag, and branch overrides stay unsigned provenance and ignore the channel pin' { + $policy | Add-Member stableVersion '2026.8.31' + foreach ($ref in @($commit, "v$version", 'feature/source')) { + $http.Responses["GitHub:commits/$([Uri]::EscapeDataString($ref))"] = [pscustomobject]@{ sha = $commit } $source = Resolve-OpenClawSource $policy -Ref $ref - Assert-TestEqual $source.requestedRef $ref - Assert-TestEqual $source.resolvedCommit $commit - Assert-TestEqual $source.packageVersion '2026.9.4-2' - foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { - Assert-TestEqual $source.$field '' - } - Assert-TestEqual ($requests -join '|') ( - "github:commits/$escapedRef|github:contents/package.json?ref=$commit" - ) - Assert-TestEqual @(Assert-OpenClawSource $source $policy).Count 0 - Assert-TestThrows { - Assert-OpenClawSource $source $policy -RequireChannel - } 'channel-resolved source' - $replayed = $source | ConvertTo-Json | ConvertFrom-Json - Assert-TestEqual @(Assert-OpenClawSource $replayed $policy).Count 0 - Assert-TestThrows { - Assert-OpenClawSource $replayed $policy -RequireChannel - } 'channel-resolved source' - } - } - - foreach ($ref in @(' ', "`t", 'branch name', "main`nINJECT=value", "main$([char]0)", "main$([char]0x2028)")) { - Invoke-Test 'whitespace and control characters in overrides are rejected before HTTP' { - Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $ref } "'Ref'" - Assert-TestEqual $requests.Count 0 - } - } - - foreach ($ref in @( - 'extended-stable', 'extended-stable/2026.9', 'Extended-Stable', - 'refs/heads/extended-stable/2026.9', 'refs/tags/extended-stable' - )) { - Invoke-Test 'explicit extended-stable selectors are rejected before networking' { - Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $ref } "'Ref'.*extended-stable" - Assert-TestEqual $requests.Count 0 - $source = Resolve-OpenClawSource $policy - foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { - $source.$field = '' - } - $source.requestedRef = $ref - Assert-TestThrows { Assert-OpenClawSource $source $policy } "'requestedRef'.*extended-stable" - } - } - - foreach ($badVersion in $invalidStableVersions) { - Invoke-Test 'override source version must belong to regular stable' { - $responses['github:commits/main'] = [pscustomobject]@{ sha = $commit } - Set-TestPackage -Version $badVersion - Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'packageVersion' - } - } - - Invoke-Test 'override source package name must be openclaw' { - $responses['github:commits/main'] = [pscustomobject]@{ sha = $commit } - Set-TestPackage -Name 'other' - Assert-TestThrows { Resolve-OpenClawSource $policy -Ref 'main' } 'source package name' - } - - Invoke-Test 'an immutable SHA override cannot opt into an extended-stable source version' { - $responses["github:commits/$commit"] = [pscustomobject]@{ sha = $commit } - Set-TestPackage -Version '2026.6.35' - Assert-TestThrows { Resolve-OpenClawSource $policy -Ref $commit } 'packageVersion' - Assert-TestEqual ($requests -join '|') ( - "github:commits/$commit|github:contents/package.json?ref=$commit" - ) - } - - foreach ($badVersion in $invalidStableVersions) { - Invoke-Test 'channel and override snapshots both require regular stable versions' { - $source = Resolve-OpenClawSource $policy - $source.packageVersion = $badVersion - Assert-TestThrows { Assert-OpenClawSource $source $policy } 'packageVersion' - foreach ($field in @('channel', 'releaseTag', 'tagObject', 'registryIntegrity')) { - $source.$field = '' - } - $source.requestedRef = 'main' - Assert-TestThrows { Assert-OpenClawSource $source $policy } 'packageVersion' - } - } - - Invoke-Test 'a previous extended-stable snapshot is rejected' { - $source = Resolve-OpenClawSource $policy - $source.requestedRef = 'extended-stable' - $source.channel = 'extended-stable' - $source.packageVersion = '2026.6.35' - $source.releaseTag = 'v2026.6.35' - Assert-TestThrows { Assert-OpenClawSource $source $policy } 'extended-stable' - } - - Invoke-Test 'snapshot timestamp can be old and extra build metadata is allowed' { - $source = Resolve-OpenClawSource $policy - $source.resolvedAt = '2000-01-01T00:00:00Z' - $source | Add-Member -NotePropertyName nodeVersion -NotePropertyValue '24.16.0' - Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 - } - - Invoke-Test 'snapshot survives default JSON timestamp conversion without mutation' { - $source = Resolve-OpenClawSource $policy - $replayed = $source | ConvertTo-Json | ConvertFrom-Json - Assert-TestEqual ($replayed.resolvedAt -is [DateTime]) $true - Assert-TestEqual $replayed.resolvedAt.Kind ([DateTimeKind]::Utc) - Assert-TestEqual $replayed.resolvedAt.ToString('o') $source.resolvedAt - Assert-TestEqual @(Assert-OpenClawSource $replayed $policy -RequireChannel).Count 0 - Assert-TestEqual ($replayed.resolvedAt -is [DateTime]) $true - $replayedAgain = $replayed | ConvertTo-Json | ConvertFrom-Json - Assert-TestEqual ( - $replayedAgain.resolvedAt | ConvertTo-Json -Compress - ) ($replayed.resolvedAt | ConvertTo-Json -Compress) - } - - Invoke-Test 'UTC DateTime timestamps may be old' { + Assert-Equal $source.requestedRef $ref + Assert-Equal $source.channel '' + Assert-Equal $source.packageVersion $version + Assert-Throws { Assert-OpenClawSource $source $policy -RequireChannel } 'channel-resolved' + } + Assert-Equal @(@($http.Calls.Key) -match '^Registry:').Count 0 + $http.Responses["GitHub:commits/$commit"].sha = 'a' * 40 + Assert-Throws { Resolve-OpenClawSource $policy -Ref $commit } 'full SHA override' + } + Invoke-Test 'extended-stable selectors and invalid source policies fail before HTTP' { + foreach ($ref in @('extended-stable', 'refs/heads/extended-stable', 'refs/tags/extended-stable/test')) { + Assert-Throws { Resolve-OpenClawSource $policy -Ref $ref } 'extended-stable' + } + $policy | Add-Member channel 'extended-stable' + Assert-Throws { Resolve-OpenClawSource $policy } 'channel' + $policy.channel = 'stable' + $policy | Add-Member stableVersion '2026.6.35' + Assert-Throws { Resolve-OpenClawSource $policy } 'packageVersion' + $policy.PSObject.Properties.Remove('stableVersion') + $policy.repository += '/other' + Assert-Throws { Resolve-OpenClawSource $policy } 'repository' + Assert-Equal $http.Calls.Count 0 + } + Invoke-Test 'snapshots are write-once UTF8 LF; replay preserves bytes without network' { + $source = & $workflowPath @workflow + $bytes = [IO.File]::ReadAllBytes($workflow.OutputPath) + Assert-Equal $bytes[0] ([byte][char]'{') + Assert-Equal ($bytes -contains 13) $false + $http.Calls.Clear() + $replayed = & $workflowPath @workflow -ReuseSnapshot + Assert-Equal $replayed.resolvedCommit $source.resolvedCommit + Assert-Equal ([Convert]::ToBase64String([IO.File]::ReadAllBytes($workflow.OutputPath))) ([Convert]::ToBase64String($bytes)) + Assert-Throws { & $workflowPath @workflow } 'already exists' + foreach ($field in @('WorkflowRunId', 'PackagingCommit', 'SigningMode', 'Ref')) { + $changed = $workflow.Clone() + $changed[$field] = @{ WorkflowRunId = '999'; PackagingCommit = 'e' * 40; SigningMode = 'test'; Ref = "v$version" }[$field] + Assert-Throws { & $workflowPath @changed -ReuseSnapshot } 'workflow identity|requested selector' + } + Assert-Equal $http.Calls.Count 0 + } + Invoke-Test 'missing snapshots require a new run' { + Assert-Throws { & $workflowPath @workflow -ReuseSnapshot } 'Start a new workflow run' + Assert-Equal $http.Calls.Count 0 + } + Invoke-Test 'changed or withdrawn policy pins cannot replay a saved selector' { + $policy | Add-Member stableVersion $version + Save-Policy + $null = & $workflowPath @workflow + $http.Calls.Clear() + $policy.stableVersion = '2026.9.5' + Save-Policy + Assert-Throws { & $workflowPath @workflow -ReuseSnapshot } 'requestedRef' + $policy.PSObject.Properties.Remove('stableVersion') + Save-Policy + Assert-Throws { & $workflowPath @workflow -ReuseSnapshot } 'requestedRef' + Assert-Equal $http.Calls.Count 0 + } + Invoke-Test 'structurally invalid saved source fields are rejected offline' { $source = Resolve-OpenClawSource $policy - $source.resolvedAt = [DateTime]::new(2000, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) - Assert-TestEqual @(Assert-OpenClawSource $source $policy -RequireChannel).Count 0 - } - - foreach ($kind in @([DateTimeKind]::Unspecified, [DateTimeKind]::Local)) { - Invoke-Test "non-UTC DateTime timestamp is rejected: $kind" { - $source = Resolve-OpenClawSource $policy - $source.resolvedAt = [DateTime]::new(2000, 1, 1, 0, 0, 0, $kind) - Assert-TestThrows { Assert-OpenClawSource $source $policy } 'resolvedAt' + $http.Calls.Clear() + foreach ($field in @('resolvedCommit', 'packageVersion', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity')) { + $changed = $source | ConvertTo-Json | ConvertFrom-Json + $changed.$field = 'invalid' + Assert-Throws { Assert-OpenClawSource $changed $policy } $field } + $source.packageVersion = @($version) + Assert-Throws { Assert-OpenClawSource $source $policy } 'packageVersion' + $source.packageVersion = $version + $source.PSObject.Properties.Remove('repository') + Assert-Throws { Assert-OpenClawSource $source $policy } 'repository' + Assert-Equal $http.Calls.Count 0 } - - foreach ($case in @( - @{ Field = 'repository'; Value = 'https://github.com/other/openclaw'; Error = 'repository' }, - @{ Field = 'requestedRef'; Value = 'main'; Error = 'requestedRef' }, - @{ Field = 'requestedRef'; Value = "stable`r`nevil=value"; Error = 'requestedRef' }, - @{ Field = 'resolvedCommit'; Value = $commit.ToUpperInvariant(); Error = 'resolvedCommit' }, - @{ Field = 'resolvedCommit'; Value = @($commit); Error = 'resolvedCommit' }, - @{ Field = 'packageVersion'; Value = '2026.9.4-beta.1'; Error = 'packageVersion' }, - @{ Field = 'channel'; Value = 'latest'; Error = 'channel' }, - @{ Field = 'channel'; Value = ''; Error = 'ref override' }, - @{ Field = 'releaseTag'; Value = 'v2026.9.3'; Error = 'releaseTag' }, - @{ Field = 'tagObject'; Value = ''; Error = 'tagObject' }, - @{ Field = 'tagObject'; Value = $tagObject.ToUpperInvariant(); Error = 'tagObject' }, - @{ Field = 'registryIntegrity'; Value = 'sha512-invalid'; Error = 'registryIntegrity' }, - @{ Field = 'resolvedAt'; Value = '2026-02-30T12:00:00Z'; Error = 'resolvedAt' }, - @{ Field = 'resolvedAt'; Value = 'not-a-date'; Error = 'resolvedAt' }, - @{ Field = 'resolvedAt'; Value = '2026-06-01'; Error = 'resolvedAt' }, - @{ Field = 'resolvedAt'; Value = '2026-06-01T00:00:00'; Error = 'resolvedAt' }, - @{ Field = 'resolvedAt'; Value = "2026-06-01T00:00:00Z`n"; Error = 'resolvedAt' } - )) { - Invoke-Test "snapshot identity rejects inconsistent $($case.Field)" { - $source = Resolve-OpenClawSource $policy - $source.($case.Field) = $case.Value - Assert-TestThrows { Assert-OpenClawSource $source $policy } $case.Error - } - } - - foreach ($field in @( - 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', - 'channel', 'releaseTag', 'tagObject', 'resolvedAt', 'registryIntegrity' - )) { - Invoke-Test "snapshot requires $field" { - $source = Resolve-OpenClawSource $policy - $source.PSObject.Properties.Remove($field) - Assert-TestThrows { Assert-OpenClawSource $source $policy } $field - } - } - - foreach ($revision in @(0, 65534)) { - Invoke-Test "policy accepts boundary packageRevision $revision" { - $candidate = New-TestPolicy - $candidate.packageRevision = $revision - $read = Read-TestPolicy ($candidate | ConvertTo-Json) - Assert-TestEqual $read.packageRevision $revision - } - } - - foreach ($pin in ($invalidStableVersions + @('', $null, 'stable', 'latest', 'extended-stable'))) { - Invoke-Test 'policy rejects invalid, extended, empty, and null stableVersion pins' { - $candidate = New-TestPolicy - $candidate | Add-Member -NotePropertyName stableVersion -NotePropertyValue $pin - Assert-TestThrows { - Read-TestPolicy ($candidate | ConvertTo-Json -Depth 8) - } 'stableVersion' - Assert-TestThrows { Get-OpenClawPolicyRef $candidate } 'stableVersion' - Assert-TestThrows { Resolve-OpenClawSource $candidate } 'stableVersion' - Assert-TestEqual $requests.Count 0 - } - } - - foreach ($case in @( - @{ Field = 'repository'; Value = 'https://github.com/other/openclaw' }, - @{ Field = 'repository'; Value = @('https://github.com/openclaw/openclaw') }, - @{ Field = 'channel'; Value = 'latest' }, - @{ Field = 'channel'; Value = 'Stable' }, - @{ Field = 'channel'; Value = 'extended-stable' }, - @{ Field = 'packageRevision'; Value = -1 }, - @{ Field = 'packageRevision'; Value = 65535 }, - @{ Field = 'packageRevision'; Value = '1' }, - @{ Field = 'packageRevision'; Value = 1.0 }, - @{ Field = 'packageRevision'; Value = $true }, - @{ Field = 'packageRevision'; Value = $null }, - @{ Field = 'publisher'; Value = '' }, - @{ Field = 'publisher'; Value = ' ' }, - @{ Field = 'publisher'; Value = "CN=Test`nINJECT=value" } - )) { - Invoke-Test "policy rejects malformed $($case.Field)" { - $candidate = New-TestPolicy - $candidate.($case.Field) = $case.Value - Assert-TestThrows { - Read-TestPolicy ($candidate | ConvertTo-Json -Depth 8) - } $case.Field - } - } - - foreach ($field in @('repository', 'channel', 'packageRevision', 'publisher')) { - Invoke-Test "policy requires $field" { - $candidate = New-TestPolicy - $candidate.PSObject.Properties.Remove($field) - Assert-TestThrows { Read-TestPolicy ($candidate | ConvertTo-Json) } $field - } - } - - foreach ($json in @('[]', '[{"repository":"ignored"}]', 'null', '"string"', '{')) { - Invoke-Test 'policy rejects nonobjects and invalid JSON' { - Assert-TestThrows { Read-TestPolicy $json } 'object|JSON' - } - } - - foreach ($case in @( - @{ Version = '2026.9.4'; Revision = 0; Expected = '2026.9.4.0' }, - @{ Version = '2026.9.4'; Revision = 1; Expected = '2026.9.4.1' }, - @{ Version = '2026.9.4'; Revision = [long]65534; Expected = '2026.9.4.65534' }, - @{ Version = '2026.9.4-1'; Revision = 0; Expected = '2026.9.4.1' }, - @{ Version = '2026.9.4-2'; Revision = 3; Expected = '2026.9.4.5' }, - @{ Version = '2026.9.4-65534'; Revision = 0; Expected = '2026.9.4.65534' }, - @{ Version = '2026.9.4-65533'; Revision = 1; Expected = '2026.9.4.65534' }, - @{ Version = '9999.12.32'; Revision = 0; Expected = '9999.12.32.0' }, - @{ Version = '2026.1.1'; Revision = 0; Expected = '2026.1.1.0' } - )) { - Invoke-Test "MSIX mapping preserves base and adds numeric corrections: $($case.Expected)" { - $results = @(Get-OpenClawMsixReleaseVersion -Version $case.Version -PackageRevision $case.Revision) - Assert-TestEqual $results.Count 1 - Assert-TestEqual ($results[0] -is [string]) $true - Assert-TestEqual $results[0] $case.Expected + foreach ($ref in @('', $commit)) { + Invoke-Test "official signing accepts the reviewed release via selector '$ref'" { + $source = & $workflowPath @workflow -SigningMode official -Ref $ref + Assert-Equal $source.resolvedCommit $commit } } - - foreach ($case in @( - @{ Version = '2026.9.4-65535'; Revision = 0 }, - @{ Version = '2026.9.4-65534'; Revision = 1 }, - @{ Version = '2026.9.4-1'; Revision = 65534 }, - @{ Version = '2026.9.4-2147483647'; Revision = 0 }, - @{ Version = '2026.9.4-2147483648'; Revision = 0 }, - @{ Version = ('2026.9.4-' + ('9' * 200)); Revision = 0 } - )) { - Invoke-Test 'MSIX correction overflow is rejected before addition' { - Assert-TestThrows { - Get-OpenClawMsixReleaseVersion -Version $case.Version -PackageRevision $case.Revision - } 'correction.*exceeds 65534' - } + Invoke-Test 'a valid newer stable channel is not official signing authority' { + Add-Release '2026.9.5' ('a' * 40) ('b' * 40) + $http.Responses['Registry:latest'].version = '2026.9.5' + Assert-Throws { & $workflowPath @workflow -SigningMode official } 'reviewed approvedCommit' + Assert-Equal (Test-Path -LiteralPath $workflow.OutputPath) $false } - - foreach ($revision in @(-1, 65535, '1', 1.0, $true, $null, [long]::MaxValue)) { - Invoke-Test 'MSIX mapping requires an in-range integer package revision' { - Assert-TestThrows { - Get-OpenClawMsixReleaseVersion -Version $version -PackageRevision $revision - } 'packageRevision' + foreach ($field in @('approvedCommit', 'payloadPackageVersion', 'gatewayTag')) { + Invoke-Test "official signing still requires the reviewed $field" { + $policy.$field = @{ approvedCommit = 'a' * 40; payloadPackageVersion = '2026.9.3'; gatewayTag = 'v2026.9.3' }[$field] + Save-Policy + Assert-Throws { & $workflowPath @workflow -SigningMode official } 'reviewed approvedCommit' } } - - foreach ($badVersion in $invalidStableVersions) { - Invoke-Test 'MSIX mapping shares regular stable version validation' { - Assert-TestThrows { - Get-OpenClawMsixReleaseVersion -Version $badVersion -PackageRevision 0 - } 'packageVersion' + Invoke-Test 'official explicit inputs must be the full approved SHA, not a tag or branch' { + foreach ($ref in @("v$version", 'main', ('a' * 40))) { + Assert-Throws { & $workflowPath @workflow -SigningMode official -Ref $ref } 'full reviewed approvedCommit' } + Assert-Equal $http.Calls.Count 0 } - - Invoke-Test 'HTTP transports pin origins, bound timeouts, and isolate GitHub credentials' { - $originalToken = $env:GH_TOKEN - try { - $env:GH_TOKEN = 'offline-test-token' - & $githubTransport -Path "git/tags/$tagObject" | Out-Null - & $registryTransport -Selector 'latest' | Out-Null - & $registryTransport -Selector $version | Out-Null - Assert-TestEqual $httpCalls[0].Uri "https://api.github.com/repos/openclaw/openclaw/git/tags/$tagObject" - Assert-TestEqual $httpCalls[0].Headers.Authorization 'Bearer offline-test-token' - Assert-TestEqual $httpCalls[1].Uri 'https://registry.npmjs.org/openclaw/latest' - Assert-TestEqual $httpCalls[2].Uri "https://registry.npmjs.org/openclaw/$version" - foreach ($call in $httpCalls) { - Assert-TestEqual $call.TimeoutSec 30 - if ($PSVersionTable.PSVersion -ge [version]'7.4') { - Assert-TestEqual $call.OperationTimeoutSeconds 30 - } - Assert-TestEqual $call.MaximumRedirection 0 - Assert-TestEqual $call.ErrorAction 'Stop' - if ($call.Uri.StartsWith('https://registry.npmjs.org/')) { - Assert-TestEqual $call.Headers.ContainsKey('Authorization') $false - } - } - $env:GH_TOKEN = $null - & $githubTransport -Path "git/tags/$tagObject" | Out-Null - Assert-TestEqual $httpCalls[3].Headers.ContainsKey('Authorization') $false - } - finally { - $env:GH_TOKEN = $originalToken - } - } - - Invoke-Test 'service helpers reject arbitrary URLs and invalid selectors before transport' { - Assert-TestThrows { - & $githubTransport -Path 'https://untrusted.example.invalid' - } 'GitHub API path' - Assert-TestThrows { & $registryTransport -Selector '../../other' } 'packageVersion' - Assert-TestEqual $httpCalls.Count 0 - } - - Invoke-Test 'HTTP helpers accept stable numeric correction manifests and annotated tag refs' { - & $registryTransport -Selector '2026.9.4-2' | Out-Null - & $githubTransport -Path 'git/ref/tags/v2026.9.4-2' | Out-Null - Assert-TestEqual $httpCalls[0].Uri 'https://registry.npmjs.org/openclaw/2026.9.4-2' - Assert-TestEqual $httpCalls[1].Uri 'https://api.github.com/repos/openclaw/openclaw/git/ref/tags/v2026.9.4-2' - } - - foreach ($selector in @('stable', 'extended-stable', 'extended-stable/2026.9', 'beta', 'LATEST')) { - Invoke-Test 'registry transport rejects logical and non-stable channel names' { - Assert-TestThrows { & $registryTransport -Selector $selector } 'packageVersion' - Assert-TestEqual $httpCalls.Count 0 - } - } - - foreach ($badVersion in ($invalidStableVersions | Where-Object { $_ -is [string] })) { - Invoke-Test 'HTTP manifest and release tag endpoints reject non-stable versions' { - Assert-TestThrows { & $registryTransport -Selector $badVersion } 'packageVersion' - Assert-TestThrows { & $githubTransport -Path "git/ref/tags/v$badVersion" } 'packageVersion|GitHub API path' - Assert-TestEqual $httpCalls.Count 0 - } - } - - Write-Host "Passed $testCount OpenClaw source resolver tests (offline)." + Write-Host "Passed $testCount OpenClaw source tests." } finally { + $env:GH_TOKEN = $originalToken Remove-Item -LiteralPath $testRoot -Recurse -Force } diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 6cf9f15b..c52c6ba3 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -16,23 +16,6 @@ $releaseIdentity = & ( $approvedPackageVersion = $releaseIdentity.PackageVersion $approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' -$sourceResolution = [ordered]@{ - repository = $policy.repository - requestedRef = $policy.channel - resolvedCommit = $approvedCommit - packageVersion = $approvedPayloadVersion - channel = $policy.channel - releaseTag = "v$approvedPayloadVersion" - tagObject = '4' * 40 - resolvedAt = '2026-09-15T00:00:00.0000000Z' - registryIntegrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) - packagingCommit = $packagingCommit - workflowRunId = '12345' - workflowRunNumber = 1 - signingMode = 'official' - msixPackageVersion = $approvedPackageVersion - msixReleaseTag = "v$approvedPackageVersion" -} $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" ) @@ -76,9 +59,6 @@ function New-TestArtifact { Set-Content ` -LiteralPath (Join-Path $applicationDirectory 'openclaw.mjs') ` -Value "payload-$Architecture" - @{ name = 'openclaw'; version = $PayloadPackageVersion } | - ConvertTo-Json | - Set-Content -LiteralPath (Join-Path $applicationDirectory 'package.json') if ($IncludeApplicationBundledNode) { Set-Content ` -LiteralPath (Join-Path $applicationDirectory 'node.exe') ` @@ -262,14 +242,9 @@ function New-TestArtifact { packagingCommit = $packagingCommit sourceTreeDirty = $SourceTreeDirty payloadRepository = $policy.repository - payloadRequestedRef = $sourceResolution.requestedRef + payloadRequestedRef = $PayloadCommit payloadResolvedCommit = $PayloadCommit payloadPackageVersion = $PayloadPackageVersion - payloadChannel = $sourceResolution.channel - payloadReleaseTag = $sourceResolution.releaseTag - payloadTagObject = $sourceResolution.tagObject - payloadResolvedAt = $sourceResolution.resolvedAt - payloadRegistryIntegrity = $sourceResolution.registryIntegrity payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count nodeRuntimeVersion = $nodeRuntimeVersion @@ -296,7 +271,7 @@ function Invoke-PolicyValidation { [Parameter(Mandatory)] [string]$Root, - [string]$SnapshotHash = '', + [string]$RequestedRef = $approvedCommit, [switch]$PreserveBundle ) @@ -305,22 +280,11 @@ function Invoke-PolicyValidation { New-TestBundle -Root $Root } - $snapshotPath = Join-Path $Root 'source-resolution.json' - if (-not (Test-Path -LiteralPath $snapshotPath)) { - $sourceResolution | ConvertTo-Json | - Set-Content -LiteralPath $snapshotPath -Encoding utf8 - } - if ($SnapshotHash -eq '') { - $SnapshotHash = (Get-FileHash -LiteralPath $snapshotPath -Algorithm SHA256).Hash - } - & (Join-Path $PSScriptRoot 'Test-SigningInputs.ps1') ` -ArtifactsDirectory $Root ` -PolicyPath $policyPath ` -BundlePath (Join-Path $Root 'bundle\OpenClawGateway.msixbundle') ` - -SourceResolutionPath $snapshotPath ` - -SourceResolutionSha256 $SnapshotHash ` - -WorkflowRunId '12345' ` + -RequestedRef $RequestedRef ` -PackagingCommit $packagingCommit } @@ -519,11 +483,11 @@ try { Reset-TestArtifacts Assert-Fails ` - -MessagePattern 'trusted resolver output' ` + -MessagePattern 'approved immutable OpenClaw commit' ` -Action { Invoke-PolicyValidation ` -Root $testRoot ` - -SnapshotHash ('0' * 64) + -RequestedRef 'v2026.8.2' } Reset-TestArtifacts @@ -705,7 +669,7 @@ try { $x64MetadataPath = Join-Path $testRoot 'x64\msix-metadata.json' $x64Metadata = Get-Content -LiteralPath $x64MetadataPath -Raw | ConvertFrom-Json - $x64Metadata.payloadFileCount = 4 + $x64Metadata.payloadFileCount = 2 $x64Metadata | ConvertTo-Json | Set-Content -LiteralPath $x64MetadataPath -Encoding utf8 @@ -758,58 +722,6 @@ try { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } - foreach ($field in @( - 'payloadChannel', 'payloadReleaseTag', 'payloadTagObject', - 'payloadRegistryIntegrity', 'payloadResolvedAt' - )) { - Reset-TestArtifacts - $metadataPath = Join-Path $testRoot 'x64\msix-metadata.json' - $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json - $metadata.$field = 'unexpected' - $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath - Assert-Fails -MessagePattern 'metadata is not eligible' -Action { - Invoke-PolicyValidation -Root $testRoot - } - } - - Reset-TestArtifacts - $override = $sourceResolution | ConvertTo-Json | ConvertFrom-Json - $override.requestedRef = $approvedCommit - $override.channel = '' - $override.releaseTag = '' - $override.tagObject = '' - $override.registryIntegrity = '' - $override | ConvertTo-Json | - Set-Content -LiteralPath (Join-Path $testRoot 'source-resolution.json') - Assert-Fails -MessagePattern 'channel' -Action { - Invoke-PolicyValidation -Root $testRoot - } - - Reset-TestArtifacts - Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { - param($Expanded) - '{"name":"openclaw","version":"2026.9.3"}' | - Set-Content -LiteralPath (Join-Path $Expanded 'app\package.json') - } - Assert-Fails -MessagePattern 'OpenClaw package version is unexpected' -Action { - Invoke-PolicyValidation -Root $testRoot - } - - Reset-TestArtifacts - New-TestBundle -Root $testRoot -BundleVersion '2026.9.3.0' - Assert-Fails -MessagePattern 'bundle manifest identity is unexpected' -Action { - Invoke-PolicyValidation -Root $testRoot -PreserveBundle - } - - $approvedPayloadVersion = '2026.9.4-1' - $approvedPackageVersion = "2026.9.4.$(1 + $policy.packageRevision)" - $sourceResolution.packageVersion = $approvedPayloadVersion - $sourceResolution.releaseTag = "v$approvedPayloadVersion" - $sourceResolution.msixPackageVersion = $approvedPackageVersion - $sourceResolution.msixReleaseTag = "v$approvedPackageVersion" - Reset-TestArtifacts - Invoke-PolicyValidation -Root $testRoot - Write-Host 'Gateway MSIX signing policy tests passed.' } finally { diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 8115919c..62c9b4b1 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -10,15 +10,7 @@ param( [string]$BundlePath, [Parameter(Mandatory)] - [string]$SourceResolutionPath, - - [Parameter(Mandatory)] - [ValidatePattern('^[0-9a-fA-F]{64}$')] - [string]$SourceResolutionSha256, - - [Parameter(Mandatory)] - [ValidatePattern('^[1-9][0-9]*$')] - [string]$WorkflowRunId, + [string]$RequestedRef, [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{40}$')] @@ -28,7 +20,6 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem -. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') function New-PackageEntryIndex { param( @@ -157,13 +148,7 @@ $resolvedArtifactsDirectory = ( Resolve-Path -LiteralPath $ArtifactsDirectory ).Path $resolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path -$policy = Read-OpenClawReleasePolicy -Path $resolvedPolicyPath -$snapshotHash = (Get-FileHash ` - -LiteralPath $SourceResolutionPath -Algorithm SHA256).Hash -if ($snapshotHash -ine $SourceResolutionSha256) { - throw 'The source snapshot does not match the trusted resolver output.' -} -$snapshot = Get-Content -LiteralPath $SourceResolutionPath -Raw | +$policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | ConvertFrom-Json if ( @@ -226,14 +211,9 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.packagingCommit -ine $expectedPackagingCommit -or $metadata.sourceTreeDirty -ne $false -or $metadata.payloadRepository -ne $policy.repository -or - $metadata.payloadRequestedRef -cne $source.requestedRef -or + $metadata.payloadRequestedRef -ine $approvedCommit -or $metadata.payloadResolvedCommit -ine $approvedCommit -or $metadata.payloadPackageVersion -ne $approvedPayloadVersion -or - $metadata.payloadChannel -cne $source.channel -or - $metadata.payloadReleaseTag -cne $source.releaseTag -or - $metadata.payloadTagObject -cne $source.tagObject -or - $metadata.payloadResolvedAt -ne $source.resolvedAt -or - $metadata.payloadRegistryIntegrity -cne $source.registryIntegrity -or $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or @@ -479,7 +459,6 @@ foreach ($architecture in @('x64', 'arm64')) { ) if ( $null -eq $identity -or - $identity.Name -ne 'OpenClaw.Gateway' -or $identity.Publisher -ne $policy.publisher -or $identity.ProcessorArchitecture -ne $architecture -or $identity.Version -ne $metadata.packageVersion @@ -487,15 +466,6 @@ foreach ($architecture in @('x64', 'arm64')) { throw "The $architecture MSIX manifest identity is unexpected." } - $applicationManifest = Read-ZipEntryText ` - -EntriesByPath $entriesByPath ` - -Path 'app/package.json' | - ConvertFrom-Json - if ($applicationManifest.name -cne 'openclaw' -or - $applicationManifest.version -cne $approvedPayloadVersion) { - throw "The embedded $architecture OpenClaw package version is unexpected." - } - $payloadFiles = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'payload/payload-files.json' | diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index 81966880..d9393762 100644 --- a/scripts/Test-WorkflowPackageVersion.Tests.ps1 +++ b/scripts/Test-WorkflowPackageVersion.Tests.ps1 @@ -123,20 +123,4 @@ if ($secondBoundaryVersion -le $firstBoundaryVersion) { throw 'The package version did not increase across the rollover boundary.' } -$officialVersionScript = Join-Path $PSScriptRoot 'Test-OfficialReleaseVersion.ps1' -& $officialVersionScript -PackageVersion '2026.6.35.0' -ExistingTags @() -& $officialVersionScript -PackageVersion '2026.6.35.1' -ExistingTags @( - 'refs/tags/v0.0.0.0', 'refs/tags/v2026.6.35.0', 'refs/tags/unrelated' -) -& $officialVersionScript -PackageVersion '2026.7.33.0' -ExistingTags @( - 'refs/tags/v2026.6.35.9' -) -foreach ($version in @('2026.6.35.0', '2026.6.34.9')) { - Assert-Fails -MessagePattern 'not newer than existing official tag' -Action { - & $officialVersionScript -PackageVersion $version -ExistingTags @( - 'refs/tags/v2026.6.35.0' - ) - } -} - Write-Host 'Workflow package-version tests passed.' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 013e4a1c..5caf2154 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -22,6 +22,20 @@ $requiredFragments = @( "contains(needs.*.result, 'failure')" "contains(needs.*.result, 'cancelled')" 'name: Upload payload' + 'name: Test stable source selection' + 'name: Restore source selection for a retry' + 'name: Save immutable source selection' + 'name: openclaw-source-resolution' + './scripts/Get-WorkflowSource.ps1' + "'scripts/OpenClawSource.ps1'" + "'scripts/Get-WorkflowSource.ps1'" + "'scripts/Test-OpenClawSource.Tests.ps1'" + '-ReuseSnapshot:($env:GITHUB_RUN_ATTEMPT -ne ''1'')' + 'ref: ${{ steps.resolve.outputs.sha }}' + '-ExpectedVersion ''${{ steps.resolve.outputs.version }}''' + 'GATEWAY_TAG: ${{ needs.build-package.outputs.source_tag }}' + '-GatewayTag $env:GATEWAY_TAG' + 'OPENCLAW_REF: ${{ inputs.openclaw_ref || needs.build-package.outputs.source_sha }}' "retention-days: `${{ github.event_name == 'pull_request' && 1 || 7 }}" 'name: Restore cached OpenClaw package' "if: `${{ github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official' }}" @@ -69,25 +83,6 @@ $requiredFragments = @( 'overwrite_files: false' 'fail_on_unmatched_files: true' 'release-assets/*.msixbundle' - 'release-assets/*.json' - 'name: Resolve immutable OpenClaw source' - 'name: Restore source snapshot for a retry' - 'name: Save source snapshot' - 'retention-days: 90' - 'ref: ${{ needs.resolve-source.outputs.source_sha }}' - 'EXPECTED_SNAPSHOT_HASH: ${{ needs.resolve-source.outputs.snapshot_sha256 }}' - 'EXPECTED_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.source_sha }}' - 'EXPECTED_PACKAGE_VERSION: ${{ needs.resolve-source.outputs.source_version }}' - '-ExpectedSourceCommit $env:EXPECTED_SOURCE_COMMIT' - '-ExpectedPackageVersion $env:EXPECTED_PACKAGE_VERSION' - 'PACKAGE_VERSION: ${{ needs.resolve-source.outputs.package_version }}' - '-SourceResolutionPath artifacts\source\source-resolution.json' - '-SourceResolutionSha256 $env:SNAPSHOT_SHA256' - '-WorkflowRunId $env:GITHUB_RUN_ID' - 'name: Reject duplicate or older official releases' - 'name: Recheck official release version before signing' - 'name: Recheck official release version before publication' - "group: gateway-msix-`${{ inputs.signing_mode == 'official' && 'official' || github.run_id }}" ) foreach ($fragment in $requiredFragments) { @@ -112,76 +107,24 @@ if ($buildMsixJob.Contains( $dispatchDefaultMatch = [regex]::Match( $workflow, - '(?ms)openclaw_ref:\s+description:.*?default:\s*(?[0-9a-f]{40})' -) -$automaticFallbackMatch = [regex]::Match( - $workflow, - "OPENCLAW_REF:.*?\|\|\s*'(?[0-9a-f]{40})'" + '(?ms)openclaw_ref:\s+description:.*?required:\s*false\s+default:\s*''''\s+type:\s*string' ) -if (-not $dispatchDefaultMatch.Success -or -not $automaticFallbackMatch.Success) { - throw 'Unable to locate both pinned OpenClaw workflow revisions.' +if (-not $dispatchDefaultMatch.Success -or + $workflow -match "(?m)^\s*OPENCLAW_REF:.*\|\|\s*'[0-9a-f]{40}'") { + throw 'An empty source input must follow stable; do not add a second source pin.' } -$releasePolicy = Get-Content ` - -LiteralPath (Join-Path $repositoryRoot 'release-policy.json') ` - -Raw | - ConvertFrom-Json -$pinnedRevisions = @( - @( - $dispatchDefaultMatch.Groups['sha'].Value - $automaticFallbackMatch.Groups['sha'].Value - [string]$releasePolicy.approvedCommit - ) | Select-Object -Unique -) -if ($pinnedRevisions.Count -ne 1) { - throw ( - 'The workflow defaults and official release policy must pin the same ' + - "OpenClaw commit; found: $($pinnedRevisions -join ', ')." - ) +if ($workflow -notmatch '(?m)^cache-mode: none$' -or + [regex]::Matches($workflow, '(?m)^\s*cache-mode:').Count -ne 1) { + throw 'All jobs must retain native cache denial when running selected upstream source.' +} +$identityCalls = [regex]::Matches($workflow, '-GatewayTag \$env:GATEWAY_TAG') +if ($identityCalls.Count -ne 3) { + throw 'MSIX, bundle and upgrade verification must use the same resolved Gateway tag.' } if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { throw 'Signing workflow must use OIDC, not an Azure client secret.' } -$cacheModes = [regex]::Matches($workflow, '(?m)^\s*cache-mode:\s*(?\S+)') -if ($cacheModes.Count -ne 1 -or - $workflow -notmatch '(?m)^cache-mode: none\s*$' -or - $workflow -match '(?m)^\s*cache:\s*true\s*$') { - throw 'Every job must inherit native cache-mode: none, without cache overrides or opt-ins.' -} -if ($workflow.Contains('use-actions-cache:', [StringComparison]::Ordinal) -or - $workflow.Contains('save-actions-cache:', [StringComparison]::Ordinal)) { - throw 'Use native cache-mode: none, not legacy cache inputs unsupported by newer upstream setup actions.' -} - -if ($workflow.Contains('OPENCLAW_REF:', [StringComparison]::Ordinal) -or - $workflow -match 'default:\s+[0-9a-f]{40}' -or - $workflow.Contains('-RequestedRef ', [StringComparison]::Ordinal)) { - throw 'The workflow must resolve the policy channel, not retain a second default pin or signing ref.' -} -if ($workflow.Contains('--allow-unreleased-changelog', [StringComparison]::Ordinal) -or - $workflow.Contains('--pnpm-pack', [StringComparison]::Ordinal)) { - throw 'Use the shared upstream packer options and its defaults, including for older stable pins.' -} -if ($workflow.Contains('extended-stable', [StringComparison]::Ordinal) -or - -not $workflow.Contains('by the `stable` release policy', [StringComparison]::Ordinal)) { - throw 'Workflow inputs and release notes must describe stable, not extended-stable selection.' -} -$policy = Get-Content -LiteralPath (Join-Path $repositoryRoot 'release-policy.json') -Raw | - ConvertFrom-Json -if ($policy.channel -cne 'stable') { - throw 'MSIX source selection must stay on the stable channel.' -} -if ($workflow.IndexOf('name: Enforce official signing policy', [StringComparison]::Ordinal) -gt - $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal)) { - throw 'Source and package authorization must precede Azure credentials.' -} -if ($workflow.IndexOf('name: Recheck official release version before signing', [StringComparison]::Ordinal) -gt - $workflow.IndexOf('name: Azure login', [StringComparison]::Ordinal) -or - $workflow.IndexOf('name: Recheck official release version before publication', [StringComparison]::Ordinal) -gt - $workflow.IndexOf('name: Create permanent GitHub release', [StringComparison]::Ordinal)) { - throw 'Signing and publication retries must recheck duplicate/downgrade protection.' -} - Write-Host 'Gateway MSIX signing workflow configuration passed.' diff --git a/scripts/Test-WorkflowSource.Tests.ps1 b/scripts/Test-WorkflowSource.Tests.ps1 deleted file mode 100644 index 38371ae5..00000000 --- a/scripts/Test-WorkflowSource.Tests.ps1 +++ /dev/null @@ -1,205 +0,0 @@ -[CmdletBinding()] -param() - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' -$scriptPath = Join-Path $PSScriptRoot 'Get-WorkflowSource.ps1' -$policyPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json' -$policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json -$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( - "openclaw-workflow-source-$([guid]::NewGuid().ToString('N'))") -$snapshotPath = Join-Path $testRoot 'source-resolution.json' -$packagingCommit = '1' * 40 -$version = '2026.9.4' -$source = [ordered]@{ - repository = $policy.repository - requestedRef = $policy.channel - resolvedCommit = '2' * 40 - packageVersion = $version - channel = $policy.channel - releaseTag = "v$version" - tagObject = '3' * 40 - resolvedAt = '2026-09-15T00:00:00.0000000Z' - registryIntegrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) - packagingCommit = $packagingCommit - workflowRunId = '12345' - workflowRunNumber = 42 - signingMode = 'official' - msixPackageVersion = "$version.$($policy.packageRevision)" - msixReleaseTag = "v$version.$($policy.packageRevision)" -} -$parameters = @{ - PolicyPath = $policyPath - OutputPath = $snapshotPath - SigningMode = 'official' - RunNumber = 42 - WorkflowRunId = '12345' - PackagingCommit = $packagingCommit -} - -$requests = [Collections.Generic.List[string]]::new() -$responses = @{} -function Invoke-RestMethod { - param($Uri, $Headers, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection, $ErrorAction) - if (-not $responses.ContainsKey([string]$Uri)) { - throw "Unexpected network request in workflow snapshot test: $Uri" - } - $requests.Add([string]$Uri) - return $responses[[string]$Uri] -} - -function Assert-Fails { - param([scriptblock]$Action, [string]$MessagePattern) - try { - & $Action | Out-Null - } - catch { - if ($_.Exception.Message -notmatch $MessagePattern) { - throw - } - return - } - throw "Expected failure matching '$MessagePattern'." -} - -try { - New-Item -ItemType Directory -Path $testRoot | Out-Null - Assert-Fails -MessagePattern 'snapshot is unavailable' -Action { - & $scriptPath @parameters -ReuseSnapshot - } - Assert-Fails -MessagePattern 'not an explicit source override' -Action { - & $scriptPath @parameters -Ref ('4' * 40) - } - - $source | ConvertTo-Json | Set-Content -LiteralPath $snapshotPath -Encoding utf8 - $hash = (Get-FileHash -LiteralPath $snapshotPath).Hash - $restored = & $scriptPath @parameters -ReuseSnapshot - if ($restored.resolvedCommit -cne $source.resolvedCommit -or - $restored.msixPackageVersion -cne $source.msixPackageVersion -or - (Get-FileHash -LiteralPath $snapshotPath).Hash -cne $hash) { - throw 'Reusing a snapshot changed its immutable identity or bytes.' - } - Assert-Fails -MessagePattern 'snapshot already exists' -Action { - & $scriptPath @parameters - } - - foreach ($field in @( - 'packagingCommit', 'workflowRunId', 'workflowRunNumber', - 'signingMode', 'msixPackageVersion', 'msixReleaseTag' - )) { - $mutated = $source | ConvertTo-Json | ConvertFrom-Json - $mutated.$field = 'unexpected' - $mutated | ConvertTo-Json | - Set-Content -LiteralPath $snapshotPath -Encoding utf8 - Assert-Fails -MessagePattern "unexpected workflow identity: $field" -Action { - & $scriptPath @parameters -ReuseSnapshot - } - } - - $source.signingMode = 'unsigned' - $source.msixPackageVersion = '0.1.42.1' - $source.msixReleaseTag = 'v0.1.42.1' - $parameters.SigningMode = 'unsigned' - $source | ConvertTo-Json | Set-Content -LiteralPath $snapshotPath -Encoding utf8 - $restored = & $scriptPath @parameters -ReuseSnapshot - if ($restored.msixPackageVersion -cne '0.1.42.1') { - throw 'An unsigned retry did not retain the original run-based MSIX version.' - } - Assert-Fails -MessagePattern 'requested selector' -Action { - & $scriptPath @parameters -Ref 'main' -ReuseSnapshot - } - - $baseUri = 'https://api.github.com/repos/openclaw/openclaw' - $registryUri = 'https://registry.npmjs.org/openclaw' - $responses["$registryUri/latest"] = @{ - name = 'openclaw'; version = $version - } - $responses["$baseUri/git/ref/tags/v$version"] = @{ - ref = "refs/tags/v$version" - object = @{ type = 'tag'; sha = '3' * 40 } - } - $responses["$baseUri/git/tags/$('3' * 40)"] = @{ - sha = '3' * 40 - tag = "v$version" - verification = @{ verified = $true; reason = 'valid' } - object = @{ type = 'commit'; sha = '2' * 40 } - } - $responses["$baseUri/contents/package.json?ref=$('2' * 40)"] = @{ - type = 'file' - encoding = 'base64' - content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( - (@{ name = 'openclaw'; version = $version } | ConvertTo-Json))) - } - $responses["$registryUri/$version"] = @{ - name = 'openclaw'; version = $version - repository = $policy.repository - dist = @{ integrity = $source.registryIntegrity } - } - $freshParameters = $parameters.Clone() - $freshParameters.OutputPath = Join-Path $testRoot 'fresh\source-resolution.json' - $freshParameters.SigningMode = 'official' - $fresh = & $scriptPath @freshParameters - if ($fresh -isnot [pscustomobject] -or - $fresh.msixPackageVersion -cne "$version.$($policy.packageRevision)" -or - $fresh.resolvedCommit -cne ('2' * 40) -or $requests.Count -ne 5) { - throw 'A new workflow did not save the resolved source and derived release identity.' - } - $hash = (Get-FileHash -LiteralPath $freshParameters.OutputPath).Hash - $responses.Clear() - $replayed = & $scriptPath @freshParameters -ReuseSnapshot - if ($requests.Count -ne 5 -or $replayed.resolvedCommit -cne $fresh.resolvedCommit -or - (Get-FileHash -LiteralPath $freshParameters.OutputPath).Hash -cne $hash) { - throw 'A retry queried the channel or changed the original source snapshot.' - } - - $corrected = $fresh | ConvertTo-Json | ConvertFrom-Json - $corrected.packageVersion = "$version-1" - $corrected.releaseTag = "v$version-1" - $corrected.msixPackageVersion = "$version.$(1 + $policy.packageRevision)" - $corrected.msixReleaseTag = "v$($corrected.msixPackageVersion)" - $corrected | ConvertTo-Json | - Set-Content -LiteralPath $freshParameters.OutputPath -Encoding utf8 - $replayedCorrection = & $scriptPath @freshParameters -ReuseSnapshot - if ($replayedCorrection.msixPackageVersion -cne $corrected.msixPackageVersion) { - throw 'A stable numeric correction did not retain its numeric MSIX identity.' - } - - $pinnedPolicy = $policy | ConvertTo-Json | ConvertFrom-Json - $pinnedPolicy | Add-Member -NotePropertyName stableVersion -NotePropertyValue '2026.8.2' - $pinnedPolicyPath = Join-Path $testRoot 'pinned-policy.json' - $pinnedPolicy | ConvertTo-Json | Set-Content -LiteralPath $pinnedPolicyPath -Encoding utf8 - $pinnedParameters = $freshParameters.Clone() - $pinnedParameters.PolicyPath = $pinnedPolicyPath - $pinned = $fresh | ConvertTo-Json | ConvertFrom-Json - $pinned.requestedRef = '2026.8.2' - $pinned.packageVersion = '2026.8.2' - $pinned.releaseTag = 'v2026.8.2' - $pinned.msixPackageVersion = "2026.8.2.$($policy.packageRevision)" - $pinned.msixReleaseTag = "v$($pinned.msixPackageVersion)" - $pinned | ConvertTo-Json | Set-Content -LiteralPath $pinnedParameters.OutputPath -Encoding utf8 - $replayedPin = & $scriptPath @pinnedParameters -ReuseSnapshot - if ($replayedPin.channel -cne 'stable' -or $replayedPin.requestedRef -cne '2026.8.2') { - throw 'A reviewed compatibility pin did not remain on stable.' - } - $pinnedPolicy.stableVersion = $version - $pinnedPolicy | ConvertTo-Json | Set-Content -LiteralPath $pinnedPolicyPath -Encoding utf8 - Assert-Fails -MessagePattern 'requestedRef|stableVersion|policy' -Action { - & $scriptPath @pinnedParameters -ReuseSnapshot - } - - $retired = $fresh | ConvertTo-Json | ConvertFrom-Json - $retired.channel = 'extended-stable' - $retired.requestedRef = 'extended-stable' - $retired.packageVersion = '2026.6.35' - $retired.releaseTag = 'v2026.6.35' - $retired | ConvertTo-Json | Set-Content -LiteralPath $freshParameters.OutputPath -Encoding utf8 - Assert-Fails -MessagePattern 'channel|packageVersion|requestedRef' -Action { - & $scriptPath @freshParameters -ReuseSnapshot - } - Write-Host 'Workflow source snapshot tests passed.' -} -finally { - if (Test-Path -LiteralPath $testRoot) { - Remove-Item -LiteralPath $testRoot -Recurse -Force - } -} From c2e878aae37b0bf3df08a61b64c7432c9aeb97bf Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Thu, 17 Sep 2026 15:14:12 -0700 Subject: [PATCH 07/10] refactor: limit the PR to stable source selection Remove the unrelated cache-access restriction and its test. Trim documentation to source-selection behavior without prior-PR history or unrelated implementation notes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 2 -- CONTRIBUTING.md | 5 +---- scripts/Test-WorkflowSigningConfiguration.ps1 | 4 ---- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 9b386152..2c8e1130 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -30,8 +30,6 @@ permissions: contents: read pull-requests: read -cache-mode: none - env: PACKAGING_ROOT: . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 689e87e9..2c3fa255 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,10 +60,7 @@ or package version logic: .\scripts\Test-GitHooks.Tests.ps1 ``` -Source-selection changes must keep `Get-MSIXReleaseIdentity.ps1` and the -reviewed official-signing policy as the authority for releases. The resolver -tests use offline registry/GitHub fixtures; source-selection changes also run -the existing hosted Windows install/upgrade gate against the selected tag. +The source-selection tests use offline npm and GitHub fixtures. The Node.js input suite requires Node.js and npm. It builds a dependency-free local fixture; it does not download or build OpenClaw. diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 5caf2154..9d17545f 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -114,10 +114,6 @@ if (-not $dispatchDefaultMatch.Success -or throw 'An empty source input must follow stable; do not add a second source pin.' } -if ($workflow -notmatch '(?m)^cache-mode: none$' -or - [regex]::Matches($workflow, '(?m)^\s*cache-mode:').Count -ne 1) { - throw 'All jobs must retain native cache denial when running selected upstream source.' -} $identityCalls = [regex]::Matches($workflow, '-GatewayTag \$env:GATEWAY_TAG') if ($identityCalls.Count -ne 3) { throw 'MSIX, bundle and upgrade verification must use the same resolved Gateway tag.' From 730c1a623e208b4d5c55f04e8b7bc24d0bf8655c Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Mon, 21 Sep 2026 14:19:49 -0700 Subject: [PATCH 08/10] fix: reconcile stable selection with current release documentation Keep main's Store-compatible versioning and scoped instructions. Carry source-selection guidance into the current README and release guide, and update the source integration test for the current release identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 52 ++++++++++++++++----------- docs/release-process.md | 31 ++++++++-------- scripts/Test-OpenClawSource.Tests.ps1 | 2 +- 3 files changed, 49 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1548de91..32faf68a 100644 --- a/README.md +++ b/README.md @@ -281,17 +281,30 @@ place so an update does not remove a running process's runtime. ## Selecting the OpenClaw revision -`.github\workflows\gateway-msix.yml` resolves an explicit OpenClaw ref before -building. Pull-request and `main` push runs use the pinned commit configured in -both: - -- `workflow_dispatch.inputs.openclaw_ref.default`; -- the non-manual fallback in `env.OPENCLAW_REF`. - -Changing only the workflow-dispatch default does not change automatic builds. -For a one-time override, run **Build OpenClaw Gateway MSIX** manually and -provide a tag, branch, or preferably a full 40-character commit SHA in -`openclaw_ref`. Payload composition validates that the selected OpenClaw +`.github\workflows\gateway-msix.yml` selects **stable** through public npm +`openclaw@latest` whenever a new packaging run starts. The resolver checks the +exact published version, its signed upstream tag and commit, and the source +package version before building. There is no automatic fallback to another +version or channel; extended-stable and named prereleases are rejected. + +The `openclaw-source-resolution` artifact records this choice once per run. +Retries reuse it without querying the moving channel again. If the snapshot +is missing or expired (90-day retention), start a new run instead of retrying. +Package and payload metadata record the resolved source commit and version. + +For a one-time unsigned/test override, provide a stable-source tag, branch, or +full commit SHA in the manual `openclaw_ref` input. Empty means follow stable. +If compatibility requires an older known-good stable release, a reviewed +`stableVersion` field in `release-policy.json` can pin its exact version, for +example `"stableVersion": "2026.9.4"`. A pin is not automatic fallback and does +not grant official-signing approval. + +For official signing, the selected source must match `approvedCommit`, +`gatewayTag`, and `payloadPackageVersion` in `release-policy.json`. An empty +input selects stable and checks that approval; an explicit input must be the +full approved commit SHA. + +Payload composition validates that the selected OpenClaw runtime discovers the packaging-owned Windows Launcher plugin in its default-disabled state, then explicitly enables only that plugin in an isolated temporary validation profile before using OpenClaw's runtime inspection pass to @@ -310,7 +323,7 @@ runtime-support policy. Non-official workflows cache the packed OpenClaw tarball by its resolved upstream commit. They also cache each architecture's Windows dependency tree by the resolved commit, tarball SHA-256, Node.js version, and payload-build script. -A tarball cache hit still verifies the recorded commit and SHA-256; a +A tarball cache hit still verifies the recorded version, commit and SHA-256; a dependency-tree hit still runs every payload validation and smoke test. Official-signing workflows bypass both caches and always rebuild upstream source and Windows dependencies. @@ -365,7 +378,7 @@ they do not represent the default-disabled state of a normal install. Full selected-theme cohesion requires the generic plugin-frame theme forwarding merged by [`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). -The current workflow remains on the release-approved OpenClaw `v2026.9.4` +The official-signing policy remains on the release-approved OpenClaw `v2026.9.4` baseline (`3a9d69db306cd7f081e06254cb89c4bcc14a7107`) while this plugin is disabled by default. That baseline packages and inspects the plugin safely but does not forward selected Control UI themes into plugin frames. The future launcher @@ -442,9 +455,9 @@ never official-signing inputs. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: -- `unsigned` accepts any OpenClaw branch, tag, or commit and publishes unsigned +- `unsigned` follows stable or a stable-source override and publishes unsigned MSIX packages; -- `test` accepts any OpenClaw ref and publishes MSIX packages signed with a +- `test` uses the same source-selection rules and publishes MSIX packages signed with a temporary self-signed certificate plus the public `.cer` needed for local installation; - `official` requires the approved immutable commit from @@ -489,9 +502,7 @@ reviewed pull request: 2. `approvedCommit` to the immutable commit resolved from that tag; 3. `payloadPackageVersion` to the version reported by the pinned payload; 4. `msixRevision` to `0`, or increment it for a packaging-only rebuild of the - same Gateway tag; -5. the workflow's `openclaw_ref` default and non-manual fallback to the same - `approvedCommit`. + same Gateway tag. After that pull request merges, manually run **Build OpenClaw Gateway MSIX** on `main` with `openclaw_ref` set to the approved commit and `signing_mode` set to @@ -514,8 +525,9 @@ release versioning download the hash-pinned standalone x64 and recommended `.msixbundle` assets, install each one on a clean GitHub-hosted Windows runner, upgrade it in place through the same delivery format, and verify that the package family remains stable and a LocalState marker is -retained. The gate also proves fresh installation of both the standalone and -bundle candidates. It refuses to run when an OpenClaw Gateway package is +retained. Changes to source-selection scripts also trigger this check against +the selected release. The gate also proves fresh installation of both the +standalone and bundle candidates. It refuses to run when an OpenClaw Gateway package is already registered and removes only packages installed by that test invocation. It temporarily trusts the ephemeral test-signing certificate in the local-machine Trusted People store, as required by Windows deployment, and diff --git a/docs/release-process.md b/docs/release-process.md index 40f1d4a2..1cc940f0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -18,7 +18,7 @@ pull request when accepting a new upstream release: | `gatewayTag` | The accepted stable upstream Gateway tag; it contributes to the release identity. | | `msixRevision` | The packaging rebuild number used to derive the MSIX and GitHub release identity. | | `payloadPackageVersion` | The expected version in the validated upstream payload. | -| `approvedCommit` | The immutable upstream commit that the workflow is allowed to package. | +| `approvedCommit` | The immutable upstream commit approved for official signing. | | `publisher` | The expected MSIX publisher subject used by packaging and signing validation. | For a new upstream tag, change `gatewayTag`, `approvedCommit`, and @@ -29,23 +29,24 @@ commit and that its payload reports that version. Keep `repository` and or MSIX version by hand: `scripts\Get-MSIXReleaseIdentity.ps1` derives them from `gatewayTag` and `msixRevision`. -The same pull request must update both the `workflow_dispatch` `openclaw_ref` -default and the non-manual `env.OPENCLAW_REF` fallback in -`.github\workflows\gateway-msix.yml`. Both values must exactly match -`approvedCommit`. The input also supplies `signing_mode`, whose choices are -`unsigned`, `test`, and `official`; select `official` only for the approved -release dispatch. +Leave the workflow's `openclaw_ref` default empty: packaging runs follow the +stable source selection described in the [README](../README.md#selecting-the-openclaw-revision). +That selection does not grant official-signing approval. For an official +dispatch, supply the full `approvedCommit`, or leave the input empty only when +the selected stable release matches the reviewed policy. The workflow also +accepts `signing_mode`, whose choices are `unsigned`, `test`, and `official`; +select `official` only for the approved release dispatch. ## Before dispatch Complete this checklist after the policy pull request has merged to `main`. -1. Confirm the dispatch target is `main`, the workflow input - `openclaw_ref` is the policy's `approvedCommit`, and `signing_mode` is - `official`. Official signing is rejected for every other branch. +1. Confirm the dispatch target is `main` and `signing_mode` is `official`. + Set `openclaw_ref` to the policy's full `approvedCommit`, or leave it empty + to select stable. Official signing is rejected for every other branch. 2. Confirm the accepted immutable commit, payload version, and publisher match - `release-policy.json`; confirm the manual input default and automatic - fallback match that same commit. + `release-policy.json`. If leaving `openclaw_ref` empty, confirm the selected + stable source matches that same approved commit and version. 3. Confirm the derived identity with `scripts\Get-MSIXReleaseIdentity.ps1` rather than calculating a version or release tag manually. Use the README's [identity guidance](../README.md#official-signing-setup) @@ -55,8 +56,8 @@ Complete this checklist after the policy pull request has merged to `main`. required title format. 5. Confirm the policy pull request's `test-msix-upgrades` job succeeded and retained its upgrade-evidence artifact. That job runs only on pull requests - that change the versioning inputs; it does not run during the later official - dispatch. + that change versioning inputs or source-selection scripts; it does not run + during the later official dispatch. 6. Do not reuse or alter an accepted GitHub release tag, and do not edit an existing proof-release entry in `scripts\msix-upgrade-baselines.json`. @@ -120,7 +121,7 @@ failed release. If the upstream tag and accepted commit are unchanged and only packaging must be rebuilt, increment `msixRevision` in a reviewed policy change, then repeat the process with the exact same upstream tag and commit. For a new upstream tag, update the reviewed policy inputs together--tag, -immutable commit, payload version, and corresponding workflow references--and +immutable commit and payload version--and start a new release decision. A signing-authorization or upgrade-validation failure is a stop condition: correct the reviewed inputs or packaging defect, then dispatch a new compliant run rather than publishing partial artifacts. diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 index 8bb14d8f..0c268484 100644 --- a/scripts/Test-OpenClawSource.Tests.ps1 +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -140,7 +140,7 @@ try { Assert-Equal $first.resolvedCommit $commit $identity = & (Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1') ` -GatewayTag $selected.releaseTag -MSIXRevision 0 - Assert-Equal $identity.PackageVersion '2026.9.5.1000' + Assert-Equal $identity.PackageVersion '2026.9.500.0' Assert-Equal $identity.ReleaseTag 'v2026.9.5-msix.0' } foreach ($missing in @('Registry:latest', "Registry:$version", "GitHub:git/ref/tags/v$version")) { From a2919dc1518ae12bdc9f4a3ddfdd33be2b9170d1 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Mon, 21 Sep 2026 15:19:29 -0700 Subject: [PATCH 09/10] fix: reject source corrections unsupported by MSIX versioning Validate selected versions through the existing release-identity helper before accepting channel sources, explicit refs, policy pins or saved selections. Cover supported correction/rebuild boundaries and regressions for -1, -10 and -64 without changing the MSIX version policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++ scripts/OpenClawSource.ps1 | 3 ++ scripts/Test-OpenClawSource.Tests.ps1 | 55 ++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32faf68a..36ed3bed 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,9 @@ place so an update does not remove a running process's runtime. exact published version, its signed upstream tag and commit, and the source package version before building. There is no automatic fallback to another version or channel; extended-stable and named prereleases are rejected. +Source selection also checks the MSIX release-version rules before building: +numeric correction suffixes must be `-2` through `-9`. Unsupported corrections +are rejected for channel selection, explicit refs, policy pins, and retries. The `openclaw-source-resolution` artifact records this choice once per run. Retries reuse it without querying the moving channel again. If the snapshot diff --git a/scripts/OpenClawSource.ps1 b/scripts/OpenClawSource.ps1 index b10d14a7..187702ad 100644 --- a/scripts/OpenClawSource.ps1 +++ b/scripts/OpenClawSource.ps1 @@ -33,6 +33,9 @@ function Assert-OpenClawSourceVersion { # Patch 33+ is extended stable; numeric suffixes are regular stable corrections. Assert-OpenClawSourceText $Version 'packageVersion' -Pattern ( '\A[1-9][0-9]{3}\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[0-2])(?:-[1-9][0-9]*)?\z') + # Keep package-version limits owned by the release identity helper. + $null = & (Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1') ` + -GatewayTag "v$Version" -MSIXRevision 0 } function Assert-OpenClawSourceRef { diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 index 0c268484..df4d4df7 100644 --- a/scripts/Test-OpenClawSource.Tests.ps1 +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -167,19 +167,64 @@ try { @{ Name = 'nested tag target'; Edit = { $http.Responses["GitHub:git/tags/$tagObject"].object.type = 'tag' }; Error = 'directly to a commit' }, @{ Name = 'source name'; Edit = { Set-Package -Name 'other' }; Error = 'source package name' }, @{ Name = 'source correction mismatch'; Edit = { - Add-Release "$version-1"; $http.Responses['Registry:latest'].version = "$version-1" + Add-Release "$version-2"; $http.Responses['Registry:latest'].version = "$version-2" Set-Package $version }; Error = 'source package version' } )) { Invoke-Test "rejects $($case.Name)" { & $case.Edit; Assert-Throws { Resolve-OpenClawSource $policy } $case.Error } } - Invoke-Test 'regular patch 32 and numeric corrections are retained verbatim; gitHead is optional' { - foreach ($stable in @('2026.9.32', '2026.9.4-1', '2026.9.4-64')) { + Invoke-Test 'selected stable versions map to supported MSIX identities; gitHead is optional' { + foreach ($case in @( + @{ Version = '2026.9.4'; Build = 400 }, + @{ Version = '2026.9.32'; Build = 3200 }, + @{ Version = '2026.9.4-2'; Build = 420 }, + @{ Version = '2026.9.4-9'; Build = 490 } + )) { + $stable = $case.Version Add-Release $stable $http.Responses["Registry:$stable"].PSObject.Properties.Remove('gitHead') $http.Responses['Registry:latest'].version = $stable - Assert-Equal (Resolve-OpenClawSource $policy).packageVersion $stable - Assert-Equal (Resolve-OpenClawSource $policy -Ref $commit).packageVersion $stable + foreach ($ref in @('', $commit)) { + $selected = Resolve-OpenClawSource $policy -Ref $ref + Assert-Equal $selected.packageVersion $stable + foreach ($revision in @(0, 9)) { + $identity = & (Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1') ` + -GatewayTag "v$($selected.packageVersion)" -MSIXRevision $revision + Assert-Equal $identity.PackageVersion "2026.9.$($case.Build + $revision).0" + Assert-Equal $identity.ReleaseTag "v$stable-msix.$revision" + } + } + } + } + foreach ($unsupported in @('2026.9.4-1', '2026.9.4-10', '2026.9.4-64')) { + Invoke-Test "rejects unsupported correction $unsupported during selection and replay" { + $saved = & $workflowPath @workflow + Remove-Item -LiteralPath $workflow.OutputPath + Add-Release $unsupported + $http.Responses['Registry:latest'].version = $unsupported + $http.Calls.Clear() + Assert-Throws { & $workflowPath @workflow } 'correction suffix must be between 2 and 9' + Assert-Equal $http.Calls.Count 1 + Assert-Equal (Test-Path -LiteralPath $workflow.OutputPath) $false + + $http.Calls.Clear() + Assert-Throws { & $workflowPath @workflow -Ref $commit } 'correction suffix must be between 2 and 9' + Assert-Equal $http.Calls.Count 2 + Assert-Equal (Test-Path -LiteralPath $workflow.OutputPath) $false + + $saved.packageVersion = $unsupported + $saved.releaseTag = "v$unsupported" + [IO.File]::WriteAllText($workflow.OutputPath, ($saved | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) + $http.Calls.Clear() + Assert-Throws { & $workflowPath @workflow -ReuseSnapshot } 'correction suffix must be between 2 and 9' + Assert-Equal $http.Calls.Count 0 + Remove-Item -LiteralPath $workflow.OutputPath + + $policy | Add-Member stableVersion $unsupported + Save-Policy + Assert-Throws { & $workflowPath @workflow } 'correction suffix must be between 2 and 9' + Assert-Equal $http.Calls.Count 0 + Assert-Equal (Test-Path -LiteralPath $workflow.OutputPath) $false } } foreach ($invalid in @('2026.9.33', '2026.6.35-2', '2026.9.4-beta.1', '2026.09.4', '2026.9.4-0', '2026.9.4-01', '2026.9.4+build')) { From 5730ca0284cfd19707ac34eee9a81e80b2470ccb Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Mon, 21 Sep 2026 15:39:17 -0700 Subject: [PATCH 10/10] fix: build ARM64 payloads with native ARM64 Node.js Run ARM64 packaging on Windows ARM64 and select matching upstream Node binaries. Reject Node/target architecture mismatches before npm or staging changes, record the install architecture for cache reuse, and smoke-test both architectures. Exercise actual npm lifecycle architecture and rejection paths in the payload tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 15 +++- CONTRIBUTING.md | 6 +- README.md | 8 +- scripts/Build-Payload.ps1 | 30 ++++--- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 8 +- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 85 +++++++++++++++---- 6 files changed, 116 insertions(+), 36 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 2c8e1130..844836c6 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -406,13 +406,15 @@ jobs: - changes - test-host - build-package - runs-on: windows-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - architecture: - - x64 - - arm64 + include: + - architecture: x64 + runner: windows-latest + - architecture: arm64 + runner: windows-11-arm steps: - name: Check out repository uses: actions/checkout@v7 @@ -423,6 +425,11 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ needs.build-package.outputs.node_version }} + architecture: ${{ matrix.architecture }} + + - name: Test native payload installation + shell: pwsh + run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 - name: Download intermediate package uses: actions/download-artifact@v8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c3fa255..caa8d8a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,11 @@ or package version logic: The source-selection tests use offline npm and GitHub fixtures. The Node.js input suite requires Node.js and npm. It builds a dependency-free -local fixture; it does not download or build OpenClaw. +local fixture, including its install script, for the running Node.js +architecture; it does not download or build OpenClaw. CI also runs this suite +on the native x64 and ARM64 packaging runners. Payload installation requires +Node.js to match the target architecture; npm's CPU flags alone do not change +the architecture seen by dependency install scripts. Run the NativeAOT publish when you change host JSON, reflection, interop, or anything else that is trimming-sensitive. A JIT `dotnet build` does not diff --git a/README.md b/README.md index 36ed3bed..f28a9e1e 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,13 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. It validates the Gateway and Control UI +architecture-specific application tree. Run it with Node.js matching both +the selected upstream version and target architecture: native install scripts +can use `process.arch` instead of npm's target-CPU flag. CI builds x64 on +`windows-latest` and ARM64 on `windows-11-arm`, using matching Node.js binaries. +Both payloads run their CLI smoke test. Cross-architecture Node.js execution +is rejected before staging or npm installation, including when reusing a tree. +It validates the Gateway and Control UI build identities on the installed tree, including reused staged installs, then provisions the packaging-owned Windows Launcher plugin into the payload copy's bundled plugin directory. Its internal package, path, and plugin ID remain diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 1e768ebb..24a02322 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -37,6 +37,17 @@ if ($sourceMetadata.nodeVersion -cne $nodeVersion) { "version '$($sourceMetadata.nodeVersion)'." ) } +$nodeArchitecture = & node -p 'process.arch' +if ($LASTEXITCODE -ne 0 -or $nodeArchitecture -notin @('x64', 'arm64')) { + throw 'Unable to determine the payload build Node.js architecture.' +} +# npm's target CPU flag does not change process.arch inside dependency install scripts. +if ($nodeArchitecture -cne $Architecture) { + throw ( + "Node.js architecture '$nodeArchitecture' does not match the '$Architecture' payload. " + + "Run this build with $Architecture Node.js on a compatible Windows runner." + ) +} $npmVersion = & npm --version if ($LASTEXITCODE -ne 0) { throw 'Unable to determine the payload build npm version.' @@ -52,6 +63,7 @@ $expectedStagingMetadata = [ordered]@{ resolvedCommit = [string]$sourceMetadata.resolvedCommit packageVersion = [string]$sourceMetadata.packageVersion nodeVersion = $nodeVersion + nodeArchitecture = $nodeArchitecture npmVersion = $npmVersion packageSha256 = $packageHash } @@ -344,18 +356,16 @@ if ($bundledNodeFiles.Count -ne 0) { ) } -if ($Architecture -eq 'x64') { - Push-Location $installedPackage - try { - & node .\openclaw.mjs --version - if ($LASTEXITCODE -ne 0) { - throw "OpenClaw payload smoke test failed with exit code $LASTEXITCODE." - } - } - finally { - Pop-Location +Push-Location $installedPackage +try { + & node .\openclaw.mjs --version + if ($LASTEXITCODE -ne 0) { + throw "OpenClaw payload smoke test failed with exit code $LASTEXITCODE." } } +finally { + Pop-Location +} [ordered]@{ repository = $sourceMetadata.repository diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index 31690c6b..9ba44316 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -190,12 +190,16 @@ console.log(JSON.stringify({ -LiteralPath (Join-Path $packageDirectory 'source.json') ` -Encoding utf8 + $nodeArchitecture = & node -p 'process.arch' + if ($LASTEXITCODE -ne 0 -or $nodeArchitecture -notin @('x64', 'arm64')) { + throw 'Unable to determine the fixture Node.js architecture.' + } $previousRunnerTemp = $env:RUNNER_TEMP try { $env:RUNNER_TEMP = $testRoot & (Join-Path $PSScriptRoot 'Build-Payload.ps1') ` -PackageDirectory $packageDirectory ` - -Architecture arm64 ` + -Architecture $nodeArchitecture ` -OutputDirectory $payloadDirectory } finally { @@ -213,7 +217,7 @@ console.log(JSON.stringify({ } $stagedPlugin = Join-Path ` $testRoot ` - 'openclaw-stage-arm64\node_modules\openclaw\dist\extensions\gateway-isolation' + "openclaw-stage-$nodeArchitecture\node_modules\openclaw\dist\extensions\gateway-isolation" if (Test-Path -LiteralPath $stagedPlugin) { throw 'Plugin provisioning must not mutate the reusable staged install.' } diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 490822c8..46da5d02 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -38,9 +38,12 @@ try { $package ` -Force | Out-Null - '{"name":"openclaw","version":"0.0.0","type":"module"}' | + '{"name":"openclaw","version":"0.0.0","type":"module","scripts":{"install":"node install.cjs"}}' | Set-Content -LiteralPath "$source\package.json" @' +require("node:fs").writeFileSync("installed-architecture.txt", process.arch); +'@ | Set-Content -LiteralPath "$source\install.cjs" + @' const fs = await import("node:fs"); const path = await import("node:path"); const args = process.argv.slice(2); @@ -108,6 +111,11 @@ console.log(JSON.stringify({ if ($LASTEXITCODE -ne 0) { throw 'Unable to determine the fixture Node.js version.' } + $nodeArchitecture = & node -p 'process.arch' + if ($LASTEXITCODE -ne 0 -or $nodeArchitecture -notin @('x64', 'arm64')) { + throw 'Unable to determine the fixture Node.js architecture.' + } + $otherArchitecture = if ($nodeArchitecture -eq 'x64') { 'arm64' } else { 'x64' } $sourceMetadata = @{ repository = 'https://github.com/openclaw/openclaw' requestedRef = '1' * 40 @@ -117,18 +125,39 @@ console.log(JSON.stringify({ } $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + $otherStage = Join-Path $testRoot "openclaw-stage-$otherArchitecture" + New-Item -ItemType Directory -Path $otherStage | Out-Null + $markerPath = Join-Path $otherStage 'preserve-existing-stage.txt' + Set-Content -LiteralPath $markerPath -Value 'preserve' + foreach ($reuse in @($false, $true)) { + $wrongOutput = Join-Path $testRoot "wrong-node-architecture-$reuse" + Assert-Fails -MessagePattern 'Node.js architecture.*does not match.*payload' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture $otherArchitecture ` + -OutputDirectory $wrongOutput -ReuseStagedInstall:$reuse + } + if ((Get-Content -LiteralPath $markerPath -Raw).Trim() -ne 'preserve' -or + (Test-Path -LiteralPath $wrongOutput) -or + (Test-Path -LiteralPath (Join-Path $otherStage 'node_modules'))) { + throw 'A mismatched Node.js architecture changed staging or output before rejection.' + } + } + & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + -PackageDirectory $package -Architecture $nodeArchitecture -OutputDirectory $payload $metadataPath = Join-Path $payload 'payload-metadata.json' $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json if ($metadata.nodeVersion -cne $nodeVersion) { throw 'The payload did not preserve the exact source build Node.js version.' } + if ((Get-Content -LiteralPath "$payload\app\installed-architecture.txt" -Raw) -cne $nodeArchitecture) { + throw 'The npm install lifecycle did not execute with the target Node.js architecture.' + } $reusedPayload = Join-Path $testRoot 'payload-reused' & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory $reusedPayload ` -ReuseStagedInstall if (-not (Test-Path -LiteralPath "$reusedPayload\app\openclaw.mjs")) { @@ -143,7 +172,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'does not match the requested packageSha256' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'wrong-package-reuse') ` -ReuseStagedInstall } @@ -154,7 +183,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'does not match the requested resolvedCommit' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'wrong-source-reuse') ` -ReuseStagedInstall } @@ -162,14 +191,28 @@ console.log(JSON.stringify({ $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" $stagingMetadataPath = Join-Path ( - Join-Path $testRoot 'openclaw-stage-x64' + Join-Path $testRoot "openclaw-stage-$nodeArchitecture" ) '.openclaw-install.json' $stagingMetadata = Get-Content -LiteralPath $stagingMetadataPath -Raw + $wrongArchitectureMetadata = $stagingMetadata | ConvertFrom-Json + if ($wrongArchitectureMetadata.nodeArchitecture -cne $nodeArchitecture) { + throw 'The staged install did not record its build Node.js architecture.' + } + $wrongArchitectureMetadata.nodeArchitecture = $otherArchitecture + $wrongArchitectureMetadata | ConvertTo-Json | + Set-Content -LiteralPath $stagingMetadataPath -Encoding utf8 + Assert-Fails -MessagePattern 'does not match the requested nodeArchitecture' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture $nodeArchitecture ` + -OutputDirectory (Join-Path $testRoot 'wrong-architecture-reuse') ` + -ReuseStagedInstall + } Remove-Item -LiteralPath $stagingMetadataPath -Force Assert-Fails -MessagePattern 'missing provenance' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'missing-provenance-reuse') ` -ReuseStagedInstall } @@ -177,7 +220,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'provenance is invalid' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'invalid-provenance-reuse') ` -ReuseStagedInstall } @@ -187,20 +230,26 @@ console.log(JSON.stringify({ [Text.UTF8Encoding]::new($false) ) - Assert-Fails -MessagePattern 'staged OpenClaw install does not exist' -Action { - & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package ` - -Architecture arm64 ` - -OutputDirectory (Join-Path $testRoot 'missing-reuse') ` - -ReuseStagedInstall + $env:RUNNER_TEMP = Join-Path $testRoot 'missing-stage-root' + try { + Assert-Fails -MessagePattern 'staged OpenClaw install does not exist' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture $nodeArchitecture ` + -OutputDirectory (Join-Path $testRoot 'missing-reuse') ` + -ReuseStagedInstall + } + } + finally { + $env:RUNNER_TEMP = $testRoot } - $stagedPackage = Join-Path $testRoot 'openclaw-stage-x64\node_modules\openclaw' + $stagedPackage = Join-Path $testRoot "openclaw-stage-$nodeArchitecture\node_modules\openclaw" Set-Content -LiteralPath (Join-Path $stagedPackage 'node.exe') -Value 'unsafe' Assert-Fails -MessagePattern 'must not bundle Node.js' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'unsafe-reuse') ` -ReuseStagedInstall } @@ -213,7 +262,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'build identity mismatch' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $nodeArchitecture ` -OutputDirectory (Join-Path $testRoot 'identity-reuse') ` -ReuseStagedInstall } @@ -222,7 +271,7 @@ console.log(JSON.stringify({ $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" Assert-Fails -MessagePattern 'does not match the source build' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + -PackageDirectory $package -Architecture $nodeArchitecture -OutputDirectory $payload } foreach ($architecture in @('x64', 'arm64')) {