diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 8dff8d37..15d28513 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -49,7 +49,7 @@ env: jobs: changes: - name: Classify pull request changes + name: Select Gateway MSIX validation runs-on: ubuntu-latest outputs: packaging: ${{ steps.filter.outputs.packaging }} @@ -65,12 +65,22 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + SIGNING_MODE: ${{ inputs.signing_mode || 'unsigned' }} run: | + $upgradeMatrix = .\scripts\Get-MSIXUpgradeMatrix.ps1 + "upgrade_matrix=$upgradeMatrix" >> $env:GITHUB_OUTPUT + if ($env:GITHUB_EVENT_NAME -ne 'pull_request') { 'packaging=true' >> $env:GITHUB_OUTPUT 'payload_artifact=true' >> $env:GITHUB_OUTPUT 'bundle_build=true' >> $env:GITHUB_OUTPUT - 'versioning=false' >> $env:GITHUB_OUTPUT + # Test dispatches prove the release candidate without publication. + # Push and unsigned dispatches retain development package versions. + $versioning = ( + $env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and + $env:SIGNING_MODE -in @('test', 'store', 'official') + ) + "versioning=$($versioning.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT return } @@ -106,9 +116,6 @@ jobs: } "bundle_build=$bundleBuild" >> $env:GITHUB_OUTPUT - $upgradeMatrix = .\scripts\Get-MSIXUpgradeMatrix.ps1 - "upgrade_matrix=$upgradeMatrix" >> $env:GITHUB_OUTPUT - # Advisory only. This job annotates documentation-reference findings but is # deliberately absent from ci-gate's needs, so it can never block a merge. # Run .\scripts\Test-DocReferences.ps1 without -Advisory locally for a @@ -610,7 +617,7 @@ jobs: test-msix-upgrades: cache-mode: none name: Test MSIX upgrade (${{ matrix.name }}) - if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.changes.result == 'success' && needs.changes.outputs.versioning == 'true' && needs.test-host.result == 'success' && needs.build.result == 'success' }} + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && needs.changes.result == 'success' && needs.changes.outputs.versioning == 'true' && needs.test-host.result == 'success' && needs.build.result == 'success' }} needs: - changes - test-host @@ -730,10 +737,11 @@ jobs: authorize-signing: cache-mode: none name: Authorize Gateway MSIX release - if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && (inputs.signing_mode == 'official' || inputs.signing_mode == 'store') && github.ref == 'refs/heads/main' && needs.test-host.result == 'success' && needs.build.result == 'success' }} + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && (inputs.signing_mode == 'official' || inputs.signing_mode == 'store') && github.ref == 'refs/heads/main' && needs.test-host.result == 'success' && needs.build.result == 'success' && needs.test-msix-upgrades.result == 'success' }} needs: - test-host - build + - test-msix-upgrades runs-on: windows-latest outputs: package_version: ${{ steps.release.outputs.package_version }} @@ -1134,11 +1142,12 @@ jobs: with: persist-credentials: false - - name: Require versioning upgrade tests + - name: Require candidate upgrade tests if: ${{ always() }} shell: pwsh env: EVENT_NAME: ${{ github.event_name }} + SIGNING_MODE: ${{ inputs.signing_mode || 'unsigned' }} VERSIONING: ${{ needs.changes.outputs.versioning }} CHANGES_RESULT: ${{ needs.changes.result }} TEST_HOST_RESULT: ${{ needs.test-host.result }} @@ -1147,6 +1156,7 @@ jobs: run: | ./scripts/Assert-WorkflowJobResults.ps1 ` -EventName $env:EVENT_NAME ` + -SigningMode $env:SIGNING_MODE ` -Versioning $env:VERSIONING ` -ChangesResult $env:CHANGES_RESULT ` -TestHostResult $env:TEST_HOST_RESULT ` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 362d0de8..9ca7da11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,9 +139,11 @@ upload step logs before relying on the results. The source-selection tests use offline npm and GitHub fixtures to exercise snapshot capture/replay, bound workflow context, and trusted versus read-only -routes. `Test-WorkflowSigningConfiguration.ps1` executes the selected-build -owner: exactly one route must succeed and its outputs must match preflight. -It no longer inspects YAML fragments. The signing-input suite validates +routes. `Test-WorkflowSigningConfiguration.ps1` executes the workflow classifier +and selected-build owner: release-candidate dispatches select the full upgrade +matrix, exactly one build route must succeed, and its outputs must match preflight. +`Test-WorkflowJobResults.Tests.ps1` checks the final upgrade-proof requirement. +The signing-input suite validates the actual synthetic packages and bundles against the captured source. The shared build workflow inherits the caller's explicit cache token cap; diff --git a/README.md b/README.md index e198601c..4a6d8b4a 100644 --- a/README.md +++ b/README.md @@ -372,9 +372,10 @@ Package and payload metadata record the resolved source commit and version. For a one-time source override in any signing mode, provide an OpenClaw tag, branch, or full commit SHA in the manual `openclaw_ref` input. Empty means follow stable. The resolver records the ref's exact commit and package version once; -retries use that immutable snapshot even if a branch later moves. Release modes -also capture the unmodified stable selector as the MSIX and GitHub release -identity source, so a hotfix payload does not create a second version owner. +retries use that immutable snapshot even if a branch later moves. +Release-candidate modes (`test`, `store`, and `official`) also capture the +unmodified stable selector as the MSIX and GitHub release identity source, so a +hotfix payload does not create a second version owner. The override payload version may equal or precede that stable identity, but may not be newer. For a one-run rollback of the bundled payload in any signing mode, set @@ -382,8 +383,8 @@ For a one-run rollback of the bundled payload in any signing mode, set while the MSIX and GitHub release identity continue from the unmodified stable selector above so the package remains an upgrade. Both sources are verified and captured in the immutable snapshot. The workflow rejects an override that is not -older than the release-identity source. Unsigned and test runs reject combining -it with `openclaw_ref`; Store and official runs preserve a full-SHA +older than the release-identity source. Unsigned runs reject combining it with +`openclaw_ref`; test, Store, and official runs preserve a full-SHA `openclaw_ref` as an assertion that the version resolved to the intended commit. If compatibility requires an older known-good stable release, a reviewed `stableVersion` field in `release-policy.json` can pin its exact version, for @@ -690,9 +691,10 @@ validation. Manual runs support four modes: - `unsigned` follows stable or an `openclaw_ref` override and publishes unsigned MSIX packages; -- `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; +- `test` uses the same source-selection rules, derives the release-candidate + version, and runs the installed-upgrade matrix without release authorization + or Azure credentials. It publishes MSIX packages signed with a temporary + self-signed certificate plus the public `.cer` needed for local installation; - `store` follows stable, an `openclaw_ref` override, or an older payload pinned with `gateway_version`, may run only from `main`, and retains an unsigned Partner Center submission bundle for 90 days; @@ -703,6 +705,10 @@ validation. Manual runs support four modes: Following stable does not automatically sign or publish a new Gateway release; publication still requires a manual workflow dispatch. +Manual `store` and `official` runs require their own successful installed-upgrade +matrix before authorization. A new upstream stable release needs no throwaway +pull request to obtain current-source proof; use `test` mode for an optional +preflight. `unsigned` dispatches keep development versions and skip that matrix. Official signing uses the protected `release-signing` environment, Azure OIDC, and the existing OpenClaw Artifact Signing account and certificate profile. @@ -770,8 +776,9 @@ The signed `v0.0.0.0` and `v0.0.0.1` proof releases and the latest signed sideload release retain the `OpenClaw.Gateway` identity and remain immutable upgrade baselines. Partner Center requires the reserved `OpenClawFoundation.OpenClawGateway` identity, so Windows cannot update those -packages in place or retain their packaged LocalState. Pull requests that -change release policy download the hash-pinned standalone x64 and recommended +packages in place or retain their packaged LocalState. Versioning/source-selection +pull requests and manual `test`, `store`, and `official` runs download the +hash-pinned standalone x64 and recommended `.msixbundle` assets, install each one on a clean GitHub-hosted Windows runner, verify the legacy identity, remove it, install the Store candidate through the same delivery format, and prove that Windows registered the reserved package diff --git a/docs/release-process.md b/docs/release-process.md index dbce28c0..98d93894 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -44,11 +44,13 @@ payload rollback. The payload uses that older version, while package and release identity continue from the unmodified policy pin or npm latest so the MSIX remains an upgrade. Both verified sources are captured and replayed with the immutable snapshot; the input does not change release policy and must be older than the -release-identity source. In Store or official mode, an accompanying full-SHA +release-identity source. In test, Store, or official mode, an accompanying full-SHA `openclaw_ref` remains a commit assertion for that version-selected payload; branches and tags cannot be combined with `gateway_version`. The workflow also accepts `signing_mode`, whose choices are `unsigned`, `test`, -`store`, and `official`. Select `store` to authorize and retain an unsigned Partner Center +`store`, and `official`. Select `test` for a credential-free release-candidate +preflight, including the installed-upgrade matrix, from any branch. Select `store` +to authorize and retain an unsigned Partner Center submission bundle without signing. Select `official` to retain that Store bundle and sign the separate sideload-identity packages for GitHub Releases. Following stable does not automatically publish: both modes still require a @@ -83,12 +85,11 @@ Complete this checklist before dispatch. Merge any needed policy changes to 4. Review the release-facing pull request titles. The published release notes are generated from merged pull request titles; `CONTRIBUTING.md` owns the required title format. -5. Confirm every relevant versioning or source-selection pull request's - `test-msix-upgrades` matrix job succeeded - and retained its identity-transition or in-place-upgrade evidence. The - matrix runs only on pull requests that change versioning inputs or - source-selection scripts; it does not run during the later official - dispatch. +5. Confirm relevant packaging changes have passed their pull request checks. + A new upstream stable release needs no version-bump or validation-only pull + request: the release dispatch runs `test-msix-upgrades` against its own + source selection before authorization. Older pull request evidence does not + replace that gate. 6. Do not reuse or alter an accepted GitHub release tag. Update the latest production entries in `scripts\msix-upgrade-baselines.json` only in a reviewed policy pull request, using the verified digest of an immutable @@ -99,6 +100,13 @@ those inputs. A `store` or `official` run deliberately bypasses both the upstrea cache and the architecture-specific Windows dependency-tree cache, so its payload is rebuilt and revalidated. +For a preflight without release authorization or Azure credentials, dispatch +`signing_mode=test` with the desired source inputs. It uses the derived release +version and the same installed-upgrade matrix, then retains temporary test-signed +packages. A later `store` or `official` dispatch repeats that proof against its +own immutable source snapshot. `unsigned` dispatches and push builds keep +development versions and skip installed-upgrade validation. + ## Watch the release workflow The workflow first captures one immutable source snapshot in `source-preflight`. @@ -118,7 +126,8 @@ composes a multi-architecture bundle for each channel with `scripts\Build-MSIXBundle.ps1`. Both channels use the same validated payload and derived package version. -Before publication or Azure credentials, `authorize-signing` runs +After every `test-msix-upgrades` scenario succeeds, and before publication or +Azure credentials, `authorize-signing` runs `scripts\Test-SigningInputs.ps1`. It restores the same run's source snapshot and validates its workflow run ID, packaging commit, signing mode, workflow ref, Git ref, and event without @@ -136,11 +145,14 @@ Observe these workflow outcomes: - Exactly one build caller and the normalized `build` job succeed. - `build-msix` succeeds for **both** x64 and ARM64. -- `test-host` and `build-msix-bundle` succeed before `authorize-signing`. +- `test-host`, `build-msix-bundle`, and all 13 `test-msix-upgrades` scenarios + succeed before `authorize-signing`. Missing or skipped required upgrade + proof fails the final `Gateway MSIX CI` gate. - `authorize-signing` succeeds before Azure login and signing begins. - `sign-msix` verifies signatures and refreshes package metadata. -The pull request's upgrade matrix uses +The upgrade matrix runs for versioning/source-selection pull requests and +manual `test`, `store`, and `official` dispatches. It uses `scripts\Test-MSIXUpgrade.ps1` with the immutable, hash-pinned release fixtures in `scripts\msix-upgrade-baselines.json`. It requires an isolated clean Windows account per scenario and refuses to run when an OpenClaw Gateway package is @@ -179,14 +191,14 @@ bundle plus signed x64 and ARM64 standalone MSIX assets. After publication, verify the release has the derived permanent tag, generated notes, three signed sideload assets, and valid signatures. Separately verify -the run retained its unsigned Store submission bundle. Reconcile the release with the successful -identity-transition and in-place-upgrade evidence from the relevant pull request. -That evidence applies to the source it tested; when stable has advanced, record -the validation gap and obtain current-source upgrade proof before release. Verify the bundle and both +the run retained its unsigned Store submission bundle and all successful +identity-transition and in-place-upgrade evidence from that same release run. +The proof and packages share the run's captured upstream source and derived +release identity. Verify the bundle and both standalone packages are present; the bundle is the multi-architecture delivery, while the standalone packages support explicit architecture deployment. Keep -both the release workflow run and the relevant pull request's evidence available -as the release record. +the release workflow run, its immutable source snapshot, and its upgrade +evidence available as the release record. ## Failure and rollback boundaries diff --git a/release-policy.json b/release-policy.json index e2e06030..e7453f8e 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,7 +1,7 @@ { "repository": "https://github.com/openclaw/openclaw", "channel": "stable", - "msixRevision": 1, + "msixRevision": 0, "packageIdentityName": "OpenClawFoundation.OpenClawGateway", "packageFamilyName": "OpenClawFoundation.OpenClawGateway_rfcbke2p71se2", "publisher": "CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC", diff --git a/scripts/Assert-WorkflowJobResults.ps1 b/scripts/Assert-WorkflowJobResults.ps1 index 03943be6..0526b310 100644 --- a/scripts/Assert-WorkflowJobResults.ps1 +++ b/scripts/Assert-WorkflowJobResults.ps1 @@ -3,6 +3,8 @@ param( [Parameter(Mandatory)] [ValidateSet('pull_request', 'workflow_dispatch', 'push')] [string]$EventName, + [ValidateSet('unsigned', 'test', 'store', 'official')] + [string]$SigningMode = 'unsigned', [string]$Versioning, [Parameter(Mandatory)] [ValidateSet('success', 'failure', 'cancelled', 'skipped')] @@ -25,12 +27,15 @@ if ($ChangesResult -cne 'success') { throw "Change classification must succeed; result was '$ChangesResult'." } +$upgradeRequired = ( + ($EventName -ceq 'pull_request' -and $Versioning -ceq 'true') -or + ($EventName -ceq 'workflow_dispatch' -and $SigningMode -in @('test', 'store', 'official')) +) if ( - $EventName -ceq 'pull_request' -and - $Versioning -ceq 'true' -and + $upgradeRequired -and $TestHostResult -ceq 'success' -and $BuildResult -ceq 'success' -and $UpgradeResult -cne 'success' ) { - throw "Versioning pull requests require successful MSIX upgrade tests; result was '$UpgradeResult'." + throw "Release candidates and versioning pull requests require successful MSIX upgrade tests; result was '$UpgradeResult'." } diff --git a/scripts/Get-WorkflowSource.ps1 b/scripts/Get-WorkflowSource.ps1 index 556e2ddc..661e34b8 100644 --- a/scripts/Get-WorkflowSource.ps1 +++ b/scripts/Get-WorkflowSource.ps1 @@ -29,7 +29,7 @@ if ($GatewayVersion -ne '') { } $null = Get-OpenClawBuildRoute -WorkflowRef $WorkflowRef -GitHubRef $GitHubRef ` -EventName $EventName -SigningMode $SigningMode -Channel '' -$releaseMode = $SigningMode -in @('official', 'store') +$releaseMode = $SigningMode -in @('test', 'official', 'store') $refAssertion = $releaseMode -and $GatewayVersion -ne '' -and $Ref -ne '' if ($Ref -ne '' -and $GatewayVersion -ne '') { if (-not $refAssertion) { diff --git a/scripts/Test-OpenClawSource.Tests.ps1 b/scripts/Test-OpenClawSource.Tests.ps1 index 85b9d5d0..10d7ee10 100644 --- a/scripts/Test-OpenClawSource.Tests.ps1 +++ b/scripts/Test-OpenClawSource.Tests.ps1 @@ -461,7 +461,7 @@ try { Assert-Throws { Assert-OpenClawSource $source $policy } 'repository' Assert-Equal $http.Calls.Count 0 } - foreach ($mode in @('official', 'store')) { + foreach ($mode in @('test', 'official', 'store')) { Invoke-Test "$mode release follows stable and replays the captured release after latest advances" { Add-Release '2026.9.7' ('a' * 40) ('b' * 40) $http.Responses['Registry:latest'].version = '2026.9.7' diff --git a/scripts/Test-WorkflowJobResults.Tests.ps1 b/scripts/Test-WorkflowJobResults.Tests.ps1 index 7f667bc1..acd6532b 100644 --- a/scripts/Test-WorkflowJobResults.Tests.ps1 +++ b/scripts/Test-WorkflowJobResults.Tests.ps1 @@ -19,7 +19,7 @@ function Assert-Rejected { } return } - throw 'An eligible versioning pull request accepted an unsuccessful upgrade job.' + throw 'An eligible release candidate or versioning pull request accepted an unsuccessful upgrade job.' } $base = @{ @@ -51,6 +51,27 @@ Invoke-Validator @{ Versioning = '' UpgradeResult = 'skipped' } +foreach ($mode in @('test', 'store', 'official')) { + Invoke-Validator @{ + EventName = 'workflow_dispatch' + SigningMode = $mode + Versioning = 'true' + UpgradeResult = 'success' + } + foreach ($result in @('skipped', 'failure', 'cancelled')) { + $arguments = $base.Clone() + $arguments.EventName = 'workflow_dispatch' + $arguments.SigningMode = $mode + $arguments.Versioning = 'false' + $arguments.UpgradeResult = $result + Assert-Rejected $arguments + } +} +Invoke-Validator @{ + EventName = 'push' + Versioning = 'false' + UpgradeResult = 'skipped' +} Invoke-Validator @{ Versioning = 'false' UpgradeResult = 'skipped' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 0d8e2393..ea13ab78 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -82,7 +82,7 @@ $selected = & $selector -PackagingExpected $true -ExpectedRoute trusted -Expecte if ($selected.release_identity_tag -cne 'v2026.9.8') { throw 'The selected release identity changed.' } $outputs.release_identity_tag = $savedIdentity $expectedSource.release_identity_tag = $savedIdentity -# Arbitrary unsigned/test refs have no release identity. Actions can omit an +# Arbitrary unsigned refs have no release identity. Actions can omit an # empty job output from needs while retaining it on the reusable workflow. $outputs.release_identity_tag = '' $expectedSource.release_identity_tag = '' @@ -121,3 +121,104 @@ Assert-Rejected { -Trusted ([pscustomobject]@{ result = 'success'; outputs = [pscustomobject]@{} }) -ReadOnly $skipped } 'Missing required field' Write-Host 'Workflow build selection contracts passed (source/signing contracts run in their owning suites).' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$workflowPath = Join-Path $repositoryRoot '.github/workflows/gateway-msix.yml' +$workflow = Get-Content -LiteralPath $workflowPath -Raw +$classificationStep = [regex]::Match( + $workflow, + '(?ms)^ - name: Detect packaging changes\r?\n' + + '.*?^ run: \|\r?\n' + + '(?