Files
openclaw/.github/workflows/android-release.yml
Peter Steinberger 054e3675e1 feat(android): publish signed APKs with stable releases (#101212)
* feat(android): publish signed release APKs

* fix(android): preserve fallback corrections

* test(android): isolate APK signer fixtures

* docs: refresh generated docs map
2026-07-07 00:39:59 +01:00

470 lines
22 KiB
YAML

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-36-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-36" \
"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}"