Skip to content
DevOps AI ToolKit
Newsletter
All prompts
AI for GitLab CI/CD Difficulty: Advanced ClaudeChatGPTCursor

GitLab CI/CD Android Signed APK/AAB Build Pipeline Prompt

Build, test, and sign Android APK/AAB artifacts in GitLab CI — keystore secrecy, Gradle caching, SDK licenses, and Play Store upload without leaking signing keys.

Target user
Mobile and DevOps engineers building Android apps in GitLab CI
Difficulty
Advanced
Tools
Claude, ChatGPT, Cursor

The prompt

You are a senior mobile CI engineer who builds and signs Android apps in GitLab CI at production scale. You know Gradle caching, the Android SDK/build-tools/NDK license dance, keystore secrecy, APK vs. AAB, `apksigner`/`jarsigner`, and Play Store upload via the Publisher API — and how each of these leaks time or secrets when done naively.

I will provide:
- App details (Gradle version, `compileSdk`/`targetSdk`, NDK use, flavors/build types)
- Current `.gitlab-ci.yml` (or none) and the base image
- Signing approach today (unsigned, debug-signed, manual)
- The goal (add signing, speed up, produce AAB, upload to Play)
- Runner type (shared / self-hosted / Kubernetes)

Your job:

1. **Pick a correct base image**: a pinned Android SDK image with the right build-tools; accept licenses non-interactively but only for pinned versions.
2. **Cache Gradle aggressively**: cache `~/.gradle/caches` and `~/.gradle/wrapper` keyed on the Gradle lockfiles; use `--build-cache` and `org.gradle.caching=true`. Separate the config cache carefully.
3. **Handle signing securely**:
   - Store keystore as base64 (`KEYSTORE_BASE64` masked var) or a GitLab Secure File.
   - Decode to a temp path at job time, pass passwords via env (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` — all masked/protected).
   - Sign the release AAB/APK with Gradle signingConfig OR post-build `apksigner`.
   - `after_script` ALWAYS deletes the decoded keystore.
4. **Build the right artifact**: AAB (`bundleRelease`) for Play Store, APK (`assembleRelease`) for direct distribution; verify signature with `apksigner verify --verbose`.
5. **Gate on protected branches/tags**: signing + Play upload only on protected refs so MRs from forks can never touch the keystore.
6. **Play Store upload** (optional): use the Publisher API service account JSON (also a Secure File), upload to internal/alpha track, and make promotion manual.
7. **Speed**: parallelize lint/unit/instrumented tests via `needs`; use a warm Gradle daemon setting suited to ephemeral runners (`--no-daemon` on throwaway runners).

Deliverables:
- A staged `.gitlab-ci.yml`: build → test → sign (protected only) → upload (manual)
- The exact secret-handling block (decode, sign, verify, cleanup)
- Gradle cache config keyed correctly
- The signature-verification step proving the artifact is signed with the right key

Mark DESTRUCTIVE or RISKY: any path where the keystore could reach logs/artifacts, running signing on unprotected refs, and Play Store promotion straight to production.

---

App: [Gradle ver, compileSdk, NDK?, flavors]
Current CI (YAML): [PASTE or none]
Signing today: [unsigned / debug / manual]
Goal: [signing / speed / AAB / Play upload]
Runner: [shared / self-hosted / kubernetes]

Run this prompt with AI

Test it, get an AI-improved version, or compare models — live in the Prompt Workspace. No copy-paste.

Why this prompt works

Android CI has two hard parts that naive pipelines get wrong: Gradle caching (the difference between a 3-minute and 25-minute build) and keystore secrecy (the difference between a normal release and an unrecoverable key leak). This prompt treats the keystore as a decode-use-destroy secret bound to protected refs, and keys the Gradle cache on lockfiles rather than hoping.

How to use it

  1. Decode the keystore from a masked base64 var at job time.
  2. Sign only on protected branches/tags.
  3. Verify the signature before uploading.
  4. Delete the keystore in after_script, always.

Useful commands

# Encode keystore for a masked CI variable (run locally)
base64 -w0 release.keystore   # paste into KEYSTORE_BASE64 (masked, protected)

# Verify a built artifact is signed with the expected key
apksigner verify --verbose --print-certs app-release.apk
jarsigner -verify -verbose -certs app-release.aab

# Non-interactive SDK license accept (pinned versions)
yes | sdkmanager --licenses > /dev/null

GitLab CI patterns

Staged build → sign (protected only) → upload

stages: [build, test, sign, publish]

variables:
  GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
  GRADLE_OPTS: "-Dorg.gradle.daemon=false"

.gradle-cache:
  cache:
    key:
      files:
        - gradle/wrapper/gradle-wrapper.properties
        - gradle.lockfile
    paths:
      - .gradle/caches
      - .gradle/wrapper

assemble:
  extends: .gradle-cache
  stage: build
  image: mobiledevops/android-sdk-image:34.0.0   # pin SDK/build-tools
  script:
    - ./gradlew --build-cache assembleRelease -x lint
  artifacts:
    paths: [app/build/outputs/apk/release/*-unsigned.apk]

unit-test:
  extends: .gradle-cache
  stage: test
  needs: [assemble]
  image: mobiledevops/android-sdk-image:34.0.0
  script:
    - ./gradlew --build-cache testReleaseUnitTest

sign:
  stage: sign
  image: mobiledevops/android-sdk-image:34.0.0
  rules:
    - if: '$CI_COMMIT_TAG'              # protected tags only
  script:
    - echo "$KEYSTORE_BASE64" | base64 -d > "$CI_PROJECT_DIR/release.keystore"
    - >
      $ANDROID_HOME/build-tools/34.0.0/apksigner sign
      --ks "$CI_PROJECT_DIR/release.keystore"
      --ks-key-alias "$KEY_ALIAS"
      --ks-pass "env:KEYSTORE_PASSWORD"
      --key-pass "env:KEY_PASSWORD"
      --out app-release.apk
      app/build/outputs/apk/release/*-unsigned.apk
    - $ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose app-release.apk
  after_script:
    - rm -f "$CI_PROJECT_DIR/release.keystore"   # ALWAYS delete keystore
  artifacts:
    paths: [app-release.apk]

play-upload:
  stage: publish
  image: ruby:3.3
  rules:
    - if: '$CI_COMMIT_TAG'
      when: manual                     # human promotes to a track
  script:
    - bundle exec fastlane supply --aab app-release.aab --track internal

AAB for Play Store via Gradle signingConfig

// build.gradle (release signingConfig reads env — no plaintext in repo)
android {
  signingConfigs {
    release {
      storeFile file(System.getenv("KEYSTORE_PATH") ?: "/dev/null")
      storePassword System.getenv("KEYSTORE_PASSWORD")
      keyAlias System.getenv("KEY_ALIAS")
      keyPassword System.getenv("KEY_PASSWORD")
    }
  }
  buildTypes { release { signingConfig signingConfigs.release } }
}

Common findings this catches

  • Keystore or passwords committed, baked into the image, or echoed to logs.
  • Signing jobs runnable from fork MRs because they are not gated to protected refs.
  • No signature verification step — a mis-signed build reaches the store.
  • Cold Gradle cache every run because the cache key is not tied to lockfiles.
  • after_script missing, leaving the decoded keystore in the build dir.

When to escalate

  • Suspected keystore leak — treat as P1; you generally cannot rotate an app signing key already trusted by installed users.
  • Play App Signing enrollment decisions — coordinate with the release owner.
  • NDK/native build times — may need a self-hosted runner with more cores and a warm cache volume.

Related prompts

More GitLab CI/CD prompts & error guides

Browse every GitLab CI/CD prompt and troubleshooting guide in one place.

Free download · 368-page PDF

Reading prompts? Get all 500 in one free PDF

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.