Skip to content

Instantly share code, notes, and snippets.

@pankajjangid
Last active March 14, 2026 17:15
Show Gist options
  • Select an option

  • Save pankajjangid/22974276ab754364f22d67717009de1b to your computer and use it in GitHub Desktop.

Select an option

Save pankajjangid/22974276ab754364f22d67717009de1b to your computer and use it in GitHub Desktop.
Set up automated Play Store publishing using the attached guide

Automated Play Store Publishing - Setup Guide for AI Coding Tools

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


Architecture Overview

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.


Prerequisites

  • 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

Step 1: Create Google Cloud Service Account (Human does this once)

This step requires human interaction with Google Cloud Console and Play Console. AI tools cannot automate this.

1A. Create Service Account + JSON Key

  1. Go to Google Cloud Console
  2. Select or create a project (can reuse existing Firebase project)
  3. Enable the Google Play Android Developer API:
    • Go to APIs & Services > Library
    • Search "Google Play Android Developer API"
    • Click Enable
  4. 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
  5. Create JSON key:
    • Click the service account > Keys tab
    • Add Key > Create new key > JSON
    • Save the downloaded file (this is your credential)

1B. Grant Access in Play Console

Important: Google removed the old "Settings > API Access" page. The new method is via "Users and permissions".

  1. Go to Play Console
  2. Navigate to Users and permissions (left sidebar)
  3. Click Invite new users
  4. Paste the service account email (from step 1A, looks like: name@project.iam.gserviceaccount.com)
  5. 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"
  6. Click Invite > Send invite

1C. Place the JSON key in your project

# Place the key file in your Android project root (next to gradlew)
cp ~/Downloads/your-key-file.json <android-project>/play-publisher-key.json

Add 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.


Step 2: Add GPP Plugin to Gradle (AI tool does this)

Version Compatibility

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.

2A. Version Catalog Setup (libs.versions.toml)

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" }

2B. Project-level build.gradle.kts

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
}

2C. App-level build.gradle.kts

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.

2D. Conditional Key Loading (Recommended)

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)
}

Step 3: Set Up Release Notes

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.txt is used for the default track; use internal.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/

Create release notes directories

# 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-US

Write release notes

echo "Bug fixes and performance improvements." > app/src/main/play/release-notes/en-US/default.txt

Step 4: Available Gradle Tasks

After setup, GPP adds these tasks (replace {Flavor} with your flavor name, capitalized):

Single-Flavor Projects

# Build AAB and upload to Play Store
./gradlew publishReleaseBundle

# Build AAB only (no upload)
./gradlew bundleRelease

Multi-Flavor Projects

# 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

Useful Options

# Publish to internal testing track instead of production
./gradlew publishReleaseBundle -Pplay.track=internal

# With stack trace for debugging
./gradlew publishReleaseBundle --stacktrace

Step 5: Create One-Command Publish Script (Optional)

For convenience, create a shell script that handles version bumping, release note generation, and publishing.

publish-playstore.sh (Template)

#!/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
fi

Make executable:

chmod +x publish-playstore.sh

Quick Reference

For AI Coding Tools - Copy-Paste Checklist

When 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

Task Name Patterns

Project Type Build Only Build + Upload
Single flavor bundleRelease publishReleaseBundle
Multi-flavor bundle{Flavor}Release publish{Flavor}ReleaseBundle

Common Issues

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

Directory Structure After Setup

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

Appendix: Full Working Example (Multi-Flavor)

libs.versions.toml (additions only)

[versions]
playPublisher = "3.13.0"

[plugins]
play-publisher = { id = "com.github.triplet.play", version.ref = "playPublisher" }

Project build.gradle.kts

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.play.publisher) apply false
}

App build.gradle.kts

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 Command

# Publish both flavors
./gradlew publishFreeReleaseBundle publishProReleaseBundle

# Publish one flavor
./gradlew publishFreeReleaseBundle

# Build only (no upload)
./gradlew bundleFreeRelease bundleProRelease

Document 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment