# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the # workflow no longer fires on every push. The `schedule` trigger polls every # 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether # anything that would enter the docker build context changed since the # revision running on the homeserver (the `org.opencontainers.image.revision` # label of the running container, read over ssh). # - Deployed == HEAD → suite + deploy skip; the run stays green. # - Anything else (production change, unknown deployed state, dispatch) → # full suite; deploy in the same window. # - `workflow_dispatch` always runs the full pipeline (explicit operator # intent); the deploy is idempotent via the "already deployed" guard in # scripts/deploy-homeserver.sh. # # Fail-open: an unreadable/unknown deployed revision makes the gate report # production=true, so the suite ALWAYS verifies main and a quiet day only # skips when we KNOW deployed == HEAD. A gate that cannot classify at all # (git/.dockerignore error) exits non-zero and turns the run RED — never a # silent green blackout. The deploy job only runs when the gate succeeded # AND production changed AND checks are green — never deploy blind. # # When the suite is green, the `deploy` job publishes teqo-1313 on the # homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver # over SSH; the remote script builds the standalone image at the pushed SHA, # pushes it to the local registry (localhost:5000), applies pending migrations # through the compose maintenance service and rolls the container out. # # SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm # install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips # every step after it, the job fails, the run fails and the slot is released # immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job, # no upgrades. `deploy` (needs: [checks]) never starts on a red run. # # Forgejo port (OPS50): Postgres services live on the job network and are # reached by NAME (postgres-int / postgres-build on 5432) — no host publish, # so concurrent runs never collide on host ports (OPS62 X1). No # `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no # `concurrency:` (unsupported). `gh workflow run` requeues became # `node scripts/forgejo-dispatch.mjs`. name: CI on: schedule: - cron: '*/30 * * * *' workflow_dispatch: env: PAYLOAD_SECRET: test-only-secret-not-used-in-production NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: gate: name: gate production change # Host runner: needs `ssh homeserver` (same environment as the deploy # job — the key lives on the workstation host) and a full clone to diff # ..HEAD. Main + dispatch only: a branch dispatch must not # run that branch's code on the host — the skipped gate makes the suite # run fail-open on ubuntu containers instead (test-only secrets). if: github.ref == 'refs/heads/main' runs-on: host timeout-minutes: 10 outputs: production: ${{ steps.classify.outputs.production }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: actions/setup-node@v5 with: node-version: 24 package-manager-cache: false - name: Read deployed revision and classify production change id: classify run: | set -euo pipefail # Truth = the RUNNING container's revision label (the compose file # can lie after a failed rollback — it is swapped before the # rollout). Empty/malformed = unknown deployed state → the # classifier reports production=true (full run, green). deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)" if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then deployed_sha="" fi echo "deployed_sha: ${deployed_sha:-}" out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")" printf '%s\n' "$out" echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT" checks: runs-on: ubuntu-latest timeout-minutes: 50 needs: [gate] # Fail-open: an errored gate never skips the verifier; only a gate that # succeeded AND found no production change skips the suite. if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' services: # Services are reached by NAME on the job network (no host publish — # OPS62 X1): concurrent runs never collide on host ports, which the # single long-lived job would hold for the whole run. Two isolated # services — int and build+e2e — keep e2e from inheriting int fixture # residue. Forgejo 9 does not expand expressions in `services.ports`, # so a per-run port scheme was dropped in favour of the job-network DNS. postgres-int: image: postgres:17-alpine env: POSTGRES_USER: teqo POSTGRES_PASSWORD: teqo POSTGRES_DB: teqo_test options: >- --health-cmd "pg_isready -U teqo" --health-interval 5s --health-timeout 5s --health-retries 12 postgres-build: image: postgres:17-alpine env: POSTGRES_USER: teqo POSTGRES_PASSWORD: teqo POSTGRES_DB: teqo_test options: >- --health-cmd "pg_isready -U teqo" --health-interval 5s --health-timeout 5s --health-retries 12 steps: - uses: actions/checkout@v5 - 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 (int service) run: | set -euo pipefail echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int: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). OPS50. echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local - name: Migrate (int database) run: pnpm migrate - name: Seed minimal (int database) run: pnpm db:seed:minimal - name: Integration tests (full suite) env: # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork # holds a Payload pool (max 10) → overflows the service Postgres' 100 # clients ("too many clients already"). 4 forks stay well under. The # unit step above stays CPU-relative (no DB). VITEST_MAX_WORKERS: 4 run: pnpm test:int - name: Prepare database URL (build/e2e service) run: | set -euo pipefail echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV" echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local - name: Migrate (build/e2e database) run: pnpm migrate - name: Seed minimal (build/e2e database) run: pnpm db:seed:minimal - name: Build (against the service database) run: pnpm build - 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). run: pnpm build env: NEXT_DIST_DIR: .next-e2e - name: E2E tests (full suite, single job, 4 workers) # Explicit CI workers; the local default stays 2 (playwright.config.ts). # 4 workers in one process replace the former 2 shards × 2 workers. # 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 run: pnpm test:e2e deploy: name: deploy homeserver (teqo-1313) # OPS53. Runs directly on the runner HOST (not a job container): the # deploy needs the workstation's SSH key to reach the homeserver # (`ssh homeserver`) and streams the whole remote build/migrate/rollout # to this job's logs. Deliberately wired ONLY here — main window + # workflow_dispatch — never in ci-pr.yml: a host job is code execution as # `fsolla` on the workstation, and main only ever moves through a merged # PR whose full suite was already green. # OPS65: double-gated — the verifier must be green AND the gate must have # found a production change. The explicit `if` prevents both "deploy # blind on a failed gate" and "deploy with a red suite" (a bare output # condition would run despite failed needs). needs: [checks, gate] if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true' runs-on: host timeout-minutes: 60 steps: - uses: actions/checkout@v5 - name: Deploy teqo-1313 on the homeserver run: | set -euo pipefail ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh