PCSalt
YouTube GitHub
Back to Security
Security · 3 min read

Secure CI/CD — Signing APKs, Protecting Secrets in GitHub Actions

Secure your Android CI/CD pipeline — APK signing in GitHub Actions, keystore management, secret protection, artifact verification, and supply chain security.


Your CI/CD pipeline builds and distributes your app. If it’s compromised, an attacker can inject malicious code into your APK and distribute it to your users. Securing the pipeline is as important as securing the app itself.

This post covers APK signing in CI/CD, secret management for keystores, and hardening your GitHub Actions workflow.

APK signing basics

Every Android APK must be signed before distribution. The signing key proves the APK comes from you. If the key is compromised, someone else can push updates to your app.

Two types of signing

TypePurposeWhere
Debug signingLocal developmentAndroid Studio (auto-generated)
Release signingProduction distributionYour keystore file

Generate a release keystore

keytool -genkey -v \
  -keystore release-keystore.jks \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000 \
  -alias release-key

This creates release-keystore.jks. Never commit this file to Git. Store it securely — losing this key means you can’t update your app on the Play Store.

Signing in GitHub Actions

Step 1: Encode the keystore as Base64

base64 -i release-keystore.jks | pbcopy  # macOS
base64 release-keystore.jks | xclip      # Linux

Step 2: Add secrets to GitHub

Go to Repository → Settings → Secrets → Actions:

Secret nameValue
KEYSTORE_BASE64Base64-encoded keystore file
KEYSTORE_PASSWORDKeystore password
KEY_ALIASKey alias
KEY_PASSWORDKey password

Step 3: Workflow

name: Build & Sign Release APK

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Decode keystore
        env:
          KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
        run: echo "$KEYSTORE_BASE64" | base64 --decode > app/release-keystore.jks

      - name: Build release APK
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew assembleRelease

      - name: Upload APK
        uses: actions/upload-artifact@v4
        with:
          name: release-apk
          path: app/build/outputs/apk/release/*.apk

      - name: Clean up keystore
        if: always()
        run: rm -f app/release-keystore.jks

Step 4: Gradle signing config

// app/build.gradle.kts
android {
    signingConfigs {
        create("release") {
            storeFile = file("release-keystore.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = System.getenv("KEY_ALIAS")
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            signingConfig = signingConfigs.getByName("release")
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

The signing config reads from environment variables — no hardcoded passwords.

Play App Signing

Google Play App Signing adds another layer. You upload your APK signed with your upload key, and Google re-signs it with the app signing key before distributing to users:

You → Upload key (yours) → Play Console → App signing key (Google) → Users

Benefits:

  • If your upload key is compromised, Google can rotate it
  • Google manages the app signing key securely
  • Smaller APKs (Google can optimize signing per device)

Enrolling

  1. Go to Play Console → Your app → Setup → App signing
  2. Follow the enrollment wizard
  3. Upload your key or let Google generate one

After enrollment, your release keystore becomes the “upload key.” Even if it’s compromised, the attacker can’t distribute directly — they’d need Play Console access too.

Hardening GitHub Actions

1. Pin action versions

# Bad — uses a mutable tag
- uses: actions/checkout@v4

# Better — pin to a specific commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

An attacker who compromises the v4 tag can inject malicious code. Pinning to a SHA ensures you get exactly the version you tested.

For less critical actions, version tags are acceptable. For actions that access secrets, pin to SHA.

2. Minimize secret exposure

# Bad — secret available to all steps
env:
  KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}

steps:
  - name: Build
    run: ./gradlew assembleRelease
  - name: Other step  # also has access to KEYSTORE_PASSWORD
    run: some-command

# Better — secret only where needed
steps:
  - name: Build
    env:
      KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    run: ./gradlew assembleRelease
  - name: Other step  # no access to KEYSTORE_PASSWORD
    run: some-command

3. Limit workflow permissions

permissions:
  contents: read
  # Only add what's needed

Default permissions are broad. Restrict to the minimum your workflow needs.

4. Prevent secret leaks in logs

- name: Build
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
  run: |
    # GitHub automatically masks secrets in logs
    # But be careful with commands that might print env vars
    ./gradlew assembleRelease
    # DON'T do: printenv | grep KEY

GitHub masks secrets in logs, but commands like printenv, env, or set -x can expose them.

5. Use environments for deployment

jobs:
  deploy:
    environment: production  # requires approval
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Play Store
        env:
          PLAY_STORE_KEY: ${{ secrets.PLAY_STORE_KEY }}
        run: ./deploy.sh

Environments can require manual approval, restrict to specific branches, and have their own secrets.

Dependency verification

Gradle dependency verification

./gradlew --write-verification-metadata sha256

This generates gradle/verification-metadata.xml with checksums for all dependencies. Gradle verifies them on every build. If a dependency is tampered with, the build fails.

GitHub Dependabot

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

Dependabot creates PRs for outdated dependencies, including security patches.

Artifact verification

Verify APK signature after build

- name: Verify APK signature
  run: |
    apksigner verify --verbose app/build/outputs/apk/release/app-release.apk

Checksum artifacts

- name: Generate checksum
  run: |
    sha256sum app/build/outputs/apk/release/app-release.apk > checksum.txt

- name: Upload checksum
  uses: actions/upload-artifact@v4
  with:
    name: checksum
    path: checksum.txt

Publish the checksum alongside the APK so users can verify the download.

Checklist

Keystore security

  • Keystore not committed to Git
  • Keystore stored as encrypted GitHub secret
  • Keystore cleaned up after build (rm -f)
  • Play App Signing enrolled
  • Backup of keystore stored securely (not just in CI)

Workflow security

  • Actions pinned to commit SHAs (critical actions)
  • Secrets scoped to specific steps
  • Workflow permissions minimized
  • Environments with approval for production deploys
  • Branch protection on main/release branches

Supply chain

  • Gradle dependency verification enabled
  • Dependabot configured for security updates
  • APK signature verified after build
  • Checksums published with artifacts

Summary

Secure CI/CD for Android:

  1. Sign APKs with secrets from GitHub Actions (never hardcode)
  2. Enroll in Play App Signing for key rotation capability
  3. Pin actions to commit SHAs for supply chain security
  4. Scope secrets to the steps that need them
  5. Verify dependencies with Gradle verification metadata
  6. Clean up keystores and sensitive files after build

Your CI/CD pipeline is a trust boundary. Treat it with the same security rigor as your production servers.