diff --git a/.github/workflows/rampcapture-github-release-signed-aab.yml b/.github/workflows/rampcapture-github-release-signed-aab.yml deleted file mode 100644 index d864a74cd4..0000000000 --- a/.github/workflows/rampcapture-github-release-signed-aab.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Build signed RAMPcapture AAB release - -on: - workflow_dispatch: - -concurrency: - group: rampcapture-github-release-signed-aab - cancel-in-progress: false - -env: - MAIN_PROJECT_MODULE: app - -jobs: - rampcapture-github-release-signed-aab: - runs-on: ubuntu-latest - permissions: - contents: write - env: - CURRENT_FORK_REPOSITORY: ${{ github.repository }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Get current date and time - id: date-time - run: echo "dateTimeUtc=$(date -u +'%Y-%m-%d-%H%M')" >> "$GITHUB_OUTPUT" - - - name: Set up JDK - uses: actions/setup-java@v5 - with: - distribution: zulu - java-version: '17' - cache: gradle - - - name: Change wrapper permissions - run: chmod +x ./gradlew - - - name: Read upstream app version - id: read-version - working-directory: ./gradle - run: echo "vName=$(grep '^vName' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '\"')" >> "$GITHUB_OUTPUT" - - - name: Determine next RAMPcapture fork release - id: release-info - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - last_fork_number=0 - - while IFS= read -r tag_name; do - [[ "$tag_name" =~ ^RAMPcapture-DHIS2-v[0-9]+(\.[0-9]+)*-fork-([0-9]+)$ ]] || continue - - candidate_fork_number="${BASH_REMATCH[2]}" - if (( candidate_fork_number > last_fork_number )); then - last_fork_number="$candidate_fork_number" - fi - done < <( - gh api --paginate "repos/$CURRENT_FORK_REPOSITORY/releases?per_page=100" --jq '.[].tag_name' - ) - - next_fork_number=$((last_fork_number + 1)) - release_tag="RAMPcapture-DHIS2-v${{ steps.read-version.outputs.vName }}-fork-${next_fork_number}" - release_aab_name="${release_tag}-signed-release.aab" - release_aab_path="$RUNNER_TEMP/$release_aab_name" - - { - echo "forkNumber=$next_fork_number" - echo "releaseTag=$release_tag" - echo "releaseAabName=$release_aab_name" - echo "releaseAabPath=$release_aab_path" - } >> "$GITHUB_OUTPUT" - - - name: Decode keystore - id: decode-keystore - # Third-party action - pinned to commit SHA. - uses: timheuer/base64-to-file@604a8926a81a2da120d09b06bb76da9bba5aee6e - with: - fileName: dhis_keystore.jks - encodedString: ${{ secrets.KEYSTORE_BASE64 }} - - - name: Build signed release AAB - run: ./gradlew app:bundleDhis2Release - env: - RAMP_CAPTURE_VERSION: ${{ steps.release-info.outputs.forkNumber }} - SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - SIGNING_KEYSTORE_PATH: ${{ steps.decode-keystore.outputs.filePath }} - - - name: Rename signed release AAB for RAMPcapture release - run: | - set -euo pipefail - - cp \ - "${MAIN_PROJECT_MODULE}/build/outputs/bundle/dhis2Release/dhis2-v${{ steps.read-version.outputs.vName }}-dhis2-release.aab" \ - "${{ steps.release-info.outputs.releaseAabPath }}" - - - name: Upload signed release AAB artifact - uses: actions/upload-artifact@v7.0.0 - with: - name: ${{ steps.release-info.outputs.releaseTag }} - path: ${{ steps.release-info.outputs.releaseAabPath }} - - - name: Create GitHub release with signed AAB - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_notes=$(cat <> "$GITHUB_OUTPUT" + + - name: Read upstream app version + id: read-version + working-directory: ./gradle + run: | + echo "vName=$(grep '^vName' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '\"')" >> "$GITHUB_OUTPUT" + echo "vCode=$(grep '^vCode' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '\"')" >> "$GITHUB_OUTPUT" + + - name: Determine next RAMPcapture fork release + id: release-info + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + last_fork_number=0 + + while IFS= read -r tag_name; do + [[ "$tag_name" =~ ^RAMPcapture-DHIS2-v[0-9]+(\.[0-9]+)*-fork-([0-9]+)$ ]] || continue + + candidate_fork_number="${BASH_REMATCH[2]}" + if (( candidate_fork_number > last_fork_number )); then + last_fork_number="$candidate_fork_number" + fi + done < <( + gh api --paginate "repos/$CURRENT_FORK_REPOSITORY/releases?per_page=100" --jq '.[].tag_name' + ) + + next_fork_number=$((last_fork_number + 1)) + + if (( next_fork_number > 99 )); then + echo "::error::Fork number $next_fork_number exceeds 2 digits (max 99). Bump the base vCode in gradle/libs.versions.toml and reset fork numbering." >&2 + exit 1 + fi + + release_tag="RAMPcapture-DHIS2-v${{ steps.read-version.outputs.vName }}-fork-${next_fork_number}" + + run_number=${{ github.run_number }} + if (( run_number > 99999 )); then + echo "::error::GitHub run number $run_number exceeds 5 digits (max 99999) required for versionCode format yyMMrrrrr." >&2 + exit 1 + fi + # yyMMrrrrr: 2-digit year, 2-digit month, 5-digit zero-padded run + # number, e.g. year 26, month 07, run 123 => 260700123. Keeps each + # fork release's version code unique and increasing for Google Play. + version_code="$(date -u +'%y%m')$(printf '%05d' "$run_number")" + + { + echo "forkNumber=$next_fork_number" + echo "releaseTag=$release_tag" + echo "versionCode=$version_code" + } >> "$GITHUB_OUTPUT" + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: '17' + cache: gradle + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + - name: Decode keystore + id: decode-keystore + # Third-party action - pinned to commit SHA. + uses: timheuer/base64-to-file@604a8926a81a2da120d09b06bb76da9bba5aee6e + with: + fileName: dhis_keystore.jks + encodedString: ${{ secrets.KEYSTORE_BASE64 }} + + - name: Build signed release AAB + run: ./gradlew app:bundleDhis2Release + env: + RAMP_CAPTURE_VERSION: ${{ steps.release-info.outputs.forkNumber }} + RAMP_CAPTURE_VERSION_CODE: ${{ steps.release-info.outputs.versionCode }} + SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + SIGNING_KEYSTORE_PATH: ${{ steps.decode-keystore.outputs.filePath }} + + - name: Copy signed release AAB to output path + run: | + set -euo pipefail + + cp \ + "app/build/outputs/bundle/dhis2Release/dhis2-v${{ steps.read-version.outputs.vName }}-dhis2-release.aab" \ + "${{ runner.temp }}/${{ steps.release-info.outputs.releaseTag }}-signed-release.aab" + + - name: Upload signed release AAB artifact + uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ steps.release-info.outputs.releaseTag }} + path: ${{ runner.temp }}/${{ steps.release-info.outputs.releaseTag }}-signed-release.aab + + create-github-release: + needs: build-aab + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete previous draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Keep only one RAMPcapture draft release at a time: remove any + # leftover draft from a prior run before creating the new one. + # Draft releases don't create an actual git tag until published, + # so there's no tag to clean up here. Published (non-draft) + # releases are left untouched. + while IFS=$'\t' read -r tag_name is_draft; do + [[ "$tag_name" =~ ^RAMPcapture-DHIS2-v[0-9]+(\.[0-9]+)*-fork-[0-9]+$ ]] || continue + [[ "$is_draft" == "true" ]] || continue + + echo "Deleting previous draft release: $tag_name" + gh release delete "$tag_name" \ + --repo "${{ github.repository }}" \ + --yes + done < <( + gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" \ + --jq '.[] | [.tag_name, .draft] | @tsv' + ) + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_notes=$(cat < 260700123. This keeps every fork release's + // version code unique and increasing for Google Play. + val baseVersionCode = libs.versions.vCode.get().toInt() + val rampCaptureVersionCode = System.getenv("RAMP_CAPTURE_VERSION_CODE")?.toIntOrNull() + versionCode = rampCaptureVersionCode ?: baseVersionCode + buildConfigField("String", "SDK_VERSION", "\"" + libs.versions.dhis2sdk.get() + "\"") buildConfigField("String", "RAMP_CAPTURE_VERSION", "\"$rampCaptureVersion\"") buildConfigField("String", "MATOMO_URL", "\"https://usage.analytics.dhis2.org/matomo.php\"") diff --git a/scripts/rampcapture-release/download-universal-apk.py b/scripts/rampcapture-release/download-universal-apk.py new file mode 100644 index 0000000000..edb62f7285 --- /dev/null +++ b/scripts/rampcapture-release/download-universal-apk.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Download the Play-signed universal APK for a given version code from Google Play. + +Uses the Android Publisher API generatedApks resource. Google Play generates APKs +from an uploaded AAB asynchronously, so this script retries until the APK is ready +or MAX_WAIT_SECONDS is exceeded. + +Required environment variables: + GOOGLE_API_KEY_JSON Service account JSON (as a string, not a file path) + VERSION_CODE Integer Android version code + PACKAGE_NAME Android package name + OUTPUT_APK Output file path (default: universal.apk) + MAX_WAIT_SECONDS Maximum seconds to wait for Play to finish processing (default: 600) +""" + +import json +import os +import sys +import time + +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseDownload + +RETRY_INTERVAL_SECONDS = 30 + + +def build_service(service_account_json: str): + creds = Credentials.from_service_account_info( + json.loads(service_account_json), + scopes=["https://www.googleapis.com/auth/androidpublisher"], + ) + return build("androidpublisher", "v3", credentials=creds, cache_discovery=False) + + +def find_universal_apk_download_id(service, package_name: str, version_code: int) -> str | None: + result = service.generatedapks().list( + packageName=package_name, + versionCode=version_code, + ).execute() + + for apk in result.get("generatedApks", []): + universal = apk.get("generatedUniversalApk") + if universal: + return universal["downloadId"] + return None + + +def download_apk(service, package_name: str, version_code: int, download_id: str, output_path: str): + request = service.generatedapks().download_media( + packageName=package_name, + versionCode=version_code, + downloadId=download_id, + ) + with open(output_path, "wb") as f: + downloader = MediaIoBaseDownload(f, request) + done = False + while not done: + status, done = downloader.next_chunk() + if status: + print(f" {int(status.progress() * 100)}%", flush=True) + + size_mb = os.path.getsize(output_path) / 1_048_576 + print(f"Saved to {output_path} ({size_mb:.1f} MB)") + + +def main(): + service_account_json = os.environ["GOOGLE_API_KEY_JSON"] + package_name = os.environ["PACKAGE_NAME"] + version_code = int(os.environ["VERSION_CODE"]) + output_path = os.environ.get("OUTPUT_APK", "universal.apk") + max_wait = int(os.environ.get("MAX_WAIT_SECONDS", "600")) + + print(f"Looking for universal APK: package={package_name} versionCode={version_code}") + + service = build_service(service_account_json) + deadline = time.monotonic() + max_wait + attempt = 0 + + while True: + attempt += 1 + try: + download_id = find_universal_apk_download_id(service, package_name, version_code) + if download_id: + print(f"Universal APK ready (attempt {attempt}): downloadId={download_id}") + break + print(f"Attempt {attempt}: APKs not generated yet by Play Store.") + except Exception as exc: + print(f"Attempt {attempt}: API error — {exc}") + + remaining = deadline - time.monotonic() + if remaining <= 0: + print(f"ERROR: Universal APK not available after {max_wait}s", file=sys.stderr) + sys.exit(1) + + wait = min(RETRY_INTERVAL_SECONDS, remaining) + print(f"Retrying in {int(wait)}s... ({int(remaining)}s remaining)") + time.sleep(wait) + + print("Downloading...") + download_apk(service, package_name, version_code, download_id, output_path) + + +if __name__ == "__main__": + main()