mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 17:31:42 +00:00
2241 lines
110 KiB
YAML
2241 lines
110 KiB
YAML
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:
|
|
GH_TOKEN: ${{ github.token }}
|
|
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 }}
|
|
WORKFLOW_SHA: ${{ github.sha }}
|
|
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
|
|
sha_pinned_release_publish=false
|
|
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then
|
|
if [[ "${WORKFLOW_SHA:0:12}" != "${BASH_REMATCH[1]}" ]]; then
|
|
echo "SHA-pinned release publish branch does not match workflow SHA ${WORKFLOW_SHA}." >&2
|
|
exit 1
|
|
fi
|
|
merge_base_sha="$(
|
|
gh api "repos/${GITHUB_REPOSITORY}/compare/${WORKFLOW_SHA}...main" \
|
|
--jq '.merge_base_commit.sha | select(test("^[a-f0-9]{40}$"))'
|
|
)"
|
|
if [[ "${merge_base_sha}" != "${WORKFLOW_SHA}" ]]; then
|
|
echo "SHA-pinned release publish workflow revision is not reachable from current main." >&2
|
|
exit 1
|
|
fi
|
|
sha_pinned_release_publish=true
|
|
fi
|
|
if [[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" && "${sha_pinned_release_publish}" != "true" ]]; then
|
|
echo "OpenClaw Release Publish must use trusted main workflow tooling, a SHA-pinned release-publish branch, or 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[@]:-<none>}" >&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:-<missing>}." >&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 | select(type == "boolean") | tostring' "${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:-<missing>}, 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("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
|
|
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
|
|
else error("malformed Windows checksum manifest entry")
|
|
end
|
|
' "${manifest_path}")"; then
|
|
echo "Stable release Windows checksum manifest contains malformed entries." >&2
|
|
return 1
|
|
fi
|
|
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
|
|
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
|
|
length == ($expected | length) and
|
|
([.[].name] | sort) == $expected and
|
|
([.[].name] | unique | length) == length
|
|
' >/dev/null; then
|
|
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
|
|
return 1
|
|
fi
|
|
for asset_name in "${installer_assets[@]}"; do
|
|
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
|
|
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
|
|
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
|
|
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
|
|
return 1
|
|
fi
|
|
expected_hash="${expected_digest#sha256:}"
|
|
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
|
|
any(.[]; .name == $name and .hash == $hash)
|
|
' >/dev/null; then
|
|
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
|
|
return 1
|
|
fi
|
|
done
|
|
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
|
|
}
|
|
|
|
promote_windows_release_assets() {
|
|
if ! is_stable_release; then
|
|
return 0
|
|
fi
|
|
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
|
|
echo "Stable release is missing prevalidated Windows installer digests." >&2
|
|
return 1
|
|
fi
|
|
# 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
|