# .gitea/workflows/ci.yml # --------------------------------------------------------------------------- # Wisp CI/CD for Gitea Actions (GitHub-Actions-compatible syntax). # # Triggers: # - push to master -> test, then (on success) deploy-web + build-apk # - pull_request -> test only (no deploys) # # Jobs: # test Lint-gate: typecheck + vitest in the Bun container. # deploy-web Build the nginx web image and ship it to briggen.dev WITHOUT a # registry (docker save | gzip | scp | docker load | compose up). # build-apk Build the Android toolchain image, prebuild + sign + assemble a # release APK, scp it to the server as /srv/wisp/web/wisp.apk, and # upload it as a CI artifact. # # Required repo secrets (Settings -> Actions -> Secrets): # SSH_HOST, SSH_USER, SSH_KEY (deploy + apk upload) # ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, # ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD (apk signing) # See docs/DEPLOY.md and scripts/gen-keystore.sh. # # Runner assumptions: a Gitea Actions runner with Docker available on the host # (the deploy/apk jobs run docker build/save/load and ssh/scp). The `test` job # runs inside the oven/bun:1 container. # --------------------------------------------------------------------------- name: CI on: push: branches: [master] pull_request: # Cancel superseded runs on the same ref to save runner time. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: # ========================================================================= # test — typecheck + unit tests. Gates both deploy jobs. # ========================================================================= test: runs-on: ubuntu-latest # Run the test job directly inside the Bun image so bun is preinstalled and # the environment matches the web build's builder stage. container: image: oven/bun:1 steps: # Check out the repository at the triggering commit. - name: Checkout uses: actions/checkout@v4 # Install dependencies exactly as locked (fails on lockfile drift). - name: Install dependencies run: bun install --frozen-lockfile # TypeScript strict typecheck (tsc --noEmit). - name: Typecheck run: bun run typecheck # Vitest unit/property tests (vitest run). - name: Test run: bun run test # ========================================================================= # deploy-web — build image, transfer registry-free, compose up on server. # Runs only on push to master, after `test` passes. # ========================================================================= deploy-web: needs: test # Guard: only deploy for pushes to master (never on PRs or other branches). if: github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 # Build the production web image on the runner. Build context is the repo # root so the Dockerfile can install deps and run the Expo export. - name: Build web image run: docker build -f docker/web.Dockerfile -t wisp-web:latest . # Make the server's host key known so the subsequent ssh/scp don't fail on # host-key verification and we are not vulnerable to a blind MITM. We key # off SSH_HOST; this writes the server's public host keys into known_hosts. - name: Add server to known_hosts run: | mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts # Install the deploy SSH private key from the secret into the agent. - name: Configure SSH key run: | install -m 600 /dev/null ~/.ssh/id_deploy printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy # Registry-free image transfer: stream `docker save | gzip` straight over # ssh to `gunzip | docker load` on the server. Avoids writing a big tarball # to disk and avoids needing a Docker registry entirely. - name: Ship image to server (save | gzip | ssh docker load) run: | docker save wisp-web:latest | gzip \ | ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ 'gunzip | docker load' # Recreate the service from the freshly loaded image. The compose file must # already exist at /srv/wisp/docker-compose.yml (one-time server setup). # `pull_policy: never` in the compose file keeps it from trying a registry pull. - name: Restart service via docker compose run: | ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ 'docker compose -f /srv/wisp/docker-compose.yml up -d --remove-orphans' # Optional: prune dangling images left behind by repeated loads so the # server disk doesn't fill up with old wisp-web layers. - name: Prune dangling images on server run: | ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ 'docker image prune -f' # ========================================================================= # build-apk — reproducible signed release APK, shipped to server + artifact. # Runs only on push to master, after `test` passes. # ========================================================================= build-apk: needs: test if: github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 # Build the Android toolchain image (JDK 17 + Android SDK + Bun). This is # the reproducible environment described in docker/android.Dockerfile. - name: Build Android builder image run: docker build -f docker/android.Dockerfile -t wisp-android:latest . # Run the full Android build INSIDE the toolchain container with the repo # bind-mounted at /workspace. All secrets are passed via -e so they live # only in the container's environment (not on the command line / git). # # Steps inside the container: # 1. bun install (frozen) — JS deps for prebuild + autolinking. # 2. expo prebuild --platform android --no-install — generate android/. # 3. scripts/ci-android-sign.sh — decode keystore + patch build.gradle. # 4. ./gradlew assembleRelease — produce the signed release APK. # # The bind mount means the generated android/ + APK are visible on the # runner afterwards (for scp + artifact upload below). - name: Build signed release APK run: | docker run --rm \ -v "${{ github.workspace }}:/workspace" \ -w /workspace \ -e ANDROID_KEYSTORE_BASE64="${{ secrets.ANDROID_KEYSTORE_BASE64 }}" \ -e ANDROID_KEYSTORE_PASSWORD="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \ -e ANDROID_KEY_ALIAS="${{ secrets.ANDROID_KEY_ALIAS }}" \ -e ANDROID_KEY_PASSWORD="${{ secrets.ANDROID_KEY_PASSWORD }}" \ wisp-android:latest \ bash -lc ' set -euo pipefail bun install --frozen-lockfile bunx expo prebuild --platform android --no-install bash scripts/ci-android-sign.sh cd android ./gradlew assembleRelease --no-daemon ' # Locate the assembled APK. The default Gradle output for the release variant # is android/app/build/outputs/apk/release/app-release.apk. Capture the path # for the next steps and fail loudly if it's missing. - name: Locate APK id: apk run: | APK_PATH="android/app/build/outputs/apk/release/app-release.apk" if [[ ! -f "$APK_PATH" ]]; then echo "APK not found at $APK_PATH" >&2 find android/app/build/outputs -name '*.apk' -print >&2 || true exit 1 fi echo "path=$APK_PATH" >> "$GITHUB_OUTPUT" # Known_hosts + SSH key for the scp upload (same pattern as deploy-web). - name: Add server to known_hosts run: | mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts - name: Configure SSH key run: | install -m 600 /dev/null ~/.ssh/id_deploy printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy # Copy the signed APK to the server where nginx serves it at /wisp.apk. # docker-compose mounts /srv/wisp/web/wisp.apk into the container's web root. # Ensure the target dir exists, then scp the APK into place. - name: Upload APK to server (/srv/wisp/web/wisp.apk) run: | ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ 'mkdir -p /srv/wisp/web' scp -i ~/.ssh/id_deploy \ "${{ steps.apk.outputs.path }}" \ "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/srv/wisp/web/wisp.apk" # Also publish the APK as a CI artifact so it can be downloaded from the # run's summary page even without server access. - name: Upload APK artifact uses: actions/upload-artifact@v4 with: name: wisp-release-apk path: ${{ steps.apk.outputs.path }} if-no-files-found: error