# Deploy (OPS71 — GitHub Actions; staging + production since OPS103; # automatic start since OPS104; approval lane decoupled since OPS107). # # STARTS BY ITSELF: a `push` to `main` starts the run (OPS104 trigger 1) — # the `preflight` job skips it when another deploy run is already # queued/in_progress (a run `waiting` on the production approval does NOT # count: a merge in that window starts a new run and both stay pending). # `workflow_dispatch` is kept as the manual escape (rollback/re-dispatch) and # never goes through the guard. # # REQUEUES ITSELF: after a green `deploy-staging`, the hosted `requeue` job # compares main's head with the run's SHA and dispatches a new run from the # new head when a merge landed mid-flight (OPS104 trigger 2). It stays OUTSIDE # the `deploy-homeserver` group — a run waiting on the production approval # must not block the requeue. # # Jobs (OPS103/OPS104): # preflight — ubuntu-latest: push-only guard (OPS104 trigger 1). # verify — ubuntu-latest (hosted): full cascade (guards for PR # contracts are left out — they gate PRs, not # production). Untouched by OPS103; it verifies the # COMMIT once for both environments. # deploy-staging — needs: [verify], environment: staging (no reviewer: # the run itself is the deliberate act). Runs the # same script with TEQO_ENV=staging (teqo-staging, # 127.0.0.1:1314). # deploy-production — needs: [deploy-staging], environment: production # (required reviewer fsolla, configured in the repo). # A red staging fail-closes production. Same script, # TEQO_ENV=production (teqo-1313, 127.0.0.1:1313). # NO concurrency group (OPS107): a job `waiting` on the # approval must not occupy the shared deploy lane — the # concurrency is evaluated before the environment gate, # and holding the group blocked every later run's staging. # requeue — hosted, after deploy-staging: re-dispatches when main # advanced (OPS104 trigger 2). # # Both deploy jobs run on the GitHub self-hosted runner ON the homeserver # (labels self-hosted + homeserver): the script executes locally — no SSH # hop, no workstation involvement. Never moved into ci-pr.yml: a self-hosted # job is code execution on the production machine, and only a run with the # full verify suite green can reach it. The hosted job never reaches the # homeserver; the self-hosted job never counts hosted minutes. # # The environment-specific builds CANNOT leave the homeserver (migrate # pre-build on the env DB — OPS66 — and the compose network / local registry # localhost:5000), which is exactly why the hosted jobs only verify/decide # and the self-hosted jobs only build + deploy. Deploy is idempotent # ("already deployed" guard in deploy-homeserver.sh) and serialized by ONE # flock on the host (both environments). The `concurrency` group below lives # ONLY on `deploy-staging` (OPS107) — it never waits on an approval, so it # always drains; the staged `needs` chain serializes the environments inside # one run. Production execution is serialized by that same chain, the single # self-hosted runner and the host flock — never by a GitHub group. # # The deploy jobs fetch the repo from github.com (public) — TEQO_REPO_URL in # deploy-homeserver.sh defaults there; the script runs to completion for the # SHA of the run even if main advances during verify (OPS102). # # OPS104 supersedes the OPS102 "manual-only" anti-goal: only the START of # the run is automatic. Production still publishes exclusively through the # `production` environment approval; there is no auto-approve, no auto-retry # and no cancel-old-runs automation. name: Deploy on: push: branches: [main] workflow_dispatch: permissions: contents: read env: PAYLOAD_SECRET: test-only-secret-not-used-in-production NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: preflight: # OPS104 trigger 1 guard: skip this push when another deploy run is # already queued/in_progress (a run `waiting` on the production approval # does NOT count). Manual dispatches skip the guard (the step is # push-only) and the output default keeps the dispatch path flowing. runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read actions: read outputs: should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }} steps: - uses: actions/checkout@v5 - name: Decide whether this push should deploy id: guard if: github.event_name == 'push' run: node scripts/deploy-preflight.mjs env: GITHUB_TOKEN: ${{ github.token }} verify: needs: [preflight] if: needs.preflight.outputs.should_deploy == 'true' runs-on: ubuntu-latest timeout-minutes: 50 services: # OPS71 — GitHub hosted: jobs run on the runner VM, so services are # reached at localhost on PUBLISHED host ports (fixed — each run is its # own VM). ONE service (OPS88): a deterministic reset (`pnpm db:reset` # — drop schema → migrate → seed minimal) between the integration # phase and the e2e build keeps e2e from inheriting int fixture # residue, instead of a second container. postgres-int: image: postgres:17-alpine env: POSTGRES_USER: teqo POSTGRES_PASSWORD: teqo POSTGRES_DB: teqo_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U teqo" --health-interval 5s --health-timeout 5s --health-retries 12 steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Check test file locations run: node scripts/check-test-locations.mjs - name: Lint run: pnpm lint - name: Format check run: pnpm format:check - name: Typecheck run: pnpm typecheck - name: Knip run: pnpm knip - name: Check cycles run: pnpm check:cycles - name: Unit tests (full suite) run: pnpm test:unit - name: Prepare database URL run: | set -euo pipefail echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV" # vitest re-loads .env.test over the process env, so the service URL # must win through the .env.test.local layer (same mechanism # `pnpm worktree next` uses). echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local - name: Migrate run: pnpm migrate - name: Seed minimal run: pnpm db:seed:minimal - name: Integration tests (full suite) env: # vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) → # overflows the service Postgres' 100 clients. 4 forks stay well # under. The unit step above stays CPU-relative (no DB). VITEST_MAX_WORKERS: 4 run: pnpm test:int - name: Reset database (drop → migrate → seed) # OPS88: one Postgres per run — the e2e build starts from the same # clean baseline (drop schema → migrate → seed:minimal) instead of a # second container. The full suite always runs here, so only the # `.next-e2e` build (below) exists — the default build is gone. run: pnpm db:reset - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | playwright-${{ runner.os }}- - name: Install Playwright chromium run: pnpm exec playwright install --with-deps chromium - name: Build for e2e # Same dir the Playwright webServer serves (playwright.config.ts # falls back to '.next-e2e'; gate-ci.mjs builds there too). # NEXT_PUBLIC_SITE_URL is inlined at build time, so the canonical # `.test` origin must reach THIS build — it is what lets the agenda # feed dialog generate a link under the production build (OPS83). run: pnpm build env: NEXT_DIST_DIR: .next-e2e NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test # S15 — deterministic cutout stub: the e2e build never downloads the # MediaPipe wasm nor runs inference. NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1' - name: E2E tests (full suite, single job, 4 workers) # Explicit CI workers; the local default stays 2 (playwright.config.ts). # No `--` separator: pnpm would forward it verbatim to playwright and # the flag would be parsed as a file pattern ("No tests found"). env: PLAYWRIGHT_WORKERS: 4 NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test run: pnpm test:e2e deploy-staging: name: deploy staging (teqo-staging) # OPS103 (+OPS104): same SHA, same verify, own environment — no reviewer # (the run was started deliberately: push to main or manual dispatch); # production is the approval that matters. The `main` guard makes a # dispatch from a branch/tag a no-op for deploys. needs: [verify] if: needs.verify.result == 'success' && github.ref == 'refs/heads/main' environment: staging runs-on: [self-hosted, homeserver] timeout-minutes: 60 concurrency: # Shared homeserver compose/workspace: never two staging deploys at once # across runs. Only staging holds this group (OPS107): it never waits on # an approval, so it always drains — production has NO group because a # `waiting` approval would block every later run's staging (the # concurrency is evaluated before the environment gate). `queue: max`: # the default single-slot queue cancels a previously pending deploy when # a new dispatch arrives, even with cancel-in-progress false — deploys # must wait their turn (OPS102: run to completion). group: deploy-homeserver cancel-in-progress: false queue: max env: TEQO_ENV: staging steps: - uses: actions/checkout@v5 - name: Deploy teqo-staging on the homeserver run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA" requeue: name: requeue when main advanced # OPS104 trigger 2: after a green staging, dispatch a fresh run from # main's new head when a merge landed mid-flight. Hosted and OUTSIDE the # `deploy-homeserver` group: a run `waiting` on the production approval # holds that group and must not block the requeue. `actions: write` is the # documented anti-recursion exception — workflow_dispatch events DO create # runs for the built-in GITHUB_TOKEN (no secret needed). needs: [deploy-staging] if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read actions: write steps: - uses: actions/checkout@v5 - name: Requeue when main advanced during the run run: node scripts/deploy-requeue.mjs env: GITHUB_TOKEN: ${{ github.token }} deploy-production: name: deploy production (teqo-1313) # OPS53 + OPS71 + OPS103 + OPS104 + OPS107. Runs on the GitHub self-hosted # runner ON the homeserver (labels self-hosted + homeserver): the script # executes locally — no SSH hop, no workstation involvement. Never wired in # ci-pr.yml: a self-hosted job is code execution on the production # machine, and only a run with the full verify suite green can reach it # (the automatic start and the manual dispatch share the same gate). # `needs: [deploy-staging]` makes a red staging fail-close production; the # environment `production` (required reviewer, configured in the repo) is # the approval gate — and the ONLY one. No concurrency group (OPS107): a # job `waiting` here must not hold the shared deploy lane (it blocked every # later run's staging, forcing old runs to be approved to reach the # newest). Execution stays serialized by the needs chain, the single # self-hosted runner and the host flock in deploy-homeserver.sh. needs: [deploy-staging] if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main' environment: production runs-on: [self-hosted, homeserver] timeout-minutes: 60 env: TEQO_ENV: production steps: - uses: actions/checkout@v5 - name: Deploy teqo-1313 on the homeserver run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"