ci: add parameterized build workflow (env x target x platform)

Manually-triggered, build-only GitHub Actions workflow on GitHub-hosted
runners. Inputs: environment (staging/prod), target (all/customer/mitra/
backend/control_center), platform (android/ios/both). Runner split: iOS app
jobs run on macos-latest, all else on ubuntu-latest. Apps build debug-signed
APKs; control_center bakes VITE_API_BASE_URL; backend exports a docker-save
tarball. iOS jobs await per-flavor Xcode schemes + signing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:10:59 +08:00
parent 91bdbd5289
commit 816e037a9a
2 changed files with 323 additions and 0 deletions

65
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,65 @@
# CI Workflows
## `build.yml` — parameterized build
Manually-triggered, **build-only** pipeline (no push, no deploy). Produces downloadable artifacts.
### How to run
1. GitHub → **Actions** tab → **Build** (left sidebar) → **Run workflow**.
2. Pick:
- **environment** — `staging` or `prod`
- **target** — `all`, `customer`, `mitra`, `backend`, or `control_center`
- **platform** — `android`, `ios`, or `both` (only affects the customer/mitra apps; ignored for backend/control_center)
3. **Run workflow**. Open the run; artifacts appear at the bottom of the summary page when it finishes.
(You can also trigger it from the CLI: `gh workflow run build.yml -f environment=staging -f target=all -f platform=android`.)
### Runner split (Linux vs macOS)
iOS can only be built on macOS, so the workflow routes jobs accordingly:
| Job | Runner |
|---|---|
| `customer-ios`, `mitra-ios` | `macos-latest` |
| `customer-android`, `mitra-android`, `backend`, `control_center` | `ubuntu-latest` |
> ⚠️ **macOS runner minutes bill at ~10× Linux.** That's why `platform` defaults to `android`. Choose `ios`/`both` deliberately.
### What each target produces
| Target | Platform | Tool | Artifact | Notes |
|---|---|---|---|---|
| `customer` | android | Flutter | `customer-<env>-android-apk``app-<env>-release.apk` | Debug-signed (no keystore in repo). Fine for internal/Firebase App Distribution. |
| `customer` | ios | Flutter | `customer-<env>-ios-app` (`Runner.app`) | **Unsigned** (`--no-codesign`). See iOS prerequisites below. |
| `mitra` | android | Flutter | `mitra-<env>-android-apk` | Same as customer-android. |
| `mitra` | ios | Flutter | `mitra-<env>-ios-app` | Same caveats as customer-ios. |
| `control_center` | — | Vite | `control-center-<env>-dist` (the `dist/` folder) | `VITE_API_BASE_URL` baked in at build time — see below. |
| `backend` | — | Docker | `backend-<env>-image` (`*.tar.gz`) | Env-**agnostic** image (config is runtime env vars). Load with `docker load`. |
`all` runs every selected job in parallel; each is independent, so one failing doesn't block the others.
### ⚠️ iOS prerequisites (not satisfied yet)
The iOS jobs are wired but will **fail until two things are done**:
1. **Per-flavor Xcode schemes** (`staging`, `prod`) must exist in `ios/Runner.xcodeproj`. Today there's only the default `Runner` scheme, so `flutter build ios --flavor staging` errors with *"The Xcode project does not define custom schemes"*. This mirrors the Android flavor setup but on the iOS side (schemes + build configurations + per-config bundle IDs).
2. **Code signing** for a distributable `.ipa`: Apple Developer certificate + provisioning profiles stored as GitHub secrets. The current jobs use `--no-codesign`, which only validates that the app **compiles** and produces an unsigned `Runner.app` (not installable on devices).
Until then, `platform=ios`/`both` is useful only once the schemes land. `platform=android` works today.
### Environment specifics
- **Apps (customer/mitra):** `environment` selects the Flutter flavor + entrypoint + `env/<env>.json` (`--flavor`, `-t lib/main_<env>.dart`, `--dart-define-from-file`). Reminder: `env/staging.json` still has a **placeholder API URL** until the staging backend is deployed.
- **control_center:** `VITE_API_BASE_URL` is compiled in. Defaults: prod → `https://internal.halobestie.com`, staging → `https://staging-internal.halobestie.com`. Override without editing the workflow by setting repo **Variables** (Settings → Secrets and variables → Actions → Variables): `CC_API_BASE_URL_PROD`, `CC_API_BASE_URL_STAGING`.
- **backend:** the image is identical across environments; env/secrets are supplied at `docker run` time (see [backend/DEPLOY.md](../../backend/DEPLOY.md)). The `<env>` in the artifact name is just a label.
### Deploying a build artifact (manual, since we don't push yet)
- **Backend:** download `backend-<env>-image`, copy to the host, then:
```bash
gunzip -c halobestie-backend-<env>.tar.gz | docker load
# then run per backend/DEPLOY.md
```
- **control_center:** download `control-center-<env>-dist`, serve the `dist/` behind Nginx (internal-only).
- **Apps:** download the APK and install / upload to Firebase App Distribution.
### Pinned versions
Flutter `3.41.9`, JDK `17`, Node `20` (see `env:` block in [build.yml](build.yml)). Bump them there when the team upgrades.
### Not included yet (by design)
Pushing images to a registry and deploying are intentionally out of scope for this first iteration. When you're ready, the natural next step is a `release.yml` that pushes to GHCR and (optionally, via a self-hosted runner or SSH) deploys to the VPS.

258
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
name: Build
# Manually triggered, parameterized build pipeline (build-only — no push/deploy).
#
# Trigger: GitHub → Actions tab → "Build" → "Run workflow" → pick:
# environment: staging | prod (which flavor/config to build)
# target: all | customer | mitra | backend | control_center
# platform: android | ios | both (only affects the customer/mitra apps)
#
# Runner split: iOS app jobs run on macOS runners (iOS can't build on Linux);
# everything else (Android, backend, control_center) runs on Linux.
#
# Outputs land as downloadable artifacts on the run summary page.
# See .github/workflows/README.md for details.
on:
workflow_dispatch:
inputs:
environment:
description: "Build environment"
type: choice
required: true
default: staging
options: [staging, prod]
target:
description: "What to build"
type: choice
required: true
default: all
options: [all, customer, mitra, backend, control_center]
platform:
description: "Mobile platform (apps only; macOS runners cost ~10x)"
type: choice
required: true
default: android
options: [android, ios, both]
run-name: "Build ${{ inputs.target }} (${{ inputs.environment }}, ${{ inputs.platform }})"
# Cancel an in-flight run of the same target+env+platform when a new one starts.
concurrency:
group: build-${{ inputs.target }}-${{ inputs.environment }}-${{ inputs.platform }}
cancel-in-progress: true
env:
FLUTTER_VERSION: "3.41.9" # keep in sync with the team's local Flutter
JAVA_VERSION: "17" # AGP 8 requires JDK 17
NODE_VERSION: "20" # matches backend engines + control_center
jobs:
# ---------------------------------------------------------------------------
# Expand the `target` input into a JSON list the build jobs gate on.
# `all` → every component; otherwise just the one chosen.
# ---------------------------------------------------------------------------
prepare:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.expand.outputs.targets }}
steps:
- id: expand
run: |
if [ "${{ inputs.target }}" = "all" ]; then
echo 'targets=["customer","mitra","backend","control_center"]' >> "$GITHUB_OUTPUT"
else
echo 'targets=["${{ inputs.target }}"]' >> "$GITHUB_OUTPUT"
fi
- run: echo "Building → ${{ steps.expand.outputs.targets }} (env=${{ inputs.environment }}, platform=${{ inputs.platform }})"
# ---------------------------------------------------------------------------
# customer app — ANDROID (Linux runner). Debug-signed (no keystore in repo).
# ---------------------------------------------------------------------------
customer-android:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'android' || inputs.platform == 'both')
runs-on: ubuntu-latest
defaults:
run:
working-directory: client_app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
- name: Build APK
run: |
flutter build apk \
--flavor ${{ inputs.environment }} \
-t lib/main_${{ inputs.environment }}.dart \
--dart-define-from-file=env/${{ inputs.environment }}.json
- uses: actions/upload-artifact@v4
with:
name: customer-${{ inputs.environment }}-android-apk
path: client_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
if-no-files-found: error
retention-days: 14
# ---------------------------------------------------------------------------
# customer app — iOS (macOS runner). Unsigned build (--no-codesign).
# PREREQ: per-flavor Xcode schemes must exist (staging/prod). Until then this
# fails with "The Xcode project does not define custom schemes". Distributable
# IPAs additionally need signing certs/profiles as secrets — not set up yet.
# ---------------------------------------------------------------------------
customer-ios:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'ios' || inputs.platform == 'both')
runs-on: macos-latest
defaults:
run:
working-directory: client_app
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
- name: Build iOS (unsigned)
run: |
flutter build ios --no-codesign \
--flavor ${{ inputs.environment }} \
-t lib/main_${{ inputs.environment }}.dart \
--dart-define-from-file=env/${{ inputs.environment }}.json
- uses: actions/upload-artifact@v4
with:
name: customer-${{ inputs.environment }}-ios-app
path: client_app/build/ios/iphoneos/Runner.app
if-no-files-found: error
retention-days: 14
# ---------------------------------------------------------------------------
# mitra app — ANDROID (Linux runner).
# ---------------------------------------------------------------------------
mitra-android:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'android' || inputs.platform == 'both')
runs-on: ubuntu-latest
defaults:
run:
working-directory: mitra_app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
- name: Build APK
run: |
flutter build apk \
--flavor ${{ inputs.environment }} \
-t lib/main_${{ inputs.environment }}.dart \
--dart-define-from-file=env/${{ inputs.environment }}.json
- uses: actions/upload-artifact@v4
with:
name: mitra-${{ inputs.environment }}-android-apk
path: mitra_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
if-no-files-found: error
retention-days: 14
# ---------------------------------------------------------------------------
# mitra app — iOS (macOS runner). Same PREREQ caveats as customer-ios.
# ---------------------------------------------------------------------------
mitra-ios:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'ios' || inputs.platform == 'both')
runs-on: macos-latest
defaults:
run:
working-directory: mitra_app
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
- name: Build iOS (unsigned)
run: |
flutter build ios --no-codesign \
--flavor ${{ inputs.environment }} \
-t lib/main_${{ inputs.environment }}.dart \
--dart-define-from-file=env/${{ inputs.environment }}.json
- uses: actions/upload-artifact@v4
with:
name: mitra-${{ inputs.environment }}-ios-app
path: mitra_app/build/ios/iphoneos/Runner.app
if-no-files-found: error
retention-days: 14
# ---------------------------------------------------------------------------
# control_center — Vite SPA static build (Linux). VITE_API_BASE_URL is baked
# in at build time. Override defaults via repo Variables CC_API_BASE_URL_PROD
# / CC_API_BASE_URL_STAGING (Settings → Secrets and variables → Actions).
# ---------------------------------------------------------------------------
control_center:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'control_center')
runs-on: ubuntu-latest
defaults:
run:
working-directory: control_center
env:
VITE_API_BASE_URL: >-
${{ inputs.environment == 'prod'
&& (vars.CC_API_BASE_URL_PROD || 'https://internal.halobestie.com')
|| (vars.CC_API_BASE_URL_STAGING || 'https://staging-internal.halobestie.com') }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: control_center/package-lock.json
- run: npm ci
- name: Build (VITE_API_BASE_URL=${{ env.VITE_API_BASE_URL }})
run: npm run build
- uses: actions/upload-artifact@v4
with:
name: control-center-${{ inputs.environment }}-dist
path: control_center/dist
if-no-files-found: error
retention-days: 14
# ---------------------------------------------------------------------------
# backend — Docker image (Linux). Env-AGNOSTIC (config is runtime env vars),
# built once and exported as a loadable tarball. Deploy without a registry:
# gunzip -c halobestie-backend-*.tar.gz | docker load
# ---------------------------------------------------------------------------
backend:
needs: prepare
if: contains(fromJSON(needs.prepare.outputs.targets), 'backend')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t halobestie-backend:${{ inputs.environment }} ./backend
- name: Export image as tarball
run: docker save halobestie-backend:${{ inputs.environment }} | gzip > halobestie-backend-${{ inputs.environment }}.tar.gz
- uses: actions/upload-artifact@v4
with:
name: backend-${{ inputs.environment }}-image
path: halobestie-backend-${{ inputs.environment }}.tar.gz
if-no-files-found: error
retention-days: 14