Skip to content

Commit 8ae1adf

Browse files
ci: gate stable releases on Windows companion assets (#92555)
* ci: gate stable releases on Windows companion assets * fix(release): reject malformed Windows checksum manifests * fix(release): make Windows recovery fail closed * fix(release): tighten Windows asset identity checks * fix(release): validate prepared candidate tarballs --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 4001be5 commit 8ae1adf

10 files changed

Lines changed: 1046 additions & 98 deletions

File tree

.agents/skills/release-openclaw-maintainer/SKILL.md

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,21 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
150150
- Stable Windows Hub release closeout requires the signed
151151
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
152152
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
153-
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
154-
workflow after the matching `openclaw/openclaw-windows-node` release exists;
155-
it verifies Authenticode signatures on Windows before uploading assets.
153+
`openclaw/openclaw` GitHub Release. Pass the exact signed
154+
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
155+
`OpenClaw Release Publish`, together with the candidate-approved
156+
`windows_node_installer_digests` map; it prevalidates the published source
157+
release and required installers against that map before any publish child,
158+
dispatches the public `Windows Node Release` workflow while the OpenClaw
159+
release is still a draft, carries those pinned source asset digests
160+
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
161+
Windows, re-downloads and checksum-verifies the promoted asset contract, and
162+
blocks publication until the canonical asset contract is present. Use direct
163+
`Windows Node Release` dispatch only for recovery, always with an exact tag,
164+
never `latest`, and the explicit `expected_installer_digests` JSON map from
165+
the approved source release. Recovery rejects unexpected
166+
`OpenClawCompanion-*` target asset names, then replaces the expected contract
167+
assets with the pinned source bytes.
156168
- Website Windows Hub download links should target exact canonical
157169
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
158170
stable release, or `releases/latest/download/...` only after verifying the
@@ -675,19 +687,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
675687
where npm did not publish the beta version, delete/recreate the same beta
676688
tag and any accidental draft/incomplete prerelease at the fixed commit
677689
instead of skipping a prerelease number.
678-
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
690+
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
679691
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
680692
`latest` only when you intentionally want direct stable publish), keep it
681693
the same as the preflight run, and pass the successful npm
682-
`preflight_run_id`.
694+
`preflight_run_id` plus the successful `full_release_validation_run_id`.
695+
For stable publish, also pass the exact non-prerelease
696+
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
697+
candidate-approved installer digest map as `windows_node_installer_digests`.
683698
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
684699
24. Wait for the real publish workflow to run postpublish verification,
685700
create or update the GitHub release as a draft, upload dependency evidence,
701+
promote and verify the required Windows Hub assets for stable releases,
686702
append release verification proof, and only then undraft/publish it. If a
687-
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
688-
the release draft with OpenClaw npm evidence and exits red; do not undraft
689-
until the plugin publish gap is repaired. The standalone verifier command
690-
remains the recovery probe:
703+
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
704+
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
705+
and exits red; do not undraft until the gap is repaired. The standalone
706+
verifier command remains the recovery probe:
691707
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
692708
25. Run the post-published beta verification roster. First scan current `main`
693709
for critical fixes that landed after the release branch cut; backport only

.github/workflows/openclaw-release-publish.yml

Lines changed: 214 additions & 2 deletions
Large diffs are not rendered by default.

.github/workflows/windows-node-release.yml

Lines changed: 191 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ on:
88
required: true
99
type: string
1010
windows_node_tag:
11-
description: openclaw-windows-node release tag to promote, or latest
11+
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
12+
required: true
13+
type: string
14+
expected_installer_digests:
15+
description: Compact JSON map of installer asset names to pinned source sha256 digests
1216
required: true
13-
default: latest
1417
type: string
1518

1619
permissions:
@@ -31,53 +34,139 @@ jobs:
3134
env:
3235
RELEASE_TAG: ${{ inputs.tag }}
3336
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
37+
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
3438
GH_TOKEN: ${{ github.token }}
3539
run: |
3640
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]*))?$') {
3741
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
3842
}
39-
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
40-
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
43+
$stableRelease = -not (
44+
$env:RELEASE_TAG.Contains("-alpha.") -or
45+
$env:RELEASE_TAG.Contains("-beta.")
46+
)
47+
if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') {
48+
throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG"
49+
}
50+
51+
try {
52+
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
53+
} catch {
54+
throw "expected_installer_digests must be a JSON object: $_"
55+
}
56+
# Add future signed installer names, such as MSIX x64/ARM64, here.
57+
$requiredInstallerNames = @(
58+
"OpenClawCompanion-Setup-x64.exe",
59+
"OpenClawCompanion-Setup-arm64.exe"
60+
)
61+
$allowedTargetCompanionAssetNames = @(
62+
$requiredInstallerNames
63+
"OpenClawCompanion-SHA256SUMS.txt"
64+
)
65+
if ($expectedDigests.Count -ne $requiredInstallerNames.Count) {
66+
throw "expected_installer_digests must contain exactly the current installer asset contract."
67+
}
68+
foreach ($name in $requiredInstallerNames) {
69+
$digest = [string]$expectedDigests[$name]
70+
if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') {
71+
throw "expected_installer_digests is missing a valid pinned digest for $name."
72+
}
73+
}
74+
75+
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
76+
if ($targetRelease.tagName -ne $env:RELEASE_TAG) {
77+
throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)"
78+
}
79+
$unexpectedTargetCompanionAssets = @(
80+
$targetRelease.assets |
81+
Where-Object {
82+
$_.name.StartsWith("OpenClawCompanion-") -and
83+
$_.name -notin $allowedTargetCompanionAssetNames
84+
} |
85+
ForEach-Object name |
86+
Sort-Object
87+
)
88+
if ($unexpectedTargetCompanionAssets.Count -ne 0) {
89+
throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')"
90+
}
91+
92+
$sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
93+
if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) {
94+
throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)"
95+
}
96+
if ($sourceRelease.isDraft) {
97+
throw "Windows source release must be published: $($sourceRelease.url)"
98+
}
99+
if ($stableRelease -and $sourceRelease.isPrerelease) {
100+
throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)"
101+
}
102+
foreach ($name in $requiredInstallerNames) {
103+
$sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name)
104+
if ($sourceAssets.Count -ne 1) {
105+
throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)."
106+
}
107+
if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) {
108+
throw "Windows source release asset digest does not match the pinned digest: $name"
109+
}
41110
}
42-
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
43111
44112
- name: Download Windows Hub release installers
45113
shell: pwsh
46114
env:
47115
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
116+
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
48117
GH_TOKEN: ${{ github.token }}
49118
run: |
50119
New-Item -ItemType Directory -Force -Path dist | Out-Null
51-
$tagArgs = @()
52-
if ($env:WINDOWS_NODE_TAG -ne "latest") {
53-
$tagArgs += $env:WINDOWS_NODE_TAG
54-
}
55-
gh release download @tagArgs `
56-
--repo openclaw/openclaw-windows-node `
57-
--pattern "OpenClawCompanion-Setup-*.exe" `
58-
--dir dist
59-
60-
$expected = @(
61-
"dist/OpenClawCompanion-Setup-x64.exe",
62-
"dist/OpenClawCompanion-Setup-arm64.exe"
120+
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
121+
# Every matched installer is signature-checked, checksummed, and promoted.
122+
$installerPatterns = @(
123+
"OpenClawCompanion-Setup-x64.exe",
124+
"OpenClawCompanion-Setup-arm64.exe"
63125
)
64-
foreach ($file in $expected) {
65-
if (-not (Test-Path -LiteralPath $file)) {
66-
throw "Missing expected Windows installer: $file"
126+
$downloadArgs = @(
127+
$env:WINDOWS_NODE_TAG,
128+
"--repo", "openclaw/openclaw-windows-node",
129+
"--dir", "dist"
130+
)
131+
foreach ($pattern in $installerPatterns) {
132+
$downloadArgs += @("--pattern", $pattern)
133+
}
134+
gh release download @downloadArgs
135+
if ($LASTEXITCODE -ne 0) {
136+
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
137+
}
138+
139+
foreach ($pattern in $installerPatterns) {
140+
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
141+
if ($patternMatches.Count -ne 1) {
142+
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
143+
}
144+
}
145+
146+
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
147+
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
148+
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
149+
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
150+
if ($actualHash -ne $expectedHash) {
151+
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
67152
}
68153
}
69154
70155
- name: Verify Authenticode signatures
71156
shell: pwsh
72157
run: |
73-
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
158+
$expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"
159+
Get-ChildItem -LiteralPath dist -File | ForEach-Object {
74160
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
75161
if ($signature.Status -ne "Valid") {
76162
throw "$($_.Name) Authenticode signature was $($signature.Status)."
77163
}
78164
if (-not $signature.SignerCertificate) {
79165
throw "$($_.Name) has no signer certificate."
80166
}
167+
if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) {
168+
throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)."
169+
}
81170
[pscustomobject]@{
82171
File = $_.Name
83172
Signer = $signature.SignerCertificate.Subject
@@ -88,7 +177,7 @@ jobs:
88177
- name: Write SHA-256 manifest
89178
shell: pwsh
90179
run: |
91-
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
180+
Get-ChildItem -LiteralPath dist -File |
92181
Sort-Object Name |
93182
ForEach-Object {
94183
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
@@ -101,12 +190,81 @@ jobs:
101190
RELEASE_TAG: ${{ inputs.tag }}
102191
GH_TOKEN: ${{ github.token }}
103192
run: |
104-
gh release upload $env:RELEASE_TAG `
105-
dist/OpenClawCompanion-Setup-x64.exe `
106-
dist/OpenClawCompanion-Setup-arm64.exe `
107-
dist/OpenClawCompanion-SHA256SUMS.txt `
108-
--repo $env:GITHUB_REPOSITORY `
109-
--clobber
193+
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
194+
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
195+
if ($LASTEXITCODE -ne 0) {
196+
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
197+
}
198+
199+
- name: Verify promoted release asset contract
200+
shell: pwsh
201+
env:
202+
RELEASE_TAG: ${{ inputs.tag }}
203+
GH_TOKEN: ${{ github.token }}
204+
run: |
205+
New-Item -ItemType Directory -Force -Path verified | Out-Null
206+
$expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name)
207+
$expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object)
208+
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
209+
$actualCompanionAssetNames = @(
210+
$targetRelease.assets |
211+
Where-Object { $_.name.StartsWith("OpenClawCompanion-") } |
212+
ForEach-Object name |
213+
Sort-Object
214+
)
215+
$assetContractDiff = @(
216+
Compare-Object `
217+
-ReferenceObject $expectedCompanionAssetNames `
218+
-DifferenceObject $actualCompanionAssetNames
219+
)
220+
if (
221+
$actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or
222+
$assetContractDiff.Count -ne 0
223+
) {
224+
throw "Promoted OpenClawCompanion asset names do not exactly match the current contract."
225+
}
226+
227+
foreach ($asset in $expectedAssets) {
228+
gh release download $env:RELEASE_TAG `
229+
--repo $env:GITHUB_REPOSITORY `
230+
--pattern $asset.Name `
231+
--dir verified
232+
if ($LASTEXITCODE -ne 0) {
233+
throw "Failed to download promoted Windows release asset $($asset.Name)."
234+
}
235+
}
236+
237+
$manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt"
238+
$manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object {
239+
if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') {
240+
throw "Invalid Windows SHA-256 manifest entry: $_"
241+
}
242+
[PSCustomObject]@{
243+
Hash = $Matches[1]
244+
Name = $Matches[2]
245+
}
246+
})
247+
$expectedInstallerNames = @(
248+
$expectedAssets |
249+
Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" |
250+
ForEach-Object Name
251+
)
252+
$manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object)
253+
$contractDiff = @(
254+
Compare-Object `
255+
-ReferenceObject $expectedInstallerNames `
256+
-DifferenceObject $manifestInstallerNames
257+
)
258+
if ($contractDiff.Count -ne 0) {
259+
throw "Promoted Windows SHA-256 manifest does not match the installer asset contract."
260+
}
261+
262+
foreach ($entry in $manifestEntries) {
263+
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash
264+
if ($hash -ne $entry.Hash) {
265+
throw "Promoted Windows release asset checksum mismatch: $($entry.Name)"
266+
}
267+
}
110268
111269
- name: Summary
112270
shell: pwsh
@@ -119,8 +277,9 @@ jobs:
119277
120278
OpenClaw release: $env:RELEASE_TAG
121279
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
122-
123-
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
124-
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
125-
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
126280
"@ >> $env:GITHUB_STEP_SUMMARY
281+
Get-ChildItem -LiteralPath dist -File |
282+
Sort-Object Name |
283+
ForEach-Object {
284+
"- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)"
285+
} >> $env:GITHUB_STEP_SUMMARY

docs/ci.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,19 @@ from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the
200200
OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`,
201201
dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches
202202
`Plugin ClawHub Release` for the same release SHA, and only then dispatches
203-
`OpenClaw NPM Release` with the saved `preflight_run_id`.
203+
`OpenClaw NPM Release` with the saved `preflight_run_id`. Stable publish also
204+
requires an exact `windows_node_tag`; the workflow verifies the Windows source
205+
release and compares its x64/ARM64 installers with the candidate-approved
206+
`windows_node_installer_digests` input before any publish child, then promotes
207+
and verifies those same pinned installer digests plus the exact companion asset
208+
and checksum contract before publishing the GitHub release draft.
204209

205210
```bash
206211
gh workflow run openclaw-release-publish.yml \
207212
--ref release/YYYY.M.PATCH \
208213
-f tag=vYYYY.M.PATCH-beta.N \
209214
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
215+
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
210216
-f npm_dist_tag=beta
211217
```
212218

0 commit comments

Comments
 (0)