How to Build an Automated Jenkins CI/CD Pipeline for Android APK Packaging
Learn how to create a standardized Jenkins CI/CD pipeline that automates Android APK building, uploading to Pgyer, and notifying teams via DingTalk, eliminating manual errors, speeding delivery, and improving collaboration across development, testing, and product teams.
Introduction
In traditional development workflows, developers often package and upload installation files locally after fixing code, which can lead to missed pushes, resurrected bugs, and unnecessary communication. To avoid these problems, this tutorial shows how to build a standardized Jenkins CI/CD pipeline that automates packaging, uploading, and notification, ensuring every build is based on the latest code and improving team efficiency.
Overall Process and Visual Overview
0 Overall Process
Developers trigger a Jenkins job via a bat script or Webhook → Jenkins pulls the code → compiles the APK → a Python script uploads to Pgyer → a DingTalk robot notifies the team.
1 Manual Trigger (bat script)
Build user: name of the person who initiates the build. Only trusted users are allowed.
Update description: description of the changes, also used as Pgyer update note.
Build branch: any Git branch can be built, avoiding the need to modify Jenkins parameters each time.
Build environment: debug/release to distinguish package type.
2 Jenkins Execution Result
3 DingTalk Notification (Success)
4 Pgyer Upload Information
5 Execution Summary
Several months of statistics show 115 successful executions, average total time 4 min 6 s, compile time 4 min 50 s, upload time 19 s.
Target Goals
Support Different Packaging Methods
Automatic Packaging
Goal: After each push, monitor version number changes; if changed, trigger automatic packaging.
Process: Use Webhooks to trigger Jenkins automatically, compare current and previous version numbers, and package when they differ.
Details: After packaging, store build description and other information for team notification.
Manual Packaging
Local bat script execution requires entering build user, update description, and people to @. The script sends a curl request to Jenkins, which then runs the pipeline.
Security Measures
Because the bat script contains a direct curl link, anyone with the link could trigger builds. Initially a base64 list of trusted users was used, but the final solution delegates verification to a backend API that checks whether the build user is in a trusted list, preventing unauthorized builds. This approach has been secure for over six months.
Open to Non‑Development Teams
Testing and product teams can use the manual packaging script to verify packages and trigger releases.
Task Breakdown (★ indicates difficulty)
1 Install Jenkins ★
Download and install Jenkins (see tutorial [1]).
Configure Jenkins and install additional plugins:
Webhook Trigger
JSON Parsing
HTTP Request
Generic Webhook Trigger
Git
Start Jenkins with parameters to keep undefined parameters and set UTF‑8 encoding:
-Dhudson.model.ParametersAction.keepUndefinedParameters=true -Dfile.encoding=UTF8Create a new Pipeline job.
2 Create the Build Pipeline
The pipeline uses Declarative Pipeline syntax. Each stage handles a functional module.
pipeline {
agent any
triggers { GenericTrigger(...) }
environment { ... }
stages {
stage('Checkout') { ... }
stage('Build') { ... }
stage('Upload APK to Pgyer') { ... }
stage('Post DingTalk') { ... }
stage('Print Parameters') { ... }
// additional stages omitted for brevity
}
post { always { ... } }
}2.1 Checkout (★)
This stage pulls the remote repository, embodying the core idea of Continuous Integration: all engineers push code to a shared repository, and the CI system integrates it continuously.
pipeline {
environment {
GIT_REPO = 'xxx.git'
GIT_USER = ''
GIT_PASSWORD = ''
}
stages {
stage('Checkout') {
steps {
script {
try {
git url: "${GIT_REPO}", credentialsId: "${GIT_USER}:${GIT_PASSWORD}"
echo "Successfully pulled ${GIT_REPO} branch ${env.BRANCH}"
} catch (Exception e) {
echo "Pull failed: ${e.message}"
error "Pull failed, check repository, credentials, and branch"
}
}
}
}
}
}2.2 Build (★★★★★)
After checkout, the project must be buildable in Linux Android Studio. Common issues include missing dependencies on headless Ubuntu, case‑sensitivity of module names, mismatched build‑tools/aidl for ARM64, and SDK path differences.
stage('Build') {
steps {
script {
try {
def settingsGradleFile = "${WORKSPACE}/local.properties"
if (!fileExists(settingsGradleFile)) {
writeFile file: settingsGradleFile, text: 'sdk.dir="/root/Android/Sdk"'
}
sh "sudo chmod +x gradlew"
def capitalizedMatrix = env.MATRIX[0].toUpperCase() + env.MATRIX.substring(1)
sh "sudo ./gradlew clean assembleForeignbeta${capitalizedMatrix}"
echo "Gradle build succeeded ${capitalizedMatrix}"
} catch (Exception e) {
error "Gradle build failed: ${e.message}"
}
}
}
}2.3 Upload APK to Pgyer (★★★)
Pgyer API Documentation [7]
The stage uploads the built APK to Pgyer. The original plugin worked with API v1; after Pgyer upgraded to v2, a Python script upload_to_pgyer.py is used.
stage('Upload APK to Pgyer') {
steps {
script {
try {
env.START_UPLOAD_TIME = new Date().format('yyyy-MM-dd HH:mm:ss')
env.START_UPLOAD_TIMESTAMP = System.currentTimeMillis()
def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
def apkFile = sh(script: "firstApk=\$(find ${apkDir} -name \"*.apk\" | head -n 1); echo $firstApk | xargs -n 1 basename", returnStdout: true).trim()
if (apkFile.isEmpty()) { error "APK file not found" }
def apkPath = "${apkDir}/${apkFile}"
if (!fileExists(apkPath)) { error "File ${apkPath} does not exist" }
echo "📤 Uploading APK to Pgyer..."
def output = sh(script: "python3 -u upload_to_pgyer.py --file ${apkPath} --install_type ${env.INSTALL_TYPE} --password '${env.PASSWORD}' --update_description '${env.UPDATE}'", returnStdout: true).trim()
echo "py: ${output}"
def result = readJSON file: 'upload_result.json'
if (result.code != 0) { error "Upload failed: ${result.message}" }
env.appVersion = result.data.buildVersion
env.appShortcutUrl = result.data.buildShortcutUrl
env.appUpdated = result.data.buildUpdated
env.appQRCodeURL = result.data.buildQRCodeURL
} catch (Exception e) {
error "Upload APK failed: ${e.message}"
}
}
}
}2.4 Post DingTalk (★★)
DingTalk Robot API [9]
This stage sends a Markdown message to a DingTalk group, @‑mentioning users based on a name‑to‑phone mapping file.
stage('Post DingTalk') {
when { expression { env.TEST?.toLowerCase() == 'true' } }
steps {
script {
try {
def nameToPhoneMap = loadNameToPhoneMap()
def atList = []
if (env.NAME) { atList.add(nameToPhoneMap.get(env.NAME) ?: env.NAME) }
if (env.AT) {
env.AT.replaceAll(/[\[\] ]/, "").split(",").each { name ->
atList.add(nameToPhoneMap.get(name) ?: name)
}
}
env.outtext = "${env.UPDATE}; Branch:${env.BRANCH}; Type:${env.MATRIX}; Version:${env.appVersion}; https://www.pgyer.com/${env.appShortcutUrl}"
dingtalk robot: env.robot, type: 'MARKDOWN', title: env.UPDATE, text: [
"### ${env.UPDATE}",
'---',
"- Branch: ${env.BRANCH}",
"- Type: ${env.MATRIX}",
"- Version: ${env.appVersion}",
'---',
"[Download] (https://www.pgyer.com/${env.appShortcutUrl})"
], at: atList
} catch (Exception e) {
echo "DingTalk push failed: ${e.message}"
}
}
}
}
def loadNameToPhoneMap() {
def mapFile = "${WORKSPACE}/name_to_phone.json"
if (!fileExists(mapFile)) { error "Missing mapping file: ${mapFile}" }
return readJSON(file: mapFile)
}2.5 Print Parameters (★★★★)
This stage simply echoes the parameters received via the Generic Webhook Trigger, useful for debugging.
pipeline {
triggers { GenericTrigger(token: 'yuu', genericVariables: [[key: 'NAME', value: '$.name']]) }
stages { stage('Print Parameters') { steps { script { echo "name: ${env.NAME}" } } } }
}3 bat Script ★★
The local bat script collects build user, update description, @‑list, branch, and environment, then sends a JSON payload via curl to the Jenkins webhook.
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
:: Parameters: name, update, at, branch, etc.
set "NAME="
if "%NAME%"=="" (
:input_name
set /p "NAME=Enter build user name: "
if "!NAME!"=="" (
echo Name cannot be empty.
goto input_name
)
)
set "MATRIX=debug"
set "BRANCH=main"
set "TEST=true"
:: other variables
set "PASSWORD="
set "INSTALL_TYPE=1"
set "update_info="
set "at_people="
set "wait_time=30"
set "query_git_commit=10"
:: confirm information
cls
echo ---------- Confirm Information ----------
echo Build User: %NAME%
echo Update: %update_info%
echo @ List: %at_people%
echo Branch: %BRANCH%
echo Environment: %MATRIX%
echo -------------------------------------
pause >nul
:: send request
set temp_json="{\"branch\":\"%BRANCH%\",\"name\":\"%NAME%\",\"update\":\"%update_info%\",\"at\":\"%at_people%\",\"test\":\"%TEST%\",\"matrix\":\"%MATRIX%\",\"password\":\"%PASSWORD%\",\"query\":%query_git_commit%,\"shortcut\":\"trtc\"}"
set "JENKINS_URL=127.0.0.1/generic-webhook-trigger/invoke?token=yuu"
curl -X POST -H "Content-Type: application/json" -d %temp_json% %JENKINS_URL% -w "%%{http_code}" -o "%TEMP%\response.txt"4 Automatic Packaging Task ★★
The automatic task is triggered by a Git webhook. It determines the latest commit branch, reads parameters from a jenkins.json file in the repository, and proceeds with the same build, upload, and notification steps as the manual flow.
5 Advanced Optional Tasks
Git Log Query (★)
Shows recent commits to help locate issues quickly. The first line can also be sent via DingTalk.
stage('Query Git Commits') {
steps {
script {
def command = "git log -n ${env.query} --pretty=\"%cn %cI %s\""
def recentCommits = sh(script: command, returnStdout: true).trim()
env.recentCommits = recentCommits
def firstLine = recentCommits.tokenize('
')[0]
env.firstGitLine = firstLine
echo "First line: ${firstLine}"
}
}
}Record Execution Information (★★)
In the post { always { ... } } block, execution times, git info, and notification content are written to a CSV file for later analysis.
post {
always {
script {
try {
writeCsvLog(true, "")
} catch (e) {
echo "CSV log failed: ${e.message}"
}
}
}
}
def writeCsvLog(boolean success, String failureReason = "") {
def endTimestamp = System.currentTimeMillis()
def startTimestamp = Long.parseLong(env.START_BUILD_TIMESTAMP ?: "0")
def uploadTimestamp = Long.parseLong(env.START_UPLOAD_TIMESTAMP ?: startTimestamp.toString())
def totalDuration = formatDuration(endTimestamp - startTimestamp)
def buildDuration = formatDuration(uploadTimestamp - startTimestamp)
def uploadDuration = formatDuration(endTimestamp - uploadTimestamp)
def csvRecord = [totalDuration, buildDuration, uploadDuration, env.START_BUILD_TIME ?: "", success, (env.firstGitLine ?: "").replaceAll(",", ";"), env.outtext?.replaceAll(",", ";") ?: "", failureReason.replaceAll(",", ";")].collect { "\"${it}\"" }.join(',')
sh "python3 -u save.py --csv '${csvRecord}'"
}
def formatDuration(long ms) { long s = ms/1000; long m = s/60; s = s%60; return String.format("%02d:%02d", m, s) }APK Size Check (★★★)
APK Checker [11]
This stage records the APK size, compares it with the previous build, runs the APK Checker if the increase exceeds 1 MB, and aborts the pipeline if the increase exceeds 5 MB.
stage('APK Size Check') {
steps {
script {
def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
def apkFile = sh(script: "firstApk=\$(find ${apkDir} -name \"*.apk\" | head -n 1); echo $firstApk | xargs -n 1 basename", returnStdout: true).trim()
if (apkFile.isEmpty()) { error "APK not found" }
def apkPath = "${apkDir}/${apkFile}"
if (!fileExists(apkPath)) { error "File ${apkPath} does not exist" }
def currentSize = new File(apkPath).length()
def jsonPath = 'apkTool.json'
def sizeMap = fileExists(jsonPath) ? readJSON(file: jsonPath) : [:]
def key = "${env.BRANCH ?: env.GIT_BRANCH ?: 'unknown'}_${env.MATRIX}_size"
def previousSize = sizeMap.get(key, 0)
def diff = currentSize - previousSize
def oneMB = 1 * 1024 * 1024
def fiveMB = 5 * 1024 * 1024
if (diff > fiveMB) { error "APK size increased by more than 5 MB (+${(diff/1024/1024).round(2)} MB)" }
else if (diff > oneMB) { sh "java -jar ApkChecker.jar --apk ${apkPath} --config apkChecker.json" }
sizeMap[key] = currentSize
writeJSON file: jsonPath, json: sizeMap, pretty: 2
}
}
}Automated Tests (★★★)
Integrate unit tests, Lint checks, and other quality gates. Example resources: Jacoco integration, Android unit testing guides.
Practice
The tutorial demonstrates the pipeline on a Linux environment using the sample project https://github.com/yuuouu/DeepLink . Steps include creating the pipeline job, configuring SCM, and running the build, which completes successfully as shown in the screenshots.
Conclusion
Jenkins pipelines act as glue code that, combined with Python and JSON, can automate the entire build‑upload‑notify cycle. By exposing the pipeline to product teams, the entire release process becomes self‑service, provided that proper testing, branch management, and permission controls are in place.
References
[1] Jenkins installation guide: https://www.jenkins.io/zh/doc/book/installing/
[2] Generic Webhook Trigger plugin: https://plugins.jenkins.io/generic-webhook-trigger/
[3] Pipeline Utility Steps (JSON parsing): https://plugins.jenkins.io/pipeline-utility-steps
[4] HTTP Request plugin: https://plugins.jenkins.io/http_request/
[5] Generic Webhook Trigger documentation: https://plugins.jenkins.io/generic-webhook-trigger/
[6] Git plugin: https://plugins.jenkins.io/git/
[7] Pgyer API documentation: https://www.pgyer.com/doc/api#uploadApp
[8] API response format: https://www.pgyer.com/doc/view/api#header-id-16
[9] DingTalk robot API: https://jenkinsci.github.io/dingtalk-plugin
[10] Git hooks: https://git-scm.com/book/zh/v2/Customizing-Git-Git-Hooks
[11] APK Checker: https://github.com/Tencent/matrix/wiki/Matrix-Android-ApkChecker
[12] Sample project repository: https://github.com/yuuouu/DeepLink
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
