diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..fedbdfb --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,76 @@ +name: Build APK + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: build-apk-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Assemble Release APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android SDK licenses and install packages + run: | + # Find sdkmanager in common locations + if [ -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + elif [ -f "$ANDROID_HOME/cmdline-tools/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_HOME/cmdline-tools/bin/sdkmanager" + elif [ -f "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + else + echo "Error: sdkmanager not found" + exit 1 + fi + echo "Using sdkmanager at: $SDK_MANAGER" + + # Accept all licenses first + yes | "$SDK_MANAGER" --licenses + + # Install packages separately (as per Stack Overflow solution) + # Install platform-tools first + yes | "$SDK_MANAGER" "platform-tools" + # Install Android API 35 + yes | "$SDK_MANAGER" "platforms;android-35" + # Install build-tools + yes | "$SDK_MANAGER" "build-tools;35.0.0" + # Install Android API 36 + yes | "$SDK_MANAGER" "platforms;android-36" + + - name: Cache Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + + - name: Assemble release APK + run: ./gradlew assembleRelease + + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-apk + path: app/build/outputs/apk/release/*.apk + diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml new file mode 100644 index 0000000..805ae6d --- /dev/null +++ b/.github/workflows/release-apk.yml @@ -0,0 +1,196 @@ +name: Release APK + +on: + workflow_dispatch: + inputs: + release_name: + description: Override release name (defaults to tag or ref name). + required: false + play_track: + description: Google Play release track (internal, alpha, beta, production). + required: false + default: internal + type: choice + options: + - internal + - alpha + - beta + - production + push: + branches: + - main + +permissions: + contents: write + +env: + BUILD_TOOLS_VERSION: 35.0.0 + RELEASE_BUNDLE_PATH: app/build/outputs/bundle/release/app-release.aab + +jobs: + release: + name: Build and Publish + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify ref on main (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Workflow must target the main branch. Ref: ${GITHUB_REF}" >&2 + exit 1 + fi + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android SDK licenses and install packages + run: | + # Find sdkmanager in common locations + if [ -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + elif [ -f "$ANDROID_HOME/cmdline-tools/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_HOME/cmdline-tools/bin/sdkmanager" + elif [ -f "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + SDK_MANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + else + echo "Error: sdkmanager not found" + exit 1 + fi + echo "Using sdkmanager at: $SDK_MANAGER" + + # Accept all licenses first + yes | "$SDK_MANAGER" --licenses + + # Install packages separately (as per Stack Overflow solution) + # Install platform-tools first + yes | "$SDK_MANAGER" "platform-tools" + # Install Android API 35 + yes | "$SDK_MANAGER" "platforms;android-35" + # Install build-tools + yes | "$SDK_MANAGER" "build-tools;${{ env.BUILD_TOOLS_VERSION }}" + # Install Android API 36 + yes | "$SDK_MANAGER" "platforms;android-36" + + - name: Cache Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + + - name: Auto-increment version code from Google Play + continue-on-error: true + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + echo "Google Play service account not configured, skipping version code check" + exit 0 + fi + + # Write service account JSON to file + echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > /tmp/service_account.json + + # Install Python dependencies + python3 -m pip install --quiet google-api-python-client google-auth-httplib2 google-auth-oauthlib + + # Run the version code increment script + python3 scripts/increment_version_code.py + + - name: Build release bundle + run: ./gradlew bundleRelease + + - name: Decode keystore + run: | + if [ -z "${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }}" ]; then + echo "ERROR: ANDROID_SIGNING_KEYSTORE_BASE64 secret is missing. Signing secrets are required for release builds." >&2 + exit 1 + fi + echo "${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }}" | base64 --decode > release.keystore + if [ ! -f "release.keystore" ] || [ ! -s "release.keystore" ]; then + echo "ERROR: Failed to decode keystore. The secret may be invalid." >&2 + exit 1 + fi + + - name: Sign AAB + env: + KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + run: | + # Validate all signing secrets are present + if [ -z "$KEY_ALIAS" ] || [ -z "$KEY_PASSWORD" ] || [ -z "$STORE_PASSWORD" ]; then + echo "ERROR: One or more signing secrets are missing:" >&2 + echo " - ANDROID_SIGNING_KEY_ALIAS: $([ -z "$KEY_ALIAS" ] && echo 'MISSING' || echo 'present')" >&2 + echo " - ANDROID_SIGNING_KEY_PASSWORD: $([ -z "$KEY_PASSWORD" ] && echo 'MISSING' || echo 'present')" >&2 + echo " - ANDROID_SIGNING_KEYSTORE_PASSWORD: $([ -z "$STORE_PASSWORD" ] && echo 'MISSING' || echo 'present')" >&2 + exit 1 + fi + + if [ ! -f "release.keystore" ]; then + echo "ERROR: Keystore file not found. Decode keystore step must have failed." >&2 + exit 1 + fi + + AAB_PATH=$(ls app/build/outputs/bundle/release/*.aab | head -n 1) + if [ -z "$AAB_PATH" ]; then + echo "ERROR: No AAB produced at app/build/outputs/bundle/release." >&2 + exit 1 + fi + + # Sign AAB using jarsigner + jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ + -keystore release.keystore \ + -storepass "$STORE_PASSWORD" \ + -keypass "$KEY_PASSWORD" \ + "$AAB_PATH" \ + "$KEY_ALIAS" + + # Verify the signature + jarsigner -verify -verbose -certs "$AAB_PATH" + if [ $? -ne 0 ]; then + echo "ERROR: Signature verification failed." >&2 + exit 1 + fi + echo "✓ AAB signed and verified successfully" + + - name: Set release variables + id: release_vars + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "play_track=${{ github.event.inputs.play_track || 'internal' }}" >> $GITHUB_OUTPUT + echo "release_name=${{ github.event.inputs.release_name || github.ref_name }}" >> $GITHUB_OUTPUT + else + # Automatic upload to internal track for main branch pushes + echo "play_track=internal" >> $GITHUB_OUTPUT + echo "release_name=main-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + fi + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: release-bundle + path: app/build/outputs/bundle/release/*.aab + + - name: Upload to Google Play Console + continue-on-error: true + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + packageName: llc.fungee.IngrediCheck + releaseFiles: app/build/outputs/bundle/release/*.aab + track: ${{ steps.release_vars.outputs.play_track }} + status: completed + diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..3f8098b --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,99 @@ +# CI/CD & Release Guide + +This project enforces guarded changes on `main` and publishes release APKs via GitHub Actions. Use this guide to configure repository settings, maintain secrets, and operate the pipelines. + +## Branch Protection + +Main must remain fast-forward only and receive all changes through reviewed pull requests. + +1. **Enable protection in GitHub** + - Go to `Settings → Branches → Branch protection rules → Add rule`. + - Target branch name pattern: `main`. + - Enable these options: + - Require a pull request before merging (minimum one approval, dismiss stale reviews). + - Require status checks to pass (`Build APK / Assemble Release APK`). + - Require branches to be up to date before merging. + - Include administrators (prevents accidental direct pushes). + - Disallow force pushes and deletions. + +## Required GitHub Secrets + +| Secret | Purpose | Notes | +| --- | --- | --- | +| `ANDROID_SIGNING_KEYSTORE_BASE64` | Base64-encoded `.jks`/`.keystore` file | Optional; omit to produce unsigned release artifacts. | +| `ANDROID_SIGNING_KEYSTORE_PASSWORD` | Password for the keystore | Required for signing. | +| `ANDROID_SIGNING_KEY_ALIAS` | Alias inside the keystore | Required for signing. | +| `ANDROID_SIGNING_KEY_PASSWORD` | Password for the key alias | Required for signing. | +| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Google Play service account JSON key | Optional; omit to skip Play Console upload. See setup instructions below. | + +If signing secrets are not provided, releases remain unsigned but still build successfully. If the Google Play secret is not provided, the workflow will skip Play Console upload but still produce artifacts. + +### Setting up Google Play Service Account + +To enable automatic uploads to Google Play Console: + +1. **Create a service account in Google Cloud Console:** + - Go to [Google Cloud Console](https://console.cloud.google.com/) + - Create a new project or select an existing one + - Navigate to `IAM & Admin → Service Accounts` + - Click `Create Service Account` + - Provide a name (e.g., "github-actions-play-upload") and description + - Click `Create and Continue` + +2. **Grant Play Console permissions:** + - In the service account details, note the email address (format: `name@project-id.iam.gserviceaccount.com`) + - Go to [Google Play Console](https://play.google.com/console/) + - Navigate to `Setup → API access` + - Find your service account email and click `Grant access` + - Grant the following permissions: + - `View app information and download bulk reports` + - `Manage production releases` + - `Manage testing track releases` (if using internal/alpha/beta tracks) + - Save the changes + +3. **Create and download the JSON key:** + - Return to Google Cloud Console → Service Accounts + - Click on your service account + - Go to the `Keys` tab + - Click `Add Key → Create new key` + - Choose `JSON` format + - Download the JSON file + +4. **Add the secret to GitHub:** + - Open the downloaded JSON file and copy its entire contents + - Go to your GitHub repository → `Settings → Secrets and variables → Actions` + - Click `New repository secret` + - Name: `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` + - Value: Paste the entire JSON file contents + - Click `Add secret` + +The workflow will now automatically upload signed APKs to the selected Play Console track when triggered. + +## Workflows + +### Build APK (`.github/workflows/build-apk.yml`) + +- Triggers on pull requests to `main` and pushes directly to `main`. +- Builds the release variant using JDK 17 and Android SDK. +- Publishes the generated APK as an artifact (`app-release-apk`) for validation. +- Use this workflow as a required status check in the branch protection rule. + +### Release APK (`.github/workflows/release-apk.yml`) + +- Trigger modes: + - **Tag push (`v*`)**: Builds, optionally signs, uploads to Google Play Console (if configured), and attaches the APK to a GitHub Release created for the tag (requires branch/tag commit on `main`). + - **Manual dispatch**: Select `main` as the branch, choose a Play Console track (internal, alpha, beta, production), optionally override the release name, and obtain the artifact from the run (no release created automatically for manual runs). +- Signing is automatic when all four signing secrets are present; otherwise an unsigned artifact is uploaded. +- Google Play Console upload is automatic when `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` is configured. For manual dispatch, you can select the release track (defaults to `internal`). +- The workflow aborts if the triggering ref/tag does not point to the latest commit on `main`, guaranteeing releases originate from protected history. + +## Release Checklist + +1. Merge all changes into `main` via reviewed pull requests. +2. Tag the `main` commit (`git tag vX.Y.Z && git push origin vX.Y.Z`) **or** run the Release workflow manually from `main` (select the Play Console track if using manual dispatch). +3. Confirm the workflow succeeds: + - The signed APK is available as a GitHub Actions artifact + - If `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` is configured, the APK is automatically uploaded to the selected Play Console track + - For tag pushes, a GitHub Release is created with the APK attached +4. Monitor the Play Console to confirm the release is processed and available to testers/users. + diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/scripts/increment_version_code.py b/scripts/increment_version_code.py new file mode 100755 index 0000000..9f90c64 --- /dev/null +++ b/scripts/increment_version_code.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Auto-increment version code by querying Google Play Console. + +This script queries Google Play Console API to find the highest version code +across all release tracks (internal, alpha, beta, production), increments it, +and updates the version code in app/build.gradle.kts. +""" + +import json +import os +import re +import sys +import traceback +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +PACKAGE_NAME = "llc.fungee.IngrediCheck" +SERVICE_ACCOUNT_FILE = "/tmp/service_account.json" +GRADLE_FILE = "app/build.gradle.kts" + + +def get_latest_version_code_from_play(): + """Query Google Play API to get the highest version code across all tracks.""" + try: + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, + scopes=['https://www.googleapis.com/auth/androidpublisher'] + ) + + service = build('androidpublisher', 'v3', credentials=credentials) + + # Create a single edit to query all tracks + edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute() + edit_id = edit['id'] + + max_version_code = 0 + tracks = ['internal', 'alpha', 'beta', 'production'] + + # List all tracks at once (more efficient) + try: + tracks_list = service.edits().tracks().list( + packageName=PACKAGE_NAME, + editId=edit_id + ).execute() + + if 'tracks' in tracks_list: + for track in tracks_list['tracks']: + if 'releases' in track: + for release in track['releases']: + if 'versionCodes' in release: + for vc in release['versionCodes']: + max_version_code = max(max_version_code, int(vc)) + except HttpError as e: + print(f"Error listing tracks: {e}") + # Fallback: query each track individually + for track_name in tracks: + try: + track_response = service.edits().tracks().get( + packageName=PACKAGE_NAME, + editId=edit_id, + track=track_name + ).execute() + + if 'releases' in track_response: + for release in track_response['releases']: + if 'versionCodes' in release: + for vc in release['versionCodes']: + max_version_code = max(max_version_code, int(vc)) + except HttpError: + continue + + # Delete the edit (cleanup) + try: + service.edits().delete(packageName=PACKAGE_NAME, editId=edit_id).execute() + except: + pass + + return max_version_code + + except Exception as e: + print(f"Error querying Google Play API: {e}") + print(traceback.format_exc()) + return None + + +def update_version_code(new_version_code): + """Update version code in build.gradle.kts.""" + with open(GRADLE_FILE, 'r') as f: + content = f.read() + + # Find and replace versionCode + pattern = r'versionCode\s*=\s*\d+' + replacement = f'versionCode = {new_version_code}' + new_content = re.sub(pattern, replacement, content) + + with open(GRADLE_FILE, 'w') as f: + f.write(new_content) + + print(f"✓ Updated {GRADLE_FILE} with version code {new_version_code}") + + +def main(): + """Main entry point.""" + # Check if service account file exists + if not os.path.exists(SERVICE_ACCOUNT_FILE): + print("Google Play service account not configured, skipping version code check") + return + + # Main logic + print("Querying Google Play Console for latest version code...") + max_version_code = get_latest_version_code_from_play() + + if max_version_code is not None and max_version_code > 0: + new_version_code = max_version_code + 1 + print(f"Latest version code found: {max_version_code}") + print(f"New version code will be: {new_version_code}") + update_version_code(new_version_code) + else: + print("Could not retrieve version code from Play Console, using fallback...") + # Fallback: increment current version code by 1 + with open(GRADLE_FILE, 'r') as f: + content = f.read() + + match = re.search(r'versionCode\s*=\s*(\d+)', content) + if match: + current_version = int(match.group(1)) + new_version = current_version + 1 + update_version_code(new_version) + print(f"Fallback: Incremented from {current_version} to {new_version}") + else: + print("ERROR: Could not find versionCode in build.gradle.kts") + sys.exit(1) + + +if __name__ == "__main__": + main() +