AndroidGitHub ActionsGoogle PlayCI/CDKotlin

Build, Sign, and Auto-Deploy Android Apps to Google Play Console with GitHub Actions

Sloth255
Sloth255
·5 min read·998 words

Introduction

Are you still releasing your Android app by manually clicking the Generate Signed Bundle button in Android Studio, then opening Play Console to drag and drop the AAB?

I was doing the same for a while, but as release frequency increased, I ran into the following problems:

  • Local environment differences breaking builds (JDK version mismatches, Gradle cache issues)
  • Keystore management becoming siloed (only exists on one developer's Mac)
  • Release procedures depending on documentation, leading to human errors

To solve all of these at once, I automated the entire flow—build, sign, and upload to Google Play Console—using GitHub Actions. Here's how.

Overview

The pipeline we'll build looks like this:

flowchart TD
  A["git tag v1.0.0 push"]
  B["GitHub Actions triggered"]
  C["Set up JDK and restore Gradle cache"]
  D["Restore Keystore from Secrets"]
  E["Build and sign AAB with ./gradlew bundleRelease"]
  F["Deliver to internal testing via r0adkll/upload-google-play"]
  G["Google receives it, re-signs with app signing key, and delivers to users"]

  A --> B --> C --> D --> E --> F --> G

The terms "AAB" and "upload key" are covered in the next section.

3 Key Concepts to Understand First

Before diving into the steps, let's clarify the key terms that appear throughout this article. If these are fuzzy, the Play App Signing parts can get confusing.

APK vs AAB

APK (Android Package Kit) AAB (Android App Bundle)
Contents Universal package combining all ABIs, screen densities, and language resources into one file Build artifact before optimization for specific devices
File Size Tends to be large because it includes everything Google Play generates Split APKs for delivery, reducing user download size by ~15% on average
Main Use Direct distribution / in-house distribution / third-party stores Google Play Store distribution (required for new apps since August 2021)
Gradle Task ./gradlew assembleRelease ./gradlew bundleRelease

Simply put: "APK = finished product that installs on user devices" and "AAB = blueprint submitted to Google Play". Google Play receives the AAB and assembles the APK for each user's device.

Since this article targets Play Store distribution, we'll use AAB (bundleRelease). For internal distribution or other use cases requiring APK, use assembleRelease.

Upload Key vs App Signing Key

Play App Signing uses two types of signing keys:

Key Type Who Manages? Purpose
Upload Key Developer Signs the AAB when uploading to Play Console
App Signing Key Google Signs the final APK delivered to users from the Play Store

In other words, Play Console verifies "is this AAB signed with the correct upload key?", which is why developers need to sign with their own upload key = keystore. What Google does for you is only the re-signing with the app signing key at distribution time.

flowchart TD
  Dev["Developer"] -->|"Sign with upload key"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google verifies upload key"]
  Verify --> Resign["Google re-signs with app signing key"]
  Resign --> User["User"]

Even with Play App Signing, the fact that "you still need to create your own keystore" hasn't changed. What changed is the safety net: "you can request a reset if you lose your upload key".

Debug Signing vs Release Signing

When you first hit Run in Android Studio without any configuration, an APK was built and ran on your device because AGP (Android Gradle Plugin) auto-generates ~/.android/debug.keystore and automatically signs debug builds.

Debug Signing Release Signing
Keystore ~/.android/debug.keystore (auto-generated by AGP) Created and managed by you (release.keystore in this article)
Password Fixed: android Set by you
Alias Fixed: androiddebugkey Set by you
Expiration ~30 years Set by you (~27 years in this article)
Use Case Development testing / ./gradlew assembleDebug Play Store distribution / ./gradlew bundleRelease
Git Management Not needed (OK if it exists per machine) Keep secure (can reset via Play App Signing if lost)

Two important properties:

  1. Play Console absolutely rejects AABs signed with debug signing
    → CI needs a separate release.keystore
  2. Debug signing and release signing are completely independent
    → Adding release signing config for CI has zero effect on local ./gradlew assembleDebug

The conditional signingConfigs.create("release") in sections 5 and 6 exists precisely to leverage these properties—creating a state where developers without the key can still run debug builds normally.

Prerequisites

  • Android app (using build.gradle or build.gradle.kts)
  • GitHub repository
  • Google Play Console developer account
  • The app's first release must already be manually submitted on Play Console (first-time creation via API is not possible)
  • Play App Signing must be enabled for the target app

For apps created after August 2021, Play App Signing is automatically enabled, so no special steps are needed. For older apps created before that with the legacy signing method, you'll first need to migrate to Play App Signing (covered in section 7-4).

1. Create the Upload Key (Keystore)

Generate the keystore locally for signing the AAB. In a Play App Signing setup, this key functions as the "upload key" (= your identity when submitting AAB to Play Console). The final delivery to users is signed by Google's "app signing key", so even if you lose this key, you can request a reset from Play Console (details in 7-3).

That said, losing it in daily operations will halt releases until the reset is complete, so keep it safe.

# macOS / Linux
keytool -genkey -v \
  -keystore release.keystore \
  -alias my-release-key \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000

On Windows (PowerShell / Command Prompt), backslash line continuation doesn't work, so run it on one line or use backtick ` for line breaks in PowerShell.

keytool -genkey -v `
  -keystore release.keystore `
  -alias my-release-key `
  -keyalg RSA `
  -keysize 2048 `
  -validity 10000

Enter the password, name, organization, etc. when prompted interactively.

2. Encode the Keystore in Base64

GitHub Secrets can't store binary directly, so convert it to a Base64 string. The workflow strips newlines with tr -d '\n\r' during decoding, but outputting without newlines is safer.

macOS

base64 -i release.keystore -o release.keystore.base64
cat release.keystore.base64 | pbcopy

Linux

base64 -w 0 release.keystore > release.keystore.base64
cat release.keystore.base64 | xclip -selection clipboard

Windows (PowerShell)

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Set-Clipboard

To write to a file:

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Out-File -Encoding ascii -NoNewline release.keystore.base64

Windows (Command Prompt)

certutil works but adds headers, footers, and line breaks that need to be removed.

certutil -encode release.keystore release.keystore.base64

Remove the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- lines and all newlines from the output. Using PowerShell is recommended.

Copy the clipboard string and paste it into GitHub Secrets in the next step.

3. Create a Service Account for Google Play Console

Automated uploads to Play Console require a Google Cloud service account.

3-1. Enable the Google Play Android Developer API

First, enable the API on the GCP side. Skipping this step will result in a 403 error later, so do it first.

  1. Go to Google Play Android Developer API
  2. Click Enable

3-2. Create a Service Account in Google Cloud Console

  1. Go to Google Cloud Console
  2. Create a project (or select an existing one)
  3. From IAM & Admin → Service Accounts, create a new one. Don't assign IAM roles (permissions are managed on the Play Console side)
  4. After creation, select Add Key → Create New Key → JSON and download the JSON file

3-3. Grant Permissions in Play Console

  1. Open Play Console
  2. Open Users and Permissions, click Invite New Users
  3. Enter the service account email from step 3-2
  4. In the App Permissions tab, add the target app and grant the following permissions:
    • Release to testing tracks — required for internal testing track delivery
    • View app information and download bulk reports — read permission

4. Register Values in GitHub Secrets

From the repository's Settings → Secrets and variables → Actions, register the following:

Secret Name Value
KEYSTORE_BASE64 Base64 string from step 2
KEYSTORE_PASSWORD Keystore password
KEY_ALIAS e.g., my-release-key
KEY_PASSWORD Key password
SERVICE_ACCOUNT_JSON Paste the contents of the JSON file downloaded in step 3-2 directly

5. Configure build.gradle to Read Signing Info from Environment Variables

Set it up so that CI reads from environment variables and local development reads from keystore.properties, and even environments without a keystore can still run debug builds normally.

Edit app/build.gradle.kts as follows:

import java.util.Properties
import java.io.FileInputStream

// --- Resolve signing info ---
// Priority: environment variables (CI) > keystore.properties (local) > none (debug build only)
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        load(FileInputStream(keystorePropertiesFile))
    }
}

fun resolveSigning(key: String, envKey: String): String? =
    System.getenv(envKey) ?: keystoreProperties.getProperty(key)

val releaseStoreFile = resolveSigning("storeFile", "KEYSTORE_FILE")
val releaseStorePassword = resolveSigning("storePassword", "KEYSTORE_PASSWORD")
val releaseKeyAlias = resolveSigning("keyAlias", "KEY_ALIAS")
val releaseKeyPassword = resolveSigning("keyPassword", "KEY_PASSWORD")

val hasReleaseSigning = listOf(
    releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword
).all { !it.isNullOrBlank() }

android {
    signingConfigs {
        if (hasReleaseSigning) {
            create("release") {
                storeFile = file(releaseStoreFile!!)
                storePassword = releaseStorePassword
                keyAlias = releaseKeyAlias
                keyPassword = releaseKeyPassword
            }
        }
    }

    buildTypes {
        getByName("debug") {
            // Add applicationIdSuffix so release and debug builds
            // can coexist on the same device
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"
            // signingConfig uses the auto-generated debug.keystore by AGP
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            // Only assign release signingConfig if signing info is available
            // (= developers without the key can still run assembleDebug)
            if (hasReleaseSigning) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}

Key points of this code:

  • signingConfigs.create("release") is guarded, so the release config isn't created in environments without signing info
  • The debug build type uses AGP's auto-generated debug.keystore and is completely unaffected by release signing settings
  • Environment variables take highest priority, so no keystore.properties file is needed on CI

6. Additional Setup to Avoid Breaking Local Development

"I modified build.gradle for CI and now local debug builds are broken" is a common accident in Android × CI setups. To prevent this, set up the following:

6-1. Create keystore.properties (Local Only)

local.properties is meant for SDK paths, and mixing signing info there makes things messy. Creating a separate keystore.properties file for signing info is the approach recommended in Android's official documentation.

Create keystore.properties in the project root (one level above app/):

storeFile=/Users/yourname/keys/release.keystore
storePassword=your-store-password
keyAlias=my-release-key
keyPassword=your-key-password

This file must never go into Git, so add it to .gitignore (see below).

6-2. What to Add to .gitignore

Signing-related entries are often missed since Android Studio's generated .gitignore doesn't include them. Add them explicitly:

# Signing - never commit these
*.keystore
*.jks
keystore.properties
release.keystore.base64

# Service account JSON
*-service-account*.json
play-publisher.json

# Temporary auth files generated by google-github-actions/auth (when using WIF)
gha-creds-*.json

# Created by Android Studio but confirm anyway
local.properties

local.properties is usually added by Android Studio, but check if it's missing in older projects.

6-3. Coexisting Release and Debug with applicationIdSuffix

The build.gradle.kts snippet above includes:

debug {
    applicationIdSuffix = ".debug"
}

This allows installing both the Play Store release version and a locally built debug version on the same device simultaneously. Without this, installing a debug build would overwrite the Play Store version on your test device.

Not directly related to CI, but once you start automating releases, you'll frequently run the Play Store version on your test device, so I strongly recommend adding this.

6-4. Using ~/.gradle/gradle.properties

Another option is to write signing info in the home directory's Gradle properties:

# macOS/Linux: ~/.gradle/gradle.properties
# Windows:     %USERPROFILE%\.gradle\gradle.properties

MYAPP_KEYSTORE_FILE=/Users/yourname/keys/release.keystore
MYAPP_KEYSTORE_PASSWORD=xxxx
MYAPP_KEY_ALIAS=my-release-key
MYAPP_KEY_PASSWORD=xxxx

In build.gradle.kts, read it with findProperty("MYAPP_KEYSTORE_FILE") as String?.

The advantage is no secret information in the project directory at all. The disadvantage is that communicating setup steps to new developers takes a bit more effort. My recommendation: use ~/.gradle/gradle.properties for solo projects, keystore.properties protected by .gitignore for team projects.

6-5. Testing Unsigned Release Builds in Android Studio

While ./gradlew assembleDebug always works, you might want to run ./gradlew assembleRelease without signing (e.g., to check ProGuard settings for release builds).

In that case, when hasReleaseSigning is false, the release build type won't have a signingConfig, so running assembleRelease produces an unsigned APK (can't install on a device, but you can check size and mapping.txt).

7. Play App Signing Operational Notes

With steps 1–6 complete, the Play App Signing setup is already done. The release.keystore created in step 1 functions as the upload key in the Play App Signing context.

This section covers supplementary notes that are useful to know in operation.

7-1. What Happens on First Upload

For a new app, when you first upload the AAB to Play Console:

  • The certificate used in that AAB is automatically registered as the "upload key certificate"
  • Google automatically generates a new "app signing key"
  • Play App Signing is activated

So no additional Play Console configuration is needed—following this article's steps as-is will establish the Play App Signing setup.

flowchart TD
  CI["CI"] -->|"Sign with release.keystore"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google verifies upload key certificate"]
  Verify --> Resign["Google re-signs with app signing key"]
  Resign --> Device["User's device"]

7-2. Going One Step Further: Creating a Dedicated Upload Key

Even for new apps, you can choose to "register the upload key separately" in Play Console.

  • During the first upload, select "Use app signing key generated by Google" in Play Console
  • On the same screen, upload the upload key certificate separately

In this case, release.keystore contains a "completely dedicated upload-only key".

Standard Pattern (7-1) Separated Key Approach (7-2)
Contents of release.keystore Upload key = key that signed the first AAB Pure upload key
If leaked Can recover with reset request Can recover with reset request
Security level Sufficient More stringent

The CI steps are the same for both patterns. The only difference is how you create release.keystore.

7-3. What to Do If You Lose the Upload Key

This is Play App Signing's biggest advantage. You can request a reset from Play Console.

  1. Create a new keystore locally

  2. Export the certificate in PEM format

    keytool -export -rfc \
      -keystore release.keystore \
      -alias my-release-key \
      -file upload_certificate.pem
    
  3. In Play Console → Setup → App integrity → App signing → Request upload key reset, upload the certificate

  4. Google support verifies (usually 1–2 business days)

  5. After approval, AABs signed with the new key will be accepted

With the legacy method, "losing your key meant you could never update the same package name again"—a permanent dead end. This recovery option alone makes Play App Signing worth adopting.

7-4. Migrating an Existing (Legacy) App

For apps created before August 2021 that haven't migrated to Play App Signing, you need to hand over the current signing key to Google:

  1. Play Console → target app → Setup → App integrity → App signing

  2. Download the PEPK (Play Encrypt Private Key) tool provided by Google

  3. Use PEPK to export the current keystore in encrypted form

    java -jar pepk.jar \
      --keystore=existing-release.keystore \
      --alias=my-release-key \
      --output=encrypted-key.zip \
      --include-cert \
      --rsa-aes-encryption \
      --encryption-key-path=public-key.pem
    
  4. Upload the output file (encrypted-key.zip) to Play Console

  5. After migration, create and register a new upload key if needed (continuing to use the existing key as the upload key is also possible)

public-key.pem is the public key displayed on the migration screen in Play Console, saved as a file.

After migration, steps 1–6 of this article apply directly.

8. Write the GitHub Actions Workflow

Now for the main event. Create .github/workflows/release.yml:

name: Android Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Key Points

  • Tag trigger (v*) prevents accidental deployment on every main push
  • Gradle cache reduces build time (from ~6 minutes to ~2 minutes on subsequent runs)
  • Artifact upload serves as a backup—if Play Console upload fails, you can manually download the AAB from the Actions UI
  • tracks: internal can be changed to alpha, beta, production, etc.
  • whatsNewDirectory is the directory for release notes by language, e.g., distribution/whatsnew/whatsnew-ja-JP

8-2. More Secure Setup: Using Workload Identity Federation

Instead of storing long-lived JSON keys in GitHub Secrets, Workload Identity Federation (WIF) allows GitHub Actions to obtain temporary credentials for accessing Play Console. This eliminates the risk of JSON key leakage and is recommended for production use.

Prerequisites (GCP Side)

The WIF setup is done once and doesn't need to be repeated. Follow the steps in google-github-actions/auth. With this approach, no JSON key is generated for the service account, so step 4 in section 3-2 (creating/downloading the key) and registering SERVICE_ACCOUNT_JSON in section 4 are not needed.

Configure with the following gcloud commands. Replace each placeholder with your actual values:

Placeholder Description
${PROJECT_ID} GCP project ID
${GITHUB_ORG} GitHub organization name or username
${REPO} Repository name in org/repo format
${SERVICE_ACCOUNT} Service account name from step 3-2 (the part before @ in the email)
${WORKLOAD_IDENTITY_POOL_ID} Full Pool ID obtained in step 2
# 1. Create Workload Identity Pool
gcloud iam workload-identity-pools create "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# 2. Get the full Pool ID
gcloud iam workload-identity-pools describe "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --format="value(name)"
# Example: projects/123456789/locations/global/workloadIdentityPools/github

# 3. Create Workload Identity Provider
#    --attribute-condition restricts to tokens from this organization only
gcloud iam workload-identity-pools providers create-oidc "my-repo" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="github" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"

# 4. Grant roles/iam.workloadIdentityUser to the service account
#    principalSet's attribute.repository restricts to this specific repository
#    ${WORKLOAD_IDENTITY_POOL_ID} = full ID obtained in step 2
gcloud iam service-accounts add-iam-policy-binding \
  "${SERVICE_ACCOUNT}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${REPO}"

After setup, note the Provider's full ID (projects/.../providers/...). It's used in the workload_identity_provider field in the GitHub Actions YAML.

Workflow YAML (WIF Version)

Add id-token: write permission to the repository and a google-github-actions/auth step:

name: Android Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read
  id-token: write          # Required for WIF token acquisition

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Authenticate to Google Cloud (WIF)
        uses: google-github-actions/auth@v3
        id: auth
        with:
          workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
          service_account: SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Replace workload_identity_provider and service_account with the values confirmed in the GCP console after WIF setup. The only difference from the JSON key version is passing the credentials file path to serviceAccountJson instead of serviceAccountJsonPlainText—everything else is the same.

9. Create the Release Notes Directory

Create distribution/whatsnew/ at the project root and place files like:

distribution/whatsnew/
├── whatsnew-en-US
└── whatsnew-ja-JP

Each file contains release notes in the respective language (up to 500 characters).

10. Let's Run It

Once you're here, all that's left is pushing a tag:

git tag v1.0.0
git push origin v1.0.0

Open the Actions tab in GitHub and the workflow will start running. If successful, a new release will appear in Internal testing in Play Console.

11. Common Pitfalls and Solutions

A few traps I fell into during actual operation:

Forgetting to Increment versionCode

Play Console doesn't allow duplicate versionCode values. Setting up automatic numbering using the GitHub Actions run number (GITHUB_RUN_NUMBER) prevents accidents. Use the tag name minus v for versionName.

android {
    defaultConfig {
        // On CI, use GITHUB_RUN_NUMBER for versionCode (fallback to 1 locally)
        versionCode = (System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1) + 1000
        // On CI, use git tag (v1.0.0 → 1.0.0) for versionName
        versionName = System.getenv("GITHUB_REF_NAME")?.removePrefix("v") ?: "1.0.0-local"
    }
}

Service Account Permission Propagation Delay

Running Actions immediately after granting permissions can result in 403 The caller does not have permission. Permission changes take some time to propagate, so be patient on the first run.

Newlines Mixed into Base64 Decode

The base64 command behaves differently across OSes:

  • macOS: Auto-inserts newlines every 76 characters → use -i / -o for clean output
  • Linux: Inserts newlines by default → use -w 0 for no newlines
  • Windows (certutil): Headers, footers, and newlines all included → post-processing required; PowerShell's [Convert]::ToBase64String is safer

Always verify there are no newlines before pasting into GitHub Secrets. Adding tr -d '\n\r' when decoding in CI is standard practice.

- name: Decode Keystore
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > release.keystore

Conclusion

With GitHub Actions, the release process becomes:

"Just create a tag and push"

This allows increasing release frequency with almost zero operational overhead.

For teams with multiple developers, just solving "only that one Mac can do releases" makes it worth adopting. If you're struggling with the same pain points in Android app development, give it a try.

References

Android / Signing

Google Play Console

GitHub Actions

Gradle