diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 0fc4775afa0..cd19b387422 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -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 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 `. 25. Run the post-published beta verification roster. First scan current `main` for critical fixes that landed after the release branch cut; backport only diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index b31c9bf8614..3a58135bef0 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -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("^(?[a-f0-9]{64}) (?[^/\\\\]+)$")) + then map(capture("^(?[a-f0-9]{64}) (?[^/\\\\]+)$")) + 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 diff --git a/.github/workflows/windows-node-release.yml b/.github/workflows/windows-node-release.yml index 61411a3cc7c..edb6107b0f0 100644 --- a/.github/workflows/windows-node-release.yml +++ b/.github/workflows/windows-node-release.yml @@ -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 diff --git a/docs/ci.md b/docs/ci.md index db9067bc9be..718edcc1e00 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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= \ + -f full_release_validation_run_id= \ -f npm_dist_tag=beta ``` diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index af9086ee3b9..5daa6d3ce20 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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--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=`. 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= \ + -f 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:","OpenClawCompanion-Setup-arm64.exe":"sha256:"}' \ -f preflight_run_id= \ + -f 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:","OpenClawCompanion-Setup-arm64.exe":"sha256:"}' \ -f preflight_run_id= \ + -f 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 diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 3eb490ae553..baa97afb058 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -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; provider: Provider; @@ -288,6 +291,7 @@ Options: --package-spec Baseline npm package spec. Default: openclaw@latest --update-target Target passed to guest 'openclaw update --tag'. Default: host-served tgz packed from current checkout. + --target-tarball Host-serve this prepared tgz for update and fresh install. --fresh-target 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 { + 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 { + 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; diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 55ab953aa82..759cd29ece6 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -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 GitHub repo. Default: ${DEFAULT_REPO} --full-release-run Reuse successful Full Release Validation run. --npm-preflight-run Reuse successful OpenClaw NPM Release preflight run. + --windows-node-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 mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE} --provider Full validation provider. Default: ${DEFAULT_PROVIDER} --mode Full validation cross-OS mode. Default: ${DEFAULT_MODE} - --release-profile Default: ${DEFAULT_RELEASE_PROFILE} + --release-profile Default: beta for prereleases; stable otherwise. --npm-dist-tag Default: ${DEFAULT_NPM_DIST_TAG} --plugin-publish-scope selected|all-publishable. Default: ${DEFAULT_PLUGIN_SCOPE} --plugins 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}${ diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 9e6b8140812..c06d614c38a 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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; diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index ae4fbad61d7..3836d0f7c42 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -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 "); + 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"); diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 489ef055531..47e8711d067 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -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", () => {