name: Windows Node Release on: workflow_dispatch: inputs: tag: description: Existing OpenClaw release tag to receive Windows Hub installers required: true type: string windows_node_tag: description: Exact openclaw-windows-node release tag to promote required: true type: string expected_installer_digests: description: Compact JSON map of installer asset names to pinned source sha256 digests required: true type: string release_publish_run_id: description: OpenClaw Release Publish run that approved this promotion required: true type: string release_publish_run_attempt: description: Exact approving OpenClaw Release Publish run attempt required: true type: string release_tooling_ref: description: Exact ref name of the approving release tooling required: true type: string release_tooling_full_ref: description: Exact full ref of the approving release tooling required: true type: string release_tooling_sha: description: Exact SHA of the approving release tooling required: true type: string release_target_sha: description: Exact OpenClaw release candidate SHA required: true type: string direct_release_recovery: description: Reuse the exact original approval after its parent run completed required: true default: false type: boolean permissions: actions: read attestations: read contents: read concurrency: group: windows-node-release-${{ inputs.tag }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.16.0" jobs: validate_signed_windows_installers: name: Validate signed Windows installers runs-on: windows-latest timeout-minutes: 30 steps: - name: Checkout exact trusted release tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_tooling_sha }} fetch-depth: 1 persist-credentials: false sparse-checkout: | scripts/lib/record-shared.mjs scripts/release-tooling-identity.mjs scripts/validate-release-publish-approval.mjs sparse-checkout-cone-mode: false - name: Download parent Windows release approval uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-release-approval-${{ inputs.release_publish_run_id }}-${{ inputs.release_publish_run_attempt }} path: ${{ runner.temp }}/windows-release-approval repository: ${{ github.repository }} run-id: ${{ inputs.release_publish_run_id }} github-token: ${{ github.token }} - name: Validate immutable release approval shell: pwsh env: APPROVAL_PATH: ${{ runner.temp }}/windows-release-approval/approval.json CHILD_WORKFLOW_FULL_REF: ${{ github.ref }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} CHILD_WORKFLOW_SHA: ${{ github.workflow_sha }} DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }} EXPECTED_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_tooling_ref }} EXPECTED_WORKFLOW_FULL_REF: ${{ inputs.release_tooling_full_ref }} EXPECTED_WORKFLOW_SHA: ${{ inputs.release_tooling_sha }} GH_TOKEN: ${{ github.token }} RELEASE_APPROVAL_KIND: windows-release RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_TARGET_SHA: ${{ inputs.release_target_sha }} RELEASE_TOOLING_FULL_REF: ${{ inputs.release_tooling_full_ref }} RELEASE_TOOLING_REF: ${{ inputs.release_tooling_ref }} RELEASE_TOOLING_SHA: ${{ inputs.release_tooling_sha }} WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} run: | $ErrorActionPreference = "Stop" if ($env:GITHUB_ACTOR -ne "github-actions[bot]" -and $env:DIRECT_RELEASE_RECOVERY -ne "true") { throw "Manual Windows release recovery must explicitly set direct_release_recovery=true." } if ( $env:CHILD_WORKFLOW_REF -ne $env:RELEASE_TOOLING_REF -or $env:CHILD_WORKFLOW_FULL_REF -ne $env:RELEASE_TOOLING_FULL_REF -or $env:CHILD_WORKFLOW_SHA -ne $env:RELEASE_TOOLING_SHA ) { throw "Windows release workflow execution does not match the attested tooling tuple." } $verifyArgs = @( "verify", "--repository", $env:GITHUB_REPOSITORY, "--workflow-ref", $env:RELEASE_TOOLING_REF, "--workflow-full-ref", $env:RELEASE_TOOLING_FULL_REF, "--workflow-sha", $env:RELEASE_TOOLING_SHA ) if ( $env:RELEASE_TOOLING_REF -ne "main" -and $env:RELEASE_TOOLING_FULL_REF -notmatch '^refs/tags/release-publish/' ) { $verifyArgs += "--allow-prevalidated-ref" } node scripts/release-tooling-identity.mjs @verifyArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "Windows release tooling identity is no longer live." } gh attestation verify $env:APPROVAL_PATH ` --repo $env:GITHUB_REPOSITORY ` --signer-workflow "$env:GITHUB_REPOSITORY/.github/workflows/openclaw-release-publish.yml" ` --source-ref $env:RELEASE_TOOLING_FULL_REF ` --source-digest $env:RELEASE_TOOLING_SHA ` --deny-self-hosted-runners if ($LASTEXITCODE -ne 0) { throw "Windows release approval attestation verification failed." } gh api ` "repos/$env:GITHUB_REPOSITORY/actions/runs/$env:RELEASE_PUBLISH_RUN_ID/attempts/$env:EXPECTED_RUN_ATTEMPT" ` --jq '{workflowName: .name, headBranch: .head_branch, headSha: .head_sha, event, status, conclusion, url: .html_url, runAttempt: .run_attempt, repository: .repository.full_name, path}' | node scripts/validate-release-publish-approval.mjs if ($LASTEXITCODE -ne 0) { throw "Windows release approval does not match the exact parent run." } - name: Validate inputs and download installers shell: pwsh env: DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }} EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_TARGET_SHA: ${{ inputs.release_target_sha }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} run: | $ErrorActionPreference = "Stop" if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') { throw "Invalid OpenClaw release tag: $env:RELEASE_TAG" } if ($env:RELEASE_TARGET_SHA -notmatch '^[a-f0-9]{40}$') { throw "release_target_sha must be a full lowercase commit SHA." } if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') { throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG" } try { $expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable } catch { throw "expected_installer_digests must be a JSON object: $_" } $requiredInstallerNames = @( "OpenClawCompanion-Setup-x64.exe", "OpenClawCompanion-Setup-arm64.exe" ) $allowedTargetCompanionAssetNames = @( $requiredInstallerNames "OpenClawCompanion-SHA256SUMS.txt" ) if ($expectedDigests.Count -ne $requiredInstallerNames.Count) { throw "expected_installer_digests must contain exactly the current installer asset contract." } foreach ($name in $requiredInstallerNames) { $digest = [string]$expectedDigests[$name] if ($digest -notmatch '^sha256:[a-f0-9]{64}$') { throw "expected_installer_digests is missing a valid pinned digest for $name." } } $targetSha = gh api "repos/$env:GITHUB_REPOSITORY/commits/$env:RELEASE_TAG" --jq .sha if ($targetSha -ne $env:RELEASE_TARGET_SHA) { throw "OpenClaw release tag no longer resolves to the approved candidate SHA." } $targetRelease = gh release view $env:RELEASE_TAG ` --repo $env:GITHUB_REPOSITORY ` --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json if ($targetRelease.tagName -ne $env:RELEASE_TAG) { throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)" } if ($env:DIRECT_RELEASE_RECOVERY -ne "true" -and -not $targetRelease.isDraft) { throw "Normal Windows promotion requires the target GitHub release to remain a draft." } $unexpectedTargetCompanionAssets = @( $targetRelease.assets | Where-Object { $_.name.StartsWith("OpenClawCompanion-") -and $_.name -notin $allowedTargetCompanionAssetNames } | ForEach-Object name | Sort-Object ) if ($unexpectedTargetCompanionAssets.Count -ne 0) { throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')" } $stableRelease = -not ( $env:RELEASE_TAG.Contains("-alpha.") -or $env:RELEASE_TAG.Contains("-beta.") ) $sourceRelease = gh release view $env:WINDOWS_NODE_TAG ` --repo openclaw/openclaw-windows-node ` --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) { throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)" } if ($sourceRelease.isDraft) { throw "Windows source release must be published: $($sourceRelease.url)" } if ($stableRelease -and $sourceRelease.isPrerelease) { throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)" } foreach ($name in $requiredInstallerNames) { $sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name) if ($sourceAssets.Count -ne 1) { throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)." } if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) { throw "Windows source release asset digest does not match the pinned digest: $name" } } New-Item -ItemType Directory -Force -Path dist | Out-Null $downloadArgs = @( $env:WINDOWS_NODE_TAG, "--repo", "openclaw/openclaw-windows-node", "--dir", "dist" ) foreach ($name in $requiredInstallerNames) { $downloadArgs += @("--pattern", $name) } gh release download @downloadArgs if ($LASTEXITCODE -ne 0) { throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG." } foreach ($name in $requiredInstallerNames) { $matches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -eq $name) if ($matches.Count -ne 1) { throw "Expected exactly one Windows installer named '$name', found $($matches.Count)." } $expectedHash = ([string]$expectedDigests[$name]) -replace '^sha256:', '' $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $matches[0].FullName).Hash if ($actualHash -ne $expectedHash) { throw "Downloaded Windows source asset does not match pinned digest: $name" } } - name: Verify Authenticode signatures shell: pwsh run: | $ErrorActionPreference = "Stop" $expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" Get-ChildItem -LiteralPath dist -File | ForEach-Object { $signature = Get-AuthenticodeSignature -LiteralPath $_.FullName if ($signature.Status -ne "Valid") { throw "$($_.Name) Authenticode signature was $($signature.Status)." } if (-not $signature.SignerCertificate) { throw "$($_.Name) has no signer certificate." } if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) { throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)." } } - name: Write SHA-256 manifest shell: pwsh run: | Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object { $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName "$($hash.Hash.ToLowerInvariant()) $($_.Name)" } | Set-Content -Encoding utf8NoBOM -Path dist/OpenClawCompanion-SHA256SUMS.txt - name: Upload validated Windows installers uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-release-prepared-${{ github.run_id }} path: dist if-no-files-found: error retention-days: 30 promote_signed_windows_installers: name: Promote signed Windows installers needs: [validate_signed_windows_installers] runs-on: windows-latest timeout-minutes: 30 environment: npm-release permissions: actions: read attestations: read contents: write steps: - name: Checkout exact trusted release tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_tooling_sha }} fetch-depth: 1 persist-credentials: false sparse-checkout: | scripts/lib/record-shared.mjs scripts/release-tooling-identity.mjs scripts/validate-release-publish-approval.mjs sparse-checkout-cone-mode: false - name: Download parent Windows release approval uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-release-approval-${{ inputs.release_publish_run_id }}-${{ inputs.release_publish_run_attempt }} path: ${{ runner.temp }}/windows-release-approval repository: ${{ github.repository }} run-id: ${{ inputs.release_publish_run_id }} github-token: ${{ github.token }} - name: Download validated Windows installers uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-release-prepared-${{ github.run_id }} path: dist - name: Upload to OpenClaw release shell: pwsh env: APPROVAL_PATH: ${{ runner.temp }}/windows-release-approval/approval.json DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }} EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} EXPECTED_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_tooling_ref }} EXPECTED_WORKFLOW_FULL_REF: ${{ inputs.release_tooling_full_ref }} EXPECTED_WORKFLOW_SHA: ${{ inputs.release_tooling_sha }} GH_TOKEN: ${{ github.token }} RELEASE_APPROVAL_KIND: windows-release RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_TARGET_SHA: ${{ inputs.release_target_sha }} RELEASE_TOOLING_FULL_REF: ${{ inputs.release_tooling_full_ref }} RELEASE_TOOLING_REF: ${{ inputs.release_tooling_ref }} RELEASE_TOOLING_SHA: ${{ inputs.release_tooling_sha }} WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} run: | $ErrorActionPreference = "Stop" function Assert-ReleaseApproval { $verifyArgs = @( "verify", "--repository", $env:GITHUB_REPOSITORY, "--workflow-ref", $env:RELEASE_TOOLING_REF, "--workflow-full-ref", $env:RELEASE_TOOLING_FULL_REF, "--workflow-sha", $env:RELEASE_TOOLING_SHA ) if ( $env:RELEASE_TOOLING_REF -ne "main" -and $env:RELEASE_TOOLING_FULL_REF -notmatch '^refs/tags/release-publish/' ) { $verifyArgs += "--allow-prevalidated-ref" } node scripts/release-tooling-identity.mjs @verifyArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "Windows release tooling identity is no longer live." } gh attestation verify $env:APPROVAL_PATH ` --repo $env:GITHUB_REPOSITORY ` --signer-workflow "$env:GITHUB_REPOSITORY/.github/workflows/openclaw-release-publish.yml" ` --source-ref $env:RELEASE_TOOLING_FULL_REF ` --source-digest $env:RELEASE_TOOLING_SHA ` --deny-self-hosted-runners if ($LASTEXITCODE -ne 0) { throw "Windows release approval attestation verification failed." } gh api ` "repos/$env:GITHUB_REPOSITORY/actions/runs/$env:RELEASE_PUBLISH_RUN_ID/attempts/$env:EXPECTED_RUN_ATTEMPT" ` --jq '{workflowName: .name, headBranch: .head_branch, headSha: .head_sha, event, status, conclusion, url: .html_url, runAttempt: .run_attempt, repository: .repository.full_name, path}' | node scripts/validate-release-publish-approval.mjs if ($LASTEXITCODE -ne 0) { throw "Windows release approval does not match the exact parent run." } } $expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable $expectedFiles = @( "OpenClawCompanion-Setup-arm64.exe", "OpenClawCompanion-Setup-x64.exe", "OpenClawCompanion-SHA256SUMS.txt" ) | Sort-Object $actualFiles = @(Get-ChildItem -LiteralPath dist -File | ForEach-Object Name | Sort-Object) if (Compare-Object -ReferenceObject $expectedFiles -DifferenceObject $actualFiles) { throw "Validated Windows artifact does not match the canonical file contract." } foreach ($file in Get-ChildItem -LiteralPath dist -File | Sort-Object Name) { if ($file.Name -ne "OpenClawCompanion-SHA256SUMS.txt") { $expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', '' $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash if ($actualHash -ne $expectedHash) { throw "Prepared Windows installer does not match its approved digest: $($file.Name)" } } Assert-ReleaseApproval gh release upload $env:RELEASE_TAG $file.FullName ` --repo $env:GITHUB_REPOSITORY ` --clobber if ($LASTEXITCODE -ne 0) { throw "Failed to upload Windows release asset $($file.Name) to $env:RELEASE_TAG." } } - name: Verify promoted release asset contract shell: pwsh env: RELEASE_TAG: ${{ inputs.tag }} GH_TOKEN: ${{ github.token }} run: | $ErrorActionPreference = "Stop" New-Item -ItemType Directory -Force -Path verified | Out-Null $expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name) $expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object) $targetRelease = gh release view $env:RELEASE_TAG ` --repo $env:GITHUB_REPOSITORY ` --json assets | ConvertFrom-Json $actualCompanionAssetNames = @( $targetRelease.assets | Where-Object { $_.name.StartsWith("OpenClawCompanion-") } | ForEach-Object name | Sort-Object ) $assetContractDiff = @( Compare-Object ` -ReferenceObject $expectedCompanionAssetNames ` -DifferenceObject $actualCompanionAssetNames ) if ( $actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or $assetContractDiff.Count -ne 0 ) { throw "Promoted OpenClawCompanion asset names do not exactly match the current contract." } foreach ($asset in $expectedAssets) { gh release download $env:RELEASE_TAG ` --repo $env:GITHUB_REPOSITORY ` --pattern $asset.Name ` --dir verified if ($LASTEXITCODE -ne 0) { throw "Failed to download promoted Windows release asset $($asset.Name)." } } $manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt" $manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object { if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') { throw "Invalid Windows SHA-256 manifest entry: $_" } [PSCustomObject]@{ Hash = $Matches[1] Name = $Matches[2] } }) $expectedInstallerNames = @( $expectedAssets | Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" | ForEach-Object Name ) $manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object) if (Compare-Object -ReferenceObject $expectedInstallerNames -DifferenceObject $manifestInstallerNames) { throw "Promoted Windows SHA-256 manifest does not match the installer asset contract." } foreach ($entry in $manifestEntries) { $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash if ($hash -ne $entry.Hash) { throw "Promoted Windows release asset checksum mismatch: $($entry.Name)" } } - name: Summary shell: pwsh env: RELEASE_TAG: ${{ inputs.tag }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} run: | @" ## Windows Hub installers promoted OpenClaw release: $env:RELEASE_TAG Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG "@ >> $env:GITHUB_STEP_SUMMARY Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object { "- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)" } >> $env:GITHUB_STEP_SUMMARY