diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 7598d91a..844836c6 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 @@ -31,7 +31,6 @@ permissions: pull-requests: read env: - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '3a9d69db306cd7f081e06254cb89c4bcc14a7107' }} PACKAGING_ROOT: . jobs: @@ -74,6 +73,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' @@ -201,6 +203,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: > @@ -233,6 +239,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 }} @@ -243,29 +250,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 @@ -314,6 +339,8 @@ 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" @@ -350,7 +377,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 @@ -378,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 @@ -395,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 @@ -477,6 +512,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 }}' @@ -489,7 +525,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 } @@ -566,6 +602,7 @@ jobs: name: Build unsigned multi-architecture Gateway MSIX bundle needs: - changes + - build-package - build-msix runs-on: windows-latest steps: @@ -591,6 +628,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 }}' @@ -603,7 +641,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 } @@ -629,6 +667,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 @@ -681,11 +720,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 ` @@ -721,6 +762,7 @@ jobs: name: Authorize official Gateway MSIX signing if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} needs: + - build-package - build-msix - build-msix-bundle runs-on: windows-latest @@ -776,7 +818,7 @@ jobs: - name: Enforce official signing policy shell: pwsh env: - OPENCLAW_REF: ${{ inputs.openclaw_ref }} + OPENCLAW_REF: ${{ inputs.openclaw_ref || needs.build-package.outputs.source_sha }} PACKAGING_COMMIT: ${{ github.sha }} run: | .\scripts\Test-SigningInputs.ps1 ` @@ -929,6 +971,7 @@ jobs: name: Publish signed Gateway MSIX release if: ${{ needs.sign-msix.result == 'success' }} needs: + - build-package - authorize-signing - sign-msix runs-on: ubuntu-latest @@ -982,8 +1025,8 @@ jobs: release-assets/*.msix release-assets/*.msixbundle body: | - Packages OpenClaw Gateway `${{ needs.authorize-signing.outputs.release_tag }}` - from [`openclaw/openclaw@${{ inputs.openclaw_ref }}`](https://github.com/openclaw/openclaw/commit/${{ inputs.openclaw_ref }}). + 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 128f5a20..caa8d8a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,12 +55,19 @@ or package version logic: .\scripts\Test-OpenClawCacheKey.Tests.ps1 .\scripts\Test-OpenClawPackage.Tests.ps1 .\scripts\Test-MSIXReleaseIdentity.Tests.ps1 +.\scripts\Test-OpenClawSource.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` +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 1548de91..f28a9e1e 100644 --- a/README.md +++ b/README.md @@ -281,17 +281,33 @@ 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. +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 +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 +326,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. @@ -341,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 @@ -365,7 +387,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 +464,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 +511,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 +534,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/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/Get-WorkflowSource.ps1 b/scripts/Get-WorkflowSource.ps1 new file mode 100644 index 00000000..c602a10e --- /dev/null +++ b/scripts/Get-WorkflowSource.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$PolicyPath, + [Parameter(Mandatory)][string]$OutputPath, + [string]$Ref = '', + [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 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'OpenClawSource.ps1') + +$policy = Read-OpenClawReleasePolicy -Path $PolicyPath +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 -Depth 16 -NoEnumerate +} +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 +$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.' +} +if ($SigningMode -eq 'official') { + 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.' + } +} +$context = [ordered]@{ + workflowRunId = $WorkflowRunId + packagingCommit = $PackagingCommit.ToLowerInvariant() + signingMode = $SigningMode +} +foreach ($field in $context.Keys) { + if ($ReuseSnapshot) { + $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] } +} +if (-not $ReuseSnapshot) { + $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 new file mode 100644 index 00000000..187702ad --- /dev/null +++ b/scripts/OpenClawSource.ps1 @@ -0,0 +1,276 @@ +function Get-OpenClawSourceField { + param([AllowNull()][object]$InputObject, [string]$Name, [switch]$Optional) + + if ($InputObject -is [Collections.IDictionary]) { + $exists = $InputObject.Contains($Name) + $value = $InputObject[$Name] + } + 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'." } + 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 text 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) + + # 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 { + param([AllowNull()][object]$Ref) + + 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.' + } +} + +function Get-OpenClawPolicyRef { + param([Parameter(Mandatory)][object]$Policy) + + $repository = Get-OpenClawSourceField $Policy 'repository' + Assert-OpenClawSourceText $repository 'repository' + if ($repository -cne 'https://github.com/openclaw/openclaw') { + throw 'The release policy repository must be https://github.com/openclaw/openclaw.' + } + $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.' + } + $pin = Get-OpenClawSourceField $Policy 'stableVersion' -Optional + if ($null -ne $pin) { + Assert-OpenClawSourceVersion $pin + return $pin + } + return 'stable' +} + +function Read-OpenClawReleasePolicy { + param([Parameter(Mandatory)][string]$Path) + + $policy = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | + ConvertFrom-Json -Depth 16 -NoEnumerate -ErrorAction Stop + $null = Get-OpenClawPolicyRef $policy + return $policy +} + +function Assert-OpenClawRegistryIntegrity { + param([AllowNull()][object]$Integrity) + + 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.' + } +} + +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" + } + } + 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) + + $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.' + } + $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.' + } + Assert-OpenClawSourceVersion $package.version + return $package.version +} + +function Resolve-OpenClawSource { + [CmdletBinding()] + param([Parameter(Mandatory)][object]$Policy, [AllowEmptyString()][string]$Ref = '') + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $policyRef = Get-OpenClawPolicyRef $Policy + Assert-OpenClawSourceText $Ref 'Ref' -AllowEmpty + $channel = $releaseTag = $tagObject = $integrity = '' + if ($Ref.Length -gt 0) { + Assert-OpenClawSourceRef $Ref + $requestedRef = $Ref + $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 = 'stable' + $requestedRef = $policyRef + $selector = if ($policyRef -ceq 'stable') { 'latest' } else { $policyRef } + $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 = $selection.version + Assert-OpenClawSourceVersion $version + if ($policyRef -cne 'stable' -and $version -cne $policyRef) { + 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-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.' + } + 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.' + } + if ($tag.object.type -isnot [string] -or $tag.object.type -cne 'commit') { + throw 'The annotated release tag must point directly to a commit.' + } + 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.' + } + $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 -ine $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 $Policy + return $source +} + +function Assert-OpenClawSource { + [CmdletBinding()] + param([Parameter(Mandatory)][object]$Source, [Parameter(Mandatory)][object]$Policy, [switch]$RequireChannel) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $policyRef = Get-OpenClawPolicyRef $Policy + $values = @{} + foreach ($field in @( + 'repository', 'requestedRef', 'resolvedCommit', 'packageVersion', + '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) + } + 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 + 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') + $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) { + 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) { + throw 'The source packageVersion must match the stableVersion pin.' + } + 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 '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-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')) { 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 new file mode 100644 index 00000000..df4d4df7 --- /dev/null +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -0,0 +1,365 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$sourcePath = Join-Path $PSScriptRoot 'OpenClawSource.ps1' +$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 -ne 0 -or $ast.BeginBlock -or $ast.ProcessBlock -or + @($ast.EndBlock.Statements | Where-Object { + $_ -isnot [Management.Automation.Language.FunctionDefinitionAst] + }).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' +$integrity = 'sha512-' + [Convert]::ToBase64String([byte[]]::new(64)) +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-source-tests-$([guid]::NewGuid().ToString('N'))" +$testCount = 0 +$http = @{ Calls = [Collections.Generic.List[object]]::new(); Responses = @{} } +$originalToken = $env:GH_TOKEN + +# 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-Throws { + param([scriptblock]$Action, [string]$Pattern) + try { & $Action | Out-Null } + catch { + if ($_.Exception.Message -notmatch $Pattern) { throw "Unexpected failure: $($_.Exception.Message)" } + return + } + throw "Expected failure matching '$Pattern'." +} + +function Set-Package { + param([object]$Version = $script:version, [string]$Commit = $script:commit, [string]$Name = 'openclaw') + $json = @{ name = $Name; version = $Version } | ConvertTo-Json -Compress + $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-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' } + } + $http.Responses["GitHub:git/ref/tags/v$Version"] = [pscustomobject]@{ + ref = "refs/tags/v$Version"; object = [pscustomobject]@{ type = 'tag'; sha = $Tag } + } + $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' + } + $http.Responses["GitHub:commits/$Commit"] = [pscustomobject]@{ sha = $Commit } + Set-Package -Version $Version -Commit $Commit +} + +function Invoke-Test { + param([string]$Name, [scriptblock]$Body) + $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" +} + +function Save-Policy { + $workflow.PolicyPath = Join-Path $testRoot "policy-$testCount.json" + [IO.File]::WriteAllText($workflow.PolicyPath, ($policy | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) +} + +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 + 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.500.0' + 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 + } + } + foreach ($case in @( + @{ 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-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 '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 + 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')) { + 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-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 + $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 ($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 + } + } + 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 ($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' + } + } + 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 + } + Write-Host "Passed $testCount OpenClaw source tests." +} +finally { + $env:GH_TOKEN = $originalToken + Remove-Item -LiteralPath $testRoot -Recurse -Force +} diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index aae8fced..9d17545f 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' }}" @@ -93,32 +107,16 @@ if ($buildMsixJob.Contains( $dispatchDefaultMatch = [regex]::Match( $workflow, - '(?ms)openclaw_ref:\s+description:.*?default:\s*(?[0-9a-f]{40})' + '(?ms)openclaw_ref:\s+description:.*?required:\s*false\s+default:\s*''''\s+type:\s*string' ) -$automaticFallbackMatch = [regex]::Match( - $workflow, - "OPENCLAW_REF:.*?\|\|\s*'(?[0-9a-f]{40})'" -) -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 ', ')." - ) +$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)) {