# Manual deploy (OPS71 — GitHub Actions; staging + production since OPS103). # `workflow_dispatch` only: there is NO automatic main verifier anymore # (ci.yml eliminated in the gate 2026-08-19) — publishing is a deliberate # operator act, and this workflow runs the FULL suite (no scope skips, e2e # included) before any deploy job touches the homeserver. # # THREE jobs, same workflow (OPS103): # 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 dispatch 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). # # 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. Deliberately wired ONLY here (manual # dispatch), never in ci-pr.yml: a self-hosted job is code execution on the # production machine, and only an explicit dispatch 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 job only verifies and the # self-hosted jobs only build + deploy. Deploy is idempotent ("already # deployed" guard in deploy-homeserver.sh) and serialized by flock per # environment; the `concurrency` group below serializes the two shared # homeserver compose/workspace consumers across runs without cancelling an # in-flight deploy (the staged `needs` chain already serializes them inside # one run). # # The deploy jobs fetch the repo from github.com (public) — TEQO_REPO_URL in # deploy-homeserver.sh now defaults there; the script runs to completion for # the dispatched SHA even if main advances during verify (OPS102 — publishing # is a deliberate operator act). name: Deploy (manual) on: workflow_dispatch: permissions: contents: read env: PAYLOAD_SECRET: test-only-secret-not-used-in-production NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: verify: 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 - 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: same SHA, same verify, own environment — no reviewer (the # manual dispatch is the deliberate act); production is the approval that # matters. The `main` guard makes a dispatch from a branch/tag a no-op # for deploys (the runbook's dispatch ref is always `main`). 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 deploys at once across # runs. The staged needs chain serializes them within one run. `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" deploy-production: name: deploy production (teqo-1313) # OPS53 + OPS71 + OPS103. Runs on the GitHub self-hosted runner ON the # homeserver (labels self-hosted + homeserver): the script executes # locally — no SSH hop, no workstation involvement. Deliberately wired # ONLY here (manual dispatch), never in ci-pr.yml: a self-hosted job is # code execution on the production machine, and only an explicit dispatch # with the full verify suite green can reach it. `needs: [deploy-staging]` # makes a red staging fail-close production; the environment `production` # (required reviewer, configured in the repo) is the approval gate. needs: [deploy-staging] if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main' environment: production runs-on: [self-hosted, homeserver] timeout-minutes: 60 concurrency: group: deploy-homeserver cancel-in-progress: false queue: max env: TEQO_ENV: production steps: - uses: actions/checkout@v5 - name: Deploy teqo-1313 on the homeserver run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"