From 34baea5db56d1894c18288ed72921db3ca66af77 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Thu, 14 May 2026 02:08:30 -0400 Subject: [PATCH] feat(actions): add android comment session and defer ios URL --- .../run-android-comment-session/action.yml | 690 ++++++++++++++++++ actions/run-ios-comment-session/action.yml | 14 +- actions/upload-android-apk/action.yml | 105 +++ docs/guide/github-actions.md | 115 ++- 4 files changed, 899 insertions(+), 25 deletions(-) create mode 100644 actions/run-android-comment-session/action.yml create mode 100644 actions/upload-android-apk/action.yml diff --git a/actions/run-android-comment-session/action.yml b/actions/run-android-comment-session/action.yml new file mode 100644 index 00000000..25d774c6 --- /dev/null +++ b/actions/run-android-comment-session/action.yml @@ -0,0 +1,690 @@ +name: Run Android App in SimDeck + +description: Start a Cloudflare Tunnel-backed SimDeck Android session for a pull request APK artifact. + +inputs: + pr_number: + description: Pull request number to run in SimDeck. + required: true + command: + description: Original issue comment body. Flags such as quality=tiny and public-health are honored. + required: false + default: simdeck run android + command_comment_id: + description: GitHub issue comment id that triggered the session. When set, the action reacts to this comment immediately. + required: false + default: "" + command_comment_author: + description: GitHub username to mention when the URL is ready. + required: false + default: "" + command_reaction: + description: Reaction to add to the triggering comment. Use one of +1, -1, laugh, confused, heart, hooray, rocket, eyes. + required: false + default: eyes + pr_sha: + description: Optional pull request head SHA. When omitted, the action resolves it through the GitHub API. + required: false + default: "" + build_workflow: + description: Workflow file name that uploads the Android APK artifact. + required: false + default: build-android-apk.yml + artifact_prefix: + description: Artifact prefix. The default artifact name is -. + required: false + default: android-apk + artifact_name: + description: Exact artifact name to download. Overrides artifact_prefix when set. + required: false + default: "" + package_name: + description: Android package name to launch when it cannot be read from the APK. + required: false + default: "" + keepalive_seconds: + description: How long to keep the session open after launch. + required: false + default: "1800" + simdeck_package: + description: npm package name to install. + required: false + default: simdeck + simdeck_version: + description: npm package version or dist-tag. + required: false + default: latest + simdeck_port: + description: Local daemon port on the runner. + required: false + default: "4310" + stream_profile: + description: SimDeck stream quality profile. + required: false + default: tiny + avd_name: + description: Android AVD name to create or reuse. + required: false + default: SimDeck_Pixel_CI + android_api_level: + description: Android API level for the managed AVD. + required: false + default: "36" + android_target: + description: Android SDK system image target. + required: false + default: google_apis + android_arch: + description: Android SDK system image architecture. When empty, the runner architecture is used. + required: false + default: "" + android_build_tools: + description: Android SDK build-tools version used to inspect APK metadata. + required: false + default: "36.0.0" + public_health_check: + description: Verify the public Cloudflare Tunnel health endpoint before continuing. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Prepare SimDeck Android session inputs + shell: bash + env: + GH_TOKEN_VALUE: ${{ github.token }} + REPO_VALUE: ${{ github.repository }} + PR_NUMBER_VALUE: ${{ inputs.pr_number }} + PR_SHA_INPUT_VALUE: ${{ inputs.pr_sha }} + SIMDECK_COMMENT_BODY_VALUE: ${{ inputs.command }} + COMMAND_COMMENT_ID_VALUE: ${{ inputs.command_comment_id }} + COMMAND_COMMENT_AUTHOR_VALUE: ${{ inputs.command_comment_author }} + COMMAND_REACTION_VALUE: ${{ inputs.command_reaction }} + ANDROID_PACKAGE_NAME_VALUE: ${{ inputs.package_name }} + SIMDECK_PORT_VALUE: ${{ inputs.simdeck_port }} + SIMDECK_PACKAGE_VALUE: ${{ inputs.simdeck_package }} + SIMDECK_VERSION_VALUE: ${{ inputs.simdeck_version }} + INPUT_STREAM_PROFILE_VALUE: ${{ inputs.stream_profile }} + INPUT_AVD_NAME_VALUE: ${{ inputs.avd_name }} + INPUT_ANDROID_API_LEVEL_VALUE: ${{ inputs.android_api_level }} + INPUT_ANDROID_TARGET_VALUE: ${{ inputs.android_target }} + INPUT_ANDROID_ARCH_VALUE: ${{ inputs.android_arch }} + INPUT_ANDROID_BUILD_TOOLS_VALUE: ${{ inputs.android_build_tools }} + INPUT_PUBLIC_HEALTH_CHECK_VALUE: ${{ inputs.public_health_check }} + KEEPALIVE_SECONDS_VALUE: ${{ inputs.keepalive_seconds }} + BUILD_WORKFLOW_VALUE: ${{ inputs.build_workflow }} + ARTIFACT_PREFIX_VALUE: ${{ inputs.artifact_prefix }} + ARTIFACT_NAME_INPUT_VALUE: ${{ inputs.artifact_name }} + run: | + set -euo pipefail + + write_env() { + local name="$1" + local value="$2" + local delimiter="SIMDECK_${name}_$(uuidgen | tr '[:lower:]' '[:upper:]')" + { + echo "${name}<<${delimiter}" + printf '%s\n' "${value}" + echo "${delimiter}" + } >> "${GITHUB_ENV}" + } + + write_env "GH_TOKEN" "${GH_TOKEN_VALUE}" + write_env "REPO" "${REPO_VALUE}" + write_env "PR_NUMBER" "${PR_NUMBER_VALUE}" + write_env "PR_SHA_INPUT" "${PR_SHA_INPUT_VALUE}" + write_env "SIMDECK_COMMENT_BODY" "${SIMDECK_COMMENT_BODY_VALUE}" + write_env "COMMAND_COMMENT_ID" "${COMMAND_COMMENT_ID_VALUE}" + write_env "COMMAND_COMMENT_AUTHOR" "${COMMAND_COMMENT_AUTHOR_VALUE}" + write_env "COMMAND_REACTION" "${COMMAND_REACTION_VALUE}" + write_env "ANDROID_PACKAGE_NAME" "${ANDROID_PACKAGE_NAME_VALUE}" + write_env "SIMDECK_PORT" "${SIMDECK_PORT_VALUE}" + write_env "SIMDECK_PACKAGE" "${SIMDECK_PACKAGE_VALUE}" + write_env "SIMDECK_VERSION" "${SIMDECK_VERSION_VALUE}" + write_env "INPUT_STREAM_PROFILE" "${INPUT_STREAM_PROFILE_VALUE}" + write_env "SIMDECK_ANDROID_AVD_NAME" "${INPUT_AVD_NAME_VALUE}" + write_env "SIMDECK_ANDROID_API_LEVEL" "${INPUT_ANDROID_API_LEVEL_VALUE}" + write_env "SIMDECK_ANDROID_TARGET" "${INPUT_ANDROID_TARGET_VALUE}" + write_env "SIMDECK_ANDROID_ARCH" "${INPUT_ANDROID_ARCH_VALUE}" + write_env "SIMDECK_ANDROID_BUILD_TOOLS" "${INPUT_ANDROID_BUILD_TOOLS_VALUE}" + write_env "INPUT_PUBLIC_HEALTH_CHECK" "${INPUT_PUBLIC_HEALTH_CHECK_VALUE}" + write_env "KEEPALIVE_SECONDS" "${KEEPALIVE_SECONDS_VALUE}" + write_env "BUILD_WORKFLOW" "${BUILD_WORKFLOW_VALUE}" + write_env "ARTIFACT_PREFIX" "${ARTIFACT_PREFIX_VALUE}" + write_env "ARTIFACT_NAME_INPUT" "${ARTIFACT_NAME_INPUT_VALUE}" + write_env "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" "true" + + - name: React to command and resolve flags + shell: bash + run: | + set -euo pipefail + + if [[ -n "${COMMAND_COMMENT_ID:-}" && -n "${COMMAND_REACTION:-}" ]]; then + gh api \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + "repos/${REPO}/issues/comments/${COMMAND_COMMENT_ID}/reactions" \ + -f content="${COMMAND_REACTION}" >/dev/null || true + fi + + body="${SIMDECK_COMMENT_BODY}" + stream_profile="${INPUT_STREAM_PROFILE:-tiny}" + public_health_check=0 + if [[ "${INPUT_PUBLIC_HEALTH_CHECK}" == "true" ]]; then + public_health_check=1 + fi + + for profile in tiny low economy fast smooth balanced full quality ci-software; do + if [[ "${body}" == *" ${profile}"* || "${body}" == *" quality=${profile}"* ]]; then + stream_profile="${profile}" + fi + done + if [[ "${body}" == *" public-health"* ]]; then + public_health_check=1 + fi + + echo "SIMDECK_STREAM_PROFILE=${stream_profile}" >> "${GITHUB_ENV}" + echo "SIMDECK_PUBLIC_HEALTH_CHECK=${public_health_check}" >> "${GITHUB_ENV}" + echo "Stream profile: ${stream_profile}" + + - name: Install Android SDK packages + shell: bash + run: | + set -euo pipefail + + export ANDROID_HOME="${ANDROID_HOME:-${HOME}/Library/Android/sdk}" + export ANDROID_SDK_ROOT="${ANDROID_HOME}" + mkdir -p "${ANDROID_HOME}" + echo "ANDROID_HOME=${ANDROID_HOME}" >> "${GITHUB_ENV}" + echo "ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}" >> "${GITHUB_ENV}" + echo "${ANDROID_HOME}/cmdline-tools/latest/bin" >> "${GITHUB_PATH}" + echo "${ANDROID_HOME}/platform-tools" >> "${GITHUB_PATH}" + echo "${ANDROID_HOME}/emulator" >> "${GITHUB_PATH}" + export PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator:${PATH}" + + if ! command -v sdkmanager >/dev/null 2>&1; then + mkdir -p "${ANDROID_HOME}/cmdline-tools" + curl -fsSL "https://dl.google.com/android/repository/commandlinetools-mac-13114758_latest.zip" -o "${RUNNER_TEMP:-/tmp}/android-commandlinetools.zip" + rm -rf "${ANDROID_HOME}/cmdline-tools/latest" + unzip -q "${RUNNER_TEMP:-/tmp}/android-commandlinetools.zip" -d "${ANDROID_HOME}/cmdline-tools" + mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" + fi + + arch="${SIMDECK_ANDROID_ARCH}" + if [[ -z "${arch}" ]]; then + case "$(uname -m)" in + arm64) arch="arm64-v8a" ;; + *) arch="x86_64" ;; + esac + fi + echo "SIMDECK_ANDROID_ARCH=${arch}" >> "${GITHUB_ENV}" + + system_image="system-images;android-${SIMDECK_ANDROID_API_LEVEL};${SIMDECK_ANDROID_TARGET};${arch}" + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "emulator" \ + "build-tools;${SIMDECK_ANDROID_BUILD_TOOLS}" \ + "platforms;android-${SIMDECK_ANDROID_API_LEVEL}" \ + "${system_image}" + echo "${ANDROID_HOME}/build-tools/${SIMDECK_ANDROID_BUILD_TOOLS}" >> "${GITHUB_PATH}" + + if ! emulator -list-avds | grep -Fxq "${SIMDECK_ANDROID_AVD_NAME}"; then + echo "no" | avdmanager create avd \ + --force \ + --name "${SIMDECK_ANDROID_AVD_NAME}" \ + --package "${system_image}" \ + --device "pixel_6" + fi + + - name: Install tools, start SimDeck and tunnel + id: stream + shell: bash + run: | + set -euo pipefail + npm_prefix="${GITHUB_WORKSPACE}/.tools/npm-global" + cloudflared_dir="${GITHUB_WORKSPACE}/.tools/cloudflared" + simdeck_dir="${GITHUB_WORKSPACE}/.tools/simdeck" + bin_dir="${GITHUB_WORKSPACE}/.tools/bin" + mkdir -p "${npm_prefix}" "${cloudflared_dir}" "${simdeck_dir}" "${bin_dir}" + + export NPM_CONFIG_PREFIX="${npm_prefix}" + export PATH="${bin_dir}:${npm_prefix}/bin:${cloudflared_dir}:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator:${PATH}" + + echo "${bin_dir}" >> "${GITHUB_PATH}" + echo "${npm_prefix}/bin" >> "${GITHUB_PATH}" + echo "${cloudflared_dir}" >> "${GITHUB_PATH}" + + ( + metadata_url="https://registry.npmjs.org/${SIMDECK_PACKAGE}/${SIMDECK_VERSION:-latest}" + metadata="$(curl -fsSL "${metadata_url}")" + tarball="$(SIMDECK_METADATA="${metadata}" python3 -c 'import json, os; print(json.loads(os.environ["SIMDECK_METADATA"])["dist"]["tarball"])')" + version="$(SIMDECK_METADATA="${metadata}" python3 -c 'import json, os; print(json.loads(os.environ["SIMDECK_METADATA"])["version"])')" + rm -rf "${simdeck_dir:?}/"* + curl -fsSL "${tarball}" | tar -xz -C "${simdeck_dir}" --strip-components=1 + chmod +x "${simdeck_dir}/bin/simdeck.mjs" "${simdeck_dir}/build/simdeck-bin" + ln -sf "${simdeck_dir}/bin/simdeck.mjs" "${bin_dir}/simdeck" + echo "Installed ${SIMDECK_PACKAGE} ${version}" + ) & + simdeck_install_pid="$!" + + ( + case "$(uname -m)" in + arm64) cloudflared_arch="arm64" ;; + x86_64) cloudflared_arch="amd64" ;; + *) + echo "Unsupported macOS architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${cloudflared_arch}.tgz" \ + | tar -xz -C "${cloudflared_dir}" + chmod +x "${cloudflared_dir}/cloudflared" + ) & + cloudflared_install_pid="$!" + + wait "${simdeck_install_pid}" + wait "${cloudflared_install_pid}" + + simdeck --version + cloudflared --version + access_token="$(openssl rand -hex 32)" + metadata_path="${RUNNER_TEMP:-/tmp}/simdeck-daemon-${GITHUB_RUN_ID}.json" + case "${SIMDECK_STREAM_PROFILE}" in + tiny) + stream_max_edge=540 + stream_fps=24 + stream_min_bitrate=600000 + stream_bits_per_pixel=2 + ;; + low | economy) + stream_max_edge=720 + stream_fps=30 + stream_min_bitrate=900000 + stream_bits_per_pixel=2 + ;; + *) + stream_max_edge=960 + stream_fps=30 + stream_min_bitrate=1200000 + stream_bits_per_pixel=2 + ;; + esac + + simdeck daemon stop || true + existing_pid="$(lsof -ti tcp:"${SIMDECK_PORT}" -sTCP:LISTEN | head -n 1 || true)" + if [[ -n "${existing_pid}" ]]; then + kill "${existing_pid}" || true + sleep 2 + fi + if lsof -ti tcp:"${SIMDECK_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Port ${SIMDECK_PORT} is still in use before starting SimDeck" >&2 + lsof -nP -iTCP:"${SIMDECK_PORT}" -sTCP:LISTEN >&2 || true + exit 1 + fi + + SIMDECK_VIDEO_CODEC=software \ + SIMDECK_ANDROID_VIDEO_CODEC=software \ + SIMDECK_ALLOWED_ORIGINS='*' \ + SIMDECK_REALTIME_STREAM=1 \ + SIMDECK_STREAM_QUALITY_PROFILE="${SIMDECK_STREAM_PROFILE}" \ + SIMDECK_REALTIME_MAX_EDGE="${stream_max_edge}" \ + SIMDECK_REALTIME_FPS="${stream_fps}" \ + SIMDECK_REALTIME_MIN_BITRATE="${stream_min_bitrate}" \ + SIMDECK_REALTIME_BITS_PER_PIXEL="${stream_bits_per_pixel}" \ + SIMDECK_LOCAL_STREAM_FPS="${stream_fps}" \ + simdeck daemon run \ + --project-root "${GITHUB_WORKSPACE}" \ + --metadata-path "${metadata_path}" \ + --port "${SIMDECK_PORT}" \ + --bind 127.0.0.1 \ + --video-codec software \ + --stream-quality "${SIMDECK_STREAM_PROFILE}" \ + --local-stream-fps "${stream_fps}" \ + --access-token "${access_token}" \ + --pairing-code 000000 \ + > simdeck-daemon.log 2>&1 & + echo "$!" > simdeck.pid + + cloudflared tunnel --url "http://127.0.0.1:${SIMDECK_PORT}" --protocol http2 --no-autoupdate > cloudflared.log 2>&1 & + echo "$!" > cloudflared.pid + + local_health_url="http://127.0.0.1:${SIMDECK_PORT}/api/health?simdeckToken=${access_token}" + local_ready=0 + for attempt in {1..120}; do + if ! kill -0 "$(cat simdeck.pid)" 2>/dev/null; then + cat simdeck-daemon.log >&2 + echo "SimDeck daemon exited before becoming healthy" >&2 + exit 1 + fi + if curl -fsS "${local_health_url}" >/dev/null; then + local_ready=1 + break + fi + sleep 0.25 + done + if [[ "${local_ready}" -ne 1 ]]; then + cat simdeck-daemon.log >&2 + echo "SimDeck daemon did not become healthy" >&2 + exit 1 + fi + + tunnel_url="" + for attempt in {1..120}; do + tunnel_url="$(grep -Eo 'https://[-a-zA-Z0-9.]+\.trycloudflare\.com' cloudflared.log | head -n 1 || true)" + if [[ -n "${tunnel_url}" ]]; then + break + fi + sleep 0.25 + done + + if [[ -z "${tunnel_url}" ]]; then + cat cloudflared.log >&2 + echo "Cloudflare Tunnel did not produce a public URL" >&2 + exit 1 + fi + + echo "url=${tunnel_url}" >> "${GITHUB_OUTPUT}" + echo "access_token=${access_token}" >> "${GITHUB_OUTPUT}" + + public_health_url="${tunnel_url}/api/health?simdeckToken=${access_token}" + tunnel_host="${tunnel_url#https://}" + tunnel_host="${tunnel_host%%/*}" + + public_health_check() { + curl -fsS "${public_health_url}" -o public-health.json || + curl -fsS --resolve "${tunnel_host}:443:104.16.230.132" "${public_health_url}" -o public-health.json || + curl -fsS --resolve "${tunnel_host}:443:104.16.231.132" "${public_health_url}" -o public-health.json + } + + verify_public_health() { + python3 - <<'PY' + import json + + with open("public-health.json", "r", encoding="utf-8") as handle: + data = json.load(handle) + + if data.get("ok") is not True: + raise SystemExit("Public health check failed") + PY + } + + if [[ "${SIMDECK_PUBLIC_HEALTH_CHECK}" != "1" ]]; then + exit 0 + fi + + for attempt in {1..10}; do + if public_health_check && verify_public_health; then + exit 0 + fi + sleep 1 + done + + echo "SimDeck public health check failed through Cloudflare Tunnel" >&2 + cat cloudflared.log >&2 + exit 1 + + - name: Resolve PR head + id: pr + shell: bash + run: | + set -euo pipefail + if [[ -n "${PR_SHA_INPUT}" ]]; then + sha="${PR_SHA_INPUT}" + else + sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + fi + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + + - name: Start APK artifact download + shell: bash + run: | + set -euo pipefail + sha="${{ steps.pr.outputs.sha }}" + if [[ -n "${ARTIFACT_NAME_INPUT}" ]]; then + artifact_name="${ARTIFACT_NAME_INPUT}" + else + artifact_name="${ARTIFACT_PREFIX}-${sha}" + fi + mkdir -p downloaded-app + rm -f /tmp/simdeck-artifact-download.status app-download.log + + ( + set +e + { + run_id="" + for attempt in {1..30}; do + run_id="$(gh api -X GET "repos/${REPO}/actions/artifacts?name=${artifact_name}&per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | .workflow_run.id' \ + | head -n 1 || true)" + + if [[ -z "${run_id}" && -n "${BUILD_WORKFLOW}" ]]; then + run_id="$(gh api --paginate "repos/${REPO}/actions/workflows/${BUILD_WORKFLOW}/runs?per_page=100" \ + --jq ".workflow_runs[] | select(.head_sha == \"${sha}\" and .conclusion == \"success\") | .id" \ + | head -n 1 || true)" + fi + + if [[ -n "${run_id}" ]]; then + echo "Using build workflow run ${run_id} for ${sha}" + gh run download "${run_id}" --repo "${REPO}" --name "${artifact_name}" --dir downloaded-app + exit_code="$?" + echo "${exit_code}" > /tmp/simdeck-artifact-download.status + exit "${exit_code}" + fi + + echo "Waiting for artifact '${artifact_name}' for PR head ${sha} (${attempt}/30)" + sleep 20 + done + + echo "No successful '${artifact_name}' artifact was found for PR head ${sha}." >&2 + echo "1" > /tmp/simdeck-artifact-download.status + exit 1 + } > app-download.log 2>&1 + ) & + echo "$!" > /tmp/simdeck-artifact-download.pid + + - name: Boot Android emulator + id: emulator + shell: bash + run: | + set -euo pipefail + date +%s > /tmp/sim-boot-start + udid="android:${SIMDECK_ANDROID_AVD_NAME}" + echo "ANDROID_UDID=${udid}" >> "${GITHUB_ENV}" + echo "udid=${udid}" >> "${GITHUB_OUTPUT}" + echo "Booting ${udid}" + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" boot "${udid}" + date +%s > /tmp/sim-boot-end + + - name: Update status comment with booted emulator URL + shell: bash + run: | + set -euo pipefail + mention="" + if [[ -n "${COMMAND_COMMENT_AUTHOR:-}" ]]; then + mention="@${COMMAND_COMMENT_AUTHOR} " + fi + + cat > comment.md <<'EOF' + __MENTION__SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ steps.emulator.outputs.udid }}) + + The selected emulator is booted and the PR APK will launch here once its build artifact is installed. + + This session will stop after ${{ inputs.keepalive_seconds }} seconds, or earlier if the emulator shuts down. + EOF + + body="$(cat comment.md)" + body="${body/__MENTION__/${mention}}" + for attempt in {1..5}; do + if [[ -n "${SIMDECK_STATUS_COMMENT_ID:-}" ]]; then + if gh api -X PATCH "repos/${REPO}/issues/comments/${SIMDECK_STATUS_COMMENT_ID}" -f body="${body}"; then + exit 0 + fi + elif comment_id="$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="${body}" --jq '.id')"; then + echo "SIMDECK_STATUS_COMMENT_ID=${comment_id}" >> "${GITHUB_ENV}" + exit 0 + fi + sleep $((attempt * 5)) + done + + exit 1 + + - name: Wait for APK artifact download + shell: bash + run: | + set -euo pipefail + + pid="$(cat /tmp/simdeck-artifact-download.pid)" + while kill -0 "${pid}" 2>/dev/null; do + sleep 2 + done + + cat app-download.log + status="$(cat /tmp/simdeck-artifact-download.status 2>/dev/null || echo 1)" + if [[ "${status}" -ne 0 ]]; then + exit "${status}" + fi + + - name: Install and launch APK + id: android + shell: bash + run: | + set -euo pipefail + + apk_path="$(find downloaded-app -name '*.apk' -type f | head -n 1)" + if [[ -z "${apk_path}" ]]; then + apk_zip="$(find downloaded-app -name '*.zip' -type f | head -n 1)" + if [[ -n "${apk_zip}" ]]; then + mkdir -p unpacked-app + unzip -q "${apk_zip}" -d unpacked-app + apk_path="$(find unpacked-app -name '*.apk' -type f | head -n 1)" + fi + fi + + if [[ -z "${apk_path}" ]]; then + echo "The artifact did not contain an APK" >&2 + find downloaded-app -maxdepth 3 -type f -print >&2 + exit 1 + fi + + udid="${{ steps.emulator.outputs.udid }}" + echo "udid=${udid}" >> "${GITHUB_OUTPUT}" + + package_name="${ANDROID_PACKAGE_NAME}" + if [[ -z "${package_name}" ]] && command -v aapt >/dev/null 2>&1; then + package_name="$(aapt dump badging "${apk_path}" | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -n 1 || true)" + fi + if [[ -z "${package_name}" ]]; then + echo "Could not determine APK package name. Pass package_name to the SimDeck session action." >&2 + exit 1 + fi + echo "package_name=${package_name}" >> "${GITHUB_OUTPUT}" + + install_start="$(date +%s)" + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" install "${udid}" "${apk_path}" + echo "APK install completed after $(( $(date +%s) - install_start )) seconds." + + launch_start="$(date +%s)" + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" launch "${udid}" "${package_name}" + echo "APK launch completed after $(( $(date +%s) - launch_start )) seconds." + + if [[ -f /tmp/sim-boot-start && -f /tmp/sim-boot-end ]]; then + echo "Android emulator boot took $(( $(cat /tmp/sim-boot-end) - $(cat /tmp/sim-boot-start) )) seconds." + fi + + - name: Update status comment after app launch + shell: bash + run: | + cat > comment.md <<'EOF' + SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ steps.android.outputs.udid }}) + + App: `${{ steps.android.outputs.package_name }}` + Commit: `${{ steps.pr.outputs.sha }}` + + This session will stop after ${{ inputs.keepalive_seconds }} seconds, or earlier if the emulator shuts down. + EOF + + body="$(cat comment.md)" + for attempt in {1..5}; do + if [[ -n "${SIMDECK_STATUS_COMMENT_ID:-}" ]] && gh api -X PATCH "repos/${REPO}/issues/comments/${SIMDECK_STATUS_COMMENT_ID}" -f body="${body}"; then + exit 0 + fi + sleep $((attempt * 5)) + done + + exit 1 + + - name: Keep session alive + shell: bash + run: | + set -euo pipefail + udid="${{ steps.android.outputs.udid }}" + end=$((SECONDS + KEEPALIVE_SECONDS)) + + while (( SECONDS < end )); do + if [[ -f simdeck.pid ]] && ! kill -0 "$(cat simdeck.pid)" 2>/dev/null; then + echo "SimDeck daemon process exited; stopping session." + exit 1 + fi + + list_json="$(simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" list --format json)" + if ! SIMDECK_LIST_JSON="${list_json}" python3 - "${udid}" <<'PY' + import json + import os + import sys + + data = json.loads(os.environ["SIMDECK_LIST_JSON"]) + udid = sys.argv[1] + simulators = data.get("simulators", data if isinstance(data, list) else []) + for simulator in simulators: + if simulator.get("udid") == udid and simulator.get("isBooted") is True: + raise SystemExit(0) + raise SystemExit(1) + PY + then + echo "Android emulator ${udid} is no longer booted; stopping session." + exit 0 + fi + + curl -fsS "http://127.0.0.1:${SIMDECK_PORT}/api/health?simdeckToken=${{ steps.stream.outputs.access_token }}" >/dev/null + sleep 15 + done + + - name: Stop session + if: always() + shell: bash + run: | + set +e + if [[ -f cloudflared.pid ]]; then + kill "$(cat cloudflared.pid)" + fi + if [[ -n "${ANDROID_UDID:-}" ]]; then + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" shutdown "${ANDROID_UDID}" || true + fi + if [[ -f simdeck.pid ]]; then + kill "$(cat simdeck.pid)" + fi + simdeck daemon stop + + - name: Update status comment at end + if: always() + shell: bash + run: | + set -euo pipefail + commit_sha="${{ steps.pr.outputs.sha }}" + if [[ -z "${commit_sha}" ]]; then + commit_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha' 2>/dev/null || true)" + fi + if [[ -z "${commit_sha}" ]]; then + commit_sha="${GITHUB_SHA}" + fi + + body="SimDeck Android session for commit \`${commit_sha}\` has ended. Re-run it by commenting \`simdeck run android\`." + for attempt in {1..5}; do + if [[ -n "${SIMDECK_STATUS_COMMENT_ID:-}" ]] && gh api -X PATCH "repos/${REPO}/issues/comments/${SIMDECK_STATUS_COMMENT_ID}" -f body="${body}"; then + exit 0 + fi + sleep $((attempt * 5)) + done + exit 0 diff --git a/actions/run-ios-comment-session/action.yml b/actions/run-ios-comment-session/action.yml index 7c411275..58a1737a 100644 --- a/actions/run-ios-comment-session/action.yml +++ b/actions/run-ios-comment-session/action.yml @@ -538,12 +538,14 @@ runs: echo "SIMULATOR_UDID=${udid}" >> "${GITHUB_ENV}" echo "Prebooting ${udid}" xcrun simctl boot "${udid}" || true - ( - xcrun simctl bootstatus "${udid}" -b > /tmp/sim-boot.log 2>&1 - date +%s > /tmp/sim-boot-end - ) & + if ! xcrun simctl bootstatus "${udid}" -b > /tmp/sim-boot.log 2>&1; then + cat /tmp/sim-boot.log || true + echo "Simulator ${udid} did not finish booting." >&2 + exit 1 + fi + date +%s > /tmp/sim-boot-end - - name: Update status comment with selected simulator URL + - name: Update status comment with booted simulator URL shell: bash run: | set -euo pipefail @@ -555,7 +557,7 @@ runs: cat > comment.md <<'EOF' __MENTION__SimDeck iOS session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ env.SIMULATOR_UDID }}) - The selected simulator is booting and the PR app will launch here once its build artifact is installed. + The selected simulator is booted and the PR app will launch here once its build artifact is installed. This session will stop after ${{ inputs.keepalive_seconds }} seconds, or earlier if the simulator shuts down. EOF diff --git a/actions/upload-android-apk/action.yml b/actions/upload-android-apk/action.yml new file mode 100644 index 00000000..aeda69a3 --- /dev/null +++ b/actions/upload-android-apk/action.yml @@ -0,0 +1,105 @@ +name: Upload Android APK for SimDeck +description: Package one Android APK and upload it using SimDeck's artifact naming convention. + +inputs: + apk-path: + description: Path to the built APK. When omitted, apk-glob is used. + required: false + default: "" + apk-glob: + description: Glob used to find the built APK when apk-path is omitted. + required: false + default: "**/build/outputs/apk/**/*.apk" + artifact-prefix: + description: Artifact prefix. The final name defaults to -. + required: false + default: android-apk + artifact-name: + description: Exact artifact name to upload. Overrides artifact-prefix and artifact-sha when set. + required: false + default: "" + artifact-sha: + description: Commit SHA used in the default artifact name. Defaults to the PR head SHA or github.sha. + required: false + default: "" + retention-days: + description: Artifact retention in days. + required: false + default: "7" + +outputs: + artifact-name: + description: Uploaded artifact name. + value: ${{ steps.package.outputs.artifact_name }} + apk-path: + description: Path to the APK artifact. + value: ${{ steps.package.outputs.apk_path }} + +runs: + using: composite + steps: + - name: Locate APK + id: package + shell: bash + env: + APK_PATH_INPUT: ${{ inputs.apk-path }} + APK_GLOB: ${{ inputs.apk-glob }} + ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} + ARTIFACT_NAME_INPUT: ${{ inputs.artifact-name }} + ARTIFACT_SHA_INPUT: ${{ inputs.artifact-sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_SHA_VALUE: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ -n "${APK_PATH_INPUT}" ]]; then + apk_path="${APK_PATH_INPUT}" + else + apk_path="$(APK_GLOB="${APK_GLOB}" python3 - <<'PY' + import glob + import os + + matches = sorted( + path + for path in glob.glob(os.environ["APK_GLOB"], recursive=True) + if os.path.isfile(path) and path.endswith(".apk") + ) + if not matches: + raise SystemExit(1) + print(matches[0]) + PY + )" || { + echo "No APK matched '${APK_GLOB}'" >&2 + exit 1 + } + fi + + if [[ ! -f "${apk_path}" || "${apk_path}" != *.apk ]]; then + echo "Expected an .apk file, got: ${apk_path}" >&2 + exit 1 + fi + + artifact_sha="${ARTIFACT_SHA_INPUT:-${PR_HEAD_SHA:-${GITHUB_SHA_VALUE}}}" + if [[ -z "${artifact_sha}" ]]; then + echo "Could not resolve artifact SHA" >&2 + exit 1 + fi + + if [[ -n "${ARTIFACT_NAME_INPUT}" ]]; then + artifact_name="${ARTIFACT_NAME_INPUT}" + else + artifact_name="${ARTIFACT_PREFIX}-${artifact_sha}" + fi + + echo "artifact_name=${artifact_name}" >> "${GITHUB_OUTPUT}" + echo "apk_path=${apk_path}" >> "${GITHUB_OUTPUT}" + echo "APK path: ${apk_path}" + echo "Artifact name: ${artifact_name}" + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.artifact_name }} + path: ${{ steps.package.outputs.apk_path }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} diff --git a/docs/guide/github-actions.md b/docs/guide/github-actions.md index d371f619..13ccde5b 100644 --- a/docs/guide/github-actions.md +++ b/docs/guide/github-actions.md @@ -1,13 +1,14 @@ # GitHub Actions -SimDeck can post a temporary hosted simulator session from a pull request comment. +SimDeck can post a temporary hosted simulator or emulator session from a pull +request comment. You need: -1. A build workflow that uploads a zipped iOS Simulator `.app`. -2. A comment workflow that starts the SimDeck session when someone comments `simdeck run ios`. +1. A build workflow that uploads a zipped iOS Simulator `.app` or Android APK. +2. A comment workflow that starts the SimDeck session when someone comments `simdeck run ios` or `simdeck run android`. -## Build Workflow +## iOS Build Workflow ```yaml name: Build iOS Simulator @@ -41,7 +42,36 @@ jobs: Pin the action to a release tag when you want a stable integration point. -## Comment Workflow +## Android Build Workflow + +```yaml +name: Build Android APK + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: macos-26 + + steps: + - uses: actions/checkout@v5 + + - name: Build Android APK + run: ./gradlew assembleDebug + + - name: Upload APK for SimDeck + uses: NativeScript/SimDeck/actions/upload-android-apk@main + with: + apk-glob: app/build/outputs/apk/debug/*.apk +``` + +## iOS Comment Workflow ```yaml name: SimDeck iOS Comment @@ -78,13 +108,51 @@ jobs: bundle_id: com.example.app ``` -## Pull Request Command +## Android Comment Workflow + +```yaml +name: SimDeck Android Comment + +on: + issue_comment: + types: [created] + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +concurrency: + group: simdeck-android-pr-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + simdeck-android: + if: github.event.issue.pull_request && startsWith(github.event.comment.body, 'simdeck run android') + runs-on: macos-26 + timeout-minutes: 35 + + steps: + - name: Run PR Android app in SimDeck + uses: NativeScript/SimDeck/actions/run-android-comment-session@main + with: + pr_number: ${{ github.event.issue.number }} + command: ${{ github.event.comment.body }} + command_comment_id: ${{ github.event.comment.id }} + command_comment_author: ${{ github.event.comment.user.login }} + build_workflow: build-android-apk.yml + package_name: com.example.app +``` + +## Pull Request Commands ```text simdeck run ios +simdeck run android ``` -Optional flags: +Optional iOS flags: ```text simdeck run ios no-cache-sim @@ -94,26 +162,35 @@ simdeck run ios quality=low simdeck run ios public-health ``` +Optional Android flags: + +```text +simdeck run android quality=low +simdeck run android public-health +``` + Supported quality values include `tiny`, `low`, `economy`, `fast`, `smooth`, `balanced`, `full`, `quality`, and `ci-software`. ## Common Inputs -| Input | Default | Purpose | -| ------------------- | ------------------------- | ------------------------------------------- | -| `bundle_id` | empty | Bundle ID to launch | -| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | -| `artifact_prefix` | `ios-simulator-app` | Artifact prefix | -| `simdeck_version` | `latest` | npm version or dist-tag | -| `stream_profile` | `tiny` | Default stream quality | -| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | -| `keepalive_seconds` | `1800` | Session lifetime after launch | -| `simulator_cache` | `true` | Restore and save simulator cache | +| Input | Default | Purpose | +| ------------------- | ----------------------------------- | ------------------------------------------- | +| `bundle_id` | empty | Bundle ID to launch | +| `package_name` | empty | Android package name to launch | +| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | +| `artifact_prefix` | `ios-simulator-app` / `android-apk` | Artifact prefix | +| `simdeck_version` | `latest` | npm version or dist-tag | +| `stream_profile` | `tiny` | Default stream quality | +| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | +| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator | +| `keepalive_seconds` | `1800` | Session lifetime after launch | +| `simulator_cache` | `true` | Restore and save simulator cache | ## What The Session Does - Installs SimDeck and tunnel tooling on a macOS runner. -- Picks or creates an iOS Simulator. +- Picks or creates an iOS Simulator or Android emulator. - Downloads the app artifact for the PR head commit. - Installs and launches the app. -- Posts a browser URL back to the pull request. +- Posts a browser URL back to the pull request after the simulator or emulator is booted. - Stops after the configured keepalive window.