diff --git a/.env.example b/.env.example index 2a227ab9..5e688ba3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2d312cfd..fbebaa02 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/AGENTS-infra.md b/AGENTS-infra.md index 6c8769be..d7a43b34 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -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 `) 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. diff --git a/AGENTS.md b/AGENTS.md index 2f90f43b..52f69ba6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- 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/curated, skips via `ci-scope.mjs`; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechado `unmapped-risk`, e unit/int `changed` com seleção vazia cai em full via `vitest-changed-or-full.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **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 script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. +- 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/curated, skips via `ci-scope.mjs`; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechado `unmapped-risk`, e unit/int `changed` com seleção vazia cai em full via `vitest-changed-or-full.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **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, intocado e rodado uma vez por commit) → `deploy-staging` (`environment: staging`, sem reviewer) → `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`; staging vermelho não deixa produção rodar — OPS103): 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 vivem só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from the environment's env file), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 77796f22..cd207a12 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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 ` **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 ` **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 ` 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 ` 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 diff --git a/docs/changelog/2026-09-12-ops103.md b/docs/changelog/2026-09-12-ops103.md new file mode 100644 index 00000000..9d915f24 --- /dev/null +++ b/docs/changelog/2026-09-12-ops103.md @@ -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). diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index d924fb27..d83eff8a 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -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 ` **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 ` — 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 ` — + 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--migrate +` → 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-`. +compose anterior fica em `~/stack/docker-compose.yml.pre-` +(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: docker compose up -d teqo-1313 ``` +Para **staging**, o mesmo procedimento com as identidades do ambiente +(`teqo-staging`, `localhost:5000/teqo-staging:`, +`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:-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:`) 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:`) 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:` (+ `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:` 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" diff --git a/docs/plans/ops103-staging-homeserver-impl.md b/docs/plans/ops103-staging-homeserver-impl.md new file mode 100644 index 00000000..76a2aec6 --- /dev/null +++ b/docs/plans/ops103-staging-homeserver-impl.md @@ -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
environment: staging · TEQO_ENV=staging"] + S -->|success| P["deploy-production
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/-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 diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index 2209b237..bbd6fa7f 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -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 # (The Forgejo-era invocation `ssh homeserver "bash -s -- " < 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 }" +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 ` (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):`) 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:` 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):`) 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:` 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 || 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")" diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 289753c0..a39a4329 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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 { ], 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', diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 00000000..a3b04d6f --- /dev/null +++ b/src/app/robots.ts @@ -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: '/' }, + } +} diff --git a/src/lib/siteEnvironment.ts b/src/lib/siteEnvironment.ts new file mode 100644 index 00000000..935eaeda --- /dev/null +++ b/src/lib/siteEnvironment.ts @@ -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) +} diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 59c19993..3d89070c 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -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, diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 9c12c49c..e8ee6bfb 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -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 `; 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 ` (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"') + }) +}) diff --git a/tests/unit/siteEnvironment.unit.spec.ts b/tests/unit/siteEnvironment.unit.spec.ts new file mode 100644 index 00000000..92949b86 --- /dev/null +++ b/tests/unit/siteEnvironment.unit.spec.ts @@ -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 },', + ) + }) +})