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 <steipete@gmail.com>
This commit is contained in:
Jason (Json) 2026-06-13 20:33:33 -06:00 committed by GitHub
parent 4001be54e4
commit 8ae1adfdcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1046 additions and 98 deletions

View file

@ -150,9 +150,21 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- Stable Windows Hub release closeout requires the signed
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
workflow after the matching `openclaw/openclaw-windows-node` release exists;
it verifies Authenticode signatures on Windows before uploading assets.
`openclaw/openclaw` GitHub Release. Pass the exact signed
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
`OpenClaw Release Publish`, together with the candidate-approved
`windows_node_installer_digests` map; it prevalidates the published source
release and required installers against that map before any publish child,
dispatches the public `Windows Node Release` workflow while the OpenClaw
release is still a draft, carries those pinned source asset digests
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
Windows, re-downloads and checksum-verifies the promoted asset contract, and
blocks publication until the canonical asset contract is present. Use direct
`Windows Node Release` dispatch only for recovery, always with an exact tag,
never `latest`, and the explicit `expected_installer_digests` JSON map from
the approved source release. Recovery rejects unexpected
`OpenClawCompanion-*` target asset names, then replaces the expected contract
assets with the pinned source bytes.
- Website Windows Hub download links should target exact canonical
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
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>
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
`preflight_run_id` plus the successful `full_release_validation_run_id`.
For stable publish, also pass the exact non-prerelease
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
candidate-approved installer digest map as `windows_node_installer_digests`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
promote and verify the required Windows Hub assets for stable releases,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View file

@ -15,6 +15,14 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
required: false
type: string
windows_node_tag:
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
required: false
type: string
windows_node_installer_digests:
description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests
required: false
type: string
npm_telegram_run_id:
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
required: false
@ -81,12 +89,15 @@ jobs:
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@ -115,6 +126,22 @@ jobs:
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
exit 1
fi
stable_release=true
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
stable_release=false
fi
if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then
echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then
echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2
exit 1
fi
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
@ -143,6 +170,73 @@ jobs:
;;
esac
- name: Validate stable Windows source release
id: windows_source
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
run: |
set -euo pipefail
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
exit 0
fi
source_json="$(gh release view "${WINDOWS_NODE_TAG}" \
--repo openclaw/openclaw-windows-node \
--json tagName,isDraft,isPrerelease,assets,url)"
if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then
echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then
echo "Stable OpenClaw publish requires a published Windows source release." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then
echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2
exit 1
fi
required_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)"
if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" '
if type == "object" and
(keys | sort) == ($names | sort) and
all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$"))
then .
else error("invalid candidate-approved Windows installer digest map")
end
')"; then
echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2
exit 1
fi
for asset_name in "${required_assets[@]}"; do
asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')"
asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')"
if [[ "${asset_match_count}" != "1" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2
exit 1
fi
asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')"
if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2
exit 1
fi
approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')"
if [[ "${asset_digest}" != "${approved_digest}" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2
exit 1
fi
done
echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT"
echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
@ -337,6 +431,7 @@ jobs:
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
run: |
{
echo "### Release target"
@ -347,13 +442,16 @@ jobs:
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then
echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120
environment: npm-release
steps:
- name: Checkout release SHA
@ -383,10 +481,16 @@ jobs:
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
is_stable_release() {
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
}
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
@ -836,10 +940,105 @@ jobs:
}
publish_github_release() {
if is_stable_release; then
verify_windows_release_asset_contract
fi
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
verify_windows_release_asset_contract() {
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
# Add future promoted installer names, such as MSIX x64/ARM64, here.
local -a installer_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
local -a required_assets=(
"${installer_assets[@]}"
"OpenClawCompanion-SHA256SUMS.txt"
)
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)"
expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
actual_companion_assets="$(printf '%s' "${release_json}" | jq -c '
[.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort
')"
if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then
echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2
return 1
fi
for asset_name in "${required_assets[@]}"; do
if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
echo "Stable release is missing required Windows asset ${asset_name}." >&2
return 1
fi
done
manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract"
manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt"
rm -rf "${manifest_dir}"
mkdir -p "${manifest_dir}"
gh release download "${RELEASE_TAG}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "OpenClawCompanion-SHA256SUMS.txt" \
--dir "${manifest_dir}"
if ! manifest_json="$(jq -Rsc '
split("\n") as $lines |
(if $lines[-1] == "" then $lines[0:-1] else $lines end) |
map(sub("\r$"; "")) |
if all(.[]; test("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
else error("malformed Windows checksum manifest entry")
end
' "${manifest_path}")"; then
echo "Stable release Windows checksum manifest contains malformed entries." >&2
return 1
fi
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
length == ($expected | length) and
([.[].name] | sort) == $expected and
([.[].name] | unique | length) == length
' >/dev/null; then
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
return 1
fi
for asset_name in "${installer_assets[@]}"; do
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
return 1
fi
expected_hash="${expected_digest#sha256:}"
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
any(.[]; .name == $name and .hash == $hash)
' >/dev/null; then
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
return 1
fi
done
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
}
promote_windows_release_assets() {
if ! is_stable_release; then
return 0
fi
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
echo "Stable release is missing prevalidated Windows installer digests." >&2
return 1
fi
windows_node_run_id="$(dispatch_workflow windows-node-release.yml \
-f tag="${RELEASE_TAG}" \
-f windows_node_tag="${WINDOWS_NODE_TAG}" \
-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")"
echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
wait_for_run windows-node-release.yml "${windows_node_run_id}"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
@ -913,7 +1112,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@ -931,6 +1130,10 @@ jobs:
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
windows_line=""
if [[ -n "${windows_node_run_id// }" ]]; then
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
@ -948,6 +1151,7 @@ jobs:
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
WINDOWS_LINE="${windows_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@ -974,6 +1178,7 @@ jobs:
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
@ -998,6 +1203,9 @@ jobs:
else
echo "- OpenClaw npm publish: skipped by input"
fi
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- Windows Hub promotion: required before the GitHub release can be published"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
@ -1142,6 +1350,7 @@ jobs:
failed=0
openclaw_failed=0
windows_node_run_id=""
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
@ -1172,6 +1381,9 @@ jobs:
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if ! promote_windows_release_assets; then
failed=1
fi
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release

View file

@ -8,9 +8,12 @@ on:
required: true
type: string
windows_node_tag:
description: openclaw-windows-node release tag to promote, or latest
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
required: true
type: string
expected_installer_digests:
description: Compact JSON map of installer asset names to pinned source sha256 digests
required: true
default: latest
type: string
permissions:
@ -31,46 +34,129 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
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:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
$stableRelease = -not (
$env:RELEASE_TAG.Contains("-alpha.") -or
$env:RELEASE_TAG.Contains("-beta.")
)
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: $_"
}
# Add future signed installer names, such as MSIX x64/ARM64, here.
$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-Fa-f0-9]{64}$') {
throw "expected_installer_digests is missing a valid pinned digest for $name."
}
}
$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)"
}
$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 ', ')"
}
$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"
}
}
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
- name: Download Windows Hub release installers
shell: pwsh
env:
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
$tagArgs = @()
if ($env:WINDOWS_NODE_TAG -ne "latest") {
$tagArgs += $env:WINDOWS_NODE_TAG
}
gh release download @tagArgs `
--repo openclaw/openclaw-windows-node `
--pattern "OpenClawCompanion-Setup-*.exe" `
--dir dist
$expected = @(
"dist/OpenClawCompanion-Setup-x64.exe",
"dist/OpenClawCompanion-Setup-arm64.exe"
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
# Every matched installer is signature-checked, checksummed, and promoted.
$installerPatterns = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
foreach ($file in $expected) {
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing expected Windows installer: $file"
$downloadArgs = @(
$env:WINDOWS_NODE_TAG,
"--repo", "openclaw/openclaw-windows-node",
"--dir", "dist"
)
foreach ($pattern in $installerPatterns) {
$downloadArgs += @("--pattern", $pattern)
}
gh release download @downloadArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
}
foreach ($pattern in $installerPatterns) {
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
if ($patternMatches.Count -ne 1) {
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
}
}
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
if ($actualHash -ne $expectedHash) {
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
}
}
- name: Verify Authenticode signatures
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
$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)."
@ -78,6 +164,9 @@ jobs:
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)."
}
[pscustomobject]@{
File = $_.Name
Signer = $signature.SignerCertificate.Subject
@ -88,7 +177,7 @@ jobs:
- name: Write SHA-256 manifest
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
@ -101,12 +190,81 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
gh release upload $env:RELEASE_TAG `
dist/OpenClawCompanion-Setup-x64.exe `
dist/OpenClawCompanion-Setup-arm64.exe `
dist/OpenClawCompanion-SHA256SUMS.txt `
--repo $env:GITHUB_REPOSITORY `
--clobber
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
}
- name: Verify promoted release asset contract
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
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)
$contractDiff = @(
Compare-Object `
-ReferenceObject $expectedInstallerNames `
-DifferenceObject $manifestInstallerNames
)
if ($contractDiff.Count -ne 0) {
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
@ -119,8 +277,9 @@ jobs:
OpenClaw release: $env:RELEASE_TAG
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
"@ >> $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

View file

@ -200,13 +200,19 @@ from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the
OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`,
dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches
`Plugin ClawHub Release` for the same release SHA, and only then dispatches
`OpenClaw NPM Release` with the saved `preflight_run_id`.
`OpenClaw NPM Release` with the saved `preflight_run_id`. Stable publish also
requires an exact `windows_node_tag`; the workflow verifies the Windows source
release and compares its x64/ARM64 installers with the candidate-approved
`windows_node_installer_digests` input before any publish child, then promotes
and verifies those same pinned installer digests plus the exact companion asset
and checksum contract before publishing the GitHub release draft.
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```

View file

@ -99,10 +99,14 @@ the maintainer-only release runbook.
file, lane, workflow job, package profile, provider, or model allowlist that
proves the fix. Rerun the full umbrella only when the changed surface makes
prior evidence stale.
9. For beta, tag `vYYYY.M.PATCH-beta.N`, then run `pnpm release:candidate -- --tag
vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helper runs
the local generated-release checks, dispatches or verifies the full release
validation and npm preflight evidence, runs Parallels and Telegram package
9. For a tagged beta candidate, run
`pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching
`release/YYYY.M.PATCH` branch. For stable, pass the required Windows source
release too:
`pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`.
The helper runs the local generated-release checks, dispatches or verifies
the full release validation and npm preflight evidence, runs Parallels
fresh/update proof against the exact prepared tarball plus Telegram package
proof, records plugin npm and ClawHub plans, and prints the exact
`OpenClaw Release Publish` command only after the evidence bundle is green.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin
@ -142,9 +146,12 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
direct push, it opens or updates an appcast PR. Stable Windows Hub
readiness requires the signed `OpenClawCompanion-Setup-x64.exe`,
`OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the OpenClaw GitHub release;
promote them with the `Windows Node Release` workflow after the matching
`openclaw/openclaw-windows-node` release has passed its signing workflow.
`OpenClawCompanion-SHA256SUMS.txt` assets on the OpenClaw GitHub release.
Pass the exact signed `openclaw/openclaw-windows-node` release tag as
`windows_node_tag` and its candidate-approved installer digest map as
`windows_node_installer_digests`; `OpenClaw Release Publish` keeps the
release draft, dispatches `Windows Node Release`, and verifies all three
assets before publication.
11. After publish, run the npm post-publish verifier, optional standalone
published-npm Telegram E2E when you need post-publish channel proof,
dist-tag promotion when needed, verify the generated GitHub release page,
@ -253,21 +260,36 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
to the GitHub release as `openclaw-<version>-dependency-evidence.zip`.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the
tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a
main-reachable tag), pass the release tag and successful OpenClaw npm
`preflight_run_id`, and keep the default plugin publish scope
`all-publishable` unless you are deliberately running a focused repair. The
workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw
npm publish so the core package is not published before its externalized
plugins.
- Run the manual `Windows Node Release` workflow for stable releases after the
matching `openclaw/openclaw-windows-node` release exists. It downloads the
signed Windows Hub installers from the companion repo, verifies their
Authenticode signatures on a Windows runner, writes a SHA-256 manifest, and
uploads the installers plus manifest onto the canonical OpenClaw GitHub
release. Website download links should target exact OpenClaw release asset
URLs for the current stable release, or `releases/latest/download/...` only
after verifying GitHub's latest redirect points at that same release; do not
link only to the companion repo release page.
main-reachable tag), pass the release tag, successful OpenClaw npm
`preflight_run_id`, and successful `full_release_validation_run_id`, and keep
the default plugin publish scope `all-publishable` unless you are deliberately
running a focused repair. The workflow serializes plugin npm publish, plugin
ClawHub publish, and OpenClaw npm publish so the core package is not published
before its externalized plugins.
- Stable `OpenClaw Release Publish` requires an exact `windows_node_tag` after
the matching non-prerelease `openclaw/openclaw-windows-node` release exists.
It also requires the candidate-approved `windows_node_installer_digests` map.
Before dispatching any publish child, it verifies that source release is
published, non-prerelease, contains the required x64/ARM64 installers, and
still matches that approved map. It then dispatches `Windows Node Release`
while the OpenClaw release is still a draft, carrying the pinned installer
digest map unchanged. The child
workflow downloads the signed Windows Hub installers from that exact tag,
matches them against the pinned digests, verifies their Authenticode
signatures use the expected OpenClaw Foundation signer on a Windows runner,
writes a SHA-256 manifest, and uploads the installers plus manifest onto the
canonical OpenClaw GitHub release, then re-downloads the promoted assets and
verifies the manifest membership and hashes. The parent verifies the current
x64, ARM64, and checksum asset contract before publication. Direct recovery
rejects unexpected `OpenClawCompanion-*` asset names before replacing the
expected contract assets with the pinned source bytes. Manually dispatch
`Windows Node Release` only for recovery, and always pass an exact tag, never
`latest`, plus the explicit `expected_installer_digests` JSON map from the
approved source release. Website download links should target exact OpenClaw
release asset URLs for the current stable release, or
`releases/latest/download/...` only after verifying GitHub's latest redirect
points at that same release; do not link only to the companion repo release
page.
- Release checks now run in a separate manual workflow:
`OpenClaw Release Checks`
- `OpenClaw Release Checks` also runs the QA Lab mock parity lane plus the fast
@ -697,7 +719,12 @@ orchestrates the trusted-publisher workflows in the order the release needs:
`ref=<release-sha>`.
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA.
6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and
saved `preflight_run_id`.
saved `preflight_run_id` after verifying the saved
`full_release_validation_run_id`.
7. For stable releases, create or update the GitHub release as a draft, dispatch
`Windows Node Release` with the explicit `windows_node_tag` and
candidate-approved `windows_node_installer_digests`, and verify the canonical
installer/checksum assets before publishing the draft.
Beta publish example:
@ -706,6 +733,7 @@ gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```
@ -715,7 +743,10 @@ Stable publish to the default beta dist-tag:
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```
@ -725,7 +756,10 @@ Stable promotion directly to `latest` is explicit:
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=latest
```
@ -755,6 +789,13 @@ package cannot ship without every publishable official plugin, including
- `tag`: required release tag; must already exist
- `preflight_run_id`: successful `OpenClaw NPM Release` preflight run id;
required when `publish_openclaw_npm=true`
- `full_release_validation_run_id`: successful `Full Release Validation` run
id; required when `publish_openclaw_npm=true`
- `windows_node_tag`: exact non-prerelease `openclaw/openclaw-windows-node`
release tag; required for stable OpenClaw publish
- `windows_node_installer_digests`: candidate-approved compact JSON map of the
current Windows installer names to their pinned `sha256:` digests; required
for stable OpenClaw publish
- `npm_dist_tag`: npm target tag for the OpenClaw package
- `plugin_publish_scope`: defaults to `all-publishable`; use `selected` only
for focused plugin-only repair work with `publish_openclaw_npm=false`
@ -800,14 +841,21 @@ When cutting a stable npm release:
Matrix, and Telegram coverage from one manual workflow
4. If you intentionally only need the deterministic normal test graph, run the
manual `CI` workflow on the release ref instead
5. Save the successful `preflight_run_id`
6. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`,
and the saved `preflight_run_id`; it publishes externalized plugins to npm
and ClawHub before promoting the OpenClaw npm package
7. If the release landed on `beta`, use the
5. Select the exact non-prerelease `openclaw/openclaw-windows-node` release tag
whose signed x64 and ARM64 installers should ship. Save it as
`windows_node_tag`, and save their validated digest map as
`windows_node_installer_digests`. The release-candidate helper records both
and includes them in its generated publish command.
6. Save the successful `preflight_run_id` and `full_release_validation_run_id`
7. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`,
the selected `windows_node_tag`, its saved `windows_node_installer_digests`,
the saved `preflight_run_id`, and the saved `full_release_validation_run_id`;
it publishes externalized plugins to npm and ClawHub before promoting the
OpenClaw npm package
8. If the release landed on `beta`, use the
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`
8. If the release intentionally published directly to `latest` and `beta`
9. If the release intentionally published directly to `latest` and `beta`
should follow the same stable build immediately, use that same release
workflow to point both dist-tags at the stable version, or let its scheduled
self-healing sync move `beta` later

View file

@ -1,8 +1,8 @@
#!/usr/bin/env -S pnpm tsx
// Npm Update Smoke script supports OpenClaw repository automation.
import { spawn } from "node:child_process";
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { copyFile, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
@ -11,6 +11,8 @@ import {
extractLastOpenClawVersionFromLog,
makeTempDir,
packOpenClaw,
packageBuildCommitFromTgz,
packageVersionFromTgz,
parsePlatformList,
parseProvider,
readPositiveIntEnv,
@ -43,6 +45,7 @@ interface NpmUpdateOptions {
freshTargetSpec?: string;
hostIp?: string;
packageSpec: string;
targetTarball?: string;
updateTarget: string;
platforms: Set<Platform>;
provider: Provider;
@ -288,6 +291,7 @@ Options:
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
--update-target <target> Target passed to guest 'openclaw update --tag'.
Default: host-served tgz packed from current checkout.
--target-tarball <path> Host-serve this prepared tgz for update and fresh install.
--fresh-target <npm-spec> Also run fresh install smoke for this package after update lanes.
--beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update
plus fresh target install. Default target when flag is bare: beta.
@ -313,6 +317,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
json: false,
modelId: undefined,
packageSpec: "",
targetTarball: undefined,
platforms: parsePlatformList("all"),
provider: "openai",
updateTarget: "",
@ -330,6 +335,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
options.updateTarget = ensureValue(args, i, arg);
i++;
break;
case "--target-tarball":
options.targetTarball = ensureValue(args, i, arg);
i++;
break;
case "--fresh-target":
options.freshTargetSpec = ensureValue(args, i, arg);
i++;
@ -377,6 +386,14 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
die(`unknown arg: ${arg}`);
}
}
if (
options.targetTarball &&
(options.betaValidation || options.updateTarget || options.freshTargetSpec)
) {
throw new Error(
"--target-tarball cannot be combined with --beta-validation, --update-target, or --fresh-target",
);
}
return options;
}
@ -435,6 +452,9 @@ export class NpmUpdateSmoke {
private updateExpectedNeedle = "";
private updateTargetPackageVersion = "";
private updateTargetTarball = "";
private targetTarballPath = "";
private targetTarballBuildCommit = "";
private targetTarballVersion = "";
private linuxVm = linuxVmDefault;
private freshStatus = platformRecord("skip");
@ -481,7 +501,7 @@ export class NpmUpdateSmoke {
}).stdout.trim();
this.harnessCheckoutVersion = readHarnessCheckoutVersion();
this.hostIp = resolveHostIp(this.options.hostIp ?? "");
this.configurePublishedTargets();
await this.configureTargets();
this.assertPublishedTargetMatchesHarnessCheckout();
if (this.options.platforms.has("linux")) {
@ -634,6 +654,31 @@ export class NpmUpdateSmoke {
}
private async prepareUpdateTarget(): Promise<void> {
if (this.targetTarballPath) {
const hostedTarballPath = path.join(this.tgzDir, path.basename(this.targetTarballPath));
await copyFile(this.targetTarballPath, hostedTarballPath);
this.artifact = {
buildCommit: this.targetTarballBuildCommit,
buildCommitShort: this.targetTarballBuildCommit.slice(0, 7),
path: hostedTarballPath,
version: this.targetTarballVersion,
};
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: "prepared candidate tgz",
port: 0,
});
const targetUrl = this.server.urlFor(this.artifact.path);
this.updateTargetEffective = targetUrl;
this.freshTargetSpec = targetUrl;
this.updateExpectedNeedle = this.targetTarballVersion;
this.updateTargetPackageVersion = this.targetTarballVersion;
this.updateTargetBuildCommit = this.artifact.buildCommitShort ?? "";
this.updateTargetTarball = targetUrl;
return;
}
if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
this.artifact = await packOpenClaw({
destination: this.tgzDir,
@ -1187,7 +1232,24 @@ export class NpmUpdateSmoke {
});
}
private configurePublishedTargets(): void {
private async configureTargets(): Promise<void> {
if (this.options.targetTarball) {
const targetTarballPath = path.resolve(this.options.targetTarball);
if (!existsSync(targetTarballPath)) {
throw new Error(`target tarball does not exist: ${targetTarballPath}`);
}
this.targetTarballPath = targetTarballPath;
[this.targetTarballVersion, this.targetTarballBuildCommit] = await Promise.all([
packageVersionFromTgz(targetTarballPath),
packageBuildCommitFromTgz(targetTarballPath),
]);
if (!this.targetTarballVersion || !this.targetTarballBuildCommit) {
throw new Error(
`target tarball is missing package or build metadata: ${targetTarballPath}`,
);
}
return;
}
if (this.options.betaValidation) {
const version = resolveOpenClawRegistryVersion(this.options.betaValidation);
if (!version) {
@ -1217,9 +1279,11 @@ export class NpmUpdateSmoke {
if (process.env.OPENCLAW_PARALLELS_ALLOW_HARNESS_TARGET_MISMATCH === "1") {
return;
}
const candidateVersion = this.freshTargetSpec
? parseOpenClawPackageSpecVersion(this.freshTargetSpec)
: parseOpenClawPackageSpecVersion(this.options.updateTarget);
const candidateVersion =
this.targetTarballVersion ||
(this.freshTargetSpec
? parseOpenClawPackageSpecVersion(this.freshTargetSpec)
: parseOpenClawPackageSpecVersion(this.options.updateTarget));
const targetFamily = openClawVersionFamily(candidateVersion);
if (!targetFamily) {
return;

View file

@ -10,11 +10,17 @@ import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs";
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_PROVIDER = "openai";
const DEFAULT_MODE = "both";
const DEFAULT_RELEASE_PROFILE = "beta";
const DEFAULT_NPM_DIST_TAG = "beta";
const DEFAULT_PLUGIN_SCOPE = "all-publishable";
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
const WINDOWS_NODE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u;
const WINDOWS_NODE_REPO = "openclaw/openclaw-windows-node";
const WINDOWS_NODE_REQUIRED_ASSETS = [
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe",
];
const SHA256_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
function usage() {
return `Usage: pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N [options]
@ -29,14 +35,15 @@ Options:
--repo <owner/repo> GitHub repo. Default: ${DEFAULT_REPO}
--full-release-run <id> Reuse successful Full Release Validation run.
--npm-preflight-run <id> Reuse successful OpenClaw NPM Release preflight run.
--windows-node-tag <tag> Exact Windows Node release tag. Required for stable.
--skip-dispatch Require both run ids; do not dispatch workflows.
--skip-local-generated-check Do not run local generated release baseline checks before dispatch.
--skip-parallels Do not run local Parallels fresh/update beta smoke.
--skip-parallels Do not run local Parallels fresh/update candidate smoke.
--skip-telegram Do not run NPM Telegram E2E against the prepared tarball.
--telegram-provider-mode <mode> mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE}
--provider <provider> Full validation provider. Default: ${DEFAULT_PROVIDER}
--mode <fresh|upgrade|both> Full validation cross-OS mode. Default: ${DEFAULT_MODE}
--release-profile <beta|stable|full> Default: ${DEFAULT_RELEASE_PROFILE}
--release-profile <beta|stable|full> Default: beta for prereleases; stable otherwise.
--npm-dist-tag <alpha|beta|latest> Default: ${DEFAULT_NPM_DIST_TAG}
--plugin-publish-scope <scope> selected|all-publishable. Default: ${DEFAULT_PLUGIN_SCOPE}
--plugins <names> Required when plugin scope is selected.
@ -61,7 +68,7 @@ export function parseArgs(argv) {
repo: DEFAULT_REPO,
provider: DEFAULT_PROVIDER,
mode: DEFAULT_MODE,
releaseProfile: DEFAULT_RELEASE_PROFILE,
releaseProfile: "",
npmDistTag: DEFAULT_NPM_DIST_TAG,
pluginPublishScope: DEFAULT_PLUGIN_SCOPE,
plugins: "",
@ -74,6 +81,8 @@ export function parseArgs(argv) {
workflowRef: "",
fullReleaseRunId: "",
npmPreflightRunId: "",
windowsNodeTag: "",
windowsNodeInstallerDigests: "",
outputDir: "",
};
parseArgv: for (let index = 0; index < args.length; index += 1) {
@ -96,6 +105,9 @@ export function parseArgs(argv) {
case "--npm-preflight-run":
options.npmPreflightRunId = requireValue(args, ++index, arg);
break;
case "--windows-node-tag":
options.windowsNodeTag = requireValue(args, ++index, arg);
break;
case "--skip-dispatch":
options.skipDispatch = true;
break;
@ -143,6 +155,11 @@ export function parseArgs(argv) {
if (!options.tag) {
throw new Error("--tag is required");
}
options.releaseProfile ||=
options.tag.includes("-alpha.") || options.tag.includes("-beta.") ? "beta" : "stable";
if (!["beta", "stable", "full"].includes(options.releaseProfile)) {
throw new Error("--release-profile must be beta, stable, or full");
}
if (options.skipDispatch && (!options.fullReleaseRunId || !options.npmPreflightRunId)) {
throw new Error("--skip-dispatch requires --full-release-run and --npm-preflight-run");
}
@ -157,6 +174,16 @@ export function parseArgs(argv) {
if (options.pluginPublishScope === "all-publishable" && options.plugins.trim()) {
throw new Error("--plugins is only valid with --plugin-publish-scope selected");
}
if (options.windowsNodeTag && !WINDOWS_NODE_TAG_PATTERN.test(options.windowsNodeTag)) {
throw new Error("--windows-node-tag must be an explicit version tag, not latest");
}
if (
!options.tag.includes("-alpha.") &&
!options.tag.includes("-beta.") &&
!options.windowsNodeTag
) {
throw new Error("stable release candidates require --windows-node-tag");
}
if (!["mock-openai", "live-frontier"].includes(options.telegramProviderMode)) {
throw new Error("--telegram-provider-mode must be mock-openai or live-frontier");
}
@ -234,6 +261,46 @@ export async function githubApi(path, options = {}) {
return response.json();
}
/**
* Validates the immutable Windows source release contract for a stable candidate.
*/
export async function validateWindowsSourceRelease(tag, options = {}) {
const release = await githubApi(
`repos/${WINDOWS_NODE_REPO}/releases/tags/${encodeURIComponent(tag)}`,
options,
);
if (release.tag_name !== tag) {
throw new Error(
`Windows source release tag mismatch: expected ${tag}, got ${release.tag_name}`,
);
}
if (release.draft) {
throw new Error(`Windows source release ${tag} must be published`);
}
if (release.prerelease) {
throw new Error(`Windows source release ${tag} must not be a prerelease`);
}
const assets = WINDOWS_NODE_REQUIRED_ASSETS.map((name) => {
const matches = (release.assets ?? []).filter((entry) => entry.name === name);
if (matches.length !== 1) {
throw new Error(
`Windows source release ${tag} must contain exactly one required asset ${name}; found ${matches.length}`,
);
}
const [asset] = matches;
if (!SHA256_DIGEST_PATTERN.test(asset.digest ?? "")) {
throw new Error(`Windows source release ${tag} asset ${name} is missing its SHA-256 digest`);
}
return { name, digest: asset.digest };
});
return {
tag,
url: release.html_url,
assets,
};
}
function currentBranch() {
return run("git", ["branch", "--show-current"], { capture: true }).trim();
}
@ -529,6 +596,12 @@ export function buildPublishCommand(options) {
if (options.npmTelegramRunId) {
fields.push(["npm_telegram_run_id", options.npmTelegramRunId]);
}
if (options.windowsNodeTag) {
fields.push(["windows_node_tag", options.windowsNodeTag]);
}
if (options.windowsNodeInstallerDigests) {
fields.push(["windows_node_installer_digests", options.windowsNodeInstallerDigests]);
}
if (options.plugins.trim()) {
fields.push(["plugins", options.plugins]);
}
@ -587,23 +660,34 @@ function validateFullManifest(manifest, params) {
}
}
async function runParallelsIfNeeded(options) {
export function candidateParallelsArgs(tarballPath) {
return ["test:parallels:npm-update", "--", "--target-tarball", tarballPath, "--json"];
}
export function candidateParallelsShellCommand(tarballPath, timeoutBin) {
return [
'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;',
"exec",
shellQuote(timeoutBin),
"--foreground",
"150m",
"pnpm",
...candidateParallelsArgs(tarballPath).map(shellQuote),
].join(" ");
}
async function runParallelsIfNeeded(options, tarballPath) {
if (options.skipParallels) {
return { status: "skipped", reason: "operator skipped --skip-parallels" };
}
const version = options.tag.replace(/^v/u, "");
run("pnpm", [
"release:beta-smoke",
"--",
"--beta",
version,
"--ref",
options.workflowRef,
"--skip-telegram",
]);
const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], {
capture: true,
}).trim();
const command = candidateParallelsShellCommand(tarballPath, timeoutBin);
run("bash", ["-lc", command]);
return {
status: "passed",
command: `pnpm release:beta-smoke -- --beta ${version} --ref ${options.workflowRef} --skip-telegram`,
command,
};
}
@ -642,6 +726,16 @@ async function main() {
options.workflowRef ||= currentBranch();
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`);
const windowsNodeSourceRelease = options.windowsNodeTag
? await validateWindowsSourceRelease(options.windowsNodeTag)
: undefined;
options.windowsNodeInstallerDigests = windowsNodeSourceRelease
? JSON.stringify(
Object.fromEntries(
windowsNodeSourceRelease.assets.map((asset) => [asset.name, asset.digest]),
),
)
: "";
const localGeneratedCheck = runLocalGeneratedCheckIfNeeded(options);
if (!options.fullReleaseRunId && !options.skipDispatch) {
@ -729,7 +823,7 @@ async function main() {
);
}
const parallels = await runParallelsIfNeeded(options);
const parallels = await runParallelsIfNeeded(options, tarballPath);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
options.npmTelegramRunId = npmTelegram.runId ?? "";
const pluginNpmPlan = await collectPluginPlanWithRetry(
@ -749,6 +843,8 @@ async function main() {
npmDistTag: options.npmDistTag,
fullReleaseValidationRunId: options.fullReleaseRunId,
npmPreflightRunId: options.npmPreflightRunId,
windowsNodeTag: options.windowsNodeTag || undefined,
windowsNodeSourceRelease,
fullReleaseValidationUrl: fullRun.url,
npmPreflightUrl: npmRun.url,
artifacts: {
@ -779,6 +875,14 @@ async function main() {
`- target SHA: ${targetSha}`,
`- full release validation: ${options.fullReleaseRunId} ${fullRun.url}`,
`- npm preflight: ${options.npmPreflightRunId} ${npmRun.url}`,
...(windowsNodeSourceRelease
? [
`- Windows Node source release: ${windowsNodeSourceRelease.tag} ${windowsNodeSourceRelease.url}`,
...windowsNodeSourceRelease.assets.map(
(asset) => `- Windows Node source asset: ${asset.name} ${asset.digest}`,
),
]
: []),
`- npm preflight artifact: ${npmArtifactName}`,
`- full release artifact: ${fullArtifactName}`,
`- local generated release checks: ${localGeneratedCheck.status}${

View file

@ -11,6 +11,7 @@ const SETUP_PNPM_STORE_CACHE_ACTION = ".github/actions/setup-pnpm-store-cache/ac
const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml";
const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml";
const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml";
const QA_LIVE_TRANSPORTS_WORKFLOW = ".github/workflows/qa-live-transports-convex.yml";
const UPDATE_MIGRATION_WORKFLOW = ".github/workflows/update-migration.yml";
@ -1503,7 +1504,7 @@ describe("package artifact reuse", () => {
const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
expect(workflow).toContain("timeout-minutes: 60");
expect(workflow).toContain("timeout-minutes: 120");
expect(workflow).toContain("environment: npm-release");
expect(workflow).toContain("Download OpenClaw npm preflight manifest");
expect(workflow).toContain("Validate OpenClaw npm preflight manifest");
@ -1548,6 +1549,143 @@ describe("package artifact reuse", () => {
expect(workflow).not.toContain("timeout-minutes: 360");
});
it("gates stable GitHub publication on the Windows Hub release asset contract", () => {
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8");
const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8");
const releaseSkill = readFileSync(
".agents/skills/release-openclaw-maintainer/SKILL.md",
"utf8",
);
expect(releaseWorkflow).toContain(
"Stable OpenClaw publish requires an explicit windows_node_tag.",
);
expect(releaseWorkflow).toContain(
"Stable OpenClaw publish requires candidate-approved windows_node_installer_digests.",
);
expect(releaseWorkflow).toContain("promote_windows_release_assets()");
expect(releaseWorkflow).toContain("dispatch_workflow windows-node-release.yml");
expect(releaseWorkflow).toContain("verify_windows_release_asset_contract");
expect(releaseWorkflow).toContain("Validate stable Windows source release");
expect(releaseWorkflow).toContain("id: windows_source");
expect(releaseWorkflow).toContain(
"windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}",
);
expect(releaseWorkflow).toContain(
"APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}",
);
expect(releaseWorkflow).toContain("no longer matches its candidate-approved digest");
expect(releaseWorkflow).toContain(
"WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}",
);
expect(releaseWorkflow).toContain(
'-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}"',
);
expect(releaseWorkflow).toContain("missing prevalidated Windows installer digests");
expect(releaseWorkflow).toContain("does not match its pinned digest");
expect(releaseWorkflow).toContain(
"Stable release OpenClawCompanion asset names do not exactly match the current contract",
);
expect(releaseWorkflow).toContain('select(.name | startswith("OpenClawCompanion-"))');
expect(releaseWorkflow).toContain(
"Windows checksum manifest does not exactly match the installer asset contract",
);
expect(releaseWorkflow).toContain("Windows checksum manifest contains malformed entries");
expect(releaseWorkflow).toContain("([.[].name] | unique | length) == length");
expect(releaseWorkflow).toContain("Windows checksum manifest does not match pinned digest");
expect(releaseWorkflow).toContain(
"Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset",
);
expect(releaseWorkflow.indexOf("Validate stable Windows source release")).toBeLessThan(
releaseWorkflow.indexOf("\n publish:\n"),
);
const createDraftCall = releaseWorkflow.lastIndexOf(
"\n create_or_update_github_release\n",
);
const promoteWindowsCall = releaseWorkflow.lastIndexOf(
"\n if ! promote_windows_release_assets; then\n",
);
const publishReleaseCall = releaseWorkflow.lastIndexOf(
"\n publish_github_release\n",
);
expect(createDraftCall).toBeGreaterThan(-1);
expect(promoteWindowsCall).toBeGreaterThan(createDraftCall);
expect(publishReleaseCall).toBeGreaterThan(promoteWindowsCall);
expect(windowsWorkflow).not.toContain("default: latest");
expect(windowsWorkflow).toContain("expected_installer_digests:");
expect(windowsWorkflow).toContain("expected_installer_digests must contain exactly");
expect(windowsWorkflow).toContain("must be an explicit openclaw-windows-node release tag");
expect(windowsWorkflow).toContain("$installerPatterns = @(");
expect(windowsWorkflow).toContain("Every matched installer is signature-checked");
expect(windowsWorkflow).toContain("Get-ChildItem -LiteralPath dist -File");
expect(windowsWorkflow).toContain(
"Downloaded Windows source asset does not match pinned digest",
);
expect(windowsWorkflow).toContain(
"--repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url",
);
expect(windowsWorkflow).toContain(
"Windows source release must contain exactly one required asset",
);
expect(windowsWorkflow).toContain(
"Windows source release asset digest does not match the pinned digest",
);
expect(windowsWorkflow).toContain(
"CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US",
);
expect(windowsWorkflow).toContain("has unexpected signer subject");
expect(windowsWorkflow).toContain("OpenClawCompanion-SHA256SUMS.txt");
expect(windowsWorkflow).toContain("Verify promoted release asset contract");
expect(windowsWorkflow).toContain(
"Promoted OpenClawCompanion asset names do not exactly match the current contract",
);
expect(windowsWorkflow).toContain(
"$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets",
);
expect(windowsWorkflow).toContain("Promoted Windows SHA-256 manifest does not match");
expect(windowsWorkflow).toContain("Promoted Windows release asset checksum mismatch");
expect(releaseDocs).toContain(
"the selected `windows_node_tag`, its saved `windows_node_installer_digests`,",
);
expect(releaseDocs).toContain(
"candidate-approved `windows_node_installer_digests`, and verify the canonical",
);
expect(releaseSkill).toContain(
"candidate-approved installer digest map as `windows_node_installer_digests`.",
);
});
it("rejects malformed Windows checksum manifest lines before parsing entries", () => {
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const validateManifestLinesIndex = releaseWorkflow.indexOf("all(.[]; test(");
const parseManifestLinesIndex = releaseWorkflow.indexOf("map(capture(");
expect(validateManifestLinesIndex).toBeGreaterThan(-1);
expect(parseManifestLinesIndex).toBeGreaterThan(validateManifestLinesIndex);
expect(releaseWorkflow).toContain('else error("malformed Windows checksum manifest entry")');
});
it("rejects unsafe direct Windows recovery before uploading assets", () => {
const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8");
const classifyStableReleaseIndex = windowsWorkflow.indexOf("$stableRelease = -not (");
const rejectPrereleaseSourceIndex = windowsWorkflow.indexOf(
"if ($stableRelease -and $sourceRelease.isPrerelease)",
);
const rejectUnexpectedTargetAssetsIndex = windowsWorkflow.indexOf(
"Target OpenClaw release contains unexpected OpenClawCompanion assets before upload",
);
const uploadAssetsIndex = windowsWorkflow.indexOf("gh release upload $env:RELEASE_TAG");
expect(classifyStableReleaseIndex).toBeGreaterThan(-1);
expect(rejectPrereleaseSourceIndex).toBeGreaterThan(classifyStableReleaseIndex);
expect(windowsWorkflow).not.toContain("-not $targetRelease.isPrerelease");
expect(rejectUnexpectedTargetAssetsIndex).toBeGreaterThan(-1);
expect(uploadAssetsIndex).toBeGreaterThan(rejectUnexpectedTargetAssetsIndex);
});
it("keeps beta release verification and ClawHub publish repair hooks wired", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
scripts?: Record<string, string>;

View file

@ -13,6 +13,7 @@ import {
import {
freshLaneTimeoutMs,
NpmUpdateSmoke,
parseArgs,
spawnLoggedCommand,
} from "../../scripts/e2e/parallels/npm-update-smoke.ts";
import type { HostServer, Platform } from "../../scripts/e2e/parallels/types.ts";
@ -70,6 +71,17 @@ afterEach(() => {
});
describe("parallels npm update smoke", () => {
it("accepts one prepared tarball target for update and fresh install", () => {
expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({
targetTarball: "/tmp/openclaw-candidate.tgz",
updateTarget: "",
freshTargetSpec: undefined,
});
expect(() =>
parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]),
).toThrow("--target-tarball cannot be combined");
});
it("stops the host artifact server when the wrapper fails mid-run", async () => {
let stopCalls = 0;
const server: HostServer = {
@ -120,6 +132,18 @@ describe("parallels npm update smoke", () => {
expect(script).toContain("freshTargetStatus");
});
it("host-serves a prepared candidate tarball for both proof phases", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--target-tarball <path>");
expect(script).toContain('label: "prepared candidate tgz"');
expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)");
expect(script).toContain("dir: this.tgzDir");
expect(script).toContain("this.updateTargetEffective = targetUrl");
expect(script).toContain("this.freshTargetSpec = targetUrl");
expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion");
});
it("guards beta validation against cross-version harness checkouts", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");

View file

@ -2,13 +2,57 @@
import { describe, expect, it, vi } from "vitest";
import {
buildPublishCommand,
candidateParallelsArgs,
candidateParallelsShellCommand,
githubApi,
parseArgs,
parseRunIdFromDispatchOutput,
resolveArtifactName,
validateWindowsSourceRelease,
} from "../../scripts/release-candidate-checklist.mjs";
describe("release candidate checklist", () => {
it("infers validation profiles from candidate tags", () => {
expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).releaseProfile).toBe("beta");
expect(parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "v0.6.3"]).releaseProfile).toBe(
"stable",
);
expect(
parseArgs([
"--tag",
"v2026.5.14",
"--windows-node-tag",
"v0.6.3",
"--release-profile",
"full",
]).releaseProfile,
).toBe("full");
});
it("runs Parallels against the exact prepared candidate tarball", () => {
expect(candidateParallelsArgs(".artifacts/preflight/openclaw.tgz")).toEqual([
"test:parallels:npm-update",
"--",
"--target-tarball",
".artifacts/preflight/openclaw.tgz",
"--json",
]);
expect(
candidateParallelsShellCommand(
".artifacts/preflight/openclaw candidate.tgz",
"/opt/homebrew/bin/gtimeout",
),
).toContain(
"set -a; source \"$HOME/.profile\" >/dev/null 2>&1 || true; set +a; exec '/opt/homebrew/bin/gtimeout' --foreground 150m pnpm",
);
expect(
candidateParallelsShellCommand(
".artifacts/preflight/openclaw candidate.tgz",
"/opt/homebrew/bin/gtimeout",
),
).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'");
});
it("requires run ids when dispatch is disabled", () => {
expect(() => parseArgs(["--tag", "v2026.5.14-beta.3", "--skip-dispatch"])).toThrow(
"--skip-dispatch requires --full-release-run and --npm-preflight-run",
@ -69,6 +113,139 @@ describe("release candidate checklist", () => {
expect(buildPublishCommand(options)).toContain("'preflight_run_id=222'");
expect(buildPublishCommand(options)).toContain("'tag=v2026.5.14-beta.3'");
expect(buildPublishCommand(options)).toContain("'plugin_publish_scope=all-publishable'");
expect(buildPublishCommand(options)).not.toContain("windows_node_tag=");
});
it("requires and carries an exact Windows Node tag for stable release candidates", () => {
expect(() => parseArgs(["--tag", "v2026.5.14"])).toThrow(
"stable release candidates require --windows-node-tag",
);
expect(() => parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "latest"])).toThrow(
"--windows-node-tag must be an explicit version tag, not latest",
);
const options = {
...parseArgs([
"--tag",
"v2026.5.14",
"--windows-node-tag",
"v0.6.3",
"--workflow-ref",
"release/2026.5.14",
]),
workflowRef: "release/2026.5.14",
windowsNodeInstallerDigests: JSON.stringify({
"OpenClawCompanion-Setup-x64.exe": `sha256:${"a".repeat(64)}`,
"OpenClawCompanion-Setup-arm64.exe": `sha256:${"b".repeat(64)}`,
}),
};
expect(buildPublishCommand(options)).toContain("'windows_node_tag=v0.6.3'");
expect(buildPublishCommand(options)).toContain(
`'windows_node_installer_digests={"OpenClawCompanion-Setup-x64.exe":"sha256:${"a".repeat(64)}","OpenClawCompanion-Setup-arm64.exe":"sha256:${"b".repeat(64)}"}'`,
);
});
it("validates the stable Windows source release and immutable installer digests", async () => {
const assets = [
{
name: "OpenClawCompanion-Setup-x64.exe",
digest: `sha256:${"a".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-arm64.exe",
digest: `sha256:${"b".repeat(64)}`,
},
];
const fetchImpl = vi.fn(async () => ({
ok: true,
json: async () => ({
tag_name: "v0.6.3",
draft: false,
prerelease: false,
html_url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
assets,
}),
}));
await expect(
validateWindowsSourceRelease("v0.6.3", {
fetchImpl,
timeoutMs: 1234,
token: "test-token",
}),
).resolves.toEqual({
tag: "v0.6.3",
url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
assets,
});
});
it.each([
[{ draft: true }, "must be published"],
[{ prerelease: true }, "must not be a prerelease"],
[{ tag_name: "v0.6.4" }, "Windows source release tag mismatch: expected v0.6.3, got v0.6.4"],
[
{ assets: [] },
"must contain exactly one required asset OpenClawCompanion-Setup-x64.exe; found 0",
],
[
{
assets: [
{
name: "OpenClawCompanion-Setup-x64.exe",
digest: `sha256:${"a".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-x64.exe",
digest: `sha256:${"c".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-arm64.exe",
digest: `sha256:${"b".repeat(64)}`,
},
],
},
"must contain exactly one required asset OpenClawCompanion-Setup-x64.exe; found 2",
],
[
{
assets: [
{ name: "OpenClawCompanion-Setup-x64.exe", digest: "" },
{ name: "OpenClawCompanion-Setup-arm64.exe", digest: `sha256:${"b".repeat(64)}` },
],
},
"asset OpenClawCompanion-Setup-x64.exe is missing its SHA-256 digest",
],
])("rejects an invalid stable Windows source release", async (override, message) => {
const fetchImpl = vi.fn(async () => ({
ok: true,
json: async () => ({
tag_name: "v0.6.3",
draft: false,
prerelease: false,
html_url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
assets: [
{
name: "OpenClawCompanion-Setup-x64.exe",
digest: `sha256:${"a".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-arm64.exe",
digest: `sha256:${"b".repeat(64)}`,
},
],
...override,
}),
}));
await expect(
validateWindowsSourceRelease("v0.6.3", {
fetchImpl,
timeoutMs: 1234,
token: "test-token",
}),
).rejects.toThrow(message);
});
it("carries the Telegram proof run into the publish command when available", () => {