diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..631f868 --- /dev/null +++ b/.github/workflows/README.md @@ -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--android-apk` → `app--release.apk` | Debug-signed (no keystore in repo). Fine for internal/Firebase App Distribution. | +| `customer` | ios | Flutter | `customer--ios-app` (`Runner.app`) | **Unsigned** (`--no-codesign`). See iOS prerequisites below. | +| `mitra` | android | Flutter | `mitra--android-apk` | Same as customer-android. | +| `mitra` | ios | Flutter | `mitra--ios-app` | Same caveats as customer-ios. | +| `control_center` | — | Vite | `control-center--dist` (the `dist/` folder) | `VITE_API_BASE_URL` baked in at build time — see below. | +| `backend` | — | Docker | `backend--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/.json` (`--flavor`, `-t lib/main_.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 `` in the artifact name is just a label. + +### Deploying a build artifact (manual, since we don't push yet) +- **Backend:** download `backend--image`, copy to the host, then: + ```bash + gunzip -c halobestie-backend-.tar.gz | docker load + # then run per backend/DEPLOY.md + ``` +- **control_center:** download `control-center--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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7e516c0 --- /dev/null +++ b/.github/workflows/build.yml @@ -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