mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
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:
parent
4001be54e4
commit
8ae1adfdcc
10 changed files with 1046 additions and 98 deletions
|
|
@ -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
|
||||
|
|
|
|||
216
.github/workflows/openclaw-release-publish.yml
vendored
216
.github/workflows/openclaw-release-publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
223
.github/workflows/windows-node-release.yml
vendored
223
.github/workflows/windows-node-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}${
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue