Build and Publish Android App to Google Play with Jenkins + Vault + Gradle Play Publisher (Docker)

Related guides (prereqs)

This post stitches the pieces into a reusable, production-safe pipeline: Jenkins builds a signed AAB in Docker, secrets come from Vault, and publishing uses Gradle Play Publisher (GPP). All sensitive files are decoded inside the container and never stashed.


What you get

  • One Jenkinsfile for build → approve → publish (Internal)
  • Two reusable shared-lib steps to remove duplication:
    • withAndroidReleaseEnv: pull & decode secrets inside Docker
    • gradleRelease: run Gradle with consistent -P flags (signing + GPP)
  • One-liners to put secrets into Vault KV v2
  • Minimal Gradle snippets only (no full file)

Prerequisites

  • Jenkins agent with label ssh-agent-with-docker (Docker available)
  • Plugins: Docker Pipeline, HashiCorp Vault, Pipeline: Shared Groovy Libraries
  • Jenkins → Manage Jenkins → System: Vault URL + AppRole credential configured
  • Global library registered as jenkins-shared-lib (branch main)
  • App already applies GPP (com.github.triplet.play)

Put secrets into Vault

vault kv put secret/jenkins/mobile/app/com.maksonlee.beepbeep \
  upload_jks_b64="$(base64 -w0 beepbeep-upload.jks)" \
  store_password='******' \
  key_password='******' \
  key_alias='upload'

vault kv put secret/jenkins/mobile/shared/play-service \
  json_b64="$(base64 -w0 maksonlee-2db639d0ad6c.json)"

Notes:

  • Store single-line base64.
  • The library strips accidental data:*;base64, prefixes and CR/LF.

Jenkins shared library

Place these in your shared-lib repo under vars/.

vars/withAndroidReleaseEnv.groovy

def call(Map cfg = [:], Closure body) {
  if (!cfg.keystoreVaultPath) error "withAndroidReleaseEnv: 'keystoreVaultPath' is required"

  def image = (cfg.image ?: 'cdlee/android-build-env:latest') as String
  def insideArgs = (cfg.insideArgs ?: '') as String
  def keystoreVP = cfg.keystoreVaultPath as String
  def playVP = (cfg.playServiceVaultPath ?: null) as String
  def jksPath = (cfg.jksPath ?: '/tmp/upload.jks') as String
  def playJsonPath = (cfg.playJsonPath ?: '/tmp/play-service.json') as String
  def extraVaults = (cfg.extraVaults ?: []) as List

  def vaults = [[
    path: keystoreVP, engineVersion: 2,
    secretValues: [
      [envVar: 'UPLOAD_JKS_B64', vaultKey: 'upload_jks_b64'],
      [envVar: 'STORE_PASSWORD', vaultKey: 'store_password'],
      [envVar: 'KEY_PASSWORD',   vaultKey: 'key_password'],
      [envVar: 'KEY_ALIAS',      vaultKey: 'key_alias'],
    ]
  ]]
  if (playVP) {
    vaults << [
      path: playVP, engineVersion: 2,
      secretValues: [[envVar: 'PLAY_SERVICE_JSON_B64', vaultKey: 'json_b64']]
    ]
  }
  vaults.addAll(extraVaults)

  docker.image(image).inside(insideArgs) {
    withVault([vaultSecrets: vaults]) {
      withEnv(["JKS_PATH=${jksPath}", "PLAY_JSON_PATH=${playJsonPath}"]) {
        sh '''#!/bin/bash
set -euo pipefail
umask 077
: "${UPLOAD_JKS_B64:?ERROR: UPLOAD_JKS_B64 is empty or unset}"
printf %s "$UPLOAD_JKS_B64" | sed 's/^data:[^,]*,//' | tr -d '\r\n ' | base64 -d > "$JKS_PATH"
chmod 600 "$JKS_PATH"
if [ -n "${PLAY_SERVICE_JSON_B64:-}" ]; then
  printf %s "$PLAY_SERVICE_JSON_B64" | sed 's/^data:[^,]*,//' | tr -d '\r\n ' | base64 -d > "$PLAY_JSON_PATH"
  chmod 600 "$PLAY_JSON_PATH"
fi
echo "JKS bytes: $(wc -c < "$JKS_PATH")"
[ -f "$PLAY_JSON_PATH" ] && echo "Play JSON bytes: $(wc -c < "$PLAY_JSON_PATH")" || true
'''
      }
      body([
        jksPath: jksPath,
        playJsonPath: playJsonPath,
        hasPlay: (playVP != null)
      ])
    }
  }
}

vars/gradleRelease.groovy

def call(String task, Map cfg = [:]) {
  if (!task?.trim()) error "gradleRelease: 'task' is required"
  if (!cfg.keystoreVaultPath) error "gradleRelease: 'keystoreVaultPath' is required"

  def image        = (cfg.image ?: 'cdlee/android-build-env:latest') as String
  def insideArgs   = (cfg.insideArgs ?: '') as String
  def jksPath      = (cfg.jksPath ?: '/tmp/upload.jks') as String
  def playJsonPath = (cfg.playJsonPath ?: '/tmp/play-service.json') as String
  def track        = (cfg.track ?: null) as String
  def gradleCmd    = (cfg.gradleCmd ?: './gradlew') as String
  def stacktrace   = (cfg.get('stacktrace', true)) as boolean
  def extraProps   = (cfg.get('extraProps', [:])) as Map
  def extraArgs    = (cfg.get('extraArgs', [])) as List
  def workDir      = (cfg.get('workDir', '')) as String

  withAndroidReleaseEnv(
    image: image,
    insideArgs: insideArgs,
    keystoreVaultPath: cfg.keystoreVaultPath,
    playServiceVaultPath: cfg.get('playServiceVaultPath', null),
    jksPath: jksPath,
    playJsonPath: playJsonPath,
    extraVaults: cfg.get('extraVaults', [])
  ) { envs ->
    def args = []
    if (envs.hasPlay) {
      args << "-Pplay.serviceAccountCredentials=${envs.playJsonPath}"
      if (track) args << "-Ptrack=${track}"
    }
    args += [
      "-Psigning.storeFile=${envs.jksPath}",
      '-Psigning.storePassword="$STORE_PASSWORD"',
      '-Psigning.keyAlias="$KEY_ALIAS"',
      '-Psigning.keyPassword="$KEY_PASSWORD"'
    ]
    extraProps.each { k, v -> args << "-P${k}=${v}" }
    extraArgs.each { a -> args << a }
    if (stacktrace) args << "--stacktrace"

    def cdPrefix = workDir?.trim() ? "cd ${workDir}\n" : ""
    sh """#!/bin/bash
set -euo pipefail
${cdPrefix}${gradleCmd} ${task} \\
  ${args.join(' ')}
"""
  }
}

Jenkinsfile (build → approve → publish)

@Library('jenkins-shared-lib@main') _
pipeline {
  agent { label 'ssh-agent-with-docker' }

  stages {
    stage('Fetch code') {
      steps {
        deleteDir()
        git changelog: false,
            credentialsId: 'vault-github-ssh',
            poll: false,
            url: 'git@github.com:maksonlee/beepbeep.git'
      }
    }

    stage('Build & Sign (AAB)') {
      steps {
        script {
          gradleRelease(':app:bundleRelease', [
            image            : 'cdlee/android-build-env:latest',
            keystoreVaultPath: 'secret/jenkins/mobile/app/com.maksonlee.beepbeep',
            jksPath          : '/tmp/beepbeep-upload.jks'
          ])
          stash name: 'aab',
               includes: 'app/build/outputs/bundle/release/*.aab, app/build/outputs/mapping/release/**'
        }
      }
    }

    stage('Approve publish?') {
      when { expression { true } }
      steps { input message: 'Publish this build to Internal?' }
    }

    stage('Publish to Google Play (GPP)') {
      steps {
        script {
          unstash 'aab'
          gradleRelease(':app:publishReleaseBundle', [
            image               : 'cdlee/android-build-env:latest',
            keystoreVaultPath   : 'secret/jenkins/mobile/app/com.maksonlee.beepbeep',
            playServiceVaultPath: 'secret/jenkins/mobile/shared/play-service',
            jksPath             : '/tmp/beepbeep-upload.jks',
            playJsonPath        : '/tmp/play-service.json',
            track               : 'internal'
          ])
        }
      }
    }
  }

  post {
    always { cleanWs(deleteDirs: true, notFailBuild: true) }
  }
}

Gradle — relevant snippets only (Kotlin DSL)

Enable GPP

plugins {
    id("com.github.triplet.play") version "3.12.2"
}

Signing: accept -P in CI; fall back to keystore.properties locally

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

val keystoreProps: Properties? = rootProject.file("keystore.properties")
    .takeIf { it.exists() }
    ?.let { f -> Properties().apply { FileInputStream(f).use { load(it) } } }

fun propOrProps(gradleKey: String, propsKey: String) = providers.gradleProperty(gradleKey).orElse(
    providers.provider {
        keystoreProps?.getProperty(propsKey)
            ?: throw GradleException("Missing '$gradleKey' and '$propsKey'. Provide -P$gradleKey=... or set $propsKey in keystore.properties")
    }
)

val signingStoreFile     = propOrProps("signing.storeFile", "storeFile")
val signingStorePassword = propOrProps("signing.storePassword", "storePassword")
val signingKeyAlias      = propOrProps("signing.keyAlias", "keyAlias")
val signingKeyPassword   = propOrProps("signing.keyPassword", "keyPassword")

android {
    signingConfigs {
        create("release") {
            storeFile = layout.projectDirectory.file(signingStoreFile.get()).asFile
            storePassword = signingStorePassword.get()
            keyAlias = signingKeyAlias.get()
            keyPassword = signingKeyPassword.get()
        }
    }
    buildTypes { release { signingConfig = signingConfigs.getByName("release") } }
}

GPP reads JSON supplied by Jenkins via -Pplay.serviceAccountCredentials

play {
    val creds = providers.gradleProperty("play.serviceAccountCredentials")
    serviceAccountCredentials.set(creds.map { layout.projectDirectory.file(it) })
    defaultToAppBundles.set(true)
}

Run it

  • Register jenkins-shared-lib@main in Jenkins global libraries
  • Ensure the agent label ssh-agent-with-docker can run Docker
  • Put secrets in Vault using the Linux commands above
  • Run the pipeline; approve publish to Internal

Source

Jenkinsfile + vars/* shared library: maksonlee/jenkins-pipelines (branch main)

https://github.com/maksonlee/jenkins-pipelines

Did this guide save you time?

Support this site

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top