diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2652de8..ca3f6f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ['**'] + branches: [main] pull_request: concurrency: @@ -15,6 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Test action scripts + run: | + python -m pip install PyYAML==6.0.2 + python -m unittest discover -s tests -v - name: actionlint run: | curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash -s -- latest /usr/local/bin @@ -127,6 +134,56 @@ jobs: [ -x "/tmp/my-android-sdk/platform-tools/adb" ] [ "$SDK_PATH" = "/tmp/my-android-sdk" ] + selftest-windows: + name: test-windows-x64 + # Only repository branches may execute code on our persistent runner. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, windows, x64] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - name: Install SDK at a path containing spaces + id: cli + uses: ./ + with: + sdk-path: ${{ runner.temp }}/android sdk + packages: | + platforms/android-34 + build-tools/34.0.0 + platform-tools + - name: Verify native Windows paths and tools + shell: pwsh + env: + SDK_PATH: ${{ steps.cli.outputs.sdk-path }} + CLI_VERSION: ${{ steps.cli.outputs.cli-version }} + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:CLI_VERSION) { throw 'Missing CLI version' } + if ($env:ANDROID_HOME -ne $env:SDK_PATH) { throw 'ANDROID_HOME mismatch' } + if ($env:ANDROID_SDK_ROOT -ne $env:SDK_PATH) { throw 'ANDROID_SDK_ROOT mismatch' } + foreach ($path in @('platforms/android-34/android.jar', 'build-tools/34.0.0/aapt2.exe', 'platform-tools/adb.exe')) { + if (-not (Test-Path (Join-Path $env:SDK_PATH $path))) { throw "Missing $path" } + } + android --no-metrics --version + if ($LASTEXITCODE -ne 0) { throw 'android failed' } + adb --version + if ($LASTEXITCODE -ne 0) { throw 'adb failed' } + - name: Verify CLI-only setup without caching + uses: ./ + with: + cache: 'false' + - name: Verify default SDK path + shell: pwsh + run: | + $expected = [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'Android/Sdk')) + if ([IO.Path]::GetFullPath($env:ANDROID_HOME) -ne $expected) { throw 'Unexpected default SDK path' } + android --no-metrics --version + if ($LASTEXITCODE -ne 0) { throw 'android failed' } + # Gate job. Name MUST be "CI" to match the org-level required_status_checks # ruleset that protects the default branch. CI: @@ -138,6 +195,7 @@ jobs: - selftest-nopackages - selftest-nocache - selftest-custom-sdk-path + - selftest-windows runs-on: ubuntu-latest steps: - name: Check results diff --git a/README.md b/README.md index c0a0a31..aaf4049 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/premex-ab/setup-android-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/premex-ab/setup-android-cli/actions/workflows/ci.yml) -A GitHub Action that installs Google's agent-first [`android` CLI](https://developer.android.com/tools/agents/android-cli), sets up the Android SDK, and caches between runs. Drop-in replacement for [`setup-android`](https://github.com/android-actions/setup-android) with a simpler, faster setup. +A GitHub Action that installs Google's agent-first [`android` CLI](https://developer.android.com/tools/agents/android-cli), sets up the Android SDK, and caches between runs. An alternative to [`setup-android`](https://github.com/android-actions/setup-android) with explicit SDK packages and caching. ## Why switch? @@ -98,7 +98,7 @@ The action exports the following environment variables for subsequent steps: ## What it does -1. Downloads the `android` CLI launcher (~5 MB) into the runner's tool cache. +1. Downloads the current `android` CLI launcher (`android.exe` on Windows) into the runner's tool cache on every run, including persistent self-hosted runners. 2. Unpacks embedded resources on first run into `~/.android/bin/` (~78 MB, cached). 3. Exports `ANDROID_HOME` and `ANDROID_SDK_ROOT`, and adds `platform-tools/`, `emulator/`, and `cmdline-tools/latest/bin/` to `PATH`. 4. Runs `android sdk install ` if packages are specified. @@ -112,15 +112,42 @@ The action exports the following environment variables for subsequent steps: | `ubuntu-latest` / `ubuntu-22.04` | Supported | | `macos-latest` / `macos-14` (Apple Silicon) | Supported | | `macos-13` (Intel, Rosetta) | Best-effort (warning emitted) | -| `windows-*` | Not yet supported (the CLI itself has limited Windows support) | +| Windows x64 | Supported with Git Bash on PATH; SDK/build tools only, not `android emulator` | | Self-hosted Linux x86_64 / macOS arm64 | Supported | ## Caching -By default the action caches `~/.android/bin` (CLI resources, ~78 MB) and the full SDK directory between runs. The cache key includes OS, architecture, and the hash of `**/libs.versions.toml`, `**/build.gradle*`, and `**/settings.gradle*` so it busts when your project's SDK requirements change. +By default the action caches `~/.android/bin` (CLI resources, ~78 MB) and the full SDK directory between runs. The cache key includes OS, architecture, and the hash of `**/libs.versions.toml`, `**/build.gradle*`, and `**/settings.gradle*` plus a hash of the requested packages, resolved SDK path, and download URL. Changing workflow inputs therefore invalidates the SDK cache too. `ANDROID_USER_HOME`, when set, determines where CLI resources are cached. Gradle caching is **not** handled by this action. Use [`gradle/actions/setup-gradle@v4`](https://github.com/gradle/actions) for that. +## Windows self-hosted runners + +The action uses Git Bash, including `curl` and `cygpath`, supplied by Git for Windows. +Install Git for Windows and ensure the runner service can find `bash` on PATH. +Java must be available for Gradle builds. SDK setup supports Windows x64; +Google's `android emulator` command currently does not support Windows. + +```yaml +jobs: + build: + runs-on: [self-hosted, windows, x64] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - uses: premex-ab/setup-android-cli@v1 + with: + packages: platforms/android-36 build-tools/36.0.0 platform-tools + - run: ./gradlew.bat assembleDebug +``` + +The default SDK path is `%LOCALAPPDATA%/Android/Sdk`; custom paths containing +spaces are supported. Paths exported through `ANDROID_HOME`, `ANDROID_SDK_ROOT`, +and the action outputs work in subsequent PowerShell steps as well as Git Bash. + ## Migrating from `setup-android` ```diff @@ -134,7 +161,12 @@ Gradle caching is **not** handled by this action. Use [`gradle/actions/setup-gra ``` Key differences: -- No `cmdline-tools-version` input (always current release). +- Specify every required platform and build-tools package explicitly. The default + installs only the CLI and does not adopt the runner's preinstalled SDK path. +- No `cmdline-tools-version` input (always current release). A custom + `install-url-base` can select a versioned mirror; the launcher is refreshed each run. +- Direct `sdkmanager`/`avdmanager` calls are not provided by the CLI launcher. + Migrate them separately or explicitly install the required command-line tools. - No `accept-android-sdk-licenses` input (auto-accepted). - Package names use slashes instead of semicolons: `platforms/android-34` not `"platforms;android-34"`. - `tools` package doesn't exist in the new CLI; drop it. diff --git a/action.yml b/action.yml index 92f1cc4..df5577a 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'Setup Android CLI' -description: 'Install and cache Google''s agent-first android CLI plus SDK packages. Drop-in setup-android replacement.' +description: 'Install and cache Google''s Android CLI and explicitly selected SDK packages.' author: 'Premex' branding: @@ -18,7 +18,8 @@ inputs: sdk-path: description: >- Override where the Android SDK is installed. Defaults to - `~/Library/Android/sdk` on macOS and `~/Android/Sdk` on Linux. The + `~/Library/Android/sdk` on macOS, `~/Android/Sdk` on Linux, and + `%LOCALAPPDATA%/Android/Sdk` on Windows. The chosen path is exported as `ANDROID_HOME` and `ANDROID_SDK_ROOT` for subsequent steps. required: false @@ -33,7 +34,7 @@ inputs: cache-key: description: >- Extra input appended to the cache key. Change to bust the cache. The - default key already includes OS, arch, and the hash of + default key already includes OS, arch, SDK path, packages, download URL, and the hash of `**/libs.versions.toml` / `**/build.gradle*`. required: false default: '' @@ -46,7 +47,7 @@ inputs: install-url-base: description: >- Override the base URL for downloading the CLI binary. The action - appends `//android` (for example `/linux_x86_64/android`). + appends `//android` (`android.exe` on Windows). Useful for internal mirrors or air-gapped environments. Defaults to Google's redirector. required: false @@ -73,6 +74,8 @@ runs: shell: bash env: INPUT_SDK_PATH: ${{ inputs.sdk-path }} + PACKAGES: ${{ inputs.packages }} + INSTALL_URL_BASE: ${{ inputs.install-url-base }} run: | set -euo pipefail @@ -85,8 +88,9 @@ runs: echo "::warning::Intel Mac runner detected. Google only ships darwin_arm64; using that binary under Rosetta. Prefer an Apple Silicon runner for faster builds." slug=darwin_arm64 ;; + MINGW*/x86_64|MSYS*/x86_64) slug=windows_x86_64 ;; *) - echo "::error::Unsupported platform: $os/$arch. Supported: Linux x86_64, macOS arm64 (darwin_arm64)." + echo "::error::Unsupported platform: $os/$arch. Supported: Linux x86_64, macOS arm64, Windows x86_64." exit 1 ;; esac @@ -95,18 +99,41 @@ runs: sdk_path="${INPUT_SDK_PATH}" elif [[ "$os" == "Darwin" ]]; then sdk_path="$HOME/Library/Android/sdk" + elif [[ "$RUNNER_OS" == "Windows" ]]; then + sdk_path="$LOCALAPPDATA/Android/Sdk" else sdk_path="$HOME/Android/Sdk" fi tool_dir="$RUNNER_TOOL_CACHE/android-cli/latest/$slug" + binary=android + resources="${ANDROID_USER_HOME:-$HOME/.android}/bin" + if [[ "$RUNNER_OS" == "Windows" ]]; then + binary=android.exe + sdk_path=$(cygpath -am "$sdk_path") + tool_dir=$(cygpath -am "$tool_dir") + resources=$(cygpath -am "${ANDROID_USER_HOME:-$USERPROFILE/.android}/bin") + else + mkdir -p "$sdk_path" + sdk_path=$(cd "$sdk_path" && pwd -P) + fi mkdir -p "$tool_dir" "$sdk_path" + # Changing only workflow inputs must also invalidate the immutable SDK cache. + if command -v sha256sum >/dev/null 2>&1; then + config_hash=$(printf '%s\0' "$PACKAGES" "$sdk_path" "$INSTALL_URL_BASE" | sha256sum | cut -d ' ' -f 1) + else + config_hash=$(printf '%s\0' "$PACKAGES" "$sdk_path" "$INSTALL_URL_BASE" | shasum -a 256 | cut -d ' ' -f 1) + fi + { echo "slug=$slug" echo "sdk-path=$sdk_path" echo "tool-dir=$tool_dir" + echo "binary=$binary" + echo "resources=$resources" + echo "config-hash=$config_hash" } >> "$GITHUB_OUTPUT" - name: Cache Android CLI and SDK @@ -115,39 +142,46 @@ runs: uses: actions/cache@v6 with: path: | - ~/.android/bin + ${{ steps.platform.outputs.resources }} ${{ steps.platform.outputs.sdk-path }} - key: setup-android-cli-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.platform.outputs.slug }}-${{ hashFiles('**/libs.versions.toml', '**/build.gradle*', '**/settings.gradle*') }}${{ inputs.cache-key && format('-{0}', inputs.cache-key) || '' }} + key: setup-android-cli-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.platform.outputs.slug }}-${{ steps.platform.outputs.config-hash }}-${{ hashFiles('**/libs.versions.toml', '**/build.gradle*', '**/settings.gradle*') }}${{ inputs.cache-key && format('-{0}', inputs.cache-key) || '' }} restore-keys: | - setup-android-cli-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.platform.outputs.slug }}- + setup-android-cli-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.platform.outputs.slug }}-${{ steps.platform.outputs.config-hash }}- - name: Install CLI launcher shell: bash env: SLUG: ${{ steps.platform.outputs.slug }} TOOL_DIR: ${{ steps.platform.outputs.tool-dir }} + BINARY: ${{ steps.platform.outputs.binary }} + NO_METRICS: ${{ inputs.no-metrics }} INSTALL_URL_BASE: ${{ inputs.install-url-base }} run: | set -euo pipefail - if [[ -x "$TOOL_DIR/android" ]]; then - echo "android CLI launcher already present in $TOOL_DIR (from runner tool cache)." - else - echo "::group::Downloading android CLI ($SLUG)" - url="${INSTALL_URL_BASE%/}/${SLUG}/android" - echo "Source: $url" - curl -fsSL --retry 3 --retry-delay 2 "$url" -o "$TOOL_DIR/android" - chmod +x "$TOOL_DIR/android" - echo "::endgroup::" - fi + # Refresh latest on persistent runners too; never reuse an unversioned launcher. + echo "::group::Downloading android CLI ($SLUG)" + url="${INSTALL_URL_BASE%/}/${SLUG}/${BINARY}" + temp_launcher=$(mktemp "$TOOL_DIR/android-download.XXXXXX") + trap 'rm -f "$temp_launcher"' EXIT + curl -fsSL --retry 3 --retry-delay 2 "$url" -o "$temp_launcher" + chmod +x "$temp_launcher" + mv -f "$temp_launcher" "$TOOL_DIR/$BINARY" + echo "::endgroup::" echo "$TOOL_DIR" >> "$GITHUB_PATH" - export PATH="$TOOL_DIR:$PATH" + shell_tool_dir="$TOOL_DIR" + if [[ "$RUNNER_OS" == "Windows" ]]; then + shell_tool_dir=$(cygpath -u "$TOOL_DIR") + fi + export PATH="$shell_tool_dir:$PATH" - # First run unpacks the embedded resources into ~/.android/bin. If - # that directory was cache-restored, this is effectively a no-op. - echo "::group::Bootstrapping CLI (one-time unpack if needed)" - ANDROID_CLI_FRESH_INSTALL=1 android --version + cli_args=(--version) + if [[ "$NO_METRICS" == "true" ]]; then + cli_args=(--no-metrics "${cli_args[@]}") + fi + echo "::group::Bootstrapping CLI" + ANDROID_CLI_FRESH_INSTALL=1 android "${cli_args[@]}" echo "::endgroup::" - name: Configure ANDROID_HOME and ANDROID_SDK_ROOT @@ -177,14 +211,13 @@ runs: run: | set -euo pipefail - metrics_flag="" + read -r -a package_args <<< "$(printf '%s' "$PACKAGES" | tr '\r\n' ' ')" + cli_args=(--sdk="$SDK_PATH" sdk install "${package_args[@]}") if [[ "$NO_METRICS" == "true" ]]; then - metrics_flag="--no-metrics" + cli_args=(--no-metrics "${cli_args[@]}") fi - echo "::group::Installing SDK packages" - # shellcheck disable=SC2086 - android $metrics_flag --sdk="$SDK_PATH" sdk install $PACKAGES + android "${cli_args[@]}" echo "::endgroup::" - name: Register Android/Gradle problem matchers @@ -201,10 +234,16 @@ runs: SLUG: ${{ steps.platform.outputs.slug }} PACKAGES: ${{ inputs.packages }} CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + NO_METRICS: ${{ inputs.no-metrics }} run: | set -euo pipefail - cli_version=$(android --version 2>/dev/null || echo "unknown") + cli_args=(--version) + if [[ "$NO_METRICS" == "true" ]]; then + cli_args=(--no-metrics "${cli_args[@]}") + fi + cli_version=$(android "${cli_args[@]}") + cli_version=$(printf '%s' "$cli_version" | tr -d '\r') { echo "sdk-path=$SDK_PATH" diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..e51d95c --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,121 @@ +"""Exercise the actual composite-action scripts with an isolated fake download.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +ACTION = yaml.safe_load((ROOT / 'action.yml').read_text()) +STEPS = {step['name']: step['run'] for step in ACTION['runs']['steps'] if 'run' in step} + + +class ActionTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='android action ') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / 'bin' + self.bin.mkdir() + self.env = dict(os.environ, HOME=str(self.root), RUNNER_OS='Linux', + RUNNER_TOOL_CACHE=str(self.root / 'tool cache'), + INPUT_SDK_PATH=str(self.root / 'sdk with spaces'), + PACKAGES='platform-tools\nplatforms/android-36', + INSTALL_URL_BASE='https://example.invalid/cli/latest', + NO_METRICS='true', CACHE_HIT='', + CALLS=str(self.root / 'calls.jsonl'), + PATH=str(self.bin) + os.pathsep + os.environ['PATH']) + self.env.pop('ANDROID_USER_HOME', None) + for key in ['GITHUB_OUTPUT', 'GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_STEP_SUMMARY']: + path = self.root / key + path.touch() + self.env[key] = str(path) + self.script('uname', 'import sys\nprint("Linux" if sys.argv[1] == "-s" else "x86_64")\n') + self.launcher = self.root / 'downloaded launcher' + self.launcher.write_text(f'#!{sys.executable}\n' + '''import json, os, sys +with open(os.environ['CALLS'], 'a') as stream: + stream.write(json.dumps(sys.argv[1:]) + '\\n') +if '--version' in sys.argv: + print('Android CLI test-version') +''') + self.env['FAKE_LAUNCHER'] = str(self.launcher) + self.script('curl', '''import os, shutil, sys +shutil.copyfile(os.environ['FAKE_LAUNCHER'], sys.argv[sys.argv.index('-o') + 1]) +''') + + def script(self, name, body): + path = self.bin / name + path.write_text(f'#!{sys.executable}\n' + body) + path.chmod(0o755) + + def run_step(self, name): + return subprocess.run(['bash', '-c', STEPS[name]], env=self.env, + text=True, capture_output=True, check=True) + + def outputs(self, key='GITHUB_OUTPUT'): + return dict(line.split('=', 1) for line in Path(self.env[key]).read_text().splitlines()) + + def resolve(self): + self.run_step('Resolve platform and paths') + outputs = self.outputs() + self.env.update(SLUG=outputs['slug'], TOOL_DIR=outputs['tool-dir'], + BINARY=outputs['binary'], SDK_PATH=outputs['sdk-path']) + return outputs + + def install(self): + self.run_step('Install CLI launcher') + self.env['PATH'] = self.env['TOOL_DIR'] + os.pathsep + self.env['PATH'] + + def test_cache_identity_tracks_workflow_inputs(self): + baseline = self.resolve()['config-hash'] + self.assertEqual(baseline, self.resolve()['config-hash']) + for key, value in [('PACKAGES', 'platform-tools platforms/android-35'), + ('INPUT_SDK_PATH', str(self.root / 'other sdk')), + ('INSTALL_URL_BASE', 'https://example.invalid/pinned')]: + with self.subTest(key=key): + previous = self.env[key] + self.env[key] = value + self.assertNotEqual(baseline, self.resolve()['config-hash']) + self.env[key] = previous + + def test_persistent_launcher_refresh_paths_and_metrics(self): + self.resolve() + stale = Path(self.env['TOOL_DIR']) / 'android' + stale.write_text('#!/bin/sh\nexit 19\n') + stale.chmod(0o755) + self.install() + self.run_step('Configure ANDROID_HOME and ANDROID_SDK_ROOT') + self.run_step('Install SDK packages') + self.run_step('Summary') + calls = [json.loads(line) for line in Path(self.env['CALLS']).read_text().splitlines()] + self.assertEqual(calls, [ + ['--no-metrics', '--version'], + ['--no-metrics', '--sdk=' + self.env['SDK_PATH'], 'sdk', 'install', + 'platform-tools', 'platforms/android-36'], + ['--no-metrics', '--version'], + ]) + self.assertEqual(self.outputs('GITHUB_ENV')['ANDROID_HOME'], self.env['SDK_PATH']) + self.assertEqual(self.outputs()['cli-version'], 'Android CLI test-version') + + def test_metrics_can_be_enabled_without_empty_arguments(self): + self.env['NO_METRICS'] = 'false' + self.resolve() + self.install() + self.run_step('Install SDK packages') + self.run_step('Summary') + calls = [json.loads(line) for line in Path(self.env['CALLS']).read_text().splitlines()] + self.assertEqual(calls[0], ['--version']) + self.assertEqual(calls[-1], ['--version']) + self.assertTrue(all('' not in call and '--no-metrics' not in call for call in calls)) + + def test_custom_android_user_home_is_cached(self): + self.env['ANDROID_USER_HOME'] = str(self.root / 'custom android home') + self.assertEqual(self.resolve()['resources'], self.env['ANDROID_USER_HOME'] + '/bin') + + +if __name__ == '__main__': + unittest.main()