feat(OPS103): staging no homeserver com verify único e aprovações separadas (Closes #968)

This commit is contained in:
Francisco Solla 2026-09-12 22:31:39 -03:00
parent 907b9ba96b
commit b03b7bd969
15 changed files with 732 additions and 161 deletions

View file

@ -14,8 +14,9 @@ PAYLOAD_SECRET=YOUR_SECRET_HERE
# (dev/test). The bucket stays PRIVATE: the app proxies the files through its
# /media/file route (the plugin streams each object from Garage), so no public
# bucket/DNS is needed. Production: S3_BUCKET=teqo-media,
# S3_ENDPOINT=http://100.119.220.31:3900 (tailnet do homeserver). Dev may use its
# own bucket — never share the production bucket across environments.
# S3_ENDPOINT=http://100.119.220.31:3900 (tailnet do homeserver). Staging
# (OPS103) has its own bucket `teqo-media-staging` — never share the
# production bucket across environments.
S3_BUCKET=
S3_ENDPOINT=
S3_REGION=garage

View file

@ -1,29 +1,45 @@
# Manual production deploy (OPS71 — GitHub Actions). `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 the
# deploy job touches the homeserver.
# 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.
#
# TWO jobs, same workflow:
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
# left out — they gate PRs, not production).
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: the GitHub
# self-hosted runner ON the homeserver executes
# scripts/deploy-homeserver.sh LOCALLY (no SSH; outbound-only
# runner, works behind the Cloudflare tunnel). The hosted job
# never reaches the homeserver; the self-hosted job never counts
# hosted minutes.
# 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).
#
# The production build CANNOT leave the homeserver (migrate pre-build on the
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
# which is exactly why the hosted job only verifies and the self-hosted job
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
# deploy-homeserver.sh) and serialized by flock on the homeserver.
# 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 `deploy` job fetches 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).
# 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)
@ -142,18 +158,54 @@ jobs:
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
run: pnpm test:e2e
deploy:
name: deploy homeserver (teqo-1313)
# OPS53 + OPS71. 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.
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'
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

View file

@ -2,7 +2,7 @@
## Deploy & CI
Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only.
Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with three jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only.
**Guarding `pnpm push` (OPS24+):** every PR push goes through `scripts/git-push.mjs`, which runs `ensure-repo-deps` (install + Husky in worktrees — OPS51), then `gate:push` (the pre-push hook), which chains `gate:ci` — a serialized mirror of the CI `checks` job (**without e2e**: OPS72 moved e2e to an explicit skill step) — plus the `docs-guards` (changelog entries additions-only + frozen HISTORY snapshot + uncommitted regenerable aggregate — OPS63/OPS85; conflict markers; the rewrite escape stays in CI only). The hook blocks `git push` on failure; escape: `git push --no-verify` (WIP only). There is no YAML `push` gate — the hook is the gate. **CI red on your PR is yours**, even pre-existing or infra — "out of feature scope" is not a stop condition (policy "Dono do PR, dono do CI" in `docs/AGENT-OPS.md`).
@ -22,6 +22,6 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
5. Review production environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to the deployed database automatically. **`NEXT_PUBLIC_SITE_URL` (ex.: `https://jorgesolla1313.com.br`) é obrigatória em Production** — sem ela, `getCampaignInviteBaseURL()` lança em runtime e o fluxo de convites/webauthn/link de import da agenda falha com mensagem genérica (C98, 2026-08-09: produção ficou sem a env e "gerar link" do feed quebrava; o preview tinha a de stage).
5. Review production environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to the deployed database automatically. **`NEXT_PUBLIC_SITE_URL` (ex.: `https://jorgesolla1313.com.br`) é obrigatória em Production** — sem ela, `getCampaignInviteBaseURL()` lança em runtime e o fluxo de convites/webauthn/link de import da agenda falha com mensagem genérica (C98, 2026-08-09: produção ficou sem a env e "gerar link" do feed quebrava; o preview tinha a de stage). Em staging (OPS103) a env carrega `https://staging.jorgesolla1313.com.br` — o mesmo fail-closed vale e a origem `staging.` também desliga o index (`robots.ts` + metadata).
6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent.
7. **Remodel deploy (historical — applied to production 2026-07-23):** the destructive remodel chain (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) already ran in production via the Vercel build: campaign vertical reset, 435 municípios seeded, public-site data untouched. Treat any future destructive migration the same way — review its SQL before the first production build that includes it.

File diff suppressed because one or more lines are too long

View file

@ -4,18 +4,19 @@ Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente
## Ambientes de dados (test ladder — nunca misturar)
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Staging** (`teqo_staging`, homeserver) | sintético (`db:seed:minimal`) | job `deploy-staging` do `deploy.yml` (OPS103); inspeção humana antes da aprovação de produção |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy-production` do `deploy.yml` (OPS53 + OPS71 + OPS103) |
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
Agentes **nunca** recebem `DATABASE_URL` de prod/staging e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml` — o staging da OPS103 é **alvo de promoção de um SHA de `main`** (dispatch manual → staging → aprovação → produção), nunca destino de PR.
## Fluxo
```text
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → deploy-staging → aprovação do environment production → deploy-production (OPS53 + OPS103)
```
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do GitHub (`github.com/fsolla/teqo/issues`).
@ -75,14 +76,14 @@ Arquivos de "lista" que N agentes escrevem em paralelo (plans, migrações, cata
## CI por alvo (GitHub Actions, OPS71)
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
| `deploy.yml` | **`workflow_dispatch` manual** | 1× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do dispatch mesmo se `main` andar durante o verify — OPS102; idempotente; flock; migrator→migrate→runner→rollout→smoke) |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
| `deploy.yml` | **`workflow_dispatch` manual** | 1× service Postgres 17 | **Três jobs (OPS103):** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy-staging` (`needs: [verify]`, `environment: staging`, `TEQO_ENV=staging`) → `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer, `TEQO_ENV=production`); ambos os deploys rodam em `runs-on: [self-hosted, homeserver]` e executam `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do dispatch mesmo se `main` andar durante o verify — OPS102; idempotente; um `flock` compartilhado + `concurrency: deploy-homeserver` com `queue: max`; migrator→migrate→runner→rollout→smoke) — staging vermelho fail-closa produção |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
@ -107,7 +108,7 @@ O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no G
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do dispatch mesmo se `main` avançar durante o verify (o antigo guard de stale run era da era automática); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
Deploy (OPS53 + OPS71 + OPS103): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full, **intocado** — verifica o commit uma vez) roda **antes** dos deploys: `deploy-staging` (`environment: staging`, sem reviewer) e então `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer; staging vermelho nunca deixa produção rodar). Os dois executam `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`). O script: flock por ambiente → guard "already deployed" (revision do container do ambiente) → build do migrator (BuildKit, secrets do env file do ambiente) → push/tag em `localhost:5000` → swap dos tags no compose (backup; sed ancorado ao serviço do ambiente) → **migrations** (`teqo-1313-migrate`/`teqo-staging-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do dispatch mesmo se `main` avançar durante o verify (o antigo guard de stale run era da era automática); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas, bootstrap do staging): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
## Cursor Cloud

View file

@ -0,0 +1 @@
**OPS103 — Staging no homeserver com verify único e aprovações separadas (2026-09-12):** o repo ganhou um alvo real e descartável entre o banco mínimo do CI e a produção. Um único `workflow_dispatch` do `deploy.yml` roda o `verify` (hosted, suíte **full** incl. e2e — **intocado**: verifica o commit, não o alvo) uma vez e gateia **três jobs**: `verify` → `deploy-staging` (`environment: staging`, sem reviewer; `TEQO_ENV=staging`) → `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer — staging vermelho nunca deixa produção rodar; `TEQO_ENV=production`). Os dois deploys rodam no runner self-hosted do homeserver e executam o **mesmo** `scripts/deploy-homeserver.sh`, agora parametrizado por `TEQO_ENV` (mapa no topo; produção default byte-for-byte a de antes: `teqo-1313`/`teqo-1313-migrate`/`~/stack/teqo-1313.env`/127.0.0.1:1313/`localhost:5000/teqo-1313(-migrator):$SHA`; staging: `teqo-staging`/`teqo-staging-migrate`/`~/stack/teqo-staging.env`/127.0.0.1:1314/bucket `teqo-media-staging`/proxy de build próprio; `TEQO_ENV` inválido fail-closed, mapa `readonly` contra retargeting pelos env files). Sem script gêmeo: o sed de imagens é ancorado ao repo do ambiente e o label `org.opencontainers.image.revision` passa a ser trocado por **range do bloco do serviço** (um replace global carimbaria o SHA de um ambiente no serviço do outro e o guard "already deployed" leria revision mentirosa); a ordem migrator→migrate→runner (OPS66), o rollback por ambiente, o healthcheck e o smoke (`http://localhost:1314` no staging) seguem idênticos. Os dois deploys compartilham o compose/workspace/registry do homeserver, então entram no mesmo `concurrency: deploy-homeserver` (`cancel-in-progress: false`, `queue: max` — o default de fila cancelaria um pending a cada dispatch novo) e num único `flock` compartilhado (locks por ambiente deixariam staging e produção buildar/swapar concorrentemente; o lock também cobre invocação manual, que não passa pelo concurrency). Staging é noindex no repo (`src/lib/siteEnvironment.ts` + `src/app/robots.ts` + metadata do layout — hostname `staging.` desliga index em qualquer alvo) e usa seed sintético, nunca PII. Pins: `tests/unit/deployScript.unit.spec.ts` (defaults de produção + mapa de staging + sed ancorado + único `exit 0`; a ordem OPS66 passou a ancorar em `--profile maintenance run --rm`/último `up -d`), `tests/unit/ciSkipInvariants.unit.spec.ts` (os três jobs, environments, `needs`, guard `main`, concurrency) e `tests/unit/siteEnvironment.unit.spec.ts` (predicado + wiring do noindex). Docs vivos: runbook `docs/ops/teqo-1313-deploy.md` (fluxo, tabela staging×produção, bootstrap manual do homeserver — DB, env file, serviços no compose, bucket, tunnel/DNS, environments e migrate+seed — e limites explícitos: WebAuthn/OAuth/Resend/e2e não são validados), `docs/AGENT-OPS.md` (ladder de ambientes e workflow), `AGENTS.md`/`AGENTS-infra.md` e `.env.example` (bucket `teqo-media-staging`). Sem migration, sem UI, sem access/Consent. Verificação: `pnpm gate:fast` verde (unit 292 arquivos/2810 testes); e2e local da mesma superfície (`frontend`, 25 testes) verde; `/robots.txt` confirmado no build (rota estática). Aceite final humano pós-merge: bootstrap ops no homeserver + dispatch real (staging verde → inspeção → aprovação → produção no mesmo SHA).

View file

@ -1,63 +1,147 @@
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 — dispatch manual)
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
O deploy é uma **action manual** (`workflow_dispatch` no GitHub Actions,
`.github/workflows/deploy.yml`): mergir em `main` **não** publica nada. O
operador dispara o deploy quando decide publicar; antes de tocar o homeserver,
o job `verify` (hosted) roda a suíte **full** (incl. e2e full) **uma vez** e
gateia os dois alvos: `deploy-staging` → aprovação do environment `production`
→ `deploy-production` (OPS103).
## Gatilho e fluxo
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
check-test-locations → lint → format → typecheck → knip → cycles → unit →
int (migrate+seed nos services) → build → e2e full.
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
conta minutos hosted).
4. O script, no **homeserver**: `flock` (serializa os dispatches; desde a
OPS102 o run vai até o fim com o SHA do dispatch mesmo se `main` avançar
durante o verify; o workspace é clonado/atualizado de `TEQO_REPO_URL` —
default `https://github.com/fsolla/teqo.git`, público) → guard "already
deployed" (revision do container rodando) → **build do migrator**
(o estágio migrator não roda `next build` — builda mesmo contra o schema
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push do migrator em
`localhost:5000` (tag **qualificada de registry** — INF13: a única ref que o
compose referencia) → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes; sed idempotente aceita as formas
antiga e qualificada e escreve sempre a qualificada) → **migrations**
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
migrado — o `next build` da geração estática lê o schema novo, OPS66;
`--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default`
para alcançar o `postgres`; o proxy é criado idempotentemente pelo script)
→ push do runner (tag qualificada de registry) → `docker compose up -d teqo-1313` → healthcheck →
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
login-options, `api/revalidate` com o secret real).
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap).
int (migrate+seed nos services) → build → e2e full. **Intocado pela
OPS103**: verifica o commit, não o alvo.
3. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`,
`environment: staging`, sem reviewer) roda no runner self-hosted e executa
`TEQO_ENV=staging bash scripts/deploy-homeserver.sh <sha>` — publica o SHA
em `staging.jorgesolla1313.com.br` (container `teqo-staging`,
`127.0.0.1:1314`).
4. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`,
`environment: production`) **aguarda a aprovação do reviewer** e então
executa `TEQO_ENV=production bash scripts/deploy-homeserver.sh <sha>` —
publica o **mesmo SHA** em produção (`teqo-1313`, `127.0.0.1:1313`).
Staging vermelho = produção nunca roda; a corrida entre os dois é
serializada pela `concurrency: deploy-homeserver`.
5. O script, no **homeserver**: `flock` único (compose, workspace e registry
são compartilhados — um deploy por vez no host; o `concurrency` do
workflow serializa os runs e o lock cobre invocação manual; desde a OPS102
o run vai até o fim com o SHA do dispatch mesmo se `main` avançar durante o
verify; o workspace é clonado/atualizado de `TEQO_REPO_URL` — default
`https://github.com/fsolla/teqo.git`, público) →
guard "already deployed" (revision do container do ambiente) → **build do
migrator** (o estágio migrator não roda `next build` — builda mesmo contra o
schema antigo; BuildKit, secrets do env file do ambiente) → push do migrator
em `localhost:5000` (tag **qualificada de registry** — INF13: a única ref
que o compose referencia) → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes; sed ancorado ao serviço do
ambiente — a linha do outro ambiente não é tocada) → **migrations**
(`docker compose --profile maintenance run --rm teqo-<env>-migrate
</dev/null`, já com a imagem do SHA novo) → **build do runner** (contra o
banco JÁ migrado — o `next build` da geração estática lê o schema novo,
OPS66; `--network host` com proxy socat por ambiente na `stack_default` para
alcançar o `postgres`; o proxy é criado idempotentemente pelo script) →
push do runner (tag qualificada de registry) → `docker compose up -d
teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do
ambiente).
6. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap, **só do ambiente que falhou**).
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
deploy manual e confira no log do job `deploy` o `set-url` do workspace
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
"already deployed" num segundo dispatch do mesmo SHA.
deploy manual e confira no log do job `deploy-production` o `set-url` do
workspace (origem Forgejo → GitHub), o `deployed_sha` lido do container e o
guard "already deployed" num segundo dispatch do mesmo SHA. Na OPS103, o
caminho de estreia é o mesmo dispatch: staging primeiro (bootstrap abaixo),
inspeção humana, aprovação, produção.
## Onde roda cada coisa
| Máquina | Papel |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
| Máquina | Papel |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente
(`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Staging (OPS103)
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes
de produção. Mesmo dispatch, mesmo `verify`, mesmo script — o que muda é
`TEQO_ENV=staging` e o environment `staging` (sem reviewer; a aprovação que
importa é a de `production`).
| Item | Staging | Produção |
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
| Container | `teqo-staging` (127.0.0.1:1314) | `teqo-1313` (127.0.0.1:1313) |
| Migrator | `teqo-staging-migrate` | `teqo-1313-migrate` |
| DB | `teqo_staging` | `teqo_1313` |
| Env file | `~/stack/teqo-staging.env` | `~/stack/teqo-1313.env` |
| Imagens | `localhost:5000/teqo-staging(-migrator):$SHA` | `localhost:5000/teqo-1313(-migrator):$SHA` |
| Bucket | `teqo-media-staging` | `teqo-media` |
| URL | `https://staging.jorgesolla1313.com.br` (noindex) | `https://jorgesolla1313.com.br` |
| Build proxy | `teqo-staging-build-proxy` (127.0.0.1:5434) | `teqo-1313-build-proxy` (127.0.0.1:5433) |
| Lock | `/tmp/teqo-deploy.lock` (compartilhado — compose/workspace/registry são um só) | idem |
### Bootstrap do homeserver (uma vez, manual — fora do repo)
1. **DB:** criar `teqo_staging` no Postgres do stack
(`docker compose exec postgres createdb -U teqo teqo_staging`).
2. **Env file:** `~/stack/teqo-staging.env` (chmod 600) com `DATABASE_URL`
apontando para `teqo_staging`, `PAYLOAD_SECRET` próprio,
`NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br`,
`REVALIDATE_SECRET` próprio e `S3_*` do bucket `teqo-media-staging`.
3. **Compose:** adicionar `teqo-staging` e `teqo-staging-migrate` ao
`~/stack/docker-compose.yml`, espelhando os serviços de produção
(`pull_policy: never`, label `org.opencontainers.image.revision` **por
serviço**, healthcheck, `extra_hosts: host-gateway`) e a porta
`127.0.0.1:1314`.
4. **Bucket:** criar `teqo-media-staging` no Garage com key própria — nunca
reutilizar a key/bucket de produção.
5. **Tunnel/DNS:** ingress `staging.jorgesolla1313.com.br` →
`http://localhost:1314` no cloudflared + DNS no Cloudflare. Opcional
(defesa em profundidade): header `X-Robots-Tag: noindex` no ingress — o
noindex versionado (robots.ts + metadata, OPS103) já cobre crawlers.
6. **GitHub Environments:** criar `staging` (sem reviewer) e `production`
(required reviewer `fsolla`) em Settings → Environments — **antes do
primeiro dispatch** (environment `production` sem reviewer é fail-open).
7. **Migrate + seed sintético (antes do primeiro dispatch):** no homeserver,
com o workspace em `~/teqo-deploy`, criar o proxy de staging manualmente
(o deploy também cria, idempotente), migrar e semear. O guard local passa
porque o alvo é `127.0.0.1` — **sem** `ALLOW_REMOTE_DB`:
```bash
ssh homeserver
cd ~/teqo-deploy && pnpm install
docker inspect teqo-staging-build-proxy >/dev/null 2>&1 || docker run -d \
--name teqo-staging-build-proxy --network stack_default \
--restart unless-stopped -p 127.0.0.1:5434:5434 \
alpine/socat TCP-LISTEN:5434,fork TCP:postgres:5432
set -a; source ~/stack/teqo-staging.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
pnpm migrate
pnpm db:seed:minimal
```
Nunca copiar dados de produção (PII/LGPD): o seed mínimo é sintético.
### Limites do staging (o que ele NÃO valida)
- e2e automatizado (o full continua no `verify`, que valida o commit);
- carga, caos, multi-região;
- integrações origin-bound sem configuração própria: WebAuthn/passkeys,
Google OAuth e envio de e-mail (Resend). "Staging verde" ≠ "produção
garantida" — é validação de migração/build/rollout/smoke + inspeção humana;
- dados são sintéticos; nunca PII real.
## Cutover OPS71 (passos manuais, ordem)
1. Remotes locais (config compartilhada pelos worktrees):
@ -97,7 +181,10 @@ history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`
(um por deploy; como o staging roda antes no mesmo SHA, o backup de produção é
o compose já com a linha do staging — o restore devolve o arquivo inteiro, o
que é o correto).
```bash
ssh homeserver
@ -117,6 +204,11 @@ docker pull localhost:5000/teqo-1313:<sha-anterior>
docker compose up -d teqo-1313
```
Para **staging**, o mesmo procedimento com as identidades do ambiente
(`teqo-staging`, `localhost:5000/teqo-staging:<sha-anterior>`,
`docker compose up -d teqo-staging`) — o rollback de um ambiente não toca o
container do outro (só restaura as linhas de imagem do arquivo compartilhado).
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
@ -132,16 +224,17 @@ a correção se re-mergeia e o próximo deploy completo publica.
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Não re-dispatchar SHA antigo; se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se o deploy ganhar frequência/automação ou se uma regressão fora-de-ordem acontecer |
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys |
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:<sha>`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:<sha>`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:<sha>` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) |
## Backup passivo no Forgejo (OPS76-FOLLOWUP, 2026-08-21)
@ -166,15 +259,18 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
## Segurança (decisão deliberada)
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
gate + setting do repo).
- Os jobs `deploy-staging`/`deploy-production` usam
`runs-on: [self-hosted, homeserver]` — executam como o usuário do runner no
homeserver. Estão **apenas** no `deploy.yml` (dispatch manual), nunca no
`ci-pr.yml`; o hosted (`verify`) não tem acesso ao homeserver e o self-hosted
não recebe secrets de produção (as envs vivem no próprio homeserver,
inclusive as de staging — nunca como GitHub secrets). `main` só anda por PR
mergeado com CI verde; o deploy só roda com `verify` full verde. Fork-PRs
não rodam CI (same-repo gate + setting do repo).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
- Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex —
nunca compartilha credencial, bucket ou dados com produção.
## C149 — Google OAuth da agenda (envs novas + GCP)
@ -336,8 +432,9 @@ Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
- `.github/workflows/deploy.yml` — verify → deploy
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
- `.github/workflows/deploy.yml` — verify → deploy-staging → deploy-production (OPS103)
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
- `docs/plans/ops103-staging-homeserver*.md` — staging com verify único e aprovações separadas
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"

View file

@ -0,0 +1,131 @@
# Impl: OPS103 — Staging no homeserver com verify único e aprovações separadas
Status: aprovado
Atualizado em: 2026-09-12
Issue: #968
Intenção: docs/plans/ops103-staging-homeserver.md
Appetite restante: herdado (~2–3 dias eng no repo + bootstrap ops no homeserver; sem migration, sem schema, sem UI)
## Leitura da intenção
- **Outcome:** um dispatch único do `deploy.yml` roda o `verify` uma vez e gateia `deploy-staging` → `deploy-production`; produção só depois de staging verde e do environment `production` aprovado; o mesmo script parametrizado por ambiente executa os dois deploys sem mudar os defaults de produção.
- **O que NÃO negociar:** `verify` intocado; runner self-hosted só nos deploys (nunca no PR CI); envs de staging só no homeserver (nunca GitHub secrets); staging sem PII real, não indexável, bucket `teqo-media-staging` próprio; ordem migrator→migrate→runner (OPS66) e rollback por ambiente; `workflow_dispatch`-only; produção `teqo-1313`/`teqo_1313`/`~/stack/teqo-1313.env`/1313 intocados.
- **O que reavaliar:** a intenção fala em environments `deploy-staging`/`deploy-production` — os nomes reais no GitHub são `staging` e `production`; e "serviços no compose `~/stack`" implica um único `docker-compose.yml` compartilhado, o que muda a decisão de lock (Decisão 2) e exige sed ancorado por serviço (Decisão 4).
## Abordagem recomendada
```mermaid
flowchart LR
D["workflow_dispatch (main)"] --> V["verify hosted — suíte full (intocado)"]
V -->|success| S["deploy-staging<br/>environment: staging · TEQO_ENV=staging"]
S -->|success| P["deploy-production<br/>environment: production (reviewer) · TEQO_ENV=production"]
S -->|fail| X["produção nunca roda (fail-closed)"]
S -. "bash deploy-homeserver.sh $GITHUB_SHA" .-> R["script único parametrizado"]
P -. idem .-> R
R --> F["migrator build → migrate → runner build → rollout → smoke (por ambiente, OPS66)"]
```
**Opções consideradas:** decisões A/B/C abaixo.
**Recomendação:** parametrizar o script por env vars com defaults de produção (D1); locks por ambiente + `concurrency` de grupo único nos jobs de deploy (D2); dois jobs explícitos com `environment:` + `TEQO_ENV` por job (D3); sed ancorado por serviço (imagens) e por range (label de revision) (D4); noindex via `robots.ts` + metadata condicionados à URL de staging (D5); pins estendendo os specs donos (D6).
**Rejeitadas:** por decisão.
### Decisão 1 — como parametrizar o script
- **Opções:** A) env vars com defaults de produção, derivadas num mapa `case "${TEQO_ENV:-production}"` no topo; B) flags CLI `--env staging` (default produção); C) dois scripts (proibido pela intenção); D) env vars sem defaults.
- **Recomendação:** **A** — `bash scripts/deploy-homeserver.sh "$SHA"` continua idêntico em produção (defaults atuais preservados) e o workflow passa `TEQO_ENV` por job. Mapa: `TEQO_CONTAINER`, `TEQO_MIGRATE_SERVICE`, `TEQO_ENV_FILE`, `TEQO_IMAGE_REPO` (+ `TEQO_REGISTRY=localhost:5000`), `TEQO_BUILD_PROXY`, `TEQO_BUILD_PROXY_PORT`, `TEQO_SMOKE_BASE`, `TEQO_LOCK`; `STACK_DIR`/`WORKSPACE_DIR`/`TEQO_REPO_URL` seguem compartilhados. `TEQO_ENV` inválido → `fatal` (fail-closed). O nome do DB não entra no script (vive no env file).
- **Rejeitadas:** B porque muda a invocação canônica (pins e runbook) por uma ergonomia que o `env:` do job já resolve; C porque a intenção proíbe gêmeo; D porque quebra produção (nenhum default).
### Decisão 2 — serialização entre ambientes
- **Opções:** A) um `flock` compartilhado (`/tmp/teqo-deploy.lock`) + `concurrency: {group: deploy-homeserver, cancel-in-progress: false, queue: max}` nos dois jobs de deploy; B) um lock por ambiente; C) só `concurrency`.
- **Recomendação:** **A** — o `docker-compose.yml` de `~/stack`, o workspace `~/teqo-deploy` e o registry `localhost:5000` são compartilhados: o swap/rollback de um ambiente reescreve o arquivo que o outro usa, e dois builds concorrentes estouram o homeserver 8c/16GB (OOM já documentado no runbook). O lock único serializa os deploys inclusive na invocação manual no host (que não passa pelo `concurrency`); a `concurrency` com `queue: max` enfileira runs em vez de cancelar o pending (o default `single` cancela um pending a cada dispatch novo, mesmo com `cancel-in-progress: false` — descoberto na revisão do /simplify) e não bloqueia o `verify` hosted de um run novo.
- **Rejeitadas:** B porque locks por ambiente não protegem os recursos compartilhados (a razão de existirem) e a independência não é um requisito — os deploys já se encadeiam por `needs`; C porque deixaria a invocação manual sem serialização.
- **Revisão /simplify (2026-09-12):** a versão inicial do plano previa lock por ambiente; os revisores apontaram a corrida no compose/workspace compartilhado e o cancelamento de pending do `queue: single` — corrigido para lock único + `queue: max`.
### Decisão 3 — como o workflow passa o ambiente
- **Opções:** A) dois jobs explícitos — `deploy-staging` (`environment: staging`, `TEQO_ENV: staging`) e `deploy-production` (`environment: production`, `needs: [deploy-staging]`, `TEQO_ENV: production`), ambos com guard `github.ref == 'refs/heads/main'` e `verify` intocado; B) manter o job `deploy` de produção e adicionar só o `deploy-staging`; C) input `environment` no dispatch ou workflow por ambiente.
- **Recomendação:** **A** — simetria entre alvos irmãos; o gate de aprovação fica no environment (ortogonal ao `TEQO_ENV` que o script lê) e o `needs` encadeia fail-closed. Reviewer obrigatório só em `production` (config do repo, não do YAML; bootstrap cria os dois environments).
- **Rejeitadas:** B porque um job chamado `deploy` que na verdade é produção e outro qualificado confunde runbook/pins; C porque um input permitiria pular staging ou escolher alvo (viola o aceite) e workflow por ambiente duplicaria o `verify` — o ponto da intenção.
### Decisão 4 — sed do compose compartilhado
- **Opções:** A) padrões derivados por ambiente (`TEQO_REGISTRY`/`TEQO_IMAGE_REPO`, migrator antes do runner) e o label `org.opencontainers.image.revision` trocado por range ancorado ao bloco do serviço (`/^ $TEQO_CONTAINER:/,/^ [A-Za-z0-9_-]+:/`); B) manter o sed global atual; C) editar YAML com `yq`/python.
- **Recomendação:** **A** — sem o range, um deploy de staging carimbaria o label de revision do serviço de produção (o guard "already deployed" leria um SHA mentiroso). Os `grep -q` pós-swap passam a checar `image: $TEQO_REGISTRY/$TEQO_IMAGE_REPO(-migrator):$SHA`; o bootstrap exige labels por serviço, sem âncora YAML.
- **Rejeitadas:** B porque corrompe o label do outro ambiente; C porque adiciona dependência ao host por um ganho que sed ancorado entrega.
### Decisão 5 — onde vive o noindex de staging
- **Opções:** A) `src/app/robots.ts` (mapeia `/robots.txt`; Next 15 só reconhece metadata route files na raiz do app dir — route group não entra no match) + `robots` do metadata do layout condicionados por `src/lib/siteEnvironment.ts` (hostname começa com `staging.`); B) só header `X-Robots-Tag` no tunnel Cloudflare; C) nada (sem links públicos).
- **Recomendação:** **A** — versionado, testável em unit e efetivo para crawlers que respeitam robots/metadata; o header no tunnel fica como defesa em profundidade opcional no bootstrap (documentado no runbook). A URL de staging é a identidade que o build já carrega (`NEXT_PUBLIC_SITE_URL`), então não há env nova para esquecer (fail-closed: qualquer host `staging.*` desindexa).
- **Rejeitadas:** B porque vive fora do repo (não testável/versionado) e não cobre acesso direto à origem; C porque o aceite exige "não indexável".
### Decisão 6 — pins
- **Opções:** A) estender `tests/unit/deployScript.unit.spec.ts` (defaults de produção idênticos, mapa de staging, sed ancorado, env inválido fail-closed) e `tests/unit/ciSkipInvariants.unit.spec.ts` (dois jobs, environments, `needs`, `TEQO_ENV`, guard de main); B) novo spec só do workflow; C) sem pins.
- **Recomendação:** **A** — cada spec fica no dono do contrato que já pina (script/workflow), em unit puro (leitura do arquivo). O pin de ordem OPS66 passa a ancorar em marcadores estáveis (`--profile maintenance run --rm`, `lastIndexOf('docker compose up -d')`), porque `indexOf('teqo-1313-migrate')` passaria a achar o mapa no topo.
- **Rejeitadas:** B porque espalha o contrato do mesmo workflow em dois donos; C porque a única rede do script é unit (nenhum e2e o exercita).
### Componentes / mudanças
- **`scripts/deploy-homeserver.sh`** (dono do fluxo): mapa `TEQO_ENV` no topo; toda ocorrência de `teqo-1313`/porta/lock/proxy/smoke/cleanup vira variável; sed de imagens ancorado; revision por range; produção default idêntica.
- **`.github/workflows/deploy.yml`**: header comentado; `verify` intocado; `deploy` renomeado para `deploy-production` + novo `deploy-staging`, com `environment`, `TEQO_ENV`, `needs`, guard de `main` e `concurrency`.
- **`src/lib/siteEnvironment.ts`** (novo): `isStagingSiteUrl(url)` puro + `isStagingSite()` (lê `NEXT_PUBLIC_SITE_URL`).
- **`src/app/robots.ts`** (novo — raiz do app dir, exigência do Next 15 para metadata routes) + **`src/app/(frontend)/layout.tsx`**: `disallow: /` e `index:false` quando staging.
- **Testes**: os dois specs acima + `tests/unit/siteEnvironment.unit.spec.ts` (predicado + pin de que robots/layout consomem o helper).
- **Docs**: `docs/ops/teqo-1313-deploy.md` (seção Staging + bootstrap + limites), `docs/AGENT-OPS.md` (ladder e linha do `deploy.yml`), `AGENTS.md`/`AGENTS-infra.md`, `.env.example` (bucket `teqo-media-staging`), `docs/changelog/<data>-ops103.md`.
- **Migration:** nenhuma. **Access/Consent:** N/A (não toca Payload). **UI:** Impeccable A — robots/metadata não é UI visual.
### Dados → forma
N/A (sem UI).
## Fases verificáveis
1. **Script + spec (RED primeiro):** mapa por ambiente, sed ancorado, proxy/lock/smoke/cleanup parametrizados; atualizar `deployScript.unit.spec.ts` (mapa de staging e ordem OPS66 falham antes do refactor). Verificação: `bash -n scripts/deploy-homeserver.sh`; `pnpm test:unit -- tests/unit/deployScript.unit.spec.ts` verde.
2. **Workflow + pin:** `deploy-staging`/`deploy-production` com environments/`needs`/guard/`concurrency`; estender `ciSkipInvariants.unit.spec.ts`. Verificação: unit verde + `pnpm lint`.
3. **noindex:** helper + `robots.ts` + metadata do layout + spec. Verificação: `pnpm test:unit -- tests/unit/siteEnvironment.unit.spec.ts`.
4. **Docs + changelog:** runbook (fluxo, identidades, bootstrap, limites), AGENT-OPS, AGENTS, `.env.example`, entrada de changelog.
5. **Gates** — `pnpm gate:fast`; push via `pnpm push`; PR `Closes #968` base `main`.
6. **Aceite humano pós-merge (bootstrap ops, fora do repo):** criar DB `teqo_staging`, serviços no compose `~/stack`, `~/stack/teqo-staging.env`, bucket Garage `teqo-media-staging`, ingress/DNS `staging.jorgesolla1313.com.br`, environments `staging`/`production` (reviewer `fsolla`); migrate+seed iniciais (proxy 5434 + `pnpm db:seed:minimal` — passa no guard local via `127.0.0.1`, sem `ALLOW_REMOTE_DB`) antes do 1º dispatch; dispatch → staging em `:1314` → inspeção → aprovação → prod no mesmo SHA; `curl https://staging.jorgesolla1313.com.br/robots.txt` = `Disallow: /`.
## Rabbit holes / Não escopo (engenharia)
- e2e contra staging, branch `stage`/PR-to-stage, preview por PR, cópia de dados de prod (fora de escopo da intenção).
- Segundo workflow/script, proxy/compose gêmeo por ambiente, lock único global, `NEXT_PUBLIC_SITE_ENV` nova.
- GitHub secrets para staging; envs fora do homeserver; runner self-hosted fora do deploy; tocar o `verify`.
- Refatorar o miolo do script além da parametrização (ordem OPS66, rollback, guard "already deployed", BuildKit) — o único default que muda é o derivado.
- `yq`/python para editar YAML; validar WebAuthn/OAuth/Resend no staging (limites documentados no runbook).
## Explicitamente fora (triage pós-simplify, 2026-09-12)
Achados dos revisores estruturais/qualidade que **não** viraram Issue nova — decisão autônoma do ator `work-issue` pela triage do `capture-review-debts`, sem editar a Issue #968 (in-progress):
| ID | Achado | Destino | Racional / gatilho |
| --- | -------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A | noindex fail-open: `staging.*` é heurística de hostname; URL ausente/malformada indexa | defer | D5 escolheu a URL como identidade do build (sem env nova); o bootstrap exige a env. Gatilho: staging mudar para domínio sem prefixo `staging.` ou crawler indexar |
| B | pins do compose swap são textuais (sem harness de fixture) | defer | Prova manual com fixture na sessão + pins de contrato. Gatilho: próxima mudança na lógica de sed/swap |
| C | `.` do registry não escapado em ERE (`localhost:5000`) | descartar | Comportamento idêntico ao pré-OPS103; registry controlado por ops — cosmético |
| D | sem e2e de `/robots.txt` | defer | Unit chama `robots()` comportamentalmente; e2e provaria wiring do Next. Gatilho: e2e passar a tocar metadata route |
| E | gate de aprovação de produção vive só no environment (config do repo, fora do YAML) | defer | D3 decidiu reviewer no repo, não no YAML; runbook documenta que environment sem reviewer é fail-open. Gatilho: environment `production` perder o reviewer ou deploy sem aprovação |
| F | staging pode subir vazio se o seed do bootstrap (passo 7) for pulado | defer | Runbook exige migrate+seed antes do 1º dispatch. Gatilho: 1º dispatch real de staging — se subir vazio, seed vira passo do deploy |
## Riscos e mitigação
- **Regressão silenciosa no deploy de produção** (script parametrizado): defaults prod pinados no unit + `bash -n` + prova RED + o staging do mesmo dispatch exercita o mesmo caminho antes de produção.
- **Sed tocando a linha/label do outro ambiente:** padrões ancorados por `TEQO_IMAGE_REPO` + range do label + `grep -q` pós-swap + backup/rollback; bootstrap exige labels por serviço sem âncora YAML.
- **Runs concorrentes** (compose/workspace/build compartilhados): `concurrency: deploy-homeserver` sem cancel + locks por ambiente; risco residual aceito — aprovação de produção segura o slot e o operador dispara um run por vez.
- **Staging indexável ou com PII:** robots + metadata condicionados à URL; bucket/DB/env próprios; seed sintético; checklist de bootstrap marca reviewer do `production` antes do 1º dispatch (environment sem reviewer é fail-open e vira critério de aceite).
- **Custo/OOM no homeserver** (2 builds por ambiente, 4 por dispatch): serialização, idempotência e cleanup pós-deploy por ambiente; re-dispatch se OOM.
- **Drift de docs/runbook:** grep de menções a `deploy`/`teqo-1313` nas docs vivas na Fase 4 + revisão do PR.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — aqui: unit do script, do wiring do workflow e do noindex; sem access/Consent
- [ ] `bash scripts/deploy-homeserver.sh "$SHA"` idêntico em produção (defaults e único `exit 0` preservados); staging mapeado e `TEQO_ENV` inválido fail-closed
- [ ] `deploy.yml`: `verify` intocado; dois deploys com `environment: staging`/`production`, `needs`, `TEQO_ENV` e guard `main`; pin novo no `ciSkipInvariants`
- [ ] Staging noindex (robots + metadata) testado; produção com `index:true` inalterado
- [ ] Runbook/AGENT-OPS/AGENTS/`.env.example` alinhados; changelog criado; PR `Closes #968`
- [ ] (humano) bootstrap ops + dispatch real: staging verde → aprovação → produção no mesmo SHA; staging não indexável

View file

@ -1,13 +1,20 @@
#!/usr/bin/env bash
#
# Deploy teqo-1313 to the homeserver (OPS53).
# Deploy teqo-1313 (production) or teqo-staging to the homeserver (OPS53,
# parameterized by OPS103).
#
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy job
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy jobs
# of .github/workflows/deploy.yml):
# bash scripts/deploy-homeserver.sh <commit-sha>
# (The Forgejo-era invocation `ssh homeserver "bash -s -- <sha>" < script`
# is gone — no SSH hop, no workstation involvement.)
#
# OPS103: one script, both environments — `TEQO_ENV=production` (default,
# byte-for-byte the pre-OPS103 behavior) and `TEQO_ENV=staging`. Each
# environment owns its container/service, env file, image repo, build-proxy
# port, smoke base and lock; the compose file, workspace and registry stay
# shared. An unknown TEQO_ENV fails closed before anything is touched.
#
# OPS102: a manual dispatch runs to the end with its SHA even if main advances
# during verify (the old stale-run guard belonged to the automatic era); the
# only early green is the idempotency guard, which proves the SHA is already
@ -35,10 +42,16 @@
set -euo pipefail
SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}"
TEQO_ENV="${TEQO_ENV:-production}"
TEQO_REPO_URL="${TEQO_REPO_URL:-https://github.com/fsolla/teqo.git}"
STACK_DIR="${STACK_DIR:-$HOME/stack}"
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}"
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}"
TEQO_REGISTRY="${TEQO_REGISTRY:-localhost:5000}"
# ONE lock for both environments: the compose file, the workspace and the
# local registry are shared, so staging and production must never build or
# swap concurrently (the workflow's job-level concurrency serializes the
# runs; this lock also covers manual invocations on the host).
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-deploy.lock}"
say() { printf '[deploy] %s\n' "$*"; }
@ -48,6 +61,40 @@ fatal() {
exit 1
}
# --- environment map (OPS103) -------------------------------------------
# Production values are the pre-OPS103 literals: the canonical invocation
# `bash scripts/deploy-homeserver.sh <sha>` (no TEQO_ENV) behaves exactly as
# before. The workflow passes TEQO_ENV per deploy job. The DB name is NOT
# here — it lives inside each environment's env file. `readonly` keeps the
# env files (sourced below) from silently retargeting the deploy.
case "$TEQO_ENV" in
production)
TEQO_CONTAINER=teqo-1313
TEQO_MIGRATE_SERVICE=teqo-1313-migrate
TEQO_ENV_FILE="$STACK_DIR/teqo-1313.env"
TEQO_IMAGE_REPO=teqo-1313
TEQO_BUILD_PROXY=teqo-1313-build-proxy
TEQO_BUILD_PROXY_PORT=5433
TEQO_SMOKE_BASE=http://localhost:1313
;;
staging)
TEQO_CONTAINER=teqo-staging
TEQO_MIGRATE_SERVICE=teqo-staging-migrate
TEQO_ENV_FILE="$STACK_DIR/teqo-staging.env"
TEQO_IMAGE_REPO=teqo-staging
TEQO_BUILD_PROXY=teqo-staging-build-proxy
TEQO_BUILD_PROXY_PORT=5434
TEQO_SMOKE_BASE=http://localhost:1314
;;
*)
fatal "unknown TEQO_ENV '$TEQO_ENV' (expected production or staging)"
;;
esac
readonly TEQO_CONTAINER TEQO_MIGRATE_SERVICE TEQO_ENV_FILE TEQO_IMAGE_REPO
readonly TEQO_BUILD_PROXY TEQO_BUILD_PROXY_PORT TEQO_SMOKE_BASE DEPLOY_LOCK
# --- serialization (flock) ----------------------------------------------
exec 9>"$DEPLOY_LOCK"
@ -61,23 +108,23 @@ flock -w 3600 9 || fatal "another deploy holds $DEPLOY_LOCK"
# effort). A container without the label (or down) counts as "not deployed":
# the deploy proceeds (safe direction — it rebuilds).
running_rev="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' teqo-1313 2>/dev/null || true)"
running_rev="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$TEQO_CONTAINER" 2>/dev/null || true)"
if [ -n "$running_rev" ] && [ "$running_rev" = "$SHA" ]; then
say "already deployed: teqo-1313 runs $SHA — nothing to do"
say "already deployed: $TEQO_CONTAINER runs $SHA — nothing to do"
exit 0
fi
# --- secrets (sourced locally, never echoed) ----------------------------
set -a
. "$STACK_DIR/teqo-1313.env"
. "$TEQO_ENV_FILE"
. "$STACK_DIR/.env"
set +a
: "${DATABASE_URL:?teqo-1313.env is missing DATABASE_URL}"
: "${PAYLOAD_SECRET:?teqo-1313.env is missing PAYLOAD_SECRET}"
: "${NEXT_PUBLIC_SITE_URL:?teqo-1313.env is missing NEXT_PUBLIC_SITE_URL}"
: "${REVALIDATE_SECRET:?teqo-1313.env is missing REVALIDATE_SECRET}"
: "${DATABASE_URL:?$TEQO_ENV_FILE is missing DATABASE_URL}"
: "${PAYLOAD_SECRET:?$TEQO_ENV_FILE is missing PAYLOAD_SECRET}"
: "${NEXT_PUBLIC_SITE_URL:?$TEQO_ENV_FILE is missing NEXT_PUBLIC_SITE_URL}"
: "${REVALIDATE_SECRET:?$TEQO_ENV_FILE is missing REVALIDATE_SECRET}"
: "${REGISTRY_USER:?stack/.env is missing REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?stack/.env is missing REGISTRY_PASSWORD}"
@ -102,23 +149,25 @@ say "workspace at $SHA"
# --- registry -----------------------------------------------------------
say "logging into local registry localhost:5000"
echo "$REGISTRY_PASSWORD" | docker login localhost:5000 --username "$REGISTRY_USER" --password-stdin >/dev/null
say "logging into local registry $TEQO_REGISTRY"
echo "$REGISTRY_PASSWORD" | docker login "$TEQO_REGISTRY" --username "$REGISTRY_USER" --password-stdin >/dev/null
# --- build --------------------------------------------------------------
# BuildKit rejects a custom bridge network on `--network`, so the build runs
# with `--network host`; the prod DB (only reachable inside stack_default) is
# with `--network host`; the env DB (only reachable inside stack_default) is
# proxied to the host loopback by a one-off socat container on the compose
# network, and DATABASE_URL is rewritten to that endpoint. The proxy is
# idempotent and survives reboots (restart: unless-stopped).
# idempotent and survives reboots (restart: unless-stopped). Each
# environment publishes its own loopback port (TEQO_BUILD_PROXY_PORT) so the
# two proxies never collide.
ensure_db_proxy() {
if ! docker inspect teqo-1313-build-proxy >/dev/null 2>&1; then
docker run -d --name teqo-1313-build-proxy \
if ! docker inspect "$TEQO_BUILD_PROXY" >/dev/null 2>&1; then
docker run -d --name "$TEQO_BUILD_PROXY" \
--network stack_default \
--restart unless-stopped \
-p 127.0.0.1:5433:5433 \
alpine/socat TCP-LISTEN:5433,fork TCP:postgres:5432 >/dev/null
-p "127.0.0.1:$TEQO_BUILD_PROXY_PORT:$TEQO_BUILD_PROXY_PORT" \
alpine/socat "TCP-LISTEN:$TEQO_BUILD_PROXY_PORT,fork TCP:postgres:5432" >/dev/null
fi
}
@ -128,7 +177,7 @@ build_image() {
ensure_db_proxy
# The build's DATABASE_URL points at the loopback proxy; the outer env
# keeps the original value and nothing is echoed.
local build_db_url="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
local build_db_url="${DATABASE_URL/@postgres:5432/@127.0.0.1:$TEQO_BUILD_PROXY_PORT}"
DATABASE_URL="$build_db_url" DOCKER_BUILDKIT=1 docker build \
--network host \
--build-arg "NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL" \
@ -143,34 +192,47 @@ cd "$WORKSPACE_DIR"
# The migrator stage never runs `next build`, so it builds fine against the
# OLD schema — unlike the runner, whose static generation reads Payload data
# and therefore needs the migrations applied first (OPS66).
build_image migrator "localhost:5000/teqo-1313-migrator:$SHA"
build_image migrator "$TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA"
# INF13: the compose references REGISTRY-QUALIFIED tags
# (`localhost:5000/teqo-1313(-migrator):<sha>`) with pull_policy: never — the
# exact name every build produces, so the ref the compose needs can never go
# missing (the old bare `teqo-1313:<sha>` alias was a second local-only ref
# that nothing recreated once lost — homeserver incident 24/08, `No such
# image` on recreate).
docker push "localhost:5000/teqo-1313-migrator:$SHA"
# (`$TEQO_REGISTRY/$TEQO_IMAGE_REPO(-migrator):<sha>`) with pull_policy: never
# — the exact name every build produces, so the ref the compose needs can
# never go missing (the old bare `teqo-1313:<sha>` alias was a second
# local-only ref that nothing recreated once lost — homeserver incident
# 24/08, `No such image` on recreate).
docker push "$TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA"
# --- compose swap (backup first; failures from here roll back) ----------
compose="$STACK_DIR/docker-compose.yml"
backup="$compose.pre-$SHA"
cp "$compose" "$backup"
# Image lines: the env-specific repo names cannot touch the other
# environment's services (the migrator pattern runs first; `teqo-1313:`
# never matches `teqo-1313-migrator:`).
sed -i -E \
-e "s|image: (localhost:5000/)?teqo-1313-migrator:[0-9a-f]+|image: localhost:5000/teqo-1313-migrator:$SHA|g" \
-e "s|image: (localhost:5000/)?teqo-1313:[0-9a-f]+|image: localhost:5000/teqo-1313:$SHA|g" \
-e "s|org.opencontainers.image.revision: [0-9a-f]+|org.opencontainers.image.revision: $SHA|g" \
-e "s|image: ($TEQO_REGISTRY/)?$TEQO_IMAGE_REPO-migrator:[0-9a-f]+|image: $TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA|g" \
-e "s|image: ($TEQO_REGISTRY/)?$TEQO_IMAGE_REPO:[0-9a-f]+|image: $TEQO_REGISTRY/$TEQO_IMAGE_REPO:$SHA|g" \
"$compose"
grep -q "image: localhost:5000/teqo-1313:$SHA" "$compose" || fatal "compose swap failed (runner image)"
grep -q "image: localhost:5000/teqo-1313-migrator:$SHA" "$compose" || fatal "compose swap failed (migrator image)"
# Revision label: it lives INSIDE the service block, so a bare global
# replace would stamp THIS env's SHA onto the other environment's service —
# and the "already deployed" guard would read a lying revision. The sed
# range starts at the service key and ends at the next two-space key (the
# next service); keys inside a service are indented four spaces. The range
# is then re-read to fail closed when the service has no revision label
# (a silent no-op would disable the idempotency guard for this env).
label_range="/^ $TEQO_CONTAINER:/,/^ [A-Za-z0-9_.-]+:/"
sed -i -E -e "$label_range s|org.opencontainers.image.revision: [0-9a-f]+|org.opencontainers.image.revision: $SHA|" "$compose"
sed -n -e "$label_range p" "$compose" | grep -q "org.opencontainers.image.revision: $SHA" \
|| fatal "compose swap failed (revision label of $TEQO_CONTAINER)"
grep -q "image: $TEQO_REGISTRY/$TEQO_IMAGE_REPO:$SHA" "$compose" || fatal "compose swap failed (runner image)"
grep -q "image: $TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA" "$compose" || fatal "compose swap failed (migrator image)"
rollback() {
trap - ERR
say "FAILED: $* — restoring previous compose and image"
cp "$backup" "$compose" 2>/dev/null || true
( cd "$STACK_DIR" && docker compose up -d teqo-1313 ) 2>/dev/null || true
( cd "$STACK_DIR" && docker compose up -d "$TEQO_CONTAINER" ) 2>/dev/null || true
exit 1
}
trap 'rollback "unexpected failure"' ERR
@ -180,32 +242,32 @@ trap 'rollback "unexpected failure"' ERR
# migration that creates a table a static route reads would otherwise
# deadlock the deploy (build fails -> migrate never runs -> build fails...).
# The migrator image above is already swapped into the compose, so this
# maintenance service runs the migrations of the NEW sha against prod.
# maintenance service runs the migrations of the NEW sha against the env DB.
cd "$STACK_DIR"
say "applying pending migrations (maintenance service teqo-1313-migrate)"
say "applying pending migrations (maintenance service $TEQO_MIGRATE_SERVICE)"
# `< /dev/null`: `compose run` attaches stdin by default; the container would
# consume the rest of the script piped into `bash -s` (bash then hits EOF and
# exits right after the migrate step, skipping the rollout).
docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null || rollback "migrations failed"
docker compose --profile maintenance run --rm "$TEQO_MIGRATE_SERVICE" </dev/null || rollback "migrations failed"
# --- runner build (against the migrated schema) --------------------------
cd "$WORKSPACE_DIR"
say "building runner"
build_image runner "localhost:5000/teqo-1313:$SHA"
docker push "localhost:5000/teqo-1313:$SHA"
build_image runner "$TEQO_REGISTRY/$TEQO_IMAGE_REPO:$SHA"
docker push "$TEQO_REGISTRY/$TEQO_IMAGE_REPO:$SHA"
# --- rollout ------------------------------------------------------------
cd "$STACK_DIR"
say "rolling out teqo-1313"
docker compose up -d teqo-1313 || rollback "compose up failed"
say "rolling out $TEQO_CONTAINER"
docker compose up -d "$TEQO_CONTAINER" || rollback "compose up failed"
say "waiting for the healthcheck to go healthy"
health="starting"
for _ in $(seq 1 30); do
health="$(docker inspect -f '{{.State.Health.Status}}' teqo-1313 2>/dev/null || true)"
health="$(docker inspect -f '{{.State.Health.Status}}' "$TEQO_CONTAINER" 2>/dev/null || true)"
[ "$health" = "healthy" ] && break
[ "$health" = "unhealthy" ] && break
sleep 10
@ -214,7 +276,7 @@ done
# --- smoke --------------------------------------------------------------
base="http://localhost:1313"
base="$TEQO_SMOKE_BASE"
smoke_fail() { rollback "smoke: $*"; }
curl -fsS -o /dev/null "$base/" || smoke_fail "GET /"
@ -241,9 +303,9 @@ echo "$body" | grep -q '"revalidated":true' || smoke_fail "POST /api/revalidate
say "post-deploy cleanup: build cache + tags locais antigas"
docker builder prune -f >/dev/null 2>&1 || true
in_use_id="$(docker inspect -f '{{.Image}}' teqo-1313 2>/dev/null || true)"
in_use_id="$(docker inspect -f '{{.Image}}' "$TEQO_CONTAINER" 2>/dev/null || true)"
if [ -n "$in_use_id" ]; then
for img in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^(localhost:5000/)?teqo-1313(-migrator)?:' || true); do
for img in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E "^($TEQO_REGISTRY/)?$TEQO_IMAGE_REPO(-migrator)?:" || true); do
img_id="$(docker image inspect -f '{{.Id}}' "$img" 2>/dev/null || true)"
[ -n "$img_id" ] && [ "$img_id" = "$in_use_id" ] && continue
docker rmi "$img" >/dev/null 2>&1 || true
@ -252,4 +314,4 @@ else
say "cleanup: container sem image ID — mantendo imagens locais"
fi
say "deploy of $SHA complete: $(docker inspect -f '{{.Image}}' teqo-1313)"
say "deploy of $SHA complete: $(docker inspect -f '{{.Image}}' "$TEQO_CONTAINER")"

View file

@ -1,5 +1,6 @@
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { ThemeProvider } from '@/components/ThemeProvider'
import { isStagingSite } from '@/lib/siteEnvironment'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { resolveSiteMetadata } from '@/utilities/seo'
@ -37,10 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
],
creator: 'Francisco Solla',
publisher: 'Teqo',
robots: {
index: true,
follow: true,
},
robots: isStagingSite() ? { index: false, follow: false } : { index: true, follow: true },
openGraph: {
type: 'website',
locale: 'pt-BR',

20
src/app/robots.ts Normal file
View file

@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next'
import { isStagingSite } from '@/lib/siteEnvironment'
/**
* Staging is a throwaway validation target — it must never be indexed
* (OPS103). Production keeps the allow-all behavior it had implicitly (there
* was no robots.txt before OPS103).
*/
export default function robots(): MetadataRoute.Robots {
if (isStagingSite()) {
return {
rules: { userAgent: '*', disallow: '/' },
}
}
return {
rules: { userAgent: '*', allow: '/' },
}
}

View file

@ -0,0 +1,25 @@
/**
* Staging detection for the public site (OPS103).
*
* Staging runs the same build with
* `NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br` — anything
* served under a `staging.` hostname must stay out of search indexes.
* Client-safe: no Payload/Next imports; `isStagingSite` reads the public
* origin that the build already carries.
*/
/** Whether a public origin belongs to a staging deployment. */
export function isStagingSiteUrl(url: string | null | undefined): boolean {
if (!url) return false
try {
return new URL(url).hostname.startsWith('staging.')
} catch {
return false
}
}
/** Whether THIS build is the staging deployment (reads the public origin). */
export function isStagingSite(): boolean {
return isStagingSiteUrl(process.env.NEXT_PUBLIC_SITE_URL)
}

View file

@ -121,6 +121,34 @@ describe('ciSkipInvariants', () => {
expect(ciPr).not.toContain('ci-classify-production.mjs')
})
it('deploy.yml chains verify → deploy-staging → deploy-production with separate environments (OPS103)', () => {
// One dispatch, one verify, two separately approved deploys. The gate is
// the GitHub Environment (reviewer on production, configured in the repo —
// not in this YAML); the chain is needs-based so a red staging fail-closes
// production. Comments are stripped first: the header prose also mentions
// these keys, and it must never satisfy the pin.
const raw = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
const deploy = raw
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.join('\n')
expect(deploy).toContain(' deploy-staging:')
expect(deploy).toContain(' deploy-production:')
expect(deploy).toContain(' environment: staging')
expect(deploy).toContain(' environment: production')
expect(deploy).toContain(' needs: [deploy-staging]')
expect(deploy).toContain(' TEQO_ENV: staging')
expect(deploy).toContain(' TEQO_ENV: production')
expect(deploy).toContain("github.ref == 'refs/heads/main'")
expect(deploy).toContain("needs.deploy-staging.result == 'success'")
// Deploys share the homeserver compose/workspace: serialize them across
// runs without cancelling an in-flight deploy, and queue (never cancel) a
// previously pending one (`queue: max`).
expect(deploy).toContain(' group: deploy-homeserver')
expect(deploy).toContain(' cancel-in-progress: false')
expect(deploy).toContain(' queue: max')
})
it('every campaign domain dir is covered by the e2e affected manifest', () => {
const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), {
withFileTypes: true,

View file

@ -50,14 +50,14 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
expect(script).toContain('id=payload_secret,env=PAYLOAD_SECRET')
})
it('reaches the prod DB from the build (loopback proxy on the compose network)', () => {
it('reaches the env DB from the build (loopback proxy on the compose network)', () => {
// BuildKit rejects `--network <bridge>`; the build uses --network host and
// a socat proxy (on stack_default, published on the host loopback) with a
// rewritten DATABASE_URL.
// rewritten DATABASE_URL. OPS103 parameterized the proxy per environment.
expect(script).toContain('--network host')
expect(script).toContain('teqo-1313-build-proxy')
expect(script).toContain('$TEQO_BUILD_PROXY')
expect(script).toContain('TCP:postgres:5432')
expect(script).toContain('127.0.0.1:5433')
expect(script).toContain('$TEQO_BUILD_PROXY_PORT')
})
it('applies migrations BEFORE the runner build — static generation reads the new schema (OPS66)', () => {
@ -66,14 +66,16 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
// failed again). The migrator stage never runs `next build`, so it must
// build first; the compose swap feeds the new migrator image to the
// maintenance service; then migrate runs; only then the runner builds
// against the migrated DB.
// against the migrated DB. OPS103 anchors the markers on the invocation
// itself (the raw `teqo-1313-migrate` literal now appears first in the
// environment map at the top of the script).
const migratorBuildIndex = script.indexOf('build_image migrator')
const swapIndex = script.indexOf('compose swap')
const migrateIndex = script.indexOf('teqo-1313-migrate')
const migrateIndex = script.indexOf('--profile maintenance run --rm')
const runnerBuildIndex = script.indexOf('build_image runner')
// The rollback helper also contains a `compose up -d` (before the migrate
// step); the REAL rollout is the last occurrence.
const upIndex = script.lastIndexOf('docker compose up -d teqo-1313')
const upIndex = script.lastIndexOf('docker compose up -d "$TEQO_CONTAINER"')
for (const i of [migratorBuildIndex, swapIndex, migrateIndex, runnerBuildIndex, upIndex]) {
expect(i).toBeGreaterThan(-1)
}
@ -154,3 +156,84 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
expect(builderStage).not.toContain('pnpm exec payload generate:importmap')
})
})
describe('scripts/deploy-homeserver.sh environment parameterization (OPS103)', () => {
const productionBlock = script.slice(
script.indexOf(' production)'),
script.indexOf(' staging)'),
)
const stagingBlock = script.slice(script.indexOf(' staging)'), script.indexOf(' *)'))
const fallbackBlock = script.slice(script.indexOf(' *)'), script.indexOf('esac'))
it('defaults to production with the exact pre-OPS103 identities', () => {
// The canonical invocation `bash scripts/deploy-homeserver.sh <sha>` (no
// env) must keep deploying production with the same names and ports as
// before — a drift here is a production regression.
expect(script).toContain('TEQO_ENV="${TEQO_ENV:-production}"')
expect(productionBlock).toContain('TEQO_CONTAINER=teqo-1313')
expect(productionBlock).toContain('TEQO_MIGRATE_SERVICE=teqo-1313-migrate')
expect(productionBlock).toContain('teqo-1313.env')
expect(productionBlock).toContain('TEQO_IMAGE_REPO=teqo-1313')
expect(productionBlock).toContain('TEQO_BUILD_PROXY=teqo-1313-build-proxy')
expect(productionBlock).toContain('TEQO_BUILD_PROXY_PORT=5433')
expect(productionBlock).toContain('TEQO_SMOKE_BASE=http://localhost:1313')
})
it('maps the staging identities (container, service, env file, ports)', () => {
expect(stagingBlock).toContain('TEQO_CONTAINER=teqo-staging')
expect(stagingBlock).toContain('TEQO_MIGRATE_SERVICE=teqo-staging-migrate')
expect(stagingBlock).toContain('teqo-staging.env')
expect(stagingBlock).toContain('TEQO_IMAGE_REPO=teqo-staging')
expect(stagingBlock).toContain('TEQO_BUILD_PROXY=teqo-staging-build-proxy')
expect(stagingBlock).toContain('TEQO_BUILD_PROXY_PORT=5434')
expect(stagingBlock).toContain('TEQO_SMOKE_BASE=http://localhost:1314')
})
it('serializes both environments on ONE shared lock (compose/workspace are shared)', () => {
// Per-env locks would let staging and production build/swap concurrently
// on the same workspace and compose file (the workflow concurrency does
// not cover manual invocations on the host).
expect(script).toContain('DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-deploy.lock}"')
expect(script).not.toContain('/tmp/teqo-1313-deploy.lock')
expect(script).not.toContain('/tmp/teqo-staging-deploy.lock')
})
it('fails closed on an unknown TEQO_ENV (case fallback, before any deploy step)', () => {
expect(fallbackBlock).toContain('fatal "unknown TEQO_ENV')
expect(script.indexOf('fatal "unknown TEQO_ENV')).toBeLessThan(script.indexOf('exec 9>'))
})
it('freezes the derived env map against env-file retargeting (readonly)', () => {
expect(script).toContain(
'readonly TEQO_CONTAINER TEQO_MIGRATE_SERVICE TEQO_ENV_FILE TEQO_IMAGE_REPO',
)
expect(script).toContain(
'readonly TEQO_BUILD_PROXY TEQO_BUILD_PROXY_PORT TEQO_SMOKE_BASE DEPLOY_LOCK',
)
// The env files are sourced after the map; a readonly assignment error
// aborts the deploy (fail-closed) instead of silently switching targets.
const readonlyIndex = script.indexOf('readonly TEQO_CONTAINER')
const sourceIndex = script.indexOf('. "$TEQO_ENV_FILE"')
expect(readonlyIndex).toBeGreaterThan(-1)
expect(readonlyIndex).toBeLessThan(sourceIndex)
})
it('parameterizes image refs, migrate service, rollout, healthcheck and cleanup', () => {
expect(script).toContain('$TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA')
expect(script).toContain('$TEQO_REGISTRY/$TEQO_IMAGE_REPO:$SHA')
expect(script).toContain('run --rm "$TEQO_MIGRATE_SERVICE"')
expect(script).toContain('docker compose up -d "$TEQO_CONTAINER"')
expect(script).toContain('\'{{.State.Health.Status}}\' "$TEQO_CONTAINER"')
expect(script).toContain('grep -E "^($TEQO_REGISTRY/)?$TEQO_IMAGE_REPO(-migrator)?:"')
})
it('anchors the revision-label sed to the service block (never stamps the other env)', () => {
// A bare global replace would stamp THIS env's SHA onto the other
// environment's service, and the "already deployed" guard would read a
// lying revision. The range is re-read afterwards to fail closed when the
// service block has no label (a silent no-op would disable the guard).
expect(script).toContain('label_range="/^ $TEQO_CONTAINER:/,/^ [A-Za-z0-9_.-]+:/"')
expect(script).not.toContain('-e "s|org.opencontainers.image.revision')
expect(script).toContain('grep -q "org.opencontainers.image.revision: $SHA"')
})
})

View file

@ -0,0 +1,72 @@
// @vitest-environment node
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import robots from '@/app/robots'
import { isStagingSite, isStagingSiteUrl } from '@/lib/siteEnvironment'
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
describe('isStagingSiteUrl (OPS103)', () => {
it('detects staging hostnames', () => {
expect(isStagingSiteUrl('https://staging.jorgesolla1313.com.br')).toBe(true)
expect(isStagingSiteUrl('http://staging.example.com:3000')).toBe(true)
expect(isStagingSiteUrl('https://staging.jorgesolla1313.com.br/caminho?q=1')).toBe(true)
})
it('leaves production, staging-lookalikes and invalid origins indexable', () => {
expect(isStagingSiteUrl('https://jorgesolla1313.com.br')).toBe(false)
expect(isStagingSiteUrl('https://example.com/staging')).toBe(false)
expect(isStagingSiteUrl('https://xstaging.example.com')).toBe(false)
expect(isStagingSiteUrl(undefined)).toBe(false)
expect(isStagingSiteUrl(null)).toBe(false)
expect(isStagingSiteUrl('')).toBe(false)
expect(isStagingSiteUrl('not-a-url')).toBe(false)
})
})
describe('isStagingSite (OPS103)', () => {
afterEach(() => vi.unstubAllEnvs())
it('reads NEXT_PUBLIC_SITE_URL at call time (the identity each build carries)', () => {
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://staging.jorgesolla1313.com.br')
expect(isStagingSite()).toBe(true)
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://jorgesolla1313.com.br')
expect(isStagingSite()).toBe(false)
})
})
describe('staging noindex wiring (OPS103)', () => {
afterEach(() => vi.unstubAllEnvs())
it('robots() disallows every path on staging and allows production', () => {
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://staging.jorgesolla1313.com.br')
expect(robots().rules).toEqual({ userAgent: '*', disallow: '/' })
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://jorgesolla1313.com.br')
expect(robots().rules).toEqual({ userAgent: '*', allow: '/' })
})
it('robots.ts lives at the app-dir root and consumes the helper', () => {
// Next 15 only recognizes metadata route files at the app-dir root (route
// groups are not part of the match), so this path is load-bearing.
const robotsSource = readFileSync(join(repoRoot, 'src/app/robots.ts'), 'utf8')
expect(robotsSource).toContain("from '@/lib/siteEnvironment'")
expect(robotsSource).toContain('isStagingSite')
})
it('the frontend layout flips robots.index off only for staging', () => {
const layout = readFileSync(join(repoRoot, 'src/app/(frontend)/layout.tsx'), 'utf8')
expect(layout).toContain("from '@/lib/siteEnvironment'")
// Pin the exact ternary: presence of `index: false` and `index: true`
// somewhere would survive an inverted condition.
expect(layout).toContain(
'robots: isStagingSite() ? { index: false, follow: false } : { index: true, follow: true },',
)
})
})