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 DockergradleRelease: run Gradle with consistent-Pflags (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(branchmain) - 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@mainin Jenkins global libraries - Ensure the agent label
ssh-agent-with-dockercan 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