name: OpenClaw Release Publish on: workflow_dispatch: inputs: tag: description: Release tag to publish, for example v2026.5.1-alpha.1 or v2026.5.1-beta.1 required: true type: string preflight_run_id: description: Successful OpenClaw NPM Release preflight run id, required when publish_openclaw_npm=true required: false type: string full_release_validation_run_id: 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 type: string npm_dist_tag: description: npm dist-tag for the OpenClaw package required: true default: beta type: choice options: - alpha - beta - latest plugin_publish_scope: description: Plugin publish scope to run before OpenClaw publish required: true default: all-publishable type: choice options: - selected - all-publishable plugins: description: Comma-separated plugin package names when plugin_publish_scope=selected required: false type: string publish_openclaw_npm: description: Publish the OpenClaw npm package after plugin npm succeeds; ClawHub may still run required: true default: true type: boolean release_profile: description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest required: false default: from-validation type: choice options: - from-validation - beta - stable - full wait_for_clawhub: description: Wait for ClawHub plugin publish before marking this workflow complete required: true default: false type: boolean permissions: actions: write contents: write concurrency: group: openclaw-release-publish-${{ inputs.tag }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" jobs: resolve_release_target: name: Resolve release target runs-on: ubuntu-latest timeout-minutes: 20 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 }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} RELEASE_PROFILE: ${{ inputs.release_profile }} WORKFLOW_REF: ${{ github.ref }} run: | set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then echo "Invalid release tag: ${RELEASE_TAG}" >&2 exit 1 fi if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" != "alpha" ]]; then echo "Alpha prerelease tags must publish OpenClaw to npm dist-tag alpha." >&2 exit 1 fi if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then echo "Beta prerelease tags must publish OpenClaw to npm dist-tag beta." >&2 exit 1 fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${PREFLIGHT_RUN_ID}" ]]; then echo "publish_openclaw_npm=true requires preflight_run_id." >&2 exit 1 fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then 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 fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2 exit 1 fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then echo "publish_openclaw_npm=true requires plugin_publish_scope=all-publishable so every publishable official plugin is released with OpenClaw." >&2 exit 1 fi if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then echo "plugin_publish_scope=selected requires plugins." >&2 exit 1 fi if [[ "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" && -n "${PLUGINS}" ]]; then echo "plugin_publish_scope=all-publishable must not include plugins." >&2 exit 1 fi case "$RELEASE_PROFILE" in from-validation|beta|stable|full) ;; *) echo "release_profile must be one of: from-validation, beta, stable, full" >&2 exit 1 ;; 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 }} env: GH_TOKEN: ${{ github.token }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail preferred_name="openclaw-npm-preflight-${RELEASE_TAG}" preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest" rm -rf "${preflight_dir}" mkdir -p "${preflight_dir}" download_named_artifact() { local artifact_name="$1" for attempt in 1 2 3; do if gh run download "${PREFLIGHT_RUN_ID}" \ --repo "${GITHUB_REPOSITORY}" \ --name "${artifact_name}" \ --dir "${preflight_dir}"; then return 0 fi if [[ "$attempt" != "3" ]]; then echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying." sleep $((attempt * 10)) fi done return 1 } if download_named_artifact "${preferred_name}"; then echo "name=${preferred_name}" >> "$GITHUB_OUTPUT" exit 0 fi echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact." mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \ --jq '.artifacts[] | select(.expired != true) | .name' | grep '^openclaw-npm-preflight-' || true) if [[ "${#matches[@]}" != "1" ]]; then echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2 printf 'Available preflight candidates:\n' >&2 printf -- '- %s\n' "${matches[@]:-}" >&2 exit 1 fi fallback_name="${matches[0]}" download_named_artifact "${fallback_name}" echo "name=${fallback_name}" >> "$GITHUB_OUTPUT" - name: Download full release validation manifest if: ${{ inputs.publish_openclaw_npm }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: full-release-validation-${{ inputs.full_release_validation_run_id }} path: ${{ runner.temp }}/full-release-validation-manifest repository: ${{ github.repository }} run-id: ${{ inputs.full_release_validation_run_id }} github-token: ${{ github.token }} - name: Checkout release tag uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 persist-credentials: false - name: Resolve checked-out release ref id: ref run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Validate OpenClaw npm preflight manifest id: manifest if: ${{ inputs.publish_openclaw_npm }} env: RELEASE_TAG: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} EXPECTED_SHA: ${{ steps.ref.outputs.sha }} run: | set -euo pipefail preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest" manifest="${preflight_dir}/preflight-manifest.json" if [[ ! -f "$manifest" ]]; then echo "OpenClaw npm preflight manifest is missing." >&2 ls -la "$preflight_dir" >&2 || true exit 1 fi release_tag="$(jq -r '.releaseTag // ""' "$manifest")" release_sha="$(jq -r '.releaseSha // ""' "$manifest")" npm_dist_tag="$(jq -r '.npmDistTag // ""' "$manifest")" tarball_name="$(jq -r '.tarballName // ""' "$manifest")" tarball_sha256="$(jq -r '.tarballSha256 // ""' "$manifest")" if [[ "$release_tag" != "$RELEASE_TAG" ]]; then echo "Preflight manifest tag mismatch: expected $RELEASE_TAG, got $release_tag" >&2 exit 1 fi if [[ "$release_sha" != "$EXPECTED_SHA" ]]; then echo "Preflight manifest SHA mismatch: expected $EXPECTED_SHA, got $release_sha" >&2 exit 1 fi if [[ "$npm_dist_tag" != "$RELEASE_NPM_DIST_TAG" ]]; then echo "Preflight manifest npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $npm_dist_tag" >&2 exit 1 fi if [[ -z "$tarball_name" || ! -f "${preflight_dir}/${tarball_name}" ]]; then echo "Preflight manifest tarball is missing: $tarball_name" >&2 exit 1 fi actual_tarball_sha256="$(sha256sum "${preflight_dir}/${tarball_name}" | awk '{print $1}')" if [[ "$actual_tarball_sha256" != "$tarball_sha256" ]]; then echo "Preflight manifest tarball digest mismatch." >&2 exit 1 fi echo "sha=$release_sha" >> "$GITHUB_OUTPUT" - name: Validate full release validation manifest id: full_manifest if: ${{ inputs.publish_openclaw_npm }} env: GH_TOKEN: ${{ github.token }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} RELEASE_TAG: ${{ inputs.tag }} EXPECTED_SHA: ${{ steps.ref.outputs.sha }} EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }} EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } const allowedBranches = new Set(["main", process.env.EXPECTED_WORKFLOW_BRANCH].filter(Boolean)); if (!allowedBranches.has(run.headBranch)) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have headBranch in ${[...allowedBranches].join(", ")}, got ${run.headBranch ?? ""}.`); process.exit(1); } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);' manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json" if [[ ! -f "$manifest" ]]; then echo "Full release validation manifest is missing." >&2 ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true exit 1 fi workflow_name="$(jq -r '.workflowName // ""' "$manifest")" target_sha="$(jq -r '.targetSha // ""' "$manifest")" release_profile="$(jq -r '.releaseProfile // ""' "$manifest")" rerun_group="$(jq -r '.rerunGroup // ""' "$manifest")" run_release_soak="$(jq -r '.runReleaseSoak // ""' "$manifest")" performance_blocking="$(jq -r '.controls.performanceBlocking // false' "$manifest")" if [[ "$workflow_name" != "Full Release Validation" ]]; then echo "Full release validation manifest workflow mismatch: $workflow_name" >&2 exit 1 fi if [[ "$target_sha" != "$EXPECTED_SHA" ]]; then echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2 exit 1 fi if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2 exit 1 fi if [[ "$rerun_group" != "all" ]]; then echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2 exit 1 fi if [[ "$performance_blocking" != "true" ]]; then echo "Full release validation manifest does not record blocking product performance evidence." >&2 exit 1 fi if [[ "$RELEASE_TAG" != *"-alpha."* && "$RELEASE_TAG" != *"-beta."* && "$run_release_soak" != "true" ]]; then echo "Stable releases require Full Release Validation with runReleaseSoak=true." >&2 exit 1 fi echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT" - name: Validate release tag is reachable from a trusted release branch env: RELEASE_TAG: ${{ inputs.tag }} WORKFLOW_REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main \ '+refs/heads/release/*:refs/remotes/origin/release/*' if git merge-base --is-ancestor HEAD origin/main; then exit 0 fi while IFS= read -r release_ref; do if git merge-base --is-ancestor HEAD "${release_ref}"; then exit 0 fi done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2 exit 1 fi git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}" if git merge-base --is-ancestor HEAD "refs/remotes/origin/${WORKFLOW_REF_NAME}"; then exit 0 fi fi echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2 exit 1 - name: Summarize release target env: RELEASE_TAG: ${{ inputs.tag }} 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" echo echo "- Tag: \`${RELEASE_TAG}\`" echo "- SHA: \`${TARGET_SHA}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" 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: 120 environment: npm-release steps: - name: Checkout release SHA uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.resolve_release_target.outputs.sha }} fetch-depth: 1 persist-credentials: false - name: Download full release validation manifest if: ${{ inputs.publish_openclaw_npm }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: full-release-validation-${{ inputs.full_release_validation_run_id }} path: ${{ runner.temp }}/full-release-validation-manifest repository: ${{ github.repository }} run-id: ${{ inputs.full_release_validation_run_id }} github-token: ${{ github.token }} - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Dispatch publish workflows env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} PLUGINS: ${{ inputs.plugins }} PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }} 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 FULL_RELEASE_VALIDATION_MANIFEST_DIR: ${{ runner.temp }}/full-release-validation-manifest run: | set -euo pipefail is_stable_release() { [[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]] } dispatch_workflow_at_ref() { local workflow_ref="$1" shift local workflow="$1" shift local dispatch_output run_id dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)" printf '%s\n' "$dispatch_output" >&2 run_id="$( printf '%s\n' "$dispatch_output" | sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | tail -n 1 )" if [[ -z "$run_id" ]]; then echo "gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2 exit 1 fi echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2 { echo "- ${workflow}: dispatched (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id})" } >> "$GITHUB_STEP_SUMMARY" printf '%s\n' "${run_id}" } dispatch_workflow() { dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@" } print_pending_deployments() { local workflow="$1" local run_id="$2" local pending_json pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)" if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then return 0 fi echo "${workflow} pending environment approval:" while IFS=$'\t' read -r env_id env_name can_approve; do echo "- env=${env_name} canApprove=${can_approve}" echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'" done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv') } approve_pending_deployments() { local workflow="$1" local run_id="$2" local pending_json approved pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)" if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then return 1 fi approved=0 while IFS=$'\t' read -r env_id env_name; do if [[ -z "${env_id}" ]]; then continue fi echo "${workflow}: approving pending environment ${env_name} (${env_id})" gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \ -F "environment_ids[]=${env_id}" \ -f state=approved \ -f comment="Approve child release gate after parent release approval" >/dev/null approved=1 done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv') if [[ "${approved}" == "1" ]]; then echo "${workflow}: approved available pending environment gates" return 0 fi return 1 } print_failed_run_summary() { local run_id="$1" local failed_json failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \ --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {databaseId, name, conclusion, url}' || true)" if [[ -z "${failed_json}" ]]; then return 0 fi echo "Failed child job summary:" printf '%s\n' "${failed_json}" while IFS=$'\t' read -r job_id job_name; do if [[ -z "${job_id}" ]]; then continue fi echo "--- ${job_name} (${job_id}) log tail ---" gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --job "${job_id}" --log 2>/dev/null | tail -200 || true done < <(printf '%s\n' "${failed_json}" | jq -r '[.databaseId, .name] | @tsv' 2>/dev/null || true) } wait_for_run() { local workflow="$1" local run_id="$2" local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json last_state="" while true; do run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,url,updatedAt)" status="$(printf '%s' "$run_json" | jq -r '.status')" if [[ "$status" == "completed" ]]; then break fi failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \ --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' || true)" if [[ -n "${failed_json}" ]] && jq -e 'length > 0' <<< "$failed_json" >/dev/null; then echo "${workflow} has failed jobs before the workflow completed: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2 jq '.[] | {name, conclusion, url}' <<< "$failed_json" >&2 || true print_failed_run_summary "${run_id}" return 1 fi url="$(printf '%s' "$run_json" | jq -r '.url')" updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')" state="${status}:${updated_at}" if [[ "$state" != "$last_state" ]]; then echo "${workflow} still ${status} (updated ${updated_at}): ${url}" print_pending_deployments "${workflow}" "${run_id}" approve_pending_deployments "${workflow}" "${run_id}" || true last_state="$state" fi sleep 30 done run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion,url,createdAt,updatedAt)" conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion')" url="$(printf '%s' "$run_json" | jq -r '.url')" created_at="$(printf '%s' "$run_json" | jq -r '.createdAt')" updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')" duration_seconds="$( CREATED_AT="${created_at}" UPDATED_AT="${updated_at}" node --input-type=module -e ' const created = Date.parse(process.env.CREATED_AT ?? ""); const updated = Date.parse(process.env.UPDATED_AT ?? ""); console.log(Number.isFinite(created) && Number.isFinite(updated) ? Math.max(0, Math.round((updated - created) / 1000)) : ""); ' )" if [[ -n "${duration_seconds}" ]]; then duration_label="$((duration_seconds / 60))m$(printf '%02d' $((duration_seconds % 60)))s" else duration_label="unknown duration" fi echo "${workflow} finished with ${conclusion} in ${duration_label}: ${url}" { echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})" } >> "$GITHUB_STEP_SUMMARY" if [[ "$conclusion" != "success" ]]; then print_failed_run_summary "${run_id}" return 1 fi } wait_for_run_background() { local workflow="$1" local run_id="$2" local result_file="$3" ( if wait_for_run "${workflow}" "${run_id}"; then printf 'success\n' > "${result_file}" else printf 'failure\n' > "${result_file}" fi ) & wait_run_pid="$!" } wait_for_job_success() { local workflow="$1" local run_id="$2" local job_name="$3" local jobs_json job_json run_status run_conclusion status conclusion url deadline deadline=$((SECONDS + 900)) while true; do jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)" run_status="$(printf '%s' "$jobs_json" | jq -r '.status')" run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')" job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)" if [[ -n "$job_json" ]]; then status="$(printf '%s' "$job_json" | jq -r '.status')" conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')" url="$(printf '%s' "$job_json" | jq -r '.url // ""')" if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then echo "${workflow} ${job_name} ${conclusion}: ${url}" echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY" return 0 fi echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2 print_failed_run_summary "${run_id}" return 1 fi echo "${workflow} ${job_name} still ${status}: ${url}" elif [[ "$run_status" == "completed" ]]; then if [[ "$run_conclusion" == "success" ]]; then echo "${workflow} completed before ${job_name} was needed." echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY" return 0 fi echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2 print_failed_run_summary "${run_id}" return 1 else echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" fi if (( SECONDS >= deadline )); then echo "${workflow} ${job_name} did not complete within 15 minutes." >&2 return 1 fi sleep 10 done } approve_child_publish_environment() { local workflow="$1" local run_id="$2" local run_json status conclusion deadline deadline=$((SECONDS + 900)) while true; do if approve_pending_deployments "${workflow}" "${run_id}"; then echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY" return 0 fi run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)" status="$(printf '%s' "$run_json" | jq -r '.status')" conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')" if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" ]]; then echo "${workflow}: completed before child environment approval was needed" return 0 fi echo "${workflow}: completed before child environment approval with ${conclusion}" >&2 print_failed_run_summary "${run_id}" return 1 fi if (( SECONDS >= deadline )); then echo "${workflow}: child environment approval was not available within 15 minutes." >&2 print_pending_deployments "${workflow}" "${run_id}" return 1 fi sleep 10 done } guard_existing_public_release() { local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then return 0 fi if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then return 0 fi is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')" if [[ "${is_draft}" == "true" ]]; then return 0 fi release_version="${RELEASE_TAG#v}" asset_name="openclaw-${release_version}-dependency-evidence.zip" has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')" has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')" has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')" release_url="$(printf '%s' "${release_json}" | jq -r '.url')" if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then return 0 fi { echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}." echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}" echo "Create a new beta tag or delete/draft the incomplete public release before retrying." } >&2 exit 1 } guard_openclaw_npm_not_already_published() { local release_version release_url if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then return 0 fi release_version="${RELEASE_TAG#v}" if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then return 0 fi release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" { echo "openclaw@${release_version} is already published on npm." echo "Refusing to dispatch publish child workflows for an already-published version." echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version." echo "Release page, if present: ${release_url}" } >&2 exit 1 } resolve_clawhub_release_plan() { local -a plan_args clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" plan_args=( --release-tag "${RELEASE_TAG}" --release-publish-branch "${CHILD_WORKFLOW_REF}" --release-publish-run-id "${GITHUB_RUN_ID}" --plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}" ) if [[ -n "${PLUGINS// }" ]]; then plan_args+=(--plugins "${PLUGINS}") fi CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \ node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}" echo "Resolved OpenClaw release ClawHub dispatch plan:" cat "${clawhub_plan_path}" clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")" normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")" bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")" missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")" normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")" bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")" missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")" { echo "### ClawHub release plan" echo echo "- Normal OIDC candidates: \`${normal_plugin_count}\`" echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`" echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`" if [[ -n "${normal_plugins}" ]]; then echo "- Normal plugins: \`${normal_plugins}\`" fi if [[ -n "${bootstrap_plugins}" ]]; then echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`" fi if [[ -n "${missing_trusted_plugins}" ]]; then echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`" fi } >> "$GITHUB_STEP_SUMMARY" } append_clawhub_dispatch_args() { local target="$1" while IFS=$'\t' read -r key value; do clawhub_dispatch_args+=(-f "${key}=${value}") done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}") } write_clawhub_runtime_state() { local force_skip_clawhub="$1" local output_path="$2" node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \ --repository "${GITHUB_REPOSITORY}" \ --wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \ --force-skip-clawhub "${force_skip_clawhub}" \ --normal-run-id "${plugin_clawhub_run_id:-}" \ --bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \ --bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}" } create_or_update_github_release() { local release_version notes_version title notes_file changelog_file latest_arg prerelease_args release_version="${RELEASE_TAG#v}" notes_version="${release_version}" if [[ "${notes_version}" =~ ^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)-(alpha|beta)\.[1-9][0-9]*$ ]]; then notes_version="${BASH_REMATCH[1]}" fi title="openclaw ${release_version}" changelog_file="${RUNNER_TEMP}/CHANGELOG.md" notes_file="${RUNNER_TEMP}/release-notes.md" git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" awk -v version="${notes_version}" ' $0 == "## " version { in_section = 1; next } /^## / && in_section { exit } in_section { print } ' "${changelog_file}" > "${notes_file}" if [[ ! -s "${notes_file}" ]] && [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then awk ' $0 == "## Unreleased" { in_section = 1; next } /^## / && in_section { exit } in_section { print } ' "${changelog_file}" > "${notes_file}" fi if [[ ! -s "${notes_file}" ]]; then echo "CHANGELOG.md does not contain release notes for ${notes_version} or an Unreleased prerelease fallback." >&2 exit 1 fi prerelease_args=() latest_arg="--latest=false" if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then prerelease_args=(--prerelease) elif [[ "${RELEASE_NPM_DIST_TAG}" == "latest" ]]; then latest_arg="--latest" fi if gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --title "${title}" \ --notes-file "${notes_file}" \ "${prerelease_args[@]}" else gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ --title "${title}" \ --notes-file "${notes_file}" \ "${prerelease_args[@]}" \ "${latest_arg}" fi echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } 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}" download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset" asset_name="openclaw-${release_version}-dependency-evidence.zip" asset_path="${RUNNER_TEMP}/${asset_name}" artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}" rm -rf "${download_dir}" "${asset_path}" mkdir -p "${download_dir}" gh run download "${PREFLIGHT_RUN_ID}" \ --repo "${GITHUB_REPOSITORY}" \ --name "${artifact_name}" \ --dir "${download_dir}" if [[ ! -d "${download_dir}/dependency-evidence" ]]; then echo "Dependency evidence is missing from OpenClaw npm preflight artifact." >&2 find "${download_dir}" -maxdepth 2 -type f -print >&2 || true exit 1 fi ( cd "${download_dir}" find dependency-evidence -type f -print | LC_ALL=C sort | zip -X -q "${asset_path}" -@ ) attach_or_verify_release_asset "${asset_path}" "${asset_name}" echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY" } attach_or_verify_release_asset() { local source_path="$1" local asset_name="$2" local existing_dir="${RUNNER_TEMP}/openclaw-release-existing-assets/${asset_name}" local existing_path="${existing_dir}/${asset_name}" if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then rm -rf "${existing_dir}" mkdir -p "${existing_dir}" gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ --pattern "${asset_name}" --dir "${existing_dir}" cmp --silent "${source_path}" "${existing_path}" || { echo "Existing release evidence asset ${asset_name} differs from this release run." >&2 exit 1 } return fi gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" --repo "${GITHUB_REPOSITORY}" } upload_release_evidence_assets() { local release_version manifest_path evidence_path manifest_asset evidence_asset release_version="${RELEASE_TAG#v}" manifest_path="${FULL_RELEASE_VALIDATION_MANIFEST_DIR}/full-release-validation-manifest.json" evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" manifest_asset="openclaw-${release_version}-release-manifest.json" evidence_asset="openclaw-${release_version}-postpublish-evidence.json" if [[ ! -f "${manifest_path}" ]]; then echo "Full release validation manifest is missing from ${FULL_RELEASE_VALIDATION_MANIFEST_DIR}." >&2 exit 1 fi if [[ ! -f "${evidence_path}" ]]; then echo "Postpublish release evidence is missing from ${POSTPUBLISH_EVIDENCE_DIR}." >&2 exit 1 fi cp "${manifest_path}" "${RUNNER_TEMP}/${manifest_asset}" cp "${evidence_path}" "${RUNNER_TEMP}/${evidence_asset}" ( cd "${RUNNER_TEMP}" sha256sum "${manifest_asset}" > "${manifest_asset}.sha256" sha256sum "${evidence_asset}" > "${evidence_asset}.sha256" ) attach_or_verify_release_asset "${RUNNER_TEMP}/${manifest_asset}" "${manifest_asset}" attach_or_verify_release_asset \ "${RUNNER_TEMP}/${manifest_asset}.sha256" \ "${manifest_asset}.sha256" attach_or_verify_release_asset "${RUNNER_TEMP}/${evidence_asset}" "${evidence_asset}" attach_or_verify_release_asset \ "${RUNNER_TEMP}/${evidence_asset}.sha256" \ "${evidence_asset}.sha256" { echo "- Immutable release manifest: \`${manifest_asset}\`" echo "- Immutable postpublish evidence: \`${evidence_asset}\`" } >> "$GITHUB_STEP_SUMMARY" } verify_published_release() { local release_version evidence_path skip_clawhub clawhub_runtime_state_path local -a verify_args skip_clawhub="${1:-false}" release_version="${RELEASE_TAG#v}" evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}" verify_args=( release:verify-beta -- "${release_version}" --tag "${RELEASE_TAG}" --dist-tag "${RELEASE_NPM_DIST_TAG}" --repo "${GITHUB_REPOSITORY}" --workflow-ref "${CHILD_WORKFLOW_REF}" --clawhub-workflow-ref "${clawhub_workflow_ref}" --full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}" --plugin-npm-run "${plugin_npm_run_id}" --openclaw-npm-run "${openclaw_npm_run_id}" --evidence-out "${evidence_path}" --skip-github-release ) clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json" write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}" while IFS= read -r arg; do verify_args+=("${arg}") done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}") if [[ -n "${PLUGINS// }" ]]; then verify_args+=(--plugins "${PLUGINS}") fi if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}") fi pnpm "${verify_args[@]}" jq --arg release_publish_run_id "$GITHUB_RUN_ID" \ '.releasePublishRunId = $release_publish_run_id' \ "${evidence_path}" > "${evidence_path}.next" mv "${evidence_path}.next" "${evidence_path}" { echo "- Postpublish verification: passed" echo "- Postpublish evidence: \`${evidence_path}\`" } >> "$GITHUB_STEP_SUMMARY" } append_release_proof_to_github_release() { local release_version body_file notes_file evidence_path 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" notes_file="${RUNNER_TEMP}/release-notes-with-proof.md" evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")" integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")" gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}" if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}" else telegram_line="- npm Telegram beta E2E: not supplied" fi clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json" 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}" \ RELEASE_VERSION="${release_version}" \ RELEASE_TAG="${RELEASE_TAG}" \ RELEASE_SHA="${TARGET_SHA}" \ RELEASE_REPO="${GITHUB_REPOSITORY}" \ RELEASE_TARBALL="${tarball}" \ RELEASE_INTEGRITY="${integrity}" \ RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \ PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \ FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \ PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \ OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \ 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"; const bodyFile = process.env.RELEASE_BODY_FILE; const notesFile = process.env.RELEASE_NOTES_FILE; if (!bodyFile || !notesFile) { throw new Error("Missing release notes file paths."); } const body = readFileSync(bodyFile, "utf8").trimEnd(); const section = [ "### Release verification", "", `- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`, `- registry tarball: ${process.env.RELEASE_TARBALL}`, `- integrity: \`${process.env.RELEASE_INTEGRITY}\``, `- release SHA: \`${process.env.RELEASE_SHA}\``, `- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`, `- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`, `- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`, `- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`, `- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`, process.env.CLAWHUB_LINE, 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## |$)/, ""); writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`); NODE gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}" echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY" } { echo "### Publish sequence" echo echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`" echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" echo "- Release tag: \`${RELEASE_TAG}\`" echo "- Release SHA: \`${TARGET_SHA}\`" echo "- Release approval: this workflow job" echo "- Plugin npm and ClawHub publish: dispatched in parallel" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then echo "- OpenClaw npm publish: starts after plugin npm succeeds" 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 echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately" fi } >> "$GITHUB_STEP_SUMMARY" guard_existing_public_release guard_openclaw_npm_not_already_published resolve_clawhub_release_plan npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") if [[ -n "${PLUGINS}" ]]; then npm_args+=(-f plugins="${PLUGINS}") fi plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")" plugin_clawhub_run_id="" if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then clawhub_dispatch_args=() append_clawhub_dispatch_args normal plugin_clawhub_run_id="$(dispatch_workflow_at_ref \ "$(jq -r '.normal.ref' "${clawhub_plan_path}")" \ "$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \ "${clawhub_dispatch_args[@]}")" else echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY" fi plugin_clawhub_bootstrap_run_id="" plugin_clawhub_bootstrap_completed="false" if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then clawhub_dispatch_args=() append_clawhub_dispatch_args bootstrap plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \ "$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \ "$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \ "${clawhub_dispatch_args[@]}")" else echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY" fi { echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`" echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`" echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`" } >> "$GITHUB_STEP_SUMMARY" if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2 if [[ -n "${plugin_clawhub_run_id}" ]]; then gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true fi if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true fi exit 1 fi if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish." if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then plugin_clawhub_bootstrap_completed="true" else if [[ -n "${plugin_clawhub_run_id}" ]]; then gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true fi exit 1 fi fi openclaw_npm_run_id="" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \ -f tag="${RELEASE_TAG}" \ -f preflight_only=false \ -f preflight_run_id="${PREFLIGHT_RUN_ID}" \ -f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \ -f release_publish_run_id="${GITHUB_RUN_ID}" \ -f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")" echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY" else echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY" fi clawhub_result="" clawhub_pid="" clawhub_bootstrap_result="" clawhub_bootstrap_pid="" if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then if [[ -n "${plugin_clawhub_run_id}" ]]; then clawhub_result="$RUNNER_TEMP/clawhub-result.txt" wait_run_pid="" wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" clawhub_pid="${wait_run_pid}" fi if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" else clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt" wait_run_pid="" wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}" clawhub_bootstrap_pid="${wait_run_pid}" fi fi else if [[ -n "${plugin_clawhub_run_id}" ]]; then wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then : else echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" fi echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" else echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY" fi if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" else wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval" if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then : else echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" fi echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" fi else echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY" fi fi openclaw_result="" openclaw_pid="" if [[ -n "${openclaw_npm_run_id}" ]]; then openclaw_result="$RUNNER_TEMP/openclaw-npm-result.txt" wait_run_pid="" wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${openclaw_result}" openclaw_pid="${wait_run_pid}" fi failed=0 openclaw_failed=0 windows_node_run_id="" if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then failed=1 openclaw_failed=1 fi if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then failed=1 openclaw_failed=1 fi if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then failed=1 fi if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then failed=1 fi if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then failed=1 fi if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then failed=1 fi if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then if [[ "${failed}" == "0" ]]; then verify_published_release else verify_published_release true fi create_or_update_github_release upload_dependency_evidence_release_asset upload_release_evidence_assets if ! promote_windows_release_assets; then failed=1 fi append_release_proof_to_github_release if [[ "${failed}" == "0" ]]; then publish_github_release else echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY" fi fi if [[ "${failed}" != "0" ]]; then exit 1 fi - name: Upload postpublish evidence if: ${{ always() && inputs.publish_openclaw_npm }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: openclaw-release-postpublish-evidence-${{ inputs.tag }} path: ${{ runner.temp }}/openclaw-release-postpublish-evidence if-no-files-found: error