mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(OPS103): staging no homeserver com verify único e aprovações separadas (Closes #968)
This commit is contained in:
parent
907b9ba96b
commit
709eea9d91
15 changed files with 732 additions and 161 deletions
|
|
@ -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
|
||||
|
|
|
|||
116
.github/workflows/deploy.yml
vendored
116
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-12-ops103.md
Normal file
1
docs/changelog/2026-09-12-ops103.md
Normal 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).
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
131
docs/plans/ops103-staging-homeserver-impl.md
Normal file
131
docs/plans/ops103-staging-homeserver-impl.md
Normal 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
|
||||
|
|
@ -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")"
|
||||
|
|
|
|||
|
|
@ -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
20
src/app/robots.ts
Normal 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: '/' },
|
||||
}
|
||||
}
|
||||
25
src/lib/siteEnvironment.ts
Normal file
25
src/lib/siteEnvironment.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
72
tests/unit/siteEnvironment.unit.spec.ts
Normal file
72
tests/unit/siteEnvironment.unit.spec.ts
Normal 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 },',
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue