Skip to content

Commit 653067a

Browse files
Merge pull request #503 from off-grid-ai/chore/beta-distribution
chore(uat): beta distribution to TestFlight + Play internal + GH prerelease (local + CI)
2 parents 79e5e44 + 6d68354 commit 653067a

18 files changed

Lines changed: 1906 additions & 24 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Back-merge main into dev
2+
3+
# Keeps dev in sync with main. Anything that lands on main — hotfixes merged straight
4+
# to main, and the release version-bump commit from release.yml — must flow back to dev,
5+
# or the next dev -> main PR shows phantom conflicts and dev slowly drifts behind prod.
6+
#
7+
# On every push to main this tries a clean merge of main into dev and pushes it. If the
8+
# merge conflicts, it opens a back-merge PR for a human to resolve instead of failing
9+
# silently or forcing anything.
10+
11+
on:
12+
push:
13+
branches: [main]
14+
workflow_dispatch: {}
15+
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
20+
concurrency:
21+
# Never run two back-merges at once (they'd race on dev).
22+
group: backmerge-main-to-dev
23+
cancel-in-progress: false
24+
25+
jobs:
26+
backmerge:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Checkout full history
30+
uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 0
33+
# Default GITHUB_TOKEN can push to dev (unless dev is protected against it —
34+
# see note in the PR/branch-protection setup).
35+
token: ${{ secrets.GITHUB_TOKEN }}
36+
37+
- name: Configure git
38+
run: |
39+
git config user.name "github-actions[bot]"
40+
git config user.email "github-actions[bot]@users.noreply.github.com"
41+
42+
- name: Attempt clean back-merge main -> dev
43+
id: merge
44+
run: |
45+
git fetch origin main dev
46+
git checkout dev
47+
48+
# Already up to date? nothing to do.
49+
if git merge-base --is-ancestor origin/main origin/dev; then
50+
echo "dev already contains main — nothing to back-merge."
51+
echo "status=noop" >> "$GITHUB_OUTPUT"
52+
exit 0
53+
fi
54+
55+
if git merge --no-edit origin/main; then
56+
git push origin dev
57+
echo "Back-merged main into dev cleanly."
58+
echo "status=merged" >> "$GITHUB_OUTPUT"
59+
else
60+
git merge --abort
61+
echo "Merge conflicts — a PR is needed."
62+
echo "status=conflict" >> "$GITHUB_OUTPUT"
63+
fi
64+
65+
- name: Open back-merge PR on conflict
66+
if: steps.merge.outputs.status == 'conflict'
67+
env:
68+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
69+
run: |
70+
BRANCH="backmerge/main-to-dev-${{ github.run_id }}"
71+
git checkout -b "$BRANCH" origin/main
72+
git push origin "$BRANCH"
73+
gh pr create \
74+
--base dev \
75+
--head "$BRANCH" \
76+
--title "Back-merge main into dev (conflicts need resolution)" \
77+
--body "Automated back-merge could not fast-merge \`main\` into \`dev\` (conflicts). Resolve here so dev stays in sync with production. Triggered by push to main: ${{ github.sha }}."

.github/workflows/release-ios.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
name: Build and Release iOS
22

33
on:
4-
workflow_dispatch: # Disabled - manual trigger only for now
4+
# Chain off the Android release: when "Build and Release Android" finishes on main,
5+
# run the iOS release too, keeping version + cadence in lockstep across platforms.
6+
workflow_run:
7+
workflows: ['Build and Release Android']
8+
types: [completed]
9+
branches: [main]
10+
workflow_dispatch: {} # manual fallback
511

612
permissions:
713
contents: write
814

915
jobs:
1016
release-ios:
1117
runs-on: macos-latest
18+
# Only run if the Android release actually succeeded (skip on its failure/cancel).
19+
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
1220

1321
steps:
1422
- name: Checkout code

.github/workflows/release.yml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@ name: Build and Release Android
33
# If you rename this workflow, update the workflow_run trigger in release-ios.yml.
44

55
on:
6-
workflow_dispatch: # Disabled - manual trigger only for now
6+
push:
7+
branches: [main] # every merge to main cuts a release
8+
workflow_dispatch: {} # manual fallback
79

810
permissions:
911
contents: write
1012

1113
jobs:
1214
release:
1315
runs-on: ubuntu-latest
16+
# Lift store secrets to job-level env so the `if:` guards on the Play steps can test
17+
# for their presence (if: conditions can read env, not secrets directly). Absent
18+
# secrets => empty => Play upload steps skip, GitHub Release path still runs.
19+
env:
20+
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
21+
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
1422

1523
steps:
1624
- name: Checkout code
@@ -34,6 +42,12 @@ jobs:
3442
- name: Setup Android SDK
3543
uses: android-actions/setup-android@v3
3644

45+
- name: Setup Ruby (fastlane)
46+
uses: ruby/setup-ruby@v1
47+
with:
48+
ruby-version: '3.3'
49+
bundler-cache: true
50+
3751
- name: Cache Gradle
3852
uses: actions/cache@v4
3953
with:
@@ -108,5 +122,33 @@ jobs:
108122
uses: actions/upload-artifact@v4
109123
with:
110124
name: OffgridMobile-v${{ env.NEW_VERSION }}
111-
path: android/app/build/outputs/apk/standard/release/OffgridMobile-v${{ env.NEW_VERSION }}.apk
125+
# No product flavors, so the APK lives under apk/release/ (not apk/standard/release/).
126+
path: android/app/build/outputs/apk/release/OffgridMobile-v${{ env.NEW_VERSION }}.apk
112127
if-no-files-found: error
128+
129+
# ── Play Store upload (in addition to the GitHub Release above) ──────────
130+
# Decodes the production keystore + Play service-account key from secrets, builds
131+
# a signed AAB, and uploads to the Play production track as a DRAFT (a human
132+
# confirms rollout in the Play Console). Skipped automatically if the secrets are
133+
# absent, so the GitHub Release path still works without them.
134+
- name: Decode signing secrets
135+
if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' && env.PLAY_STORE_JSON_KEY != '' }}
136+
run: |
137+
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.keystore"
138+
# PLAY_STORE_JSON_KEY may be stored raw-JSON or base64 — handle both (matches uat-dev.yml).
139+
echo "$PLAY_STORE_JSON_KEY" | base64 --decode > "$RUNNER_TEMP/play-key.json" \
140+
|| printf '%s' "$PLAY_STORE_JSON_KEY" > "$RUNNER_TEMP/play-key.json"
141+
echo "PLAY_UPLOAD=1" >> $GITHUB_ENV
142+
143+
- name: Build signed AAB + upload to Play (draft)
144+
if: ${{ env.PLAY_UPLOAD == '1' }}
145+
env:
146+
PLAY_STORE_JSON_KEY_PATH: ${{ runner.temp }}/play-key.json
147+
# build.gradle gates release signing on project.hasProperty(...), so these must be
148+
# Gradle PROJECT properties (ORG_GRADLE_PROJECT_<name>), not plain env — otherwise
149+
# the AAB falls back to debug signing and Play rejects it.
150+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_STORE_FILE: ${{ runner.temp }}/release.keystore
151+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
152+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
153+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
154+
run: bundle exec fastlane android release

.github/workflows/uat-dev.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# UAT / internal-tester builds on merge to `dev` (or manual dispatch).
2+
#
3+
# OPTIONAL cloud path. The FAST path is local: `scripts/uat.sh` on your Mac (minutes).
4+
# This runs the SAME script on a single macOS runner so CI and local are ONE pipeline
5+
# (one source of truth): it computes the next beta version, generates release notes,
6+
# builds every artifact BEFORE publishing any, uploads Android -> Play "internal" +
7+
# iOS -> TestFlight, then cuts the GitHub prerelease tag with the APK + AAB attached.
8+
#
9+
# Cost note: iOS on GitHub's free macOS runners is slow (~30-90 min) and macOS minutes
10+
# bill at 10x. That's the price of a fully-consistent CI path; the local script stays the
11+
# fast default. Use workflow_dispatch with platform=android for a quick Play-only run.
12+
#
13+
# Uses the credentials ALREADY stored as repo secrets (nothing new to add):
14+
# Android: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS,
15+
# ANDROID_KEY_PASSWORD, PLAY_STORE_JSON_KEY
16+
# iOS: ASC_API_KEY_P8, ASC_KEY_ID, ASC_ISSUER_ID (TestFlight upload)
17+
# IOS_CERTIFICATE_P12, IOS_CERTIFICATE_PASSWORD, IOS_PROVISION_PROFILE,
18+
# KEYCHAIN_PASSWORD (signing)
19+
name: UAT build (dev → internal testers)
20+
21+
on:
22+
push:
23+
branches: [dev]
24+
workflow_dispatch:
25+
inputs:
26+
platform:
27+
description: Which platform(s) to build
28+
type: choice
29+
default: both
30+
options: [both, ios, android]
31+
32+
concurrency:
33+
group: uat-${{ github.ref }}
34+
# Do NOT cancel a run in flight: once it starts publishing (Play + TestFlight), a mid-run
35+
# cancel could half-publish. Let it finish; the next push queues behind it.
36+
cancel-in-progress: false
37+
38+
permissions:
39+
contents: write # uat.sh pushes the beta bump commit + tag and creates the GH release
40+
41+
jobs:
42+
beta:
43+
# Single macOS runner: it has Xcode AND the Android SDK, so one machine builds both and
44+
# runs scripts/uat.sh end to end (bump -> build all -> publish all -> tag -> GH release).
45+
# Skip runs triggered by the bot's back-merge of main -> dev (that push isn't a real
46+
# change to ship a beta for); real human pushes to dev and manual dispatch still run.
47+
if: ${{ github.event_name == 'workflow_dispatch' || github.actor != 'github-actions[bot]' }}
48+
runs-on: macos-latest
49+
steps:
50+
- uses: actions/checkout@v4
51+
with:
52+
submodules: false # pro/ is a private repo — fetch it below with a PAT
53+
fetch-depth: 0 # uat.sh needs full tag history to compute the next beta N
54+
- name: Check out pro submodule (private)
55+
# pro/ lives in a separate private repo; the default GITHUB_TOKEN can't read it, so
56+
# authenticate with PRO_SUBMODULE_PAT (same mechanism as ci.yml). uat.sh's build needs
57+
# the real @offgrid/pro package present.
58+
env:
59+
PRO_PAT: ${{ secrets.PRO_SUBMODULE_PAT }}
60+
run: |
61+
if [ -z "$PRO_PAT" ]; then echo "PRO_SUBMODULE_PAT not set — cannot fetch private pro/ submodule"; exit 1; fi
62+
git config --global url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf "https://github.com/"
63+
git submodule update --init --recursive pro
64+
git config --global --unset url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf || true
65+
- uses: actions/setup-node@v4
66+
with: { node-version: '20', cache: npm }
67+
- run: npm ci
68+
- uses: actions/setup-java@v4
69+
with: { distribution: temurin, java-version: '17' }
70+
- uses: ruby/setup-ruby@v1
71+
with: { ruby-version: '3.3', bundler-cache: true }
72+
- name: pod install
73+
# Restore Podfile.lock afterward: a CI CocoaPods version can rewrite it, which would
74+
# dirty the tree and trip uat.sh's clean-tree pre-check. The pods are already installed,
75+
# so the lock's content doesn't affect the build.
76+
run: |
77+
cd ios && pod install
78+
cd .. && git checkout -- ios/Podfile.lock 2>/dev/null || true
79+
- name: Decode signing secrets
80+
run: |
81+
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/release.keystore"
82+
# PLAY_STORE_JSON_KEY may be stored raw-JSON or base64 — handle both.
83+
echo "${{ secrets.PLAY_STORE_JSON_KEY }}" | base64 --decode > "$RUNNER_TEMP/play-key.json" \
84+
|| printf '%s' '${{ secrets.PLAY_STORE_JSON_KEY }}' > "$RUNNER_TEMP/play-key.json"
85+
- name: Configure git for the beta bump commit + push
86+
# actions/checkout leaves HEAD detached; uat.sh commits the bump and `git push`es it,
87+
# which needs a real branch with an upstream. Re-attach to the triggering branch.
88+
run: |
89+
git config user.name "offgrid-ci"
90+
git config user.email "ci@offgridmobile.ai"
91+
git checkout -B "$GITHUB_REF_NAME"
92+
git branch --set-upstream-to="origin/$GITHUB_REF_NAME" "$GITHUB_REF_NAME" || true
93+
- name: Ship beta (scripts/uat.sh)
94+
env:
95+
# Which platform(s): push events build both; manual dispatch honors the picker.
96+
UAT_PLATFORM_ARG: ${{ github.event_name == 'workflow_dispatch' && inputs.platform != 'both' && format('--{0}', inputs.platform) || '' }}
97+
# gh release create / git push
98+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
99+
# Android signing — build.gradle reads these via project.hasProperty(...), so pass
100+
# them as Gradle project properties (ORG_GRADLE_PROJECT_<name>), not plain env.
101+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_STORE_FILE: ${{ runner.temp }}/release.keystore
102+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
103+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
104+
ORG_GRADLE_PROJECT_OFFGRID_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
105+
# Play upload
106+
PLAY_STORE_JSON_KEY_PATH: ${{ runner.temp }}/play-key.json
107+
# iOS auth + signing (imported into a temp keychain by the Fastfile's setup_signing)
108+
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
109+
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
110+
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
111+
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
112+
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
113+
IOS_PROVISION_PROFILE: ${{ secrets.IOS_PROVISION_PROFILE }}
114+
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
115+
FASTLANE_SKIP_UPDATE_CHECK: '1'
116+
FASTLANE_HIDE_CHANGELOG: '1'
117+
run: bash scripts/uat.sh $UAT_PLATFORM_ARG

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ yarn-error.log
5757
**/fastlane/screenshots
5858
**/fastlane/test_output
5959

60+
# fastlane credentials — never commit store keys or local env
61+
fastlane/.env
62+
fastlane/play-store-key.json
63+
fastlane/asc-api-key.json
64+
fastlane/*.p8
65+
# but DO commit the template (overrides the global .env.* rule above)
66+
!fastlane/.env.example
67+
6068
# Bundle artifact
6169
*.jsbundle
6270

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[submodule "pro"]
22
path = pro
3-
url = https://github.com/wednesday-solutions/offgrid-pro.git
3+
url = https://github.com/off-grid-ai/mobile-pro.git

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ gem 'bigdecimal'
1414
gem 'logger'
1515
gem 'benchmark'
1616
gem 'mutex_m'
17+
18+
# Release automation (lanes in fastlane/Fastfile).
19+
gem 'fastlane'
20+
21+
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
22+
eval_gemfile(plugins_path) if File.exist?(plugins_path)

0 commit comments

Comments
 (0)