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 for OpenClaw npm or all-publishable plugin publication required: false type: string full_release_validation_run_id: description: Successful Full Release Validation run id for this tag/SHA, required for OpenClaw npm or all-publishable plugin publication required: false type: string full_release_validation_run_attempt: description: Exact Full Release Validation run attempt; when omitted, the current attempt is resolved once before publish 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 attestations: read 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 }} full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }} 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 }} FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} 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 release_evidence_required=false if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" || "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" ]]; then release_evidence_required=true fi release_evidence_supplied=false if [[ -n "${PREFLIGHT_RUN_ID//[[:space:]]/}" || -n "${FULL_RELEASE_VALIDATION_RUN_ID//[[:space:]]/}" || -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT//[[:space:]]/}" ]]; then release_evidence_supplied=true fi if [[ "${release_evidence_required}" == "true" || "${release_evidence_supplied}" == "true" ]]; then if [[ -z "${PREFLIGHT_RUN_ID//[[:space:]]/}" ]]; then echo "OpenClaw npm and all-publishable plugin publication require preflight_run_id; supplied release evidence must also be complete." >&2 exit 1 fi if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID//[[:space:]]/}" ]]; then echo "OpenClaw npm and all-publishable plugin publication require full_release_validation_run_id; supplied release evidence must also be complete." >&2 exit 1 fi fi if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then echo "full_release_validation_run_attempt requires full_release_validation_run_id." >&2 exit 1 fi if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then echo "full_release_validation_run_attempt must be a positive integer." >&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 [[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" ]]; then echo "OpenClaw Release Publish must use trusted main workflow tooling, except for a matching Tideclaw alpha branch." >&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//[[:space:]]/}" ]]; then echo "plugin_publish_scope=selected requires plugins." >&2 exit 1 fi if [[ "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" && -n "${PLUGINS//[[:space:]]/}" ]]; 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 || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} 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: Resolve full release validation run id: full_run if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} env: GH_TOKEN: ${{ github.token }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} run: | set -euo pipefail run_file="${RUNNER_TEMP}/full-release-validation-run.json" run_endpoint="repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}" if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" ]]; then run_endpoint+="/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" fi gh api "$run_endpoint" > "$run_file" run_attempt="$(jq -r '.run_attempt // ""' "$run_file")" if [[ ! "$run_attempt" =~ ^[1-9][0-9]*$ ]]; then echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} has invalid attempt: ${run_attempt:-}." >&2 exit 1 fi if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then echo "Full Release Validation run attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt}." >&2 exit 1 fi echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT" - name: Download full release validation manifest if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }} path: ${{ runner.temp }}/full-release-validation-manifest repository: ${{ github.repository }} run-id: ${{ inputs.full_release_validation_run_id }} github-token: ${{ github.token }} - name: Download trusted release validation tooling if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} env: GH_TOKEN: ${{ github.token }} WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail tooling_dir="${RUNNER_TEMP}/release-validation-tooling" mkdir -p "${tooling_dir}/lib" gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/validate-full-release-validation-evidence.mjs?ref=${WORKFLOW_SHA}" \ --jq .content | base64 --decode > "${tooling_dir}/validate-full-release-validation-evidence.mjs" gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/release-ci-summary.mjs?ref=${WORKFLOW_SHA}" \ --jq .content | base64 --decode > "${tooling_dir}/release-ci-summary.mjs" gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/plain-gh.mjs?ref=${WORKFLOW_SHA}" \ --jq .content | base64 --decode > "${tooling_dir}/lib/plain-gh.mjs" - 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 || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} 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 || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} 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_JSON_FILE: ${{ runner.temp }}/full-release-validation-run.json TRUSTED_MAIN_REF: refs/remotes/origin/main VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs STRICT_VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/release-ci-summary.mjs run: | set -euo pipefail 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 git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE" 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] permissions: actions: write attestations: write contents: write id-token: write 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: Checkout trusted release tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.sha }} path: .release-harness 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 }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }} 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: Resolve ClawHub release plan id: clawhub_plan env: TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} PLUGINS: ${{ inputs.plugins }} run: | set -euo pipefail plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" bootstrap_workflow_sha="$( gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ --jq '.object.sha | select(test("^[a-f0-9]{40}$"))' )" plan_args=( --bootstrap-workflow-sha "${bootstrap_workflow_sha}" --release-tag "${RELEASE_TAG}" --release-sha "${TARGET_SHA}" --release-publish-branch "${CHILD_WORKFLOW_REF}" --release-publish-run-attempt "${GITHUB_RUN_ATTEMPT}" --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 \ "${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-release-clawhub-plan.ts" \ "${plan_args[@]}" > "${plan_path}" cat "${plan_path}" bootstrap_should_dispatch="$(jq -er '.bootstrap.shouldDispatch' "${plan_path}")" echo "bootstrap_should_dispatch=${bootstrap_should_dispatch}" >> "${GITHUB_OUTPUT}" if [[ "${bootstrap_should_dispatch}" != "true" ]]; then exit 0 fi jq -e \ --arg branch "${CHILD_WORKFLOW_REF}" \ --arg bootstrap_workflow_sha "${bootstrap_workflow_sha}" \ --arg attempt "${GITHUB_RUN_ATTEMPT}" \ --arg run_id "${GITHUB_RUN_ID}" \ --arg tag "${RELEASE_TAG}" \ --arg target "${TARGET_SHA}" \ '.bootstrapWorkflowSha == $bootstrap_workflow_sha and .bootstrap.ref == "main" and .bootstrap.inputs.ref == $target and .bootstrap.inputs.bootstrap_workflow_sha == $bootstrap_workflow_sha and .bootstrap.inputs.release_tag == $tag and .bootstrap.inputs.release_publish_branch == $branch and .bootstrap.inputs.release_publish_run_attempt == $attempt and .bootstrap.inputs.release_publish_run_id == $run_id' \ "${plan_path}" >/dev/null approval_dir="${RUNNER_TEMP}/clawhub-bootstrap-approval" mkdir -p "${approval_dir}" PLAN_PATH="${plan_path}" APPROVAL_PATH="${approval_dir}/approval.json" \ node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; const plan = JSON.parse(readFileSync(process.env.PLAN_PATH, "utf8")); const packages = [...plan.bootstrap.packages].toSorted((a, b) => a < b ? -1 : a > b ? 1 : 0, ); const approval = { version: 2, kind: "clawhub-bootstrap", repository: process.env.GITHUB_REPOSITORY, workflow: "OpenClaw Release Publish", parentRunId: process.env.GITHUB_RUN_ID, parentRunAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), workflowBranch: process.env.CHILD_WORKFLOW_REF, parentWorkflowSha: process.env.GITHUB_SHA, bootstrapWorkflowSha: plan.bootstrapWorkflowSha, releaseTag: process.env.RELEASE_TAG, targetSha: process.env.TARGET_SHA, packages, }; writeFileSync(process.env.APPROVAL_PATH, `${JSON.stringify(approval, null, 2)}\n`); NODE - name: Attest ClawHub bootstrap approval if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }} uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json - name: Upload ClawHub bootstrap approval if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: clawhub-bootstrap-approval-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json if-no-files-found: error retention-days: 30 - name: Prepare GitHub release notes if: ${{ inputs.publish_openclaw_npm }} env: RELEASE_TAG: ${{ inputs.tag }} TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} run: | # Fail-before-mutation gate: prove the canonical release body renders # within GitHub's limits before any publish-side dispatch runs. The # publish sequence re-renders the same body from the same tag SHA. changelog_file="${RUNNER_TEMP}/CHANGELOG.md" notes_file="${RUNNER_TEMP}/release-notes.md" git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" node scripts/render-github-release-notes.mjs \ --changelog "${changelog_file}" \ --tag "${RELEASE_TAG}" \ --repository "${GITHUB_REPOSITORY}" \ --output "${notes_file}" - name: Write Android release approval if: ${{ inputs.publish_openclaw_npm && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }} env: RELEASE_PUBLISH_BRANCH: ${{ github.ref_name }} RELEASE_PUBLISH_RUN_ID: ${{ github.run_id }} RELEASE_TAG: ${{ inputs.tag }} TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} run: | set -euo pipefail approval_dir="${RUNNER_TEMP}/android-release-approval" mkdir -p "${approval_dir}" node --input-type=module <<'NODE' import { writeFileSync } from "node:fs"; import { join } from "node:path"; const approval = { version: 1, repository: process.env.GITHUB_REPOSITORY, workflow: "OpenClaw Release Publish", parentRunId: process.env.RELEASE_PUBLISH_RUN_ID, workflowBranch: process.env.RELEASE_PUBLISH_BRANCH, releaseTag: process.env.RELEASE_TAG, targetSha: process.env.TARGET_SHA, }; writeFileSync( join(process.env.RUNNER_TEMP, "android-release-approval", "approval.json"), `${JSON.stringify(approval, null, 2)}\n`, ); NODE - name: Attest Android release approval if: ${{ inputs.publish_openclaw_npm && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }} uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-path: ${{ runner.temp }}/android-release-approval/approval.json - name: Upload Android release approval if: ${{ inputs.publish_openclaw_npm && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: android-release-approval-${{ github.run_id }} path: ${{ runner.temp }}/android-release-approval/approval.json if-no-files-found: error retention-days: 30 - name: Dispatch publish workflows env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }} 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 CLAWHUB_PLAN_PATH: ${{ runner.temp }}/openclaw-release-clawhub-plan.json run: | set -euo pipefail is_stable_release() { [[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]] } is_android_release() { [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]] } verify_child_run_sha() { local workflow="$1" local run_id="$2" local expected_sha="$3" local run_json child_head_sha child_url attempt run_json="" for attempt in $(seq 1 12); do if run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json headSha,url 2>/dev/null)"; then child_head_sha="$(printf '%s' "$run_json" | jq -r '.headSha // ""')" if [[ -n "$child_head_sha" ]]; then break fi fi if [[ "$attempt" != "12" ]]; then sleep 5 fi done child_head_sha="$(printf '%s' "$run_json" | jq -r '.headSha // ""' 2>/dev/null || true)" child_url="$(printf '%s' "$run_json" | jq -r '.url // ""' 2>/dev/null || true)" if [[ "$child_head_sha" != "$expected_sha" ]]; then echo "${workflow} child run ${run_id} used workflow SHA ${child_head_sha:-}, expected ${expected_sha}: ${child_url}" >&2 gh run cancel --repo "$GITHUB_REPOSITORY" "$run_id" >/dev/null 2>&1 || true return 1 fi } dispatch_workflow_at_ref() { local workflow_ref="$1" local expected_sha="$2" shift 2 local workflow="$1" shift local dispatch_body dispatch_response encoded_workflow_ref field key resolved_workflow_sha run_id run_url value encoded_workflow_ref="$(jq -rn --arg value "$workflow_ref" '$value | @uri')" resolved_workflow_sha="$( gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" \ --jq '.sha | select(test("^[a-f0-9]{40}$"))' )" if [[ "$resolved_workflow_sha" != "$expected_sha" ]]; then echo "Child workflow ref ${workflow_ref} resolved to ${resolved_workflow_sha}, expected ${expected_sha}; refusing dispatch." >&2 exit 1 fi local inputs_json='{}' while (( $# > 0 )); do if [[ "$1" != "-f" && "$1" != "--raw-field" && "$1" != "-F" && "$1" != "--field" ]]; then echo "Unsupported workflow dispatch argument for ${workflow}: $1" >&2 exit 1 fi if (( $# < 2 )) || [[ "$2" != *=* ]]; then echo "Workflow dispatch fields must use key=value syntax for ${workflow}." >&2 exit 1 fi field="$2" shift 2 key="${field%%=*}" value="${field#*=}" inputs_json="$(jq -cn \ --argjson inputs "$inputs_json" \ --arg key "$key" \ --arg value "$value" \ '$inputs + {($key): $value}')" done dispatch_body="$(jq -cn \ --arg ref "$workflow_ref" \ --argjson inputs "$inputs_json" \ '{ref: $ref, inputs: $inputs}')" # API 2026-03-10 removed return_run_details and always returns the # workflow_run_id, API run_url, and browser html_url in a 200 response. dispatch_response="$(printf '%s' "$dispatch_body" | gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2026-03-10" \ "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/dispatches" \ --input -)" run_id="$(printf '%s' "$dispatch_response" | jq -er '.workflow_run_id')" run_url="$(printf '%s' "$dispatch_response" | jq -er '.html_url')" verify_child_run_sha "$workflow" "$run_id" "$expected_sha" || return 1 echo "Dispatched ${workflow} from ${workflow_ref} at ${expected_sha}: ${run_url}" >&2 { echo "- ${workflow}: dispatched from \`${workflow_ref}\` at \`${expected_sha}\` (${run_url})" } >> "$GITHUB_STEP_SUMMARY" printf '%s\n' "${run_id}" } dispatch_workflow() { dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$@" } verify_bootstrap_workflow_sha() { local approved_sha current_sha approved_sha="$(jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}")" current_sha="$( gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ --jq '.object.sha | select(test("^[a-f0-9]{40}$"))' )" [[ "${current_sha}" == "${approved_sha}" ]] || { echo "Trusted main moved from approved ClawHub bootstrap workflow SHA ${approved_sha} to ${current_sha}; rerun release approval." >&2 exit 1 } printf '%s\n' "${approved_sha}" } 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') } # Returns 0 after an approval, 1 when no gate is ready yet, and 2 for # identity or mutation failures that must not be retried as "pending". approve_pending_deployments() { local workflow="$1" local run_id="$2" local expected_sha="$3" local pending_json approved if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then echo "${workflow}: refusing environment approval because the child workflow SHA is not approved." >&2 return 2 fi if ! pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null)"; then return 1 fi 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})" if ! 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; then echo "${workflow}: failed to approve pending environment ${env_name} (${env_id})." >&2 return 2 fi approved=1 done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv') if [[ "${approved}" == "1" ]]; then if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 2 fi 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 expected_sha="$3" local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json approval_status if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 1 fi 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}" approval_status=0 approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || approval_status=$? if (( approval_status > 1 )); then return 1 fi last_state="$state" fi sleep 30 done if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 1 fi 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 expected_sha="$3" local result_file="$4" ( if wait_for_run "${workflow}" "${run_id}" "${expected_sha}"; 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 expected_sha="$4" local jobs_json job_json run_status run_conclusion status conclusion url deadline if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 1 fi 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 if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 1 fi 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 if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 1 fi 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 expected_sha="$3" local run_json status conclusion deadline approval_status deadline=$((SECONDS + 900)) while true; do approval_status=0 approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || approval_status=$? if (( approval_status == 0 )); then echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY" return 0 fi if (( approval_status > 1 )); then return "${approval_status}" fi if ! run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"; then sleep 10 continue fi if ! status="$(printf '%s' "$run_json" | jq -er '.status | select(type == "string" and length > 0)')" || ! conclusion="$(printf '%s' "$run_json" | jq -er '(.conclusion // "") | select(type == "string")')"; then echo "${workflow}: invalid run state while waiting for environment approval." >&2 return 2 fi if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" ]]; then if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then return 2 fi 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 } approve_clawhub_bootstrap_environments() { local run_id="$1" local expected_sha="$2" wait_for_job_success \ plugin-clawhub-new.yml \ "${run_id}" \ "Validate release publish approval" \ "${expected_sha}" || return 1 approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" "${expected_sha}" || return 1 wait_for_job_success \ plugin-clawhub-new.yml \ "${run_id}" \ "Validate immutable bootstrap handoff" \ "${expected_sha}" || return 1 approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" "${expected_sha}" || return 1 } guard_existing_public_release() { local release_version asset_name release_json is_draft has_sha has_proof has_asset has_canonical_body release_url release_body release_body_file 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')" release_body="$(printf '%s' "${release_json}" | jq -r '.body')" release_body_file="${RUNNER_TEMP}/existing-public-release-body.md" printf '%s' "${release_body}" > "${release_body_file}" has_canonical_body="false" if canonical_release_body_matches "${release_body_file}"; then has_canonical_body="true" fi if [[ "${has_asset}" == "true" && "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_canonical_body}" == "true" ]]; then return 0 fi # The renderer omits the verification tail when the canonical body # already reaches GitHub's limit. A canonical proofless body with # intact dependency evidence is retry-safe: postpublish re-attempts # the proof append on this run. if [[ "${has_asset}" == "true" && "${has_canonical_body}" == "true" && "${has_proof}" != "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 } resolve_openclaw_npm_publish_state() { local artifact_name manifest_dir manifest_path manifest_sha manifest_tarball_sha published_sha published_tarball_path published_tarball_url release_version openclaw_npm_already_published="false" 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 # A published core package means a prior publish run already got # that far. Resume is only safe when the registry serves the exact # tarball this tag's preflight built; the same version from any # other artifact is immutable on npm and needs a correction tag. artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}" manifest_dir="${RUNNER_TEMP}/openclaw-npm-resume-preflight" rm -rf "${manifest_dir}" mkdir -p "${manifest_dir}" gh run download "${PREFLIGHT_RUN_ID}" \ --repo "${GITHUB_REPOSITORY}" \ --name "${artifact_name}" \ --dir "${manifest_dir}" manifest_path="${manifest_dir}/preflight-manifest.json" manifest_sha="$(jq -er '.releaseSha' "${manifest_path}")" manifest_tarball_sha="$(jq -er '.tarballSha256' "${manifest_path}")" if [[ "${manifest_sha}" != "${TARGET_SHA}" ]]; then echo "openclaw@${release_version} is already on npm but preflight ${PREFLIGHT_RUN_ID} was built from ${manifest_sha}, not ${TARGET_SHA}; refusing to resume." >&2 exit 1 fi published_tarball_url="$(npm view "openclaw@${release_version}" dist.tarball)" published_tarball_path="${manifest_dir}/published.tgz" curl -fsSL --retry 3 -o "${published_tarball_path}" "${published_tarball_url}" published_sha="$(sha256sum "${published_tarball_path}" | awk '{print $1}')" if [[ "${published_sha}" != "${manifest_tarball_sha}" ]]; then { echo "openclaw@${release_version} is already published on npm but its tarball does not match this tag's preflight artifact." echo "Published sha256: ${published_sha}" echo "Preflight tarballSha256: ${manifest_tarball_sha}" echo "Cut a correction tag instead of resuming this publish." } >&2 exit 1 fi openclaw_npm_already_published="true" echo "openclaw@${release_version} is already published on npm with this tag's preflight tarball; skipping the core npm dispatch and resuming the remaining publish stages." } resolve_clawhub_release_plan() { clawhub_plan_path="${CLAWHUB_PLAN_PATH}" test -s "${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 \ "${GITHUB_WORKSPACE}/.release-harness/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}" } render_github_release_notes() { local output_file="$1" local verification_file="${2:-}" local metadata_file="${3:-}" local changelog_file="${RUNNER_TEMP}/CHANGELOG.md" local -a render_args=( node scripts/render-github-release-notes.mjs --changelog "${changelog_file}" --tag "${RELEASE_TAG}" --repository "${GITHUB_REPOSITORY}" --output "${output_file}" ) git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" if [[ -n "${verification_file}" ]]; then render_args+=(--verification-file "${verification_file}") fi if [[ -n "${metadata_file}" ]]; then render_args+=(--metadata-output "${metadata_file}") fi "${render_args[@]}" } verify_release_tag_target() { local direct_sha peeled_sha remote_refs remote_sha remote_refs="$(git ls-remote --tags origin \ "refs/tags/${RELEASE_TAG}" \ "refs/tags/${RELEASE_TAG}^{}")" direct_sha="$(printf '%s\n' "${remote_refs}" | awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')" peeled_sha="$(printf '%s\n' "${remote_refs}" | awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')" remote_sha="${peeled_sha:-${direct_sha}}" if [[ -z "${remote_sha}" ]]; then echo "Release tag ${RELEASE_TAG} no longer exists on origin." >&2 exit 1 fi if [[ "${remote_sha}" != "${TARGET_SHA}" ]]; then echo "Release tag ${RELEASE_TAG} moved: expected ${TARGET_SHA}, found ${remote_sha}." >&2 exit 1 fi } canonical_release_body_matches() { local body_file="$1" local changelog_file="${RUNNER_TEMP}/release-body-changelog.md" git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" RELEASE_BODY_FILE="${body_file}" \ RELEASE_CHANGELOG_FILE="${changelog_file}" \ RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \ RELEASE_TAG="${RELEASE_TAG}" \ node --input-type=module <<'NODE' import { readFileSync } from "node:fs"; import { releaseNotesVersionForTag, verifyGithubReleaseNotes, } from "./scripts/render-github-release-notes.mjs"; const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8"); const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8"); const result = verifyGithubReleaseNotes({ body, changelog, version: releaseNotesVersionForTag(process.env.RELEASE_TAG), tag: process.env.RELEASE_TAG, repository: process.env.RELEASE_REPOSITORY, }); if (!result.matches) { process.exitCode = 1; } NODE } create_or_update_github_release() { local existing_body_file existing_state release_version title latest_arg prerelease_arg verify_release_tag_target release_version="${RELEASE_TAG#v}" title="openclaw ${release_version}" prerelease_arg="--prerelease=false" latest_arg="--latest=false" if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then prerelease_arg="--prerelease" elif [[ "${RELEASE_NPM_DIST_TAG}" == "latest" ]]; then latest_arg="--latest" fi if existing_state="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,body 2>/dev/null)"; then # A public page only reaches this call after # guard_existing_public_release accepted it as canonical; leave # it untouched so a failed resume cannot strip its verification # proof before the proof append re-runs. if [[ "$(printf '%s' "${existing_state}" | jq -r '.isDraft')" != "true" ]]; then existing_body_file="${RUNNER_TEMP}/existing-public-release-notes.md" printf '%s' "$(printf '%s' "${existing_state}" | jq -r '.body')" > "${existing_body_file}" if canonical_release_body_matches "${existing_body_file}"; then echo "- GitHub release: existing public page left untouched until proof append" >> "$GITHUB_STEP_SUMMARY" return 0 fi fi gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --title "${title}" \ --notes-file "${prepared_release_notes_file}" \ "${prerelease_arg}" \ "${latest_arg}" else gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ --title "${title}" \ --notes-file "${prepared_release_notes_file}" \ "${prerelease_arg}" \ "${latest_arg}" fi echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } publish_github_release() { local expected_prerelease release_json verify_release_tag_target if is_android_release; then verify_android_release_asset_contract fi if is_stable_release; then verify_windows_release_asset_contract fi gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease)" expected_prerelease="false" if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then expected_prerelease="true" fi if [[ "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "false" ]] || [[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" != "${expected_prerelease}" ]]; then echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2 exit 1 fi echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } verify_android_release_asset_contract() { local actual_android_assets actual_digest expected_android_assets expected_digest expected_hash release_json verify_dir local -a required_assets=( "OpenClaw-Android.apk" "OpenClaw-Android-SHA256SUMS.txt" ) release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets,url)" || return 1 expected_android_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')" actual_android_assets="$(printf '%s' "${release_json}" | jq -c ' [.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | sort ')" if [[ "${actual_android_assets}" != "${expected_android_assets}" ]]; then echo "Stable release Android asset names do not match the canonical contract." >&2 return 1 fi verify_dir="${RUNNER_TEMP}/openclaw-android-release-contract" rm -rf "${verify_dir}" mkdir -p "${verify_dir}" gh release download "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --pattern "OpenClaw-Android.apk" \ --pattern "OpenClaw-Android-SHA256SUMS.txt" \ --dir "${verify_dir}" || return 1 ( cd "${verify_dir}" sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt ) || return 1 expected_hash="$(awk '$2 == "OpenClaw-Android.apk" { print $1 }' "${verify_dir}/OpenClaw-Android-SHA256SUMS.txt")" if [[ ! "${expected_hash}" =~ ^[a-f0-9]{64}$ ]]; then echo "Android checksum manifest does not contain the canonical APK entry." >&2 return 1 fi expected_digest="sha256:${expected_hash}" actual_digest="$(printf '%s' "${release_json}" | jq -r '.assets[]? | select(.name == "OpenClaw-Android.apk") | .digest // empty')" if [[ "${actual_digest}" != "${expected_digest}" ]]; then echo "Android release APK digest does not match its checksum manifest." >&2 return 1 fi # Explicit guard: this function doubles as an `if` predicate, where # bash suppresses errexit, so a failed attestation must not fall # through to the summary echo. gh attestation verify "${verify_dir}/OpenClaw-Android.apk" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \ --source-ref "refs/tags/${RELEASE_TAG}" \ --deny-self-hosted-runners || return 1 echo "- Android APK asset contract: verified" >> "${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)" || return 1 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}" || return 1 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 # Retry-safe: the asset contract is the done-condition, so a prior # publish run's verified promotion is reused instead of re-running # the Windows child workflow. if verify_windows_release_asset_contract >/dev/null 2>&1; then echo "- Windows Hub promotion: assets already promoted and verified; skipping dispatch" >> "$GITHUB_STEP_SUMMARY" return 0 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}")" # Promotion runs in a background subshell; hand the run id to the # parent shell for the release proof links. printf '%s' "${windows_node_run_id}" > "${RUNNER_TEMP}/windows-node-run-id.txt" echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY" wait_for_run windows-node-release.yml "${windows_node_run_id}" "${PARENT_WORKFLOW_SHA}" } promote_android_release_asset() { if ! is_android_release; then return 0 fi # Retry-safe: the asset contract is the done-condition, so a prior # publish run's verified APK promotion is reused instead of # re-running the Android child workflow. if verify_android_release_asset_contract >/dev/null 2>&1; then echo "- Android APK: assets already promoted and verified; skipping dispatch" >> "${GITHUB_STEP_SUMMARY}" return 0 fi android_release_run_id="$(dispatch_workflow_at_ref "${RELEASE_TAG}" "${TARGET_SHA}" android-release.yml \ -f tag="${RELEASE_TAG}" \ -f release_publish_run_id="${GITHUB_RUN_ID}" \ -f release_publish_branch="${CHILD_WORKFLOW_REF}" \ -f release_target_sha="${TARGET_SHA}" \ -f direct_release_recovery=false)" # Promotion runs in a background subshell; hand the run id to the # parent shell for the release proof links. printf '%s' "${android_release_run_id}" > "${RUNNER_TEMP}/android-release-run-id.txt" echo "- Android release run ID: \`${android_release_run_id}\`" >> "${GITHUB_STEP_SUMMARY}" wait_for_run android-release.yml "${android_release_run_id}" "${TARGET_SHA}" } 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}" } replace_release_asset() { local source_path="$1" local asset_name="$2" # Postpublish evidence embeds this run's id, so resumable retries # regenerate it; the newest verification replaces the asset while # prior copies persist as workflow artifacts. gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" \ --repo "${GITHUB_REPOSITORY}" --clobber } 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" replace_release_asset "${RUNNER_TEMP}/${evidence_asset}" "${evidence_asset}" replace_release_asset \ "${RUNNER_TEMP}/${evidence_asset}.sha256" \ "${evidence_asset}.sha256" { echo "- Immutable release manifest: \`${manifest_asset}\`" echo "- Postpublish evidence (latest verification): \`${evidence_asset}\`" } >> "$GITHUB_STEP_SUMMARY" } verify_published_release() { local release_version evidence_path skip_clawhub clawhub_runtime_state_path bootstrap_run_arg_present local validation_manifest validation_run_attempt validation_run_id local validation_target_sha validation_url validation_workflow_ref 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_version}" --tag "${RELEASE_TAG}" --dist-tag "${RELEASE_NPM_DIST_TAG}" --repo "${GITHUB_REPOSITORY}" --release-sha "${TARGET_SHA}" --workflow-ref "${CHILD_WORKFLOW_REF}" --clawhub-workflow-ref "${clawhub_workflow_ref}" --plugin-npm-run "${plugin_npm_run_id}" --evidence-out "${evidence_path}" --skip-github-release ) # Resumed publishes have no core npm run of their own; the # registry package check still verifies the published state. if [[ -n "${openclaw_npm_run_id// }" ]]; then verify_args+=(--openclaw-npm-run "${openclaw_npm_run_id}") fi 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}") bootstrap_run_arg_present="$( jq -r \ '.verifierArgs | index("--plugin-clawhub-bootstrap-run") != null' \ "${clawhub_runtime_state_path}" )" if [[ -n "${PLUGINS// }" ]]; then verify_args+=(--plugins "${PLUGINS}") fi if [[ -n "${bootstrap_plugins// }" && "${bootstrap_run_arg_present}" == "true" ]]; then verify_args+=(--clawhub-bootstrap-plugins "${bootstrap_plugins}") fi if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}") fi node --import tsx \ "${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts" \ "${verify_args[@]}" # Resolve already validated this exact v3 run before mutation. Append # its immutable tuple here so frozen targets need no new CLI option. validation_manifest="${FULL_RELEASE_VALIDATION_MANIFEST_DIR}/full-release-validation-manifest.json" validation_run_id="$(jq -er '.runId | select(type == "string" and length > 0)' "${validation_manifest}")" validation_run_attempt="$(jq -er '.runAttempt | select(type == "string" and test("^[1-9][0-9]*$"))' "${validation_manifest}")" validation_workflow_ref="$(jq -er '.workflowRef | select(type == "string" and length > 0)' "${validation_manifest}")" validation_target_sha="$(jq -er '.targetSha | select(type == "string" and test("^[a-f0-9]{40}$"))' "${validation_manifest}")" if [[ "${validation_run_id}" != "${FULL_RELEASE_VALIDATION_RUN_ID}" || "${validation_run_attempt}" != "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" || "${validation_target_sha}" != "${TARGET_SHA}" ]]; then echo "Full release validation evidence changed after prepublish validation." >&2 exit 1 fi validation_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${validation_run_id}" jq \ --arg release_publish_run_id "$GITHUB_RUN_ID" \ --arg validation_run_id "${validation_run_id}" \ --arg validation_run_attempt "${validation_run_attempt}" \ --arg validation_target_sha "${validation_target_sha}" \ --arg validation_url "${validation_url}" \ --arg validation_workflow_ref "${validation_workflow_ref}" ' .releasePublishRunId = $release_publish_run_id | .workflowRuns += [{ id: $validation_run_id, label: "Full Release Validation", runAttempt: $validation_run_attempt, targetSha: $validation_target_sha, url: $validation_url, workflowRef: $validation_workflow_ref }] ' \ "${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 proof_file notes_file metadata_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path android_line windows_line release_version="${RELEASE_TAG#v}" proof_file="${RUNNER_TEMP}/release-verification.md" notes_file="${RUNNER_TEMP}/release-notes-with-proof.md" metadata_file="${RUNNER_TEMP}/release-notes-with-proof.json" 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}")" 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 android_line="" if [[ -n "${android_release_run_id// }" ]]; then android_line="- Android APK: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android.apk (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${android_release_run_id})" fi RELEASE_PROOF_FILE="${proof_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}" \ ANDROID_LINE="${android_line}" \ WINDOWS_LINE="${windows_line}" \ node --input-type=module <<'NODE' import { writeFileSync } from "node:fs"; const proofFile = process.env.RELEASE_PROOF_FILE; if (!proofFile) { throw new Error("Missing release proof file path."); } 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, // Resumed publishes reuse the already-published npm package and // have no core npm run of their own to cite. ...(process.env.OPENCLAW_NPM_RUN_ID ? [ `- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`, ] : []), process.env.TELEGRAM_LINE, ...(process.env.ANDROID_LINE ? [process.env.ANDROID_LINE] : []), ...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []), ].join("\n"); writeFileSync(proofFile, section); NODE render_github_release_notes "${notes_file}" "${proof_file}" "${metadata_file}" gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}" if jq -e '.verificationIncluded == true' "${metadata_file}" >/dev/null; then echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY" else echo "::warning::Release verification proof omitted because the canonical release notes already reach GitHub's body limit." echo "- Release proof: omitted from body at GitHub limit; immutable evidence remains attached" >> "$GITHUB_STEP_SUMMARY" fi } { echo "### Publish sequence" echo echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`" echo "- Normal ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" echo "- ClawHub bootstrap workflow ref: trusted \`main\`, target \`${TARGET_SHA}\`" 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_android_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then echo "- Android APK: promoted concurrently with the OpenClaw npm publish; required before the GitHub release can be published" fi if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then echo "- Windows Hub promotion: promoted concurrently with the OpenClaw npm publish; 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" prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md" prepared_release_notes_metadata_file="${RUNNER_TEMP}/release-notes-prepublish.json" verify_release_tag_target if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then render_github_release_notes \ "${prepared_release_notes_file}" \ "" \ "${prepared_release_notes_metadata_file}" guard_existing_public_release fi resolve_openclaw_npm_publish_state resolve_clawhub_release_plan bootstrap_workflow_sha="" if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then bootstrap_workflow_sha="$(verify_bootstrap_workflow_sha)" fi 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}")" \ "${TARGET_SHA}" \ "$(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}")" \ "${bootstrap_workflow_sha}" \ "$(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}" "${PARENT_WORKFLOW_SHA}"; 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}" "${bootstrap_workflow_sha}"; 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 # The draft release page is the only shared prerequisite for the # Windows/Android promotions and the core npm publish; create it # first so those stages can run concurrently. create_or_update_github_release if [[ "${openclaw_npm_already_published}" == "true" ]]; then echo "- OpenClaw npm publish: already on npm; resuming postpublish stages" >> "$GITHUB_STEP_SUMMARY" else 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 full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" \ -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" fi 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}" "${TARGET_SHA}" "${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}" "${bootstrap_workflow_sha}" "${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" "${TARGET_SHA}" if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${TARGET_SHA}"; 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 if approve_clawhub_bootstrap_environments "${plugin_clawhub_bootstrap_run_id}" "${bootstrap_workflow_sha}"; then : else echo "- plugin-clawhub-new.yml: child environment gates 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}" "${PARENT_WORKFLOW_SHA}" "${openclaw_result}" openclaw_pid="${wait_run_pid}" fi # Windows/Android promotion only needs the draft release page, so it # runs concurrently with the core npm publish instead of after it. android_promote_result="" android_promote_pid="" windows_promote_result="" windows_promote_pid="" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then android_promote_result="$RUNNER_TEMP/android-promote-result.txt" ( if promote_android_release_asset; then echo "success" > "${android_promote_result}" else echo "failed" > "${android_promote_result}" fi ) & android_promote_pid=$! windows_promote_result="$RUNNER_TEMP/windows-promote-result.txt" ( if promote_windows_release_assets; then echo "success" > "${windows_promote_result}" else echo "failed" > "${windows_promote_result}" fi ) & windows_promote_pid=$! fi failed=0 openclaw_failed=0 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 clawhub_failed=0 if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then failed=1 clawhub_failed=1 fi if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then failed=1 clawhub_failed=1 fi if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then failed=1 clawhub_failed=1 fi if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then failed=1 clawhub_failed=1 fi if [[ -n "${android_promote_pid}" ]]; then wait "${android_promote_pid}" || true if [[ ! -f "${android_promote_result}" || "$(cat "${android_promote_result}")" != "success" ]]; then failed=1 fi fi if [[ -n "${windows_promote_pid}" ]]; then wait "${windows_promote_pid}" || true if [[ ! -f "${windows_promote_result}" || "$(cat "${windows_promote_result}")" != "success" ]]; then failed=1 fi fi windows_node_run_id="$(cat "${RUNNER_TEMP}/windows-node-run-id.txt" 2>/dev/null || true)" android_release_run_id="$(cat "${RUNNER_TEMP}/android-release-run-id.txt" 2>/dev/null || true)" if [[ ( -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ) || "${openclaw_npm_already_published}" == "true" ]]; then # Only a ClawHub failure skips ClawHub verification; unrelated # promotion failures must not reduce the recorded evidence. if [[ "${clawhub_failed}" == "0" ]]; then verify_published_release else verify_published_release true fi upload_dependency_evidence_release_asset upload_release_evidence_assets 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