← Back to Blog

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.

CICDAndroidGitHub ActionsDevOps

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 variantsDebugRelease
./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.

Step 01. Source code

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.kts file. 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 .github directory.
  • Inside the .github directory, create another directory named workflows.
  • Within the workflows directory, create a .yml file 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 02. Source code

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

GitHub action variable docs

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.

  1. Go to your repository’s Settings.
  2. In the left panel, select Secrets and variables > Actions.
  3. Click on the New repository secret button and add your secrets, for example:
NameSecret
KEYSTORE_PASSWORD123456
KEY_ALIASkey0
KEY_PASSWORD123456

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 03. Source code

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 04. Source code

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

  1. Open the keystore file (key_cicd_sample.jks.txt) in a text editor and copy its entire content.
  2. Navigate to your repository’s Settings.
  3. In the left panel, select Secrets and variables > Actions.
  4. Click on the New repository secret button.
  5. Name the secret KEYSTORE_FILE_BASE64 and paste the copied content of the keystore file into the Secret field.
  6. 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.

Step 05. Source code