Skip to content

Commit 1d29e80

Browse files
committed
mobile: build Google Play bundles and publish on demand
Release builds were signed with the debug key, which Google Play rejects, and the only CI path was an APK baked into the goreleaser workflow. Gradle now signs release builds with an upload key read from android/key.properties or ANDROID_KEYSTORE_* environment variables, falling back to the debug key so `flutter run --release` keeps working. `make mobile-app-bundle` produces the .aab and refuses to run without a key; MOBILE_BUILD_NAME/MOBILE_BUILD_NUMBER override the pubspec version so Play always sees a fresh versionCode. The mobile build moves out of release.yml into its own Mobile App workflow that runs on release tags in parallel with goreleaser (waiting for the release to exist before attaching the APK and bundle) and on workflow_dispatch from any ref. A dispatch may name a Play track; the publish job mints a short-lived androidpublisher token via Workload Identity Federation and drives the Play Developer API with hack/publish-play.sh, passing the token through a header file rather than argv. No long-lived service-account key is stored. Keystores and google-services.json are git-ignored repo-wide: they are generated next to the checkout and must never be committed.
1 parent 65fa8f5 commit 1d29e80

7 files changed

Lines changed: 396 additions & 65 deletions

File tree

.github/workflows/mobile.yml

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
name: Mobile App
2+
3+
# Builds SAM Connect (Android) on every release tag, in parallel with the
4+
# goreleaser workflow that publishes the Go binaries, and on demand from any
5+
# ref. A dispatch may also publish the bundle to a Google Play track, keyless
6+
# via Workload Identity Federation: repository variables
7+
# WIF_PROVIDER_NAME_APP_STORE / SERVICE_ACCOUNT_EMAIL_APP_STORE name a
8+
# service account invited in Play Console with release rights.
9+
#
10+
# Play needs a strictly increasing versionCode. It defaults to this
11+
# workflow's run number, which is tied to this file's path: renaming the
12+
# file resets the counter, so override build_number if that ever happens.
13+
14+
on:
15+
push:
16+
tags:
17+
- "v*"
18+
workflow_dispatch:
19+
inputs:
20+
track:
21+
description: 'Google Play track to publish to (none = build only)'
22+
type: choice
23+
options: [none, internal, alpha, beta, production]
24+
default: none
25+
build_number:
26+
description: 'Android versionCode (default: this workflow run number)'
27+
type: string
28+
default: ''
29+
30+
run-name: "mobile ${{ github.ref_name }}${{ inputs.track && format(' ({0})', inputs.track) || '' }} by ${{ github.actor }}"
31+
32+
permissions: {}
33+
34+
env:
35+
APP_DIR: mobile/sam-node-app
36+
PACKAGE_NAME: dev.sammesh.connect
37+
38+
jobs:
39+
build:
40+
name: Build APK and bundle
41+
runs-on: ubuntu-latest
42+
timeout-minutes: 45
43+
permissions:
44+
contents: read
45+
outputs:
46+
bundle: ${{ steps.upload-key.outputs.present }}
47+
steps:
48+
- name: Checkout
49+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
50+
with:
51+
fetch-depth: 0
52+
persist-credentials: false
53+
- name: Set up Go
54+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
55+
with:
56+
go-version: stable
57+
cache: false
58+
- name: Set up Java
59+
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
60+
with:
61+
distribution: 'temurin'
62+
java-version: '17'
63+
- name: Set up Android NDK
64+
run: |
65+
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses
66+
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;26.1.10909125"
67+
- name: Set up Flutter
68+
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
69+
with:
70+
channel: 'stable'
71+
cache: true
72+
73+
- name: Determine version
74+
env:
75+
REF_TYPE: ${{ github.ref_type }}
76+
REF_NAME: ${{ github.ref_name }}
77+
INPUT_BUILD_NUMBER: ${{ inputs.build_number }}
78+
RUN_NUMBER: ${{ github.run_number }}
79+
run: |
80+
if [[ "${REF_TYPE}" == "tag" ]]; then
81+
name="${REF_NAME#v}"
82+
else
83+
name="$(git describe --tags --always)"; name="${name#v}"
84+
fi
85+
number="${INPUT_BUILD_NUMBER:-${RUN_NUMBER}}"
86+
if ! [[ "${number}" =~ ^[0-9]+$ ]]; then
87+
echo "::error::build_number must be a positive integer (got '${number}')"; exit 1
88+
fi
89+
echo "MOBILE_BUILD_NAME=${name}" >> "$GITHUB_ENV"
90+
echo "MOBILE_BUILD_NUMBER=${number}" >> "$GITHUB_ENV"
91+
echo "Building ${name} (versionCode ${number})"
92+
93+
- name: Install upload key
94+
# Step-level `if` cannot read `secrets`, so surface presence as an output.
95+
# The key signs both the APK and the bundle when present; without it the
96+
# APK falls back to the debug key and the bundle is skipped.
97+
id: upload-key
98+
env:
99+
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
100+
run: |
101+
if [[ -z "${ANDROID_KEYSTORE_BASE64}" ]]; then
102+
echo "present=false" >> "$GITHUB_OUTPUT"
103+
echo "::notice::ANDROID_KEYSTORE_BASE64 not set; skipping the Google Play bundle."
104+
exit 0
105+
fi
106+
path="$RUNNER_TEMP/upload-keystore.jks"
107+
echo "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${path}"
108+
echo "ANDROID_KEYSTORE_PATH=${path}" >> "$GITHUB_ENV"
109+
echo "present=true" >> "$GITHUB_OUTPUT"
110+
- name: Refuse to publish without an upload key
111+
if: github.event_name == 'workflow_dispatch' && inputs.track != 'none' && steps.upload-key.outputs.present != 'true'
112+
run: |
113+
echo "::error::Publishing to Google Play needs the ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD secrets."
114+
exit 1
115+
116+
- name: Build APK
117+
env:
118+
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
119+
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
120+
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
121+
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
122+
run: make mobile-app-apk
123+
- name: Build Google Play bundle
124+
if: steps.upload-key.outputs.present == 'true'
125+
env:
126+
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
127+
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
128+
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
129+
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
130+
run: make mobile-app-bundle
131+
- name: Remove upload key
132+
if: always() && steps.upload-key.outputs.present == 'true'
133+
run: rm -f "$ANDROID_KEYSTORE_PATH"
134+
135+
- name: Upload APK artifact
136+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
137+
with:
138+
name: sam-connect-apk
139+
path: ${{ env.APP_DIR }}/build/app/outputs/flutter-apk/app-release.apk
140+
if-no-files-found: error
141+
- name: Upload bundle artifact
142+
if: steps.upload-key.outputs.present == 'true'
143+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
144+
with:
145+
name: sam-connect-aab
146+
path: ${{ env.APP_DIR }}/build/app/outputs/bundle/release/app-release.aab
147+
if-no-files-found: error
148+
149+
attach-to-release:
150+
name: Attach to GitHub release
151+
if: github.ref_type == 'tag'
152+
needs: build
153+
runs-on: ubuntu-latest
154+
timeout-minutes: 40
155+
permissions:
156+
contents: write
157+
steps:
158+
- name: Download artifacts
159+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
160+
with:
161+
pattern: sam-connect-*
162+
merge-multiple: true
163+
path: dist
164+
- name: Upload to release
165+
env:
166+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167+
GH_REPO: ${{ github.repository }}
168+
TAG_NAME: ${{ github.ref_name }}
169+
run: |
170+
# The goreleaser workflow creates the release from the same tag push;
171+
# wait for it rather than racing to create one ourselves.
172+
for _ in $(seq 1 60); do
173+
gh release view "$TAG_NAME" >/dev/null 2>&1 && break
174+
echo "waiting for release $TAG_NAME to be created by goreleaser..."; sleep 30
175+
done
176+
gh release view "$TAG_NAME" >/dev/null
177+
gh release upload "$TAG_NAME" dist/* --clobber
178+
179+
publish:
180+
name: Publish to Google Play (${{ inputs.track }})
181+
if: github.event_name == 'workflow_dispatch' && inputs.track != 'none'
182+
needs: build
183+
runs-on: ubuntu-latest
184+
timeout-minutes: 15
185+
permissions:
186+
contents: read
187+
id-token: write
188+
steps:
189+
- name: Checkout
190+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
191+
with:
192+
persist-credentials: false
193+
sparse-checkout: hack/publish-play.sh
194+
sparse-checkout-cone-mode: false
195+
- name: Download bundle
196+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
197+
with:
198+
name: sam-connect-aab
199+
path: dist
200+
- name: Google Auth
201+
id: auth
202+
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
203+
with:
204+
workload_identity_provider: ${{ vars.WIF_PROVIDER_NAME_APP_STORE }}
205+
service_account: ${{ vars.SERVICE_ACCOUNT_EMAIL_APP_STORE }}
206+
token_format: access_token
207+
access_token_scopes: https://www.googleapis.com/auth/androidpublisher
208+
- name: Upload to track
209+
env:
210+
PLAY_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
211+
TRACK: ${{ inputs.track }}
212+
run: ./hack/publish-play.sh "$PACKAGE_NAME" "$TRACK" dist/app-release.aab

.github/workflows/release.yml

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,43 +25,13 @@ jobs:
2525
with:
2626
go-version: stable
2727
cache: false
28-
- name: Set up Java
29-
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
30-
with:
31-
distribution: 'temurin'
32-
java-version: '17'
33-
- name: Set up Android NDK
34-
run: |
35-
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses
36-
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;26.1.10909125"
37-
- name: Set up Flutter
38-
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
39-
with:
40-
channel: 'stable'
41-
- name: Build Mobile App
42-
env:
43-
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
44-
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
45-
run: make mobile-app-apk
46-
- name: Restore files rewritten by the Flutter build
47-
# `flutter build` re-resolves pubspec.lock (and may rewrite
48-
# analysis_options.yaml), leaving the tree dirty and failing
49-
# GoReleaser's git state validation. Build outputs are untracked, so
50-
# restoring tracked files keeps the APK available for the upload step.
51-
run: |
52-
git -C mobile/sam-node-app checkout -- .
53-
git status --porcelain
5428
- name: Run GoReleaser
29+
# The mobile app is built and attached to this release by the
30+
# parallel "Mobile App" workflow (.github/workflows/mobile.yml).
5531
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
5632
with:
5733
distribution: goreleaser
5834
version: "~> v2"
5935
args: release --clean
6036
env:
6137
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62-
- name: Upload Mobile App to Release
63-
env:
64-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65-
GH_REPO: ${{ github.repository }}
66-
TAG_NAME: ${{ github.ref_name }}
67-
run: gh release upload "$TAG_NAME" mobile/sam-node-app/build/app/outputs/flutter-apk/app-release.apk --clobber

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,9 @@ mobile/logcat.txt
6161
/rootfs.ext4
6262
/rootfs.tar
6363

64+
# Android upload keystore and Firebase config: secrets, wherever they land.
65+
*.jks
66+
*.keystore
67+
key.properties
68+
google-services.json
69+

Makefile

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ build:
3939
go -C cmd/sam-a2a-bridge build -v -o "$(OUT_DIR)/sam-a2a-bridge" .
4040

4141

42-
.PHONY: mobile-ffi-host mobile-ffi-android mobile-ffi-android-x86_64 mobile-ffi-ios mobile-ffi mobile-app-apk mobile-app-apk-emulator
42+
.PHONY: mobile-ffi-host mobile-ffi-android mobile-ffi-android-x86_64 mobile-ffi-ios mobile-ffi mobile-app-apk mobile-app-apk-emulator mobile-app-bundle
4343
mobile-ffi-host:
4444
mkdir -p "$(OUT_DIR)"
4545
CGO_ENABLED=1 go build -v -buildmode=c-shared -o "$(OUT_DIR)/libsam.so" ./mobile/sam-node-ffi
@@ -65,47 +65,58 @@ mobile-ffi-ios:
6565

6666
mobile-ffi: mobile-ffi-host mobile-ffi-android mobile-ffi-android-x86_64 mobile-ffi-ios
6767

68-
mobile-app-apk: mobile-ffi-android
68+
MOBILE_APP_DIR=mobile/sam-node-app
69+
# Optional overrides of pubspec.yaml's `version: X.Y.Z+N`. Google Play rejects
70+
# a bundle whose versionCode (N) it has already seen, so CI passes a fresh one.
71+
MOBILE_BUILD_NAME?=
72+
MOBILE_BUILD_NUMBER?=
73+
MOBILE_FLUTTER_BUILD_FLAGS=$(if $(MOBILE_BUILD_NAME),--build-name=$(MOBILE_BUILD_NAME)) $(if $(MOBILE_BUILD_NUMBER),--build-number=$(MOBILE_BUILD_NUMBER))
74+
75+
.PHONY: mobile-app-google-services
76+
mobile-app-google-services:
6977
@if [ -n "$$GOOGLE_SERVICES_JSON" ]; then \
7078
echo "Decoding google-services.json from GOOGLE_SERVICES_JSON environment variable..."; \
71-
echo "$$GOOGLE_SERVICES_JSON" | base64 --decode > mobile/sam-node-app/android/app/google-services.json; \
79+
echo "$$GOOGLE_SERVICES_JSON" | base64 --decode > $(MOBILE_APP_DIR)/android/app/google-services.json; \
7280
elif [ -n "$$GOOGLE_SERVICES_JSON_BASE64" ]; then \
7381
echo "Decoding google-services.json from GOOGLE_SERVICES_JSON_BASE64 environment variable..."; \
74-
echo "$$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > mobile/sam-node-app/android/app/google-services.json; \
75-
elif [ ! -f mobile/sam-node-app/android/app/google-services.json ]; then \
76-
if [ -f mobile/sam-node-app/android/app/google-services.json.tmpl ]; then \
82+
echo "$$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > $(MOBILE_APP_DIR)/android/app/google-services.json; \
83+
elif [ ! -f $(MOBILE_APP_DIR)/android/app/google-services.json ]; then \
84+
if [ -f $(MOBILE_APP_DIR)/android/app/google-services.json.tmpl ]; then \
7785
echo "Copying google-services.json from template..."; \
78-
cp mobile/sam-node-app/android/app/google-services.json.tmpl mobile/sam-node-app/android/app/google-services.json; \
86+
cp $(MOBILE_APP_DIR)/android/app/google-services.json.tmpl $(MOBILE_APP_DIR)/android/app/google-services.json; \
7987
else \
8088
echo "Error: google-services.json is missing."; \
81-
echo "Please set GOOGLE_SERVICES_JSON or GOOGLE_SERVICES_JSON_BASE64 environment variable with the base64-encoded configuration, or place the file directly at mobile/sam-node-app/android/app/google-services.json"; \
89+
echo "Please set GOOGLE_SERVICES_JSON or GOOGLE_SERVICES_JSON_BASE64 environment variable with the base64-encoded configuration, or place the file directly at $(MOBILE_APP_DIR)/android/app/google-services.json"; \
8290
exit 1; \
8391
fi; \
8492
fi
85-
mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a
86-
cp "$(OUT_DIR)/android/libsam.so" mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/libsam.so
87-
cd mobile/sam-node-app && flutter build apk --release
8893

89-
mobile-app-apk-emulator: mobile-ffi-android-x86_64
90-
@if [ -n "$$GOOGLE_SERVICES_JSON" ]; then \
91-
echo "Decoding google-services.json from GOOGLE_SERVICES_JSON environment variable..."; \
92-
echo "$$GOOGLE_SERVICES_JSON" | base64 --decode > mobile/sam-node-app/android/app/google-services.json; \
93-
elif [ -n "$$GOOGLE_SERVICES_JSON_BASE64" ]; then \
94-
echo "Decoding google-services.json from GOOGLE_SERVICES_JSON_BASE64 environment variable..."; \
95-
echo "$$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > mobile/sam-node-app/android/app/google-services.json; \
96-
elif [ ! -f mobile/sam-node-app/android/app/google-services.json ]; then \
97-
if [ -f mobile/sam-node-app/android/app/google-services.json.tmpl ]; then \
98-
echo "Copying google-services.json from template..."; \
99-
cp mobile/sam-node-app/android/app/google-services.json.tmpl mobile/sam-node-app/android/app/google-services.json; \
100-
else \
101-
echo "Error: google-services.json is missing."; \
102-
echo "Please set GOOGLE_SERVICES_JSON or GOOGLE_SERVICES_JSON_BASE64 environment variable with the base64-encoded configuration, or place the file directly at mobile/sam-node-app/android/app/google-services.json"; \
103-
exit 1; \
104-
fi; \
94+
.PHONY: mobile-app-jnilibs-arm64
95+
mobile-app-jnilibs-arm64: mobile-ffi-android
96+
mkdir -p $(MOBILE_APP_DIR)/android/app/src/main/jniLibs/arm64-v8a
97+
cp "$(OUT_DIR)/android/libsam.so" $(MOBILE_APP_DIR)/android/app/src/main/jniLibs/arm64-v8a/libsam.so
98+
99+
mobile-app-apk: mobile-app-jnilibs-arm64 mobile-app-google-services
100+
cd $(MOBILE_APP_DIR) && flutter build apk --release $(MOBILE_FLUTTER_BUILD_FLAGS)
101+
102+
mobile-app-apk-emulator: mobile-ffi-android-x86_64 mobile-app-google-services
103+
mkdir -p $(MOBILE_APP_DIR)/android/app/src/main/jniLibs/x86_64
104+
cp "$(OUT_DIR)/android-x86_64/libsam.so" $(MOBILE_APP_DIR)/android/app/src/main/jniLibs/x86_64/libsam.so
105+
cd $(MOBILE_APP_DIR) && flutter build apk --release $(MOBILE_FLUTTER_BUILD_FLAGS)
106+
107+
# Android App Bundle for Google Play. Play rejects debug-signed bundles, so an
108+
# upload key is required: either $(MOBILE_APP_DIR)/android/key.properties or
109+
# the ANDROID_KEYSTORE_PATH / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS /
110+
# ANDROID_KEY_PASSWORD environment variables (see mobile/sam-node-app/README.md).
111+
.PHONY: mobile-app-bundle
112+
mobile-app-bundle: mobile-app-jnilibs-arm64 mobile-app-google-services
113+
@if [ ! -f $(MOBILE_APP_DIR)/android/key.properties ] && [ -z "$$ANDROID_KEYSTORE_PATH" ]; then \
114+
echo "Error: no upload key configured; Google Play rejects debug-signed bundles." >&2; \
115+
echo "Create $(MOBILE_APP_DIR)/android/key.properties or export ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD. See $(MOBILE_APP_DIR)/README.md#publishing-to-google-play." >&2; \
116+
exit 1; \
105117
fi
106-
mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/x86_64
107-
cp "$(OUT_DIR)/android-x86_64/libsam.so" mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/libsam.so
108-
cd mobile/sam-node-app && flutter build apk --release
118+
cd $(MOBILE_APP_DIR) && flutter build appbundle --release $(MOBILE_FLUTTER_BUILD_FLAGS)
119+
@echo "Bundle: $(MOBILE_APP_DIR)/build/app/outputs/bundle/release/app-release.aab"
109120

110121
.PHONY: proto
111122
proto:

0 commit comments

Comments
 (0)