2024-09-30
Step-by-Step Guide to CI/CD in Android
A comprehensive, practical guide to setting up secure and automated CI/CD pipelines for Android apps using GitHub Actions, including signing, secrets, and keystore management.
To successfully build our app, we require the following:
- Project source code: The codebase for your Android application.
- A computer: The machine where the build process will run.
- Gradle (via Wrapper): Gradle is included in the project as a wrapper, so no separate installation is required.
- Java compiler: A Java Development Kit (JDK) compatible with the Gradle wrapper version used in the project.
From the project’s root directory, we can build the desired APK variant by running the appropriate commands. The output APK will be generated in the app/build/outputs/apk directory.
| All variants | Debug | Release |
|---|---|---|
./gradlew assemble | ./gradlew assembleDebug | ./gradlew assembleRelease |
Step 01. Build Signed APK
To build a signed APK, Android Studio provides a GUI for the task. However, in a CI/CD environment, where Android Studio is unavailable, we need to rely on the terminal for this process.
By running ./gradlew assemble, an unsigned APK will be generated by default. To produce a signed APK, we need to configure the project to use the keystore (JKS) file. This requires editing the app/build.gradle.kts file.
Within the android block, we’ll add a signingConfigs {} section to specify the necessary signing credentials (such as the keystore file, alias, and password). Once configured, we’ll reference the created signing configuration in the release block under the buildTypes section. This ensures the APK is signed during the release build process.
android {
signingConfigs {
create("signingKey") {
storeFile = file("key_cicd_sample.jks")
storePassword = "123456"
keyAlias = "key0"
keyPassword = "123456"
}
}
release {
//...
signingConfig = signingConfigs.getByName("signingKey")
}
} Now if we run ./gradlew assemble It will generate our signed apk.
Verify Signature (Optional) To verify the certificate used to sign the APK, we can use the apksigner tool from the terminal. This tool is located in {your-sdk-folder}/build-tools/{version}.
Run the following commands to check the signature of your APK:
apksigner verify --print-certs app/build/outputs/apk/debug/app-debug.apk
apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk In this example, the APKs were built locally. However, our next goal is to configure the project for APK signing within a CI/CD environment.
[!CAUTION] We are currently providing the signing credentials directly within the
app/build.gradle.ktsfile. If your project is public, this exposes sensitive information, which poses a security risk. Even in a private repository, embedding credentials in your code is considered poor practice. We will address this issue and implement a more secure solution in a later step.
Step 02. Build APK automatically on push
There are several CI/CD solutions available, and for this project, we are using GitHub Actions. To implement this, we need to create a workflow file within our repository.
- At the root of your project, create a
.githubdirectory. - Inside the
.githubdirectory, create another directory namedworkflows. - Within the
workflowsdirectory, create a.ymlfile and name it as desired.
Let’s read our .github/workflows/build.yml workflow step by step: Run on push
name: Build
on:
push:
branches: ['main'] It will create a workflow which will run if you push on main branch. We have not created any jobs yet though. Create jobs and run on the Ubuntu operating system
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Setup JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17' Our build process will run on a Linux desktop. We use the following GitHub Actions to automate the setup:
- Source Code Checkout: We retrieve the project’s source code using
actions/checkout@v4, which is provided by GitHub. - Java Setup: To configure the appropriate Java version, we use
actions/setup-java@v4, also provided by GitHub.
For further details on these actions, refer to their official documentation.
Grant execute permission for gradle wrapper of our project
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew Run assemble command with Gradle
- name: Build release variant with Gradle
run: ./gradlew assembleRelease Final workflow file
name: Build
on:
push:
branches: ['main']
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Setup JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release variant with Gradle
run: ./gradlew assembleRelease Once you commit and push your code to the main branch, GitHub Actions will automatically trigger the build process, and a signed release APK will be generated.
Step 03. Remove Signing Credentials from the Source Code
Storing sensitive credentials in source code is not secure. To enhance security, we will remove these credentials and instead retrieve them from system environment variables. We can access environment variables in our code using the System.getenv("variable_key") method.
Defining Environment Variables in GitHub Actions
Since we’re working on a virtual environment within GitHub Actions, we can define environment variables directly in our workflow .yml file:
env:
KEYSTORE_PASSWORD: 123456
KEY_ALIAS: key0
KEY_PASSWORD: 123456 Next, we will modify the app/build.gradle.kts file to fetch the storePassword, keyAlias, and keyPassword from the system environment variables:
signingConfigs {
create("signingKey"){
storeFile = file("key_cicd_sample.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
} However, since our .yml file is included in version control, the credentials would still be exposed in the repository. To mitigate this risk, we can use GitHub Secrets.
- Go to your repository’s Settings.
- In the left panel, select Secrets and variables > Actions.
- Click on the New repository secret button and add your secrets, for example:
| Name | Secret |
|---|---|
| KEYSTORE_PASSWORD | 123456 |
| KEY_ALIAS | key0 |
| KEY_PASSWORD | 123456 |
After adding your secrets, update your .yml file to reference these secrets securely:
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} This way, our sensitive information is securely stored in GitHub Secrets and not exposed in your codebase.
Step 04. Local Build System Issue
Our local build system is currently broken. Building the project through Android Studio or the local terminal fails because the required environment variables are not available in the local environment.
To fix this, we will use the local.properties file to store sensitive credential variables locally. This file is typically ignored by version control systems, but it’s crucial to verify that it is indeed excluded from the repository. Under no circumstances should this file be committed to Git.
An example of the local.properties file for storing credentials:
KEYSTORE_PASSWORD=123456
KEY_ALIAS=key0
KEY_PASSWORD=123456 Loading local.properties in app/build.gradle.kts
To load the properties from local.properties, use the following Kotlin code in your build.gradle.kts:
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) }
} Updating the signingConfigs Block
Modify the signingConfigs block to use local.properties for local development builds and environment variables for CI/CD environments:
signingConfigs {
create("signingKey") {
storeFile = file("key_cicd_sample.jks")
if (localPropertiesFile.exists()) {
// Use local.properties for local builds
storePassword = localProperties["KEYSTORE_PASSWORD"] as String
keyAlias = localProperties["KEY_ALIAS"] as String
keyPassword = localProperties["KEY_PASSWORD"] as String
} else {
// Use environment variables for CI/CD
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
} Explanation
In this setup, we first check whether the local.properties file exists. If it does, this indicates that the build is running in a local development environment, and the credentials are loaded from the file. If not, the build is assumed to be in a CI/CD environment, and the credentials are retrieved from environment variables.
Step 05. Securely Manage the Keystore File
In our previous steps, we have securely managed our signing credentials. However, the keystore file (key_cicd_sample.jks) is still part of our source code, which is not secure. To enhance security, we will remove the keystore file from our repository and instead retrieve it securely during the CI/CD build process.
How to encode the Keystore File
To securely store the keystore file as a GitHub Secret, we need to encode it in base64 format. Run the following command in your bash terminal:
cat key_cicd_sample.jks | base64 -w 0 > key_cicd_sample.jks.txt This command encodes the key_cicd_sample.jks file into base64 format and saves the output to key_cicd_sample.jks.txt. The -w 0 option ensures that the output is a single line, which is suitable for storing as a GitHub Secret.
Storing the Keystore File as a GitHub Secret
- Open the keystore file (
key_cicd_sample.jks.txt) in a text editor and copy its entire content. - Navigate to your repository’s Settings.
- In the left panel, select Secrets and variables > Actions.
- Click on the New repository secret button.
- Name the secret
KEYSTORE_FILE_BASE64and paste the copied content of the keystore file into the Secret field. - Click Add secret to save it.
Modifying the GitHub Actions Workflow
Next, we will update our GitHub Actions workflow to create the keystore file during the build process using the secret we just created.
Add the following step before the build step in your .github/workflows/build.yml file:
- name: Decode Keystore
run: echo "${{ secrets.KEYSTORE_FILE_BASE64 }}" | base64 --decode > app/key_cicd_sample.jks Explanation
In this step, we create the keystore file by decoding the base64-encoded content stored in the KEYSTORE_FILE_BASE64 secret. The echo command outputs the content, which is then piped to base64 --decode to reconstruct the original binary keystore file. The output is redirected to create the key_cicd_sample.jks file in the working directory.