|
7 | 7 | pull_request: |
8 | 8 | branches: |
9 | 9 | - main |
| 10 | + release: |
| 11 | + types: |
| 12 | + - published |
10 | 13 |
|
11 | 14 | jobs: |
12 | 15 | build: |
13 | 16 | runs-on: ubuntu-latest |
14 | | - |
| 17 | + env: |
| 18 | + PACKAGE_NAME: com.mua.prayertracker |
| 19 | + PLAY_TRACK: production |
| 20 | + |
15 | 21 | steps: |
16 | 22 | - name: Checkout code |
17 | 23 | uses: actions/checkout@v4 |
18 | | - |
| 24 | + |
19 | 25 | - name: Set up JDK 17 |
20 | 26 | uses: actions/setup-java@v4 |
21 | 27 | with: |
22 | 28 | distribution: 'temurin' |
23 | 29 | java-version: '17' |
24 | | - |
| 30 | + |
25 | 31 | - name: Setup Gradle |
26 | 32 | uses: gradle/actions/setup-gradle@v3 |
27 | | - |
| 33 | + |
28 | 34 | - name: Decode and save keystore |
29 | 35 | run: | |
30 | 36 | mkdir -p secret |
31 | 37 | echo "${{ secrets.PRAYER_TRACKER_JKS }}" | base64 -d > secret/key-store.jks |
32 | | - |
| 38 | +
|
33 | 39 | - name: Clean project |
34 | 40 | run: ./gradlew clean |
35 | | - |
| 41 | + |
36 | 42 | - name: Build release bundle |
37 | 43 | run: | |
38 | 44 | ./gradlew bundleRelease \ |
39 | 45 | -PPRAYER_TRACKER_STORE_PASSWORD="${{ secrets.PRAYER_TRACKER_STORE_PASSWORD }}" \ |
40 | 46 | -PPRAYER_TRACKER_KEY_ALIAS="${{ secrets.PRAYER_TRACKER_KEY_ALIAS }}" \ |
41 | 47 | -PPRAYER_TRACKER_KEY_PASSWORD="${{ secrets.PRAYER_TRACKER_KEY_PASSWORD }}" |
42 | | - |
| 48 | +
|
43 | 49 | - name: Verify bundle signature |
44 | 50 | run: jarsigner -verify -verbose -certs app/build/outputs/bundle/release/app-release.aab |
| 51 | + |
| 52 | + - name: Decode Play service account (release only) |
| 53 | + if: ${{ github.event_name == 'release' && github.event.action == 'published' }} |
| 54 | + env: |
| 55 | + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PRAYER_TRACKER_PLAY_SERVICE_ACCOUNT_JSON }} |
| 56 | + run: | |
| 57 | + if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then |
| 58 | + echo "Missing secret: PRAYER_TRACKER_PLAY_SERVICE_ACCOUNT_JSON" |
| 59 | + exit 1 |
| 60 | + fi |
| 61 | +
|
| 62 | + mkdir -p secret |
| 63 | + python3 - <<'PY' |
| 64 | + import base64 |
| 65 | + import json |
| 66 | + import os |
| 67 | + from pathlib import Path |
| 68 | +
|
| 69 | + raw = os.environ["PLAY_SERVICE_ACCOUNT_JSON"].strip() |
| 70 | + output_path = Path("secret/play-service-account.json") |
| 71 | +
|
| 72 | + try: |
| 73 | + parsed = json.loads(raw) |
| 74 | + output_path.write_text(json.dumps(parsed), encoding="utf-8") |
| 75 | + except json.JSONDecodeError: |
| 76 | + decoded = base64.b64decode(raw).decode("utf-8") |
| 77 | + parsed = json.loads(decoded) |
| 78 | + output_path.write_text(json.dumps(parsed), encoding="utf-8") |
| 79 | + PY |
| 80 | +
|
| 81 | + - name: Publish to Google Play (release only) |
| 82 | + if: ${{ github.event_name == 'release' && github.event.action == 'published' }} |
| 83 | + run: | |
| 84 | + set -euo pipefail |
| 85 | +
|
| 86 | + AAB_PATH="app/build/outputs/bundle/release/app-release.aab" |
| 87 | + SERVICE_ACCOUNT_FILE="secret/play-service-account.json" |
| 88 | +
|
| 89 | + if [ ! -f "$AAB_PATH" ]; then |
| 90 | + echo "AAB not found at $AAB_PATH" |
| 91 | + exit 1 |
| 92 | + fi |
| 93 | +
|
| 94 | + if [ ! -f "$SERVICE_ACCOUNT_FILE" ]; then |
| 95 | + echo "Service account file not found at $SERVICE_ACCOUNT_FILE" |
| 96 | + exit 1 |
| 97 | + fi |
| 98 | +
|
| 99 | + # Build and sign a JWT assertion for OAuth 2.0 service-account flow. |
| 100 | + python3 - <<'PY' |
| 101 | + import base64 |
| 102 | + import json |
| 103 | + import time |
| 104 | + from pathlib import Path |
| 105 | +
|
| 106 | + service_account = json.loads(Path("secret/play-service-account.json").read_text(encoding="utf-8")) |
| 107 | + now = int(time.time()) |
| 108 | +
|
| 109 | + def b64url(data: bytes) -> str: |
| 110 | + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") |
| 111 | +
|
| 112 | + header = {"alg": "RS256", "typ": "JWT"} |
| 113 | + payload = { |
| 114 | + "iss": service_account["client_email"], |
| 115 | + "scope": "https://www.googleapis.com/auth/androidpublisher", |
| 116 | + "aud": "https://oauth2.googleapis.com/token", |
| 117 | + "iat": now, |
| 118 | + "exp": now + 3600, |
| 119 | + } |
| 120 | +
|
| 121 | + unsigned_jwt = ( |
| 122 | + f"{b64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))}." |
| 123 | + f"{b64url(json.dumps(payload, separators=(',', ':')).encode('utf-8'))}" |
| 124 | + ) |
| 125 | +
|
| 126 | + Path("secret/unsigned-jwt.txt").write_text(unsigned_jwt, encoding="utf-8") |
| 127 | + Path("secret/service-account-private-key.pem").write_text( |
| 128 | + service_account["private_key"], |
| 129 | + encoding="utf-8", |
| 130 | + ) |
| 131 | + PY |
| 132 | +
|
| 133 | + UNSIGNED_JWT="$(cat secret/unsigned-jwt.txt)" |
| 134 | + JWT_SIGNATURE="$(printf '%s' "$UNSIGNED_JWT" | openssl dgst -sha256 -sign secret/service-account-private-key.pem | openssl base64 -A | tr '+/' '-_' | tr -d '=')" |
| 135 | + JWT_ASSERTION="$UNSIGNED_JWT.$JWT_SIGNATURE" |
| 136 | +
|
| 137 | + TOKEN_RESPONSE="$(curl -sS --fail-with-body -X POST \ |
| 138 | + -H "Content-Type: application/x-www-form-urlencoded" \ |
| 139 | + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ |
| 140 | + --data-urlencode "assertion=$JWT_ASSERTION" \ |
| 141 | + https://oauth2.googleapis.com/token)" |
| 142 | +
|
| 143 | + ACCESS_TOKEN="$(printf '%s' "$TOKEN_RESPONSE" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read()).get("access_token", ""))')" |
| 144 | + if [ -z "$ACCESS_TOKEN" ]; then |
| 145 | + echo "Failed to obtain Google OAuth access token" |
| 146 | + exit 1 |
| 147 | + fi |
| 148 | +
|
| 149 | + CREATE_EDIT_RESPONSE="$(curl -sS --fail-with-body -X POST \ |
| 150 | + -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 151 | + -H "Content-Type: application/json" \ |
| 152 | + "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PACKAGE_NAME/edits")" |
| 153 | +
|
| 154 | + EDIT_ID="$(printf '%s' "$CREATE_EDIT_RESPONSE" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read()).get("id", ""))')" |
| 155 | + if [ -z "$EDIT_ID" ]; then |
| 156 | + echo "Failed to create Play edit" |
| 157 | + exit 1 |
| 158 | + fi |
| 159 | +
|
| 160 | + UPLOAD_RESPONSE="$(curl -sS --fail-with-body -X POST \ |
| 161 | + -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 162 | + -H "Content-Type: application/octet-stream" \ |
| 163 | + --data-binary "@$AAB_PATH" \ |
| 164 | + "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$PACKAGE_NAME/edits/$EDIT_ID/bundles?uploadType=media")" |
| 165 | +
|
| 166 | + VERSION_CODE="$(printf '%s' "$UPLOAD_RESPONSE" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read()).get("versionCode", ""))')" |
| 167 | + if [ -z "$VERSION_CODE" ]; then |
| 168 | + echo "Failed to upload bundle to Play edit" |
| 169 | + exit 1 |
| 170 | + fi |
| 171 | + export VERSION_CODE |
| 172 | +
|
| 173 | + TRACK_PAYLOAD="$(python3 - <<'PY' |
| 174 | + import json |
| 175 | + import os |
| 176 | +
|
| 177 | + print( |
| 178 | + json.dumps( |
| 179 | + { |
| 180 | + "releases": [ |
| 181 | + { |
| 182 | + "status": "completed", |
| 183 | + "versionCodes": [os.environ["VERSION_CODE"]], |
| 184 | + } |
| 185 | + ] |
| 186 | + } |
| 187 | + ) |
| 188 | + ) |
| 189 | + PY |
| 190 | + )" |
| 191 | +
|
| 192 | + curl -sS --fail-with-body -X PUT \ |
| 193 | + -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 194 | + -H "Content-Type: application/json" \ |
| 195 | + -d "$TRACK_PAYLOAD" \ |
| 196 | + "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PACKAGE_NAME/edits/$EDIT_ID/tracks/$PLAY_TRACK" >/dev/null |
| 197 | +
|
| 198 | + curl -sS --fail-with-body -X POST \ |
| 199 | + -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 200 | + "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PACKAGE_NAME/edits/$EDIT_ID:commit" >/dev/null |
| 201 | +
|
| 202 | + # Best effort cleanup for generated credential material. |
| 203 | + rm -f secret/unsigned-jwt.txt secret/service-account-private-key.pem secret/play-service-account.json |
0 commit comments