name: Android Release on: workflow_dispatch: inputs: tag: description: Existing stable OpenClaw release tag to receive the signed Android APK required: true type: string release_publish_run_id: description: OpenClaw Release Publish run that approved this release required: true type: string release_publish_branch: description: Branch used by the approving OpenClaw Release Publish run required: true type: string release_target_sha: description: Exact release tag commit resolved by OpenClaw Release Publish required: true type: string direct_release_recovery: description: Allow a completed parent run and published release for explicit backfill or recovery required: true default: false type: boolean permissions: actions: read attestations: write contents: write id-token: write concurrency: group: android-release-${{ inputs.tag }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" jobs: publish_signed_android_apk: name: Publish signed Android APK runs-on: ubuntu-latest timeout-minutes: 45 environment: android-release steps: - name: Checkout release tag uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 persist-credentials: false - name: Download parent release approval uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: android-release-approval-${{ inputs.release_publish_run_id }} path: ${{ runner.temp }}/android-release-approval repository: ${{ github.repository }} run-id: ${{ inputs.release_publish_run_id }} github-token: ${{ github.token }} - name: Validate release approval and target env: APPROVAL_PATH: ${{ runner.temp }}/android-release-approval/approval.json DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }} EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch }} GH_TOKEN: ${{ github.token }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_TARGET_SHA: ${{ inputs.release_target_sha }} run: | set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then echo "Android APK publishing requires a final or correction OpenClaw release tag: ${RELEASE_TAG}" >&2 exit 1 fi if [[ "${EXPECTED_WORKFLOW_BRANCH}" != "main" && ! "${EXPECTED_WORKFLOW_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then echo "release_publish_branch must be main or release/YYYY.M.PATCH." >&2 exit 1 fi expected_source_ref="refs/tags/${RELEASE_TAG}" if [[ "${GITHUB_REF}" != "${expected_source_ref}" ]]; then echo "Android publication must run from ${expected_source_ref}, got ${GITHUB_REF}." >&2 exit 1 fi if [[ ! "${RELEASE_TARGET_SHA}" =~ ^[a-f0-9]{40}$ ]]; then echo "release_target_sha must be a full lowercase commit SHA." >&2 exit 1 fi gh attestation verify "${APPROVAL_PATH}" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/openclaw-release-publish.yml" \ --source-ref "refs/heads/${EXPECTED_WORKFLOW_BRANCH}" \ --deny-self-hosted-runners run_json="$(gh run view "${RELEASE_PUBLISH_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --json workflowName,headBranch,event,status,conclusion,url)" printf '%s' "${run_json}" | node scripts/validate-release-publish-approval.mjs tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" if [[ "${RELEASE_TARGET_SHA}" != "${tag_sha}" ]]; then echo "Release target SHA ${RELEASE_TARGET_SHA} does not match ${RELEASE_TAG} (${tag_sha})." >&2 exit 1 fi release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,assets,url)" if [[ "$(printf '%s' "${release_json}" | jq -r '.tagName')" != "${RELEASE_TAG}" ]]; then echo "GitHub release tag does not match ${RELEASE_TAG}." >&2 exit 1 fi if [[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" == "true" ]]; then echo "Android APK publishing requires a stable GitHub release." >&2 exit 1 fi if [[ "${DIRECT_RELEASE_RECOVERY}" != "true" && "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "true" ]]; then echo "Normal Android promotion requires the target GitHub release to remain a draft." >&2 exit 1 fi unexpected_assets="$(printf '%s' "${release_json}" | jq -r '[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | join(", ")')" if [[ -n "${unexpected_assets}" ]]; then echo "Target release already contains Android assets: ${unexpected_assets}. Immutable recovery accepts them only after rebuilding identical bytes." >&2 fi - name: Verify release source and Android version id: release_source env: RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail release_version="${RELEASE_TAG#v}" android_release_version="${release_version%%-*}" package_version="$(node -p 'require("./package.json").version')" android_version="$(node -p 'require("./apps/android/version.json").version')" fallback_base_tag="" fallback_base_sha="" if [[ "${package_version}" == "${release_version}" ]]; then : elif [[ "${RELEASE_TAG}" =~ -[1-9][0-9]*$ && "${package_version}" == "${android_release_version}" ]]; then fallback_base_tag="v${package_version}" fallback_base_sha="$(git rev-parse "${fallback_base_tag}^{commit}")" release_sha="$(git rev-parse HEAD)" if [[ "${fallback_base_sha}" != "${release_sha}" ]]; then echo "Fallback correction ${RELEASE_TAG} must resolve to the same source commit as ${fallback_base_tag}." >&2 exit 1 fi else echo "Release tag version ${release_version} does not match package.json ${package_version} or a same-commit fallback correction." >&2 exit 1 fi if [[ "${android_version}" != "${android_release_version}" ]]; then echo "Android version ${android_version} does not match release train ${android_release_version}." >&2 exit 1 fi echo "fallback_base_tag=${fallback_base_tag}" >> "${GITHUB_OUTPUT}" echo "FALLBACK_ANDROID_BASE_TAG=${fallback_base_tag}" >> "${GITHUB_ENV}" echo "FALLBACK_ANDROID_BASE_SHA=${fallback_base_sha}" >> "${GITHUB_ENV}" git diff --exit-code - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "true" install-deps: "false" use-actions-cache: "false" - name: Setup Java uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: temurin java-version: 17 cache: gradle cache-dependency-path: | apps/android/**/*.gradle* apps/android/**/gradle-wrapper.properties apps/android/gradle/libs.versions.toml - name: Cache Android SDK uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.android-sdk key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0 restore-keys: | ${{ runner.os }}-android-sdk-v1- - name: Setup Android SDK run: | set -euo pipefail ANDROID_SDK_ROOT="$HOME/.android-sdk" CMDLINE_TOOLS_VERSION="14742923" ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" URL="https://dl.google.com/android/repository/${ARCHIVE}" if [[ ! -x "${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager" ]]; then mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" curl -fsSL "${URL}" -o "${RUNNER_TEMP}/${ARCHIVE}" rm -rf "${ANDROID_SDK_ROOT}/cmdline-tools/latest" unzip -q "${RUNNER_TEMP}/${ARCHIVE}" -d "${ANDROID_SDK_ROOT}/cmdline-tools" mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" fi echo "ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}" >> "${GITHUB_ENV}" echo "ANDROID_HOME=${ANDROID_SDK_ROOT}" >> "${GITHUB_ENV}" echo "${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin" >> "${GITHUB_PATH}" echo "${ANDROID_SDK_ROOT}/platform-tools" >> "${GITHUB_PATH}" - name: Install Android SDK packages run: | set -eu yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \ "platform-tools" \ "platforms;android-37.0" \ "build-tools;36.0.0" - name: Create apps-signing read token if: ${{ steps.release_source.outputs.fallback_base_tag == '' }} id: apps-signing-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: openclaw repositories: apps-signing permission-contents: read - name: Checkout encrypted Android signing assets if: ${{ steps.release_source.outputs.fallback_base_tag == '' }} uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: repository: openclaw/apps-signing ref: main path: apps/android/build/release-signing/apps-signing token: ${{ steps.apps-signing-token.outputs.token }} persist-credentials: false - name: Materialize Android release signing if: ${{ steps.release_source.outputs.fallback_base_tag == '' }} env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} run: | set -euo pipefail if [[ -z "${MATCH_PASSWORD}" ]]; then echo "Android release signing secrets are unavailable." >&2 exit 1 fi node scripts/android-release-signing.mjs --mode materialize - name: Prepare and verify signed Android APK env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail mkdir -p dist if [[ -n "${FALLBACK_ANDROID_BASE_TAG}" ]]; then base_dir="${RUNNER_TEMP}/fallback-android-release" mkdir -p "${base_dir}" gh release download "${FALLBACK_ANDROID_BASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ --pattern OpenClaw-Android.apk \ --pattern OpenClaw-Android-SHA256SUMS.txt \ --dir "${base_dir}" ( cd "${base_dir}" sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt ) gh attestation verify "${base_dir}/OpenClaw-Android.apk" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \ --source-ref "refs/tags/${FALLBACK_ANDROID_BASE_TAG}" \ --source-digest "${FALLBACK_ANDROID_BASE_SHA}" \ --deny-self-hosted-runners bun apps/android/scripts/build-release-artifacts.ts \ --verify-apk "${base_dir}/OpenClaw-Android.apk" cp "${base_dir}/OpenClaw-Android.apk" dist/OpenClaw-Android.apk cp "${base_dir}/OpenClaw-Android-SHA256SUMS.txt" dist/OpenClaw-Android-SHA256SUMS.txt else node --input-type=module <<'NODE' import { readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; const path = "apps/android/build/release-signing/gradle.properties"; const expected = new Set([ "OPENCLAW_ANDROID_STORE_FILE", "OPENCLAW_ANDROID_STORE_PASSWORD", "OPENCLAW_ANDROID_KEY_ALIAS", "OPENCLAW_ANDROID_KEY_PASSWORD", ]); const properties = new Map(); for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const separator = line.indexOf("="); if (separator <= 0) throw new Error("Malformed Android signing property."); properties.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim()); } for (const name of expected) { const value = properties.get(name); if (!value) throw new Error(`Missing Android signing property: ${name}`); process.env[`ORG_GRADLE_PROJECT_${name}`] = value; } const result = spawnSync( "bun", ["apps/android/scripts/build-release-artifacts.ts", "--artifact", "third-party"], { env: process.env, stdio: "inherit" }, ); process.exit(result.status ?? 1); NODE version="$(node -p 'require("./apps/android/version.json").version')" source_apk="apps/android/build/release-artifacts/openclaw-${version}-third-party-release.apk" source_checksum="${source_apk}.sha256" test -s "${source_apk}" test -s "${source_checksum}" cp "${source_apk}" dist/OpenClaw-Android.apk ( cd dist sha256sum OpenClaw-Android.apk > OpenClaw-Android-SHA256SUMS.txt ) fi expected_version_name="${RELEASE_TAG#v}" expected_version_name="${expected_version_name%%-*}" expected_version_code="$(node -p 'require("./apps/android/version.json").versionCode')" actual_app_id="$(apkanalyzer manifest application-id dist/OpenClaw-Android.apk)" actual_version_name="$(apkanalyzer manifest version-name dist/OpenClaw-Android.apk)" actual_version_code="$(apkanalyzer manifest version-code dist/OpenClaw-Android.apk)" if [[ "${actual_app_id}" != "ai.openclaw.app" ]]; then echo "Standalone APK has unexpected application ID ${actual_app_id}." >&2 exit 1 fi if [[ "${actual_version_name}" != "${expected_version_name}" || "${actual_version_code}" != "${expected_version_code}" ]]; then echo "Standalone APK version metadata does not match apps/android/version.json." >&2 exit 1 fi if [[ -n "${FALLBACK_ANDROID_BASE_TAG}" ]]; then echo "Reusing verified Android APK from ${FALLBACK_ANDROID_BASE_TAG} for same-commit fallback correction ${RELEASE_TAG}." elif [[ "${RELEASE_TAG}" =~ -([1-9][0-9]*)$ ]]; then correction_number="${BASH_REMATCH[1]}" releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" previous_tag="$(printf '%s' "${releases_json}" | jq -r \ --arg base_tag "v${expected_version_name}" \ --argjson correction_number "${correction_number}" ' [ .[][] | select(.draft == false and .prerelease == false) | select(any(.assets[]?; .name == "OpenClaw-Android.apk")) | select(any(.assets[]?; .name == "OpenClaw-Android-SHA256SUMS.txt")) | if .tag_name == $base_tag then { tag: .tag_name, number: 0 } elif (.tag_name | startswith($base_tag + "-")) then (.tag_name | ltrimstr($base_tag + "-")) as $suffix | select($suffix | test("^[1-9][0-9]*$")) | { tag: .tag_name, number: ($suffix | tonumber) } else empty end | select(.number < $correction_number) ] | sort_by(.number) | last | .tag // empty ')" if [[ -n "${previous_tag}" ]]; then previous_dir="${RUNNER_TEMP}/previous-android-release" mkdir -p "${previous_dir}" gh release download "${previous_tag}" --repo "${GITHUB_REPOSITORY}" \ --pattern OpenClaw-Android.apk \ --pattern OpenClaw-Android-SHA256SUMS.txt \ --dir "${previous_dir}" ( cd "${previous_dir}" sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt ) gh attestation verify "${previous_dir}/OpenClaw-Android.apk" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \ --source-ref "refs/tags/${previous_tag}" \ --deny-self-hosted-runners bun apps/android/scripts/build-release-artifacts.ts \ --verify-apk "${previous_dir}/OpenClaw-Android.apk" previous_version_code="$(apkanalyzer manifest version-code "${previous_dir}/OpenClaw-Android.apk")" if (( actual_version_code <= previous_version_code )); then echo "Android correction versionCode ${actual_version_code} must exceed ${previous_tag} versionCode ${previous_version_code}." >&2 exit 1 fi else echo "No earlier verified standalone APK exists for ${expected_version_name}; accepting this correction as the standalone channel bootstrap." fi fi - name: Attest Android APK provenance uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-path: dist/OpenClaw-Android.apk - name: Upload immutable Android release assets env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail attach_or_verify_asset() { local source_path="$1" local asset_name="$2" local existing_dir="${RUNNER_TEMP}/existing-${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_dir}/${asset_name}" || { echo "Existing Android release asset ${asset_name} differs from this exact-tag build." >&2 exit 1 } return fi gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" --repo "${GITHUB_REPOSITORY}" } attach_or_verify_asset dist/OpenClaw-Android.apk OpenClaw-Android.apk attach_or_verify_asset dist/OpenClaw-Android-SHA256SUMS.txt OpenClaw-Android-SHA256SUMS.txt - name: Verify published Android release assets env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail verify_dir="${RUNNER_TEMP}/verify-android-release" mkdir -p "${verify_dir}" gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ --pattern OpenClaw-Android.apk \ --pattern OpenClaw-Android-SHA256SUMS.txt \ --dir "${verify_dir}" ( cd "${verify_dir}" sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt ) 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 bun apps/android/scripts/build-release-artifacts.ts \ --verify-apk "${verify_dir}/OpenClaw-Android.apk" release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets)" expected_names='["OpenClaw-Android-SHA256SUMS.txt","OpenClaw-Android.apk"]' actual_names="$(printf '%s' "${release_json}" | jq -c '[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | sort')" if [[ "${actual_names}" != "${expected_names}" ]]; then echo "Android release asset names do not match the canonical contract." >&2 exit 1 fi - name: Remove materialized signing files if: ${{ always() }} run: rm -rf apps/android/build/release-signing - name: Summary env: RELEASE_TAG: ${{ inputs.tag }} run: | { echo "## Signed Android APK published" echo echo "- APK: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android.apk" echo "- Checksums: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android-SHA256SUMS.txt" echo "- Provenance: \`gh attestation verify OpenClaw-Android.apk --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/android-release.yml\`" } >> "${GITHUB_STEP_SUMMARY}"