Purpose: This document enables any AI coding tool (Claude Code, Cursor, Windsurf, Copilot, etc.) to set up fully automated Play Store publishing for any Android project. One command builds AABs and uploads them to Google Play Console with release notes.
Cost: Free (uses Google Play Developer API + open-source Gradle plugin) Time: ~15 min one-time setup, then one command forever
Developer runs one command
|
v
Gradle Play Publisher (GPP) plugin
|
├── Builds signed AAB(s)
├── Uploads to Play Developer API
├── Sets release notes per locale
└── Commits release to chosen track (internal/production)
|
v
App live on Google Play Store
Key component: Gradle Play Publisher (GPP) - a Gradle plugin that adds publish* tasks to your Android build.
- Android project using Gradle (Groovy or Kotlin DSL)
- App already published on Google Play (at least one manual upload done)
- Google Play Developer account ($25 one-time fee, already paid if you have apps)
- App signing key configured in your build
This step requires human interaction with Google Cloud Console and Play Console. AI tools cannot automate this.
- Go to Google Cloud Console
- Select or create a project (can reuse existing Firebase project)
- Enable the Google Play Android Developer API:
- Go to APIs & Services > Library
- Search "Google Play Android Developer API"
- Click Enable
- Create a Service Account:
- Go to IAM & Admin > Service Accounts
- Click Create Service Account
- Name:
play-publisher(or any name) - Click Create and Continue > skip roles > Done
- Create JSON key:
- Click the service account > Keys tab
- Add Key > Create new key > JSON
- Save the downloaded file (this is your credential)
Important: Google removed the old "Settings > API Access" page. The new method is via "Users and permissions".
- Go to Play Console
- Navigate to Users and permissions (left sidebar)
- Click Invite new users
- Paste the service account email (from step 1A, looks like:
name@project.iam.gserviceaccount.com) - Set permissions:
- App permissions: Select all apps (or specific apps)
- Account permissions: Enable:
- "Release to production, exclude devices, and use Play App Signing"
- "Manage testing tracks and edit tester lists"
- Click Invite > Send invite
# Place the key file in your Android project root (next to gradlew)
cp ~/Downloads/your-key-file.json <android-project>/play-publisher-key.jsonAdd to .gitignore:
play-publisher-key.json
One service account handles ALL apps under the same Play Console developer account. You do NOT need separate accounts per app.
| GPP Version | Required AGP Version | Notes |
|---|---|---|
| 4.x | AGP 9+ | Latest, requires newest Android Studio |
| 3.13.0 | AGP 7.x - 8.x | Recommended for most projects |
| 3.10.x | AGP 7.x | Older but stable |
Check your AGP version in gradle/libs.versions.toml or project-level build.gradle(.kts) to choose the right GPP version.
If the project uses a version catalog (gradle/libs.versions.toml):
[versions]
# ... existing versions ...
playPublisher = "3.13.0" # Use 4.x if AGP 9+
[plugins]
# ... existing plugins ...
play-publisher = { id = "com.github.triplet.play", version.ref = "playPublisher" }plugins {
// ... existing plugins ...
alias(libs.plugins.play.publisher) apply false
}If NOT using version catalog, add directly:
plugins {
id("com.github.triplet.play") version "3.13.0" apply false
}Apply the plugin and configure:
plugins {
// ... existing plugins ...
id("com.github.triplet.play") // or: alias(libs.plugins.play.publisher)
}
// ... android { } block ...
play {
// Path to the service account JSON key
serviceAccountCredentials.set(file("play-publisher-key.json"))
// Track to publish to: "internal", "alpha", "beta", or "production"
track.set("production")
// Use AAB format (recommended over APK)
defaultToAppBundles.set(true)
// Optional: release status
// "completed" = auto-rollout, "draft" = manual review needed
// releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.COMPLETED)
}For multi-flavor projects, the play {} block applies to all flavors automatically. GPP creates per-flavor publish tasks.
To avoid build failures when the key file is missing (e.g., on CI without credentials or other developers' machines):
play {
val keyFile = file("play-publisher-key.json")
if (keyFile.exists()) {
serviceAccountCredentials.set(keyFile)
}
track.set("production")
defaultToAppBundles.set(true)
}Or load the key path from local.properties:
// At top of build.gradle.kts
val localProperties = java.util.Properties().apply {
val f = rootProject.file("local.properties")
if (f.exists()) load(java.io.FileInputStream(f))
}
play {
val keyPath = localProperties.getProperty("PLAY_PUBLISHER_KEY_FILE", "play-publisher-key.json")
val keyFile = rootProject.file(keyPath)
if (keyFile.exists()) {
serviceAccountCredentials.set(keyFile)
}
track.set(localProperties.getProperty("PLAY_TRACK", "production"))
defaultToAppBundles.set(true)
}GPP reads release notes from a specific directory structure:
app/src/
├── main/play/
│ └── release-notes/
│ ├── en-US/
│ │ └── default.txt # English release notes
│ └── hi-IN/
│ └── default.txt # Hindi release notes
│
├── flavorOne/play/ # For multi-flavor projects
│ └── release-notes/
│ └── en-US/
│ └── default.txt # Flavor-specific notes
│
└── flavorTwo/play/
└── release-notes/
└── en-US/
└── default.txt
Rules:
- Max 500 characters per release note file (Play Store limit)
- Locale codes follow BCP-47 format:
en-US,hi-IN,es-ES, etc. default.txtis used for the default track; useinternal.txt,beta.txt, etc. for track-specific notes- For multi-flavor projects, put notes under
src/{flavorName}/play/release-notes/ - For single-flavor projects, put notes under
src/main/play/release-notes/
# Single flavor project
mkdir -p app/src/main/play/release-notes/en-US
# Multi-flavor project (repeat for each flavor)
mkdir -p app/src/flavorOne/play/release-notes/en-US
mkdir -p app/src/flavorTwo/play/release-notes/en-USecho "Bug fixes and performance improvements." > app/src/main/play/release-notes/en-US/default.txtAfter setup, GPP adds these tasks (replace {Flavor} with your flavor name, capitalized):
# Build AAB and upload to Play Store
./gradlew publishReleaseBundle
# Build AAB only (no upload)
./gradlew bundleRelease# Publish one flavor
./gradlew publish{Flavor}ReleaseBundle
# Publish ALL flavors (list all tasks)
./gradlew publishFlavorOneReleaseBundle publishFlavorTwoReleaseBundle
# Build AAB for one flavor (no upload)
./gradlew bundle{Flavor}Release# Publish to internal testing track instead of production
./gradlew publishReleaseBundle -Pplay.track=internal
# With stack trace for debugging
./gradlew publishReleaseBundle --stacktraceFor convenience, create a shell script that handles version bumping, release note generation, and publishing.
#!/bin/bash
set -e
# ============================================================
# Automated Play Store Publisher
# Builds AABs and uploads to Google Play Console
#
# Usage:
# ./publish-playstore.sh # Interactive
# ./publish-playstore.sh --all # All flavors
# ./publish-playstore.sh --flavor myFlavor # Single flavor
# ./publish-playstore.sh --all --notes "text" # Custom notes
# ./publish-playstore.sh --all --track internal # Internal track
# ./publish-playstore.sh --all --dry-run # Build only
# ============================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# ----- CONFIGURATION: Edit these for your project -----
# Directory containing your Android project (where gradlew lives)
ANDROID_DIR="$(cd "$(dirname "$0")" && pwd)"
# If gradlew is in a subdirectory, adjust:
# ANDROID_DIR="$(cd "$(dirname "$0")/android-app" && pwd)"
# Your product flavor names (leave empty array for single-flavor projects)
ALL_FLAVORS=("flavorOne" "flavorTwo")
FLAVOR_NAMES=("Flavor One App" "Flavor Two App")
# Service account key filename (relative to ANDROID_DIR)
KEY_FILE="play-publisher-key.json"
# Default locale for release notes
LOCALE="en-US"
# ----- END CONFIGURATION -----
# Parse arguments
PUBLISH_ALL=false
SINGLE_FLAVOR=""
CUSTOM_NOTES=""
TRACK="production"
DRY_RUN=false
SKIP_VERSION_BUMP=false
while [[ $# -gt 0 ]]; do
case $1 in
--all) PUBLISH_ALL=true; shift ;;
--flavor) SINGLE_FLAVOR="$2"; shift 2 ;;
--notes) CUSTOM_NOTES="$2"; shift 2 ;;
--track) TRACK="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--no-version-bump) SKIP_VERSION_BUMP=true; shift ;;
--help|-h)
echo "Usage:"
echo " ./publish-playstore.sh --all # Publish all flavors"
echo " ./publish-playstore.sh --flavor <name> # Publish one flavor"
echo " ./publish-playstore.sh --all --notes \"text\" # Custom release notes"
echo " ./publish-playstore.sh --all --track internal # Internal track"
echo " ./publish-playstore.sh --all --dry-run # Build only"
echo " ./publish-playstore.sh --all --no-version-bump # Skip version increment"
exit 0 ;;
*) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
esac
done
cd "$ANDROID_DIR"
chmod +x ./gradlew
# Verify key file
if [ ! -f "$KEY_FILE" ]; then
echo -e "${RED}Error: Service account key not found: $ANDROID_DIR/$KEY_FILE${NC}"
echo ""
echo "Setup instructions:"
echo " 1. Create service account in Google Cloud Console"
echo " 2. Download JSON key and save as $KEY_FILE"
echo " 3. Invite service account email in Play Console > Users and permissions"
exit 1
fi
# Determine flavors to publish
SELECTED_FLAVORS=()
if [ ${#ALL_FLAVORS[@]} -eq 0 ]; then
# Single-flavor project
SELECTED_FLAVORS=("")
elif [ -n "$SINGLE_FLAVOR" ]; then
SELECTED_FLAVORS=("$SINGLE_FLAVOR")
elif [ "$PUBLISH_ALL" = true ]; then
SELECTED_FLAVORS=("${ALL_FLAVORS[@]}")
else
echo -e "${YELLOW}Select flavor to publish:${NC}"
for i in "${!ALL_FLAVORS[@]}"; do
echo " $((i+1)). ${FLAVOR_NAMES[$i]} (${ALL_FLAVORS[$i]})"
done
echo " $((${#ALL_FLAVORS[@]}+1)). ALL"
read -rp "Choice: " CHOICE
if [ "$CHOICE" = "$((${#ALL_FLAVORS[@]}+1))" ]; then
SELECTED_FLAVORS=("${ALL_FLAVORS[@]}")
else
SELECTED_FLAVORS=("${ALL_FLAVORS[$((CHOICE-1))]}")
fi
fi
# Auto-increment version for a flavor
increment_version() {
local flavor=$1
local build_gradle="app/build.gradle.kts"
if [ -z "$flavor" ]; then
# Single-flavor: look for defaultConfig
local vc=$(grep "versionCode" "$build_gradle" | head -1 | sed 's/.*versionCode\s*=\?\s*\([0-9]*\).*/\1/')
local vn=$(grep "versionName" "$build_gradle" | head -1 | sed 's/.*versionName\s*=\?\s*"\([^"]*\)".*/\1/')
else
local vc=$(grep -A 20 "create(\"$flavor\")" "$build_gradle" | grep "versionCode" | head -1 | sed 's/.*versionCode\s*=\?\s*\([0-9]*\).*/\1/')
local vn=$(grep -A 20 "create(\"$flavor\")" "$build_gradle" | grep "versionName" | head -1 | sed 's/.*versionName\s*=\?\s*"\([^"]*\)".*/\1/')
fi
if [ -n "$vc" ] && [ -n "$vn" ]; then
local new_vc=$((vc + 1))
local major=$(echo "$vn" | cut -d. -f1)
local minor=$(echo "$vn" | cut -d. -f2)
local new_vn="$major.$((minor + 1))"
echo -e "${GREEN} Version: $vn ($vc) -> $new_vn ($new_vc)${NC}"
sed -i.bak "s/versionCode\s*=\s*$vc/versionCode = $new_vc/" "$build_gradle"
sed -i.bak "s/versionName\s*=\s*\"$vn\"/versionName = \"$new_vn\"/" "$build_gradle"
rm -f "$build_gradle.bak"
fi
}
# Write release notes
write_release_notes() {
local flavor=$1
local notes_dir
if [ -z "$flavor" ]; then
notes_dir="app/src/main/play/release-notes/$LOCALE"
else
notes_dir="app/src/$flavor/play/release-notes/$LOCALE"
fi
mkdir -p "$notes_dir"
if [ -n "$CUSTOM_NOTES" ]; then
echo "$CUSTOM_NOTES" > "$notes_dir/default.txt"
fi
echo -e "${GREEN} Notes: $(cat "$notes_dir/default.txt" | head -1)${NC}"
}
# Summary
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Play Store Publisher${NC}"
echo -e "${BLUE}========================================${NC}"
echo -e "${YELLOW}Flavors:${NC} ${SELECTED_FLAVORS[*]:-default}"
echo -e "${YELLOW}Track:${NC} $TRACK"
echo -e "${YELLOW}Dry run:${NC} $DRY_RUN"
echo ""
read -rp "Proceed? [y/N]: " CONFIRM
[[ ! "$CONFIRM" =~ ^[Yy]$ ]] && exit 0
# Version bump
if [ "$SKIP_VERSION_BUMP" = false ]; then
echo -e "${BLUE}Incrementing versions...${NC}"
for f in "${SELECTED_FLAVORS[@]}"; do
increment_version "$f"
done
fi
# Release notes
echo -e "${BLUE}Setting release notes...${NC}"
for f in "${SELECTED_FLAVORS[@]}"; do
write_release_notes "$f"
done
# Build Gradle task list
GRADLE_TASKS=()
for f in "${SELECTED_FLAVORS[@]}"; do
if [ -z "$f" ]; then
# Single-flavor
[ "$DRY_RUN" = true ] && GRADLE_TASKS+=("bundleRelease") || GRADLE_TASKS+=("publishReleaseBundle")
else
local cap="$(echo ${f:0:1} | tr '[:lower:]' '[:upper:]')${f:1}"
[ "$DRY_RUN" = true ] && GRADLE_TASKS+=("bundle${cap}Release") || GRADLE_TASKS+=("publish${cap}ReleaseBundle")
fi
done
# Clean and publish
echo -e "${BLUE}Cleaning...${NC}"
./gradlew clean 2>/dev/null || true
echo -e "${BLUE}Building and publishing...${NC}"
echo -e "${YELLOW}Tasks: ${GRADLE_TASKS[*]}${NC}"
echo ""
if ./gradlew "${GRADLE_TASKS[@]}" --stacktrace; then
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Published Successfully!${NC}"
echo -e "${GREEN}========================================${NC}"
if [ "$DRY_RUN" = true ]; then
echo -e "${YELLOW}Dry run: AABs built but NOT uploaded.${NC}"
else
echo -e "${GREEN}Apps uploaded to $TRACK track.${NC}"
echo -e "Check: https://play.google.com/console"
fi
else
echo -e "${RED}Publish FAILED. Check errors above.${NC}"
exit 1
fiMake executable:
chmod +x publish-playstore.shWhen asked to "set up automated Play Store publishing" for any Android project:
1. [ ] Check AGP version -> pick GPP 3.13.0 (AGP 7-8) or 4.x (AGP 9+)
2. [ ] Add GPP to version catalog (libs.versions.toml) or build.gradle
3. [ ] Apply plugin in project-level build.gradle.kts (apply false)
4. [ ] Apply plugin in app-level build.gradle.kts
5. [ ] Add play {} config block with serviceAccountCredentials + track
6. [ ] Add play-publisher-key.json to .gitignore
7. [ ] Create release-notes directory structure
8. [ ] Write default release notes
9. [ ] Create publish-playstore.sh convenience script
10. [ ] Tell user to: create service account + invite in Play Console
| Project Type | Build Only | Build + Upload |
|---|---|---|
| Single flavor | bundleRelease |
publishReleaseBundle |
| Multi-flavor | bundle{Flavor}Release |
publish{Flavor}ReleaseBundle |
| Problem | Cause | Solution |
|---|---|---|
No service account credentials |
Key file missing or wrong path | Verify play-publisher-key.json exists at the configured path |
403 Forbidden |
Service account not invited in Play Console | Go to Play Console > Users and permissions > Invite the service account email |
App not found |
Wrong package name or app not yet on Play Store | Upload first APK/AAB manually via Play Console |
Version code already used |
Forgot to increment versionCode | Bump versionCode in build.gradle.kts |
APK/AAB not signed |
Missing signing config | Ensure signingConfigs block exists in build.gradle.kts with valid keystore |
Release notes too long |
Over 500 chars | Trim release notes file to under 500 characters |
| GPP 4.x fails | AGP < 9 | Downgrade to GPP 3.13.0 |
tasks command fails |
Known Gradle bug, unrelated | Actual build/publish tasks work fine, ignore this |
android-project/
├── gradlew
├── play-publisher-key.json # Service account key (GITIGNORED)
├── publish-playstore.sh # One-command publish script
├── .gitignore # Contains: play-publisher-key.json
├── build.gradle.kts # Has: play-publisher plugin (apply false)
├── gradle/
│ └── libs.versions.toml # Has: playPublisher version
├── app/
│ ├── build.gradle.kts # Has: play {} config block
│ └── src/
│ ├── main/play/ # Single-flavor release notes
│ │ └── release-notes/en-US/default.txt
│ ├── flavorOne/play/ # Multi-flavor release notes
│ │ └── release-notes/en-US/default.txt
│ └── flavorTwo/play/
│ └── release-notes/en-US/default.txt
[versions]
playPublisher = "3.13.0"
[plugins]
play-publisher = { id = "com.github.triplet.play", version.ref = "playPublisher" }plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.play.publisher) apply false
}plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.play.publisher)
}
android {
// ... existing config ...
productFlavors {
create("free") {
applicationId = "com.example.myapp.free"
versionCode = 10
versionName = "2.0"
}
create("pro") {
applicationId = "com.example.myapp.pro"
versionCode = 8
versionName = "1.5"
}
}
}
play {
val keyFile = file("play-publisher-key.json")
if (keyFile.exists()) {
serviceAccountCredentials.set(keyFile)
}
track.set("production")
defaultToAppBundles.set(true)
}# Publish both flavors
./gradlew publishFreeReleaseBundle publishProReleaseBundle
# Publish one flavor
./gradlew publishFreeReleaseBundle
# Build only (no upload)
./gradlew bundleFreeRelease bundleProReleaseDocument created: Feb 2026. Tested with GPP 3.13.0 + AGP 8.13.2 on Windows 11. Reference project: OnlineStoriesKidsHindi (4 flavors, all published successfully in 8m 20s).