name: CI # Every push, on every branch — not just main — so build breakage shows up # on the first commit of a branch, not only once a PR targets main. No # separate pull_request trigger: for a PR from a branch on this same repo, # push already fires on that branch, and GitHub attaches the run to the PR # by commit SHA regardless of which event triggered it — adding # pull_request too would just run every job twice per push. (Re-add it, # scoped to forks, only if this repo starts accepting fork PRs — push # events from a fork never reach here.) on: push: env: # Hostname `postgres` = the container the `test` job starts itself (see # that job's "Start Postgres" step) — a plain `docker run`, not a # `services:` block, for the same reason the intent-service below can't be # one: this repo's Forgejo `act` runner does not keep a `services:` # container as the *same* instance across a job's steps, so `migrate # deploy` would land the schema in one Postgres and `pnpm --filter api # test`'s `beforeAll` would then hit an empty one ("table … does not # exist" right after "migrations successfully applied"). One long-lived # container in the job, shared by every step, removes that failure mode. DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public" # Test-only secret, never used outside CI — real deployments must set their own. JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+" # Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts # exercise the success path (matching secret), not just the "unset" # rejection every environment that doesn't set this gets by default. INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" # Same reasoning — lets admin-auth.test.ts exercise the admin login # success path + the requireAdmin-guarded routes, not just the "no admin # secret configured -> 401" path. ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+" # Shared between the `test` job's own uvicorn step (below) and apps/api's # IntentServiceClient — that step runs `uvicorn` as a plain background # process (not a `services:` block or even a container) because CI can # only pull a published image, not build # services/tech-step-intent-service/Dockerfile from this checkout. INTENT_SERVICE_BASE_URL: "http://localhost:8000" INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" jobs: # Five independent jobs, no needs: between them — each starts in parallel # and reports as its own check, instead of the previous single chained # "lint-and-test then e2e" pipeline. lint: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: https://github.com/actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - name: Install curl and the docker CLI run: | apt-get update && apt-get install -y curl docker.io # Postgres as a plain background container instead of a `services:` # block — see this workflow's top-level `env.DATABASE_URL` comment for # why. Reachable as `postgres` from this job (the runner mounts the # docker socket and runs jobs on a shared user-defined network, so a # `--name postgres` sibling resolves by name); the network is # discovered from the job container itself rather than hardcoded. - name: Start Postgres in the background run: | net="$(docker inspect "$(hostname)" \ --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' \ | awk '{print $1}')" echo "job network: ${net:?could not determine the job container network}" docker rm -f postgres >/dev/null 2>&1 || true docker run -d --name postgres --network "$net" \ -e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \ postgres:16-alpine for _ in $(seq 1 60); do docker exec postgres pg_isready -U ci -d batchcooking_ci && exit 0 sleep 2 done echo "Postgres did not become ready in time" >&2 docker logs postgres || true exit 1 - uses: https://github.com/actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: https://github.com/actions/setup-node@v4 with: node-version: 22 cache: pnpm - uses: https://github.com/astral-sh/setup-uv@v5 with: python-version: "3.12" enable-cache: true # A `services:` block can only pull an already-published image — it # can't build services/tech-step-intent-service/Dockerfile from this # checkout. Running `uvicorn` as a plain background step instead: it # keeps running for the rest of this job (steps in one job share the # same process tree), and `pnpm --filter api test` below needs a real # instance to talk to per this repo's "never mock an internal service" # test convention — same "real dependency, started by the job itself" # posture as the Postgres step above, not a mock HTTP server. - name: Install services/tech-step-intent-service working-directory: services/tech-step-intent-service run: uv sync --frozen - name: Start services/tech-step-intent-service in the background working-directory: services/tech-step-intent-service run: | uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 & # `/health` only returns 200 once this service has finished # training itself from scratch (no model ever persisted to disk — # see its own README) — measured at ~3min combined (fr+en) against # the current ~74-technique corpus, down from ~930s once # `spacy.TextCatBOW.v3`'s `length` stopped being left at its # 262144 default (see `_TEXTCAT_MODEL_LENGTH` in # `locale_pipeline.py`, and docker-compose.yml's healthcheck for # the same reasoning). The timeout stays well above that: it's a # ceiling for a slow/contended runner, not a target. timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done' - run: pnpm install --frozen-lockfile - run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api test intent-service-test: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/astral-sh/setup-uv@v5 with: python-version: "3.12" enable-cache: true - name: Install services/tech-step-intent-service working-directory: services/tech-step-intent-service run: uv sync --frozen - name: Run pytest working-directory: services/tech-step-intent-service run: uv run pytest -q build: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: https://github.com/actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build e2e: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - name: Install Xvfb and Cypress dependencies run: | apt-get update apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth dbus-x11 - uses: pnpm/action-setup@v4 - uses: https://github.com/actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Cache Cypress binary uses: https://github.com/actions/cache@v4 with: path: ~/.cache/Cypress key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - run: pnpm install --frozen-lockfile # pnpm install doesn't reliably trigger Cypress's postinstall binary # download (see apps/web's cypress caveat in the README) — install it # explicitly so `cypress run` finds it. - run: pnpm --filter web exec cypress install - name: Run E2E tests env: HOST: "0.0.0.0" run: pnpm --filter web e2e # No dev server needed here — Cypress spins up its own Vite dev # server internally for component testing (see cypress.config.ts's # `component.devServer`), unlike `e2e` above which needs the real app # running first. - run: pnpm --filter web cy:run:component # Builds and packages a signed, downloadable Android APK on every push to # `main` — issue #64. `needs:` on every other job is what actually makes # this "build, TEST, and package" rather than just "build and package in # parallel with tests that might still be failing": Forgejo/GitHub Actions # has no native way for a job in a *different* workflow file to block on # one here, so this has to live in this same file to get a real dependency # rather than a same-push coincidence. `if:` (not a separate trigger) keeps # every other job running on every branch as today — only this one is # main-only. # # No dedicated Android test suite exists (zero native code beyond the # Capacitor-generated shell) — "testée" is satisfied by gating on the 5 # existing jobs, not by inventing an instrumented test suite for this. # # Unverified on this runner as of writing: no Android build has ever run # on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e, # exercised for months). If SDK installation turns out too slow/heavy here, # cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache # in `e2e` above) rather than accepting a slow job on every push. build-android: runs-on: ubuntu-latest needs: [lint, test, intent-service-test, build, e2e] if: github.ref == 'refs/heads/main' steps: - uses: https://github.com/actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: https://github.com/actions/setup-node@v4 with: node-version: 22 cache: pnpm - uses: https://github.com/actions/setup-java@v4 with: distribution: temurin # Matches what Android Gradle Plugin 8.x (bundled with the # Capacitor-generated project, see apps/web/android/build.gradle) # expects — bump alongside AGP if that version ever moves. java-version: "21" - uses: https://github.com/android-actions/setup-android@v3 - run: pnpm install --frozen-lockfile # Unlike the normal web build (served same-origin as the API), a # packaged Capacitor build runs from the WebView's own local origin and # needs an absolute, public HTTPS URL baked in at build time — see # apps/web/.env.capacitor.example's own comment on this. - name: Build le bundle web pour Capacitor env: VITE_API_URL: ${{ secrets.MOBILE_API_URL }} run: pnpm --filter web build:capacitor - run: pnpm --filter web exec cap sync android # Le keystore de release est un secret généré une fois à la main (voir # le README, section "Application mobile") — jamais régénéré ici, un # nouveau keystore casserait la mise à jour de l'appli déjà installée # (Android refuse une signature différente de celle déjà installée). - name: Décode le keystore de release env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore - name: Build l'APK signé working-directory: apps/web/android env: ANDROID_KEYSTORE_PATH: release.keystore ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: ./gradlew assembleRelease - uses: https://github.com/actions/upload-artifact@v4 with: name: batchcooking-release.apk path: apps/web/android/app/build/outputs/apk/release/app-release.apk