chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: android-cicd
|
||||
description: "Automated Android CI/CD pipeline to Google Play — supports TWA, React Native, Flutter, and native Android. Run npx android-cicd to set up keystore generation, GitHub Secrets, and a multi-stage workflow (internal/alpha/beta/production) with auto-bump versionCode."
|
||||
risk: safe
|
||||
source: community
|
||||
date_added: "2026-05-12"
|
||||
license: MIT
|
||||
metadata:
|
||||
author: ezescholz
|
||||
version: "1.0.0"
|
||||
npm: android-cicd
|
||||
---
|
||||
|
||||
# Skill: android-cicd
|
||||
|
||||
## Purpose
|
||||
|
||||
Set up a complete, multi-stage Android CI/CD pipeline that automatically builds and publishes to Google Play via GitHub Actions. Supports TWA (Trusted Web Activity / Bubblewrap), React Native, Flutter, and native Android (Gradle) projects.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The project has an Android app tracked in a GitHub repository
|
||||
- No CI/CD pipeline exists yet for the Android build
|
||||
- Goal: automate publishing to Google Play on every push to `main` and on version tags
|
||||
- User wants to avoid manual `versionCode` bumping
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the interactive setup wizard from the root of the target project:
|
||||
|
||||
```sh
|
||||
npx android-cicd
|
||||
```
|
||||
|
||||
The wizard handles: framework detection → keystore generation → GitHub Secrets → workflow scaffold.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running the wizard, ensure:
|
||||
|
||||
- Node.js ≥ 18
|
||||
- JDK 17 installed with `keytool` accessible (`JAVA_HOME` set, or installed via Eclipse Adoptium / Android Studio)
|
||||
- [`gh` CLI](https://cli.github.com) installed and authenticated (`gh auth login`)
|
||||
- App already created in [Google Play Console](https://play.google.com/console) — at least **one manual AAB/APK upload** done (required before the API can publish)
|
||||
- App enrolled in **Play App Signing** (Google manages the signing key; you manage the upload key)
|
||||
- Google Play Android Developer API enabled in Google Cloud Console
|
||||
- Service account JSON key downloaded (see Manual Steps below)
|
||||
|
||||
---
|
||||
|
||||
## Framework Detection
|
||||
|
||||
The wizard auto-detects the framework from the project directory structure:
|
||||
|
||||
| Condition | Detected framework |
|
||||
|---|---|
|
||||
| `pubspec.yaml` contains `flutter:` | `flutter` |
|
||||
| `android/app/build.gradle` exists + `package.json` has `react-native` dep | `react-native` |
|
||||
| `android-root-app/build.gradle` or `twa-manifest.json` or `.bubblewrap/config.json` exists | `twa` |
|
||||
| `app/build.gradle` exists | `native` |
|
||||
| `android/app/build.gradle` exists (fallback) | `native` |
|
||||
|
||||
The user can override the detected framework during the wizard.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Stage Pipeline
|
||||
|
||||
The scaffolded workflow publishes to different tracks based on the git ref:
|
||||
|
||||
| Git event | Google Play track |
|
||||
|---|---|
|
||||
| Push to `main` | `internal` |
|
||||
| Tag matching `v*-alpha` (e.g. `v1.2-alpha`) | `alpha` |
|
||||
| Tag matching `v*-beta` (e.g. `v1.2-beta`) | `beta` |
|
||||
| Tag matching `v*` (e.g. `v1.2.0`) | `production` |
|
||||
| Manual `workflow_dispatch` | User-selectable (internal / alpha / beta / production) |
|
||||
|
||||
To release to production:
|
||||
```sh
|
||||
git tag v1.2.0
|
||||
git push origin v1.2.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto-Bump versionCode
|
||||
|
||||
On every push to `main`, CI automatically:
|
||||
|
||||
1. Reads the current `versionCode` from the version file for the detected framework
|
||||
2. Increments it by 1
|
||||
3. Commits the change with `[skip ci]` (prevents re-triggering the workflow)
|
||||
4. Pushes the commit back to `main`
|
||||
|
||||
Version file by framework:
|
||||
|
||||
| Framework | Version file | Field |
|
||||
|---|---|---|
|
||||
| TWA | `android-root-app/build.gradle` | `versionCode` |
|
||||
| React Native | `android/app/build.gradle` | `versionCode` |
|
||||
| Flutter | `pubspec.yaml` | `version: x.y.z+N` (the `+N` build number) |
|
||||
| Native | `app/build.gradle` | `versionCode` |
|
||||
|
||||
For tag-based builds (alpha / beta / production), auto-bump does **not** run — the tag represents a pinned commit. Increment the version manually before tagging.
|
||||
|
||||
---
|
||||
|
||||
## Required GitHub Secrets
|
||||
|
||||
The wizard sets these automatically via `gh secret set`:
|
||||
|
||||
| Secret | Description |
|
||||
|---|---|
|
||||
| `KEYSTORE_FILE` | Base64-encoded upload keystore (`.jks`) |
|
||||
| `KEYSTORE_PASSWORD` | Keystore password |
|
||||
| `KEY_ALIAS` | Key alias (e.g. `upload`) |
|
||||
| `KEY_PASSWORD` | Key password (usually same as `KEYSTORE_PASSWORD`) |
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON content of the service account key |
|
||||
|
||||
---
|
||||
|
||||
## Signing Configuration
|
||||
|
||||
### TWA / Native Android
|
||||
|
||||
Add to your `build.gradle` (see `templates/gradle/signing.gradle`):
|
||||
|
||||
```groovy
|
||||
android {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("keystore.jks")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias System.getenv("KEY_ALIAS")
|
||||
keyPassword System.getenv("KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Never** set `org.gradle.java.home` in `gradle.properties` — it breaks Linux CI runners.
|
||||
|
||||
### Flutter
|
||||
|
||||
The CI workflow creates `android/key.properties` at build time (from secrets) and cleans it up after. Your `android/app/build.gradle` should read from it:
|
||||
|
||||
```groovy
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Steps (Cannot Be Automated)
|
||||
|
||||
### 1. Create the service account
|
||||
|
||||
1. [Google Cloud Console](https://console.cloud.google.com) → your project → **IAM & Admin** → **Service Accounts**
|
||||
2. **Create service account** → name: `github-play-publisher` → **Done** (no roles needed)
|
||||
3. Click the service account → **Keys** tab → **Add key** → **Create new key** → **JSON** → download
|
||||
|
||||
### 2. Enable the Play API
|
||||
|
||||
Google Cloud Console → **APIs & Services** → search **Google Play Android Developer API** → **Enable**
|
||||
|
||||
### 3. Invite the service account in Play Console
|
||||
|
||||
1. Play Console → **Users and permissions** → **Invite new user**
|
||||
2. Email: `github-play-publisher@YOUR-PROJECT.iam.gserviceaccount.com`
|
||||
3. Account-level permissions:
|
||||
- ✅ Release apps to testing tracks
|
||||
- ✅ Manage testing tracks and edit testers
|
||||
4. **Apply**
|
||||
|
||||
### 4. First manual upload
|
||||
|
||||
Google Play requires at least one manually uploaded AAB before the API can publish. If this is a brand-new app, upload the first build from your local machine before running the CI pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Java home supplied is invalid` | `org.gradle.java.home` hardcoded in `gradle.properties` | Remove that line |
|
||||
| `signed with the wrong key` | Keystore in secret doesn't match Play's registered upload key | Update `KEYSTORE_FILE` secret |
|
||||
| `The caller does not have permission` | Service account missing permissions or API not enabled | Re-check Manual Steps 2 and 3 |
|
||||
| `Upload failed — wrong versionCode` | versionCode not incremented (tag-based build) | Increment versionCode manually before tagging |
|
||||
| `shallow update not allowed` | Shallow git checkout when pushing version bump | Workflow uses `fetch-depth: 0` — verify the checkout step |
|
||||
| Workflow not triggering on tag | Tag not pushed to remote | Run `git push origin TAG_NAME` |
|
||||
| `gh: command not found` | gh CLI not installed | Install from https://cli.github.com |
|
||||
| `keytool not found` | JDK not installed or not on PATH | Set `JAVA_HOME` or install JDK 17 |
|
||||
|
||||
---
|
||||
|
||||
## Recovering a Lost Upload Keystore
|
||||
|
||||
If the app uses **Play App Signing** (recommended):
|
||||
|
||||
1. Generate a new keystore: `npx android-cicd` and choose "I already have a keystore: No"
|
||||
2. Export the PEM certificate:
|
||||
```sh
|
||||
keytool -export -rfc -keystore upload.jks -alias ALIAS -storepass PASSWORD -file cert.pem
|
||||
```
|
||||
3. Play Console → app → **App integrity** → **App signing** → **Request upload key reset**
|
||||
4. Select "I forgot my password" → upload `cert.pem`
|
||||
5. Wait 1–2 business days for Google approval
|
||||
6. Update the `KEYSTORE_FILE` secret with the new keystore base64
|
||||
|
||||
---
|
||||
|
||||
## Manually Bumping the Version (Tag Releases)
|
||||
|
||||
Before pushing a tag for alpha / beta / production:
|
||||
|
||||
**TWA / Native / React Native** — edit `build.gradle`:
|
||||
```groovy
|
||||
versionCode 8 // increment
|
||||
versionName "1.2.0"
|
||||
```
|
||||
|
||||
**Flutter** — edit `pubspec.yaml`:
|
||||
```yaml
|
||||
version: 1.2.0+8
|
||||
```
|
||||
|
||||
Then tag and push:
|
||||
```sh
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.2.0"
|
||||
git tag v1.2.0
|
||||
git push origin main --tags
|
||||
```
|
||||
Reference in New Issue
Block a user