Skip to content

Release

Release #32

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v1.0.0)'
required: true
type: string
track:
description: 'Play Store release track'
required: false
type: choice
default: 'internal'
options:
- internal
- alpha
- beta
- production
status:
description: 'Play Store release status'
required: false
type: choice
default: 'draft'
options:
- draft
- completed
permissions:
contents: write
jobs:
prepare:
name: Prepare Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
version_code: ${{ steps.version_info.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${{ github.ref_name }}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Extract version code
id: version_info
run: |
# Extract version code from config.gradle
VERSION_CODE=$(grep "versionCode:" config.gradle | sed 's/.*versionCode: \([0-9]*\).*/\1/')
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
echo "Version Code: $VERSION_CODE"
build-apk:
name: Build Release APK
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Decode Keystore
if: ${{ vars.ENABLE_SIGNING == 'true' && env.KEYSTORE_BASE64 != '' }}
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > app/keystore.jks
- name: Debug keystore info
if: ${{ vars.ENABLE_SIGNING == 'true' }}
run: |
echo "Keystore file exists: $([ -f app/keystore.jks ] && echo 'Yes' || echo 'No')"
echo "Keystore size: $([ -f app/keystore.jks ] && ls -la app/keystore.jks | awk '{print $5}' || echo 'N/A')"
echo "Key alias configured: ${{ secrets.KEY_ALIAS != '' && 'Yes' || 'No' }}"
- name: Build release APK
run: |
if [ "${{ vars.ENABLE_SIGNING }}" = "true" ] && [ -f "app/keystore.jks" ]; then
echo "Building signed release APK"
echo "Using key alias: ${{ secrets.KEY_ALIAS }}"
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=${{ github.workspace }}/app/keystore.jks \
-Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
else
echo "Building unsigned release APK"
./gradlew assembleRelease --stacktrace
fi
- name: Clean up keystore
if: always()
run: |
rm -f app/keystore.jks
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/**/*.apk
retention-days: 30
- name: APK Summary
run: |
echo "## APK Build Results :package:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
APK_PATH=$(find app/build/outputs/apk -name "*.apk" | grep -E "release" | head -1)
if [ -f "$APK_PATH" ]; then
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
echo "- APK Size: $APK_SIZE" >> $GITHUB_STEP_SUMMARY
echo "- APK Name: \`$(basename "$APK_PATH")\`" >> $GITHUB_STEP_SUMMARY
echo "- Signed: ${{ vars.ENABLE_SIGNING == 'true' && 'Yes' || 'No' }}" >> $GITHUB_STEP_SUMMARY
else
echo "No APK found" >> $GITHUB_STEP_SUMMARY
fi
build-aab:
name: Build Release Bundle
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Decode Keystore
if: ${{ vars.ENABLE_SIGNING == 'true' && env.KEYSTORE_BASE64 != '' }}
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > app/keystore.jks
- name: Build release bundle
run: |
if [ "${{ vars.ENABLE_SIGNING }}" = "true" ] && [ -f "app/keystore.jks" ]; then
echo "Building signed release bundle"
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=${{ github.workspace }}/app/keystore.jks \
-Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
else
echo "Skipping bundle build - signing not configured"
fi
- name: Generate debug symbols
if: ${{ vars.ENABLE_SIGNING == 'true' }}
run: |
echo "Checking for debug symbols..."
find app/build/outputs -name "*.zip" -type f | grep -i debug || echo "No debug symbol zips found"
find app/build/outputs -name "*symbols*" -type f || echo "No symbol files found"
ls -la app/build/outputs/bundle/release/ || true
- name: Clean up keystore
if: always()
run: |
rm -f app/keystore.jks
- name: Upload release bundle
if: ${{ vars.ENABLE_SIGNING == 'true' }}
uses: actions/upload-artifact@v4
with:
name: release-bundle
path: |
app/build/outputs/bundle/**/*.aab
app/build/outputs/mapping/release/mapping.txt
retention-days: 30
release:
name: Create GitHub Release
needs: [prepare, build-apk, build-aab]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: release-apk
path: release-artifacts/
- name: Download AAB artifact
if: ${{ vars.ENABLE_SIGNING == 'true' }}
uses: actions/download-artifact@v4
with:
name: release-bundle
path: release-artifacts/
continue-on-error: true
- name: Prepare release assets
id: assets
run: |
# Find APK
APK_PATH=$(find release-artifacts -name "*.apk" | grep -E "release" | head -1)
if [ -f "$APK_PATH" ]; then
APK_NAME="v2er-${{ needs.prepare.outputs.version }}.apk"
mv "$APK_PATH" "$APK_NAME"
echo "apk_path=$APK_NAME" >> $GITHUB_OUTPUT
fi
# Find AAB
AAB_PATH=$(find release-artifacts -name "*.aab" 2>/dev/null | head -1)
if [ -f "$AAB_PATH" ]; then
AAB_NAME="v2er-${{ needs.prepare.outputs.version }}.aab"
mv "$AAB_PATH" "$AAB_NAME"
echo "aab_path=$AAB_NAME" >> $GITHUB_OUTPUT
fi
- name: Generate changelog
id: changelog
run: |
echo "## What's Changed" > CHANGELOG.md
echo "" >> CHANGELOG.md
# Get commits since last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
git log --pretty=format:"* %s by @%an" "$LAST_TAG"..HEAD >> CHANGELOG.md
else
git log --pretty=format:"* %s by @%an" -10 >> CHANGELOG.md
fi
echo "" >> CHANGELOG.md
echo "" >> CHANGELOG.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...${{ needs.prepare.outputs.version }}" >> CHANGELOG.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.version }}
name: Release ${{ needs.prepare.outputs.version }}
body_path: CHANGELOG.md
draft: false
prerelease: false
files: |
${{ steps.assets.outputs.apk_path }}
${{ steps.assets.outputs.aab_path }}
fail_on_unmatched_files: false
play-store-upload:
name: Upload to Play Store
needs: [prepare, build-aab]
if: ${{ vars.ENABLE_PLAY_STORE_UPLOAD == 'true' && vars.ENABLE_SIGNING == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download AAB artifact
uses: actions/download-artifact@v4
with:
name: release-bundle
path: release-artifacts/
- name: Find bundle and symbols
id: find-files
run: |
AAB_PATH=$(find release-artifacts -name "*.aab" | head -1)
echo "aab_path=$AAB_PATH" >> $GITHUB_OUTPUT
# Look for debug symbols
SYMBOLS_PATH=$(find release-artifacts -name "native-debug-symbols.zip" 2>/dev/null | head -1)
if [ -n "$SYMBOLS_PATH" ]; then
echo "symbols_path=$SYMBOLS_PATH" >> $GITHUB_OUTPUT
echo "Found debug symbols at: $SYMBOLS_PATH"
else
echo "No debug symbols found"
fi
# Look for ProGuard/R8 mapping file
MAPPING_PATH=$(find release-artifacts -name "mapping.txt" 2>/dev/null | head -1)
if [ -n "$MAPPING_PATH" ]; then
echo "mapping_path=$MAPPING_PATH" >> $GITHUB_OUTPUT
echo "Found ReTrace mapping file at: $MAPPING_PATH"
else
echo "No ReTrace mapping file found"
fi
- name: Determine release track and status
id: release-config
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TRACK="${{ github.event.inputs.track }}"
STATUS="${{ github.event.inputs.status }}"
else
# Default for tag pushes
TRACK="internal"
STATUS="draft"
fi
echo "track=$TRACK" >> $GITHUB_OUTPUT
echo "status=$STATUS" >> $GITHUB_OUTPUT
echo "Deploying to track: $TRACK with status: $STATUS"
- name: Create whatsnew directory
run: |
mkdir -p whatsnew
# Generate release notes
echo "Release ${{ needs.prepare.outputs.version }}" > whatsnew/whatsnew-en-US
echo "" >> whatsnew/whatsnew-en-US
# Get recent commits
git log --pretty=format:"• %s" -5 >> whatsnew/whatsnew-en-US
# Chinese version
echo "版本 ${{ needs.prepare.outputs.version }}" > whatsnew/whatsnew-zh-CN
echo "" >> whatsnew/whatsnew-zh-CN
git log --pretty=format:"• %s" -5 >> whatsnew/whatsnew-zh-CN
- name: Upload to Play Store (with debug symbols)
if: steps.find-files.outputs.symbols_path != ''
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: me.ghui.v2er
releaseFiles: ${{ steps.find-files.outputs.aab_path }}
track: ${{ steps.release-config.outputs.track }}
status: ${{ steps.release-config.outputs.status }}
debugSymbols: ${{ steps.find-files.outputs.symbols_path }}
mappingFile: ${{ steps.find-files.outputs.mapping_path }}
whatsNewDirectory: whatsnew/
continue-on-error: true
id: upload-with-symbols
- name: Upload to Play Store (without debug symbols)
if: steps.find-files.outputs.symbols_path == '' || steps.upload-with-symbols.outcome == 'failure'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: me.ghui.v2er
releaseFiles: ${{ steps.find-files.outputs.aab_path }}
track: ${{ steps.release-config.outputs.track }}
status: ${{ steps.release-config.outputs.status }}
mappingFile: ${{ steps.find-files.outputs.mapping_path }}
whatsNewDirectory: whatsnew/
- name: Play Store Upload Summary
if: success()
run: |
echo "## Play Store Upload Complete :rocket:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ needs.prepare.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Track**: ${{ steps.release-config.outputs.track }}" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: ${{ steps.release-config.outputs.status }}" >> $GITHUB_STEP_SUMMARY
echo "- **Package**: me.ghui.v2er" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View in Play Console](https://play.google.com/console/u/0/app/me.ghui.v2er)" >> $GITHUB_STEP_SUMMARY
download-signed-apk:
name: Download Google Play Signed APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib requests
- name: Wait for Google Play processing
run: |
echo "Waiting for Google Play to process and sign the APK..."
sleep 120 # Wait 2 minutes for Google Play to process
# AAB artifact not needed for Google Play signed APK download
- name: Download Google Play Signed APK
id: download-apk
env:
PLAY_STORE_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
run: |
VERSION_NAME="v2.3.3"
VERSION_CODE="233"
PACKAGE_NAME="me.ghui.v2er"
# Create Python script to download signed universal APK
cat > download_signed_apk.py << 'EOF'
import json
import os
import sys
import requests
from google.oauth2 import service_account
from googleapiclient.discovery import build
def download_signed_apk():
try:
# Load service account credentials
service_account_info = json.loads(os.environ['PLAY_STORE_SERVICE_ACCOUNT_JSON'])
credentials = service_account.Credentials.from_service_account_info(
service_account_info,
scopes=['https://www.googleapis.com/auth/androidpublisher']
)
# Build the service
service = build('androidpublisher', 'v3', credentials=credentials)
package_name = os.environ['PACKAGE_NAME']
version_code = int(os.environ['VERSION_CODE'])
print(f"Attempting to download signed APK for {package_name} version {version_code}")
# Step 1: Get the generated APKs list to find downloadId
print("Getting generated APKs list...")
result = service.generatedapks().list(
packageName=package_name,
versionCode=version_code
).execute()
if 'generatedApks' not in result or not result['generatedApks']:
print("No generated APKs found. App may not be processed yet by Google Play.")
return False
print(f"Found {len(result['generatedApks'])} generated APKs")
# Debug: Print all APK structures
for i, apk in enumerate(result['generatedApks']):
print(f"APK {i} structure:")
for key, value in apk.items():
print(f" {key}: {value}")
print()
# Find universal APK using the correct API structure
download_id = None
universal_apk = None
# First, try to find a universal APK in generatedUniversalApk
for apk in result['generatedApks']:
if 'generatedUniversalApk' in apk:
universal_apk = apk['generatedUniversalApk']
download_id = universal_apk.get('downloadId')
print(f"Found universal APK: {universal_apk}")
break
# Fallback: Look for base module in split APKs if no universal APK found
if not download_id:
print("No generatedUniversalApk found, trying split APKs...")
for apk in result['generatedApks']:
if 'generatedSplitApks' in apk:
split_apks = apk['generatedSplitApks']
print(f"Found {len(split_apks)} split APKs")
# Print all base modules for debugging
base_candidates = []
for split_apk in split_apks:
if split_apk.get('moduleName') == 'base':
base_candidates.append(split_apk)
print(f"Base APK: variantId={split_apk.get('variantId')}, splitId={split_apk.get('splitId', 'None')}")
# Try variantId=2 first (based on previous observations)
for split_apk in split_apks:
if (split_apk.get('moduleName') == 'base' and
split_apk.get('variantId') == 2 and
'splitId' not in split_apk):
download_id = split_apk.get('downloadId')
universal_apk = split_apk
print(f"Found base APK (variantId=2): {universal_apk}")
break
# Try other variants if variantId=2 not found
if not download_id:
for split_apk in split_apks:
if (split_apk.get('moduleName') == 'base' and
'splitId' not in split_apk):
download_id = split_apk.get('downloadId')
universal_apk = split_apk
print(f"Found base APK (variantId={split_apk.get('variantId')}): {universal_apk}")
break
if download_id:
break
if not download_id:
print("No universal or base APK found")
print("Available APK structure:")
print(json.dumps(result['generatedApks'], indent=2))
return False
print(f"Found universal APK with downloadId: {download_id}")
# Step 2: Download the APK using the downloadId
print("Downloading APK binary...")
# Use alt=media to get the actual binary content instead of metadata
download_request = service.generatedapks().download(
packageName=package_name,
versionCode=version_code,
downloadId=download_id
)
# Add alt=media parameter correctly (URL already has query params, so use &)
if '?' in download_request.uri:
download_request.uri += '&alt=media'
else:
download_request.uri += '?alt=media'
output_filename = f"v2er-{os.environ['VERSION_NAME']}_google_play_signed.apk"
# Use media download with googleapiclient.http to handle binary content
import io
from googleapiclient.http import MediaIoBaseDownload
file_io = io.BytesIO()
downloader = MediaIoBaseDownload(file_io, download_request)
done = False
while done is False:
status, done = downloader.next_chunk()
if status:
print(f"Download progress: {int(status.progress() * 100)}%")
# Write to file
with open(output_filename, 'wb') as f:
f.write(file_io.getvalue())
print(f"Successfully downloaded: {output_filename}")
print(f"apk_path={output_filename}")
return True
except Exception as e:
print(f"Error downloading signed APK: {str(e)}")
print("This may be because:")
print("1. The app hasn't been processed by Google Play yet")
print("2. The version hasn't been released to any track")
print("3. API permissions are insufficient")
return False
if __name__ == "__main__":
success = download_signed_apk()
sys.exit(0 if success else 1)
EOF
# Set environment variables for the script
export PACKAGE_NAME="$PACKAGE_NAME"
export VERSION_CODE="$VERSION_CODE"
export VERSION_NAME="$VERSION_NAME"
# Run the download script
echo "Attempting to download Google Play signed APK..."
if python3 download_signed_apk.py > download_output.txt 2>&1; then
echo "Successfully downloaded Google Play signed APK"
cat download_output.txt
# Extract the APK path from output
APK_PATH=$(grep "apk_path=" download_output.txt | cut -d'=' -f2)
if [ -f "$APK_PATH" ]; then
echo "apk_path=$APK_PATH" >> $GITHUB_OUTPUT
echo "found=true" >> $GITHUB_OUTPUT
else
echo "APK file not found after download"
echo "found=false" >> $GITHUB_OUTPUT
fi
else
echo "Failed to download Google Play signed APK"
cat download_output.txt
echo "found=false" >> $GITHUB_OUTPUT
fi
- name: Create Google Play link info
if: steps.download-apk.outputs.found == 'true'
id: play-link
run: |
VERSION_NAME="v2.3.3"
VERSION_CODE="233"
# Create info file about Google Play signing
cat > "v2er-${VERSION_NAME}_google_play_signed_info.txt" << EOF
Google Play Signed APK Information
==================================
Version: ${VERSION_NAME}
Version Code: ${VERSION_CODE}
Package: me.ghui.v2er
The APK attached (v2er-${VERSION_NAME}_google_play_signed.apk) is a universal APK
generated from the AAB (Android App Bundle) that was uploaded to Google Play.
When downloaded from Google Play Store, the APK will be signed with Google Play's
app signing certificate instead of the upload certificate.
Internal Testing Link:
https://play.google.com/apps/test/me.ghui.v2er/${VERSION_CODE}
Note: Access to internal testing track required.
EOF
echo "info_path=v2er-${VERSION_NAME}_google_play_signed_info.txt" >> $GITHUB_OUTPUT
- name: Upload signed APK to GitHub Release
if: steps.download-apk.outputs.found == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: v2.3.3
files: |
${{ steps.download-apk.outputs.apk_path }}
${{ steps.play-link.outputs.info_path }}
fail_on_unmatched_files: false
- name: Summary
if: always()
run: |
echo "## Google Play Signed APK :package:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.download-apk.outputs.found }}" = "true" ]; then
echo "✅ **Universal APK generated successfully**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: v2.3.3" >> $GITHUB_STEP_SUMMARY
echo "- **Version Code**: 233" >> $GITHUB_STEP_SUMMARY
echo "- **File**: v2er-v2.3.3_google_play_signed.apk" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Notes" >> $GITHUB_STEP_SUMMARY
echo "- This APK is generated from the AAB uploaded to Google Play" >> $GITHUB_STEP_SUMMARY
echo "- When installed from Play Store, it will use Google Play's signing certificate" >> $GITHUB_STEP_SUMMARY
echo "- The APK has been uploaded to the GitHub Release" >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ **No AAB found in artifacts**" >> $GITHUB_STEP_SUMMARY
echo "Signed APK generation requires a release bundle (AAB)" >> $GITHUB_STEP_SUMMARY
fi