From 84e7a56b1374d706a9cdc8a05c77e94df4fb9cc9 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Sat, 13 Jun 2026 18:27:26 +0200 Subject: [PATCH] CI: Gitea Actions web auto-deploy (runner-on-server) + signing prep - .gitea/workflows/ci.yml rewritten for a self-hosted runner co-located with the host Docker daemon + Traefik: push to master -> test -> deploy-web (docker compose up --build in place; no SSH, no registry). APK job documented as the next iteration (socket-safe build+extract; signing secrets already set). - .gitignore: never commit the release keystore (*.keystore + wisp-release.keystore). Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 228 ++++++++-------------------------------- .gitignore | 2 + 2 files changed, 43 insertions(+), 187 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 264b5a0..ec269c4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,28 +1,18 @@ -# .gitea/workflows/ci.yml +# .gitea/workflows/ci.yml — Wisp CI/CD (Gitea Actions, GitHub-Actions syntax) # --------------------------------------------------------------------------- -# Wisp CI/CD for Gitea Actions (GitHub-Actions-compatible syntax). +# Runner model: a single self-hosted act_runner ON the briggen.dev server, with +# the host Docker socket mounted into job containers (see /root/act_runner/ +# config.yaml). Because the runner is co-located with the host Docker daemon + +# the Traefik `proxy` network, deploy needs NO ssh keys and NO image registry — +# `docker compose` simply builds and (re)creates the `wisp` container on the host. # # Triggers: -# - push to master -> test, then (on success) deploy-web + build-apk -# - pull_request -> test only (no deploys) +# push to master -> test, then deploy-web +# pull_request -> test only # -# 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. +# Required repo secrets (Settings -> Actions -> Secrets) — used by the APK job +# once it is enabled (see TODO at the bottom): ANDROID_KEYSTORE_BASE64, +# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD. # --------------------------------------------------------------------------- name: CI @@ -32,189 +22,53 @@ on: 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: typecheck + unit tests, in the Bun image ---------------------- 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 + - uses: actions/checkout@v4 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run test - # 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: build + (re)start the nginx web container on the host ---- + # Runs on the server runner; the mounted host Docker socket means `docker + # compose` builds the image and recreates the `wisp` service in place, joined + # to the existing Traefik `proxy` network. No SSH, no registry. 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 + - uses: actions/checkout@v4 + - name: Build + (re)start wisp on the host + run: docker compose -f deploy/wisp.compose.yml --project-directory . up -d --build --remove-orphans + - name: Health check 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' + for i in $(seq 1 10); do + if docker exec wisp wget -qO- http://localhost:8080/healthz | grep -q ok; then + echo "healthy"; exit 0 + fi + sleep 2 + done + echo "wisp container did not become healthy" >&2; exit 1 # ========================================================================= - # build-apk — reproducible signed release APK, shipped to server + artifact. - # Runs only on push to master, after `test` passes. + # TODO (next iteration, once the runner is validated): build-apk. + # The Android build is genuinely risky (whisper.rn C++/NDK on RN 0.85) and + # untested locally (no Android SDK on the dev box). It must be socket-safe: + # a FULL `docker build` of a build-stage in docker/android.Dockerfile (repo + # COPYed in, prebuild + gradlew assembleRelease, signing via BuildKit + # --secret using the ANDROID_* secrets), then extract the APK with + # `docker create`/`docker cp` (NO host bind-mounts — they don't resolve over + # the mounted socket) and `docker cp` it into the running `wisp` container at + # /usr/share/nginx/html/wisp.apk (served at https://wisp.briggen.dev/wisp.apk), + # plus upload it as a CI artifact. Keystore secrets are already configured. # ========================================================================= - 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 diff --git a/.gitignore b/.gitignore index 4b00baf..459e25d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ expo-env.d.ts .kotlin/ *.orig.* *.jks +*.keystore +wisp-release.keystore *.p8 *.p12 *.key