Files
openclaw/.github/workflows/windows-node-release.yml
2026-08-20 21:20:07 -07:00

522 lines
23 KiB
YAML

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