{
 "repo": "melgarafael/DeskcommCRM",
 "pr:1170": {
  "author": {
   "id": "MDQ6VXNlcjc1NTU2MjQy",
   "is_bot": false,
   "login": "webtecnica",
   "name": ""
  },
  "headRefOid": "88d44de6bbe2964c2123a859fc59248c877476d0",
  "isCrossRepository": true,
  "mergeStateStatus": "BLOCKED",
  "mergeable": "MERGEABLE",
  "number": 1170,
  "state": "OPEN"
 },
 "protecao": [
  "verify",
  "build-and-size",
  "invariants",
  "e2e",
  "imagens-ok"
 ],
 "main_head": "95462bd6e563ea50c029c548f71cc3d82a643aeb",
 "check_runs:95462bd6e563ea50c029c548f71cc3d82a643aeb": [
  {
   "id": 105638438948,
   "name": "tick",
   "status": "completed",
   "conclusion": "skipped",
   "started_at": "2026-09-18T14:33:05Z",
   "completed_at": "2026-09-18T14:33:04Z",
   "suite": 95756230988
  },
  {
   "id": 105632499274,
   "name": "Analyze (javascript-typescript)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:44Z",
   "completed_at": null,
   "suite": 95751173075
  },
  {
   "id": 105632499219,
   "name": "Analyze (actions)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:44Z",
   "completed_at": null,
   "suite": 95751173075
  },
  {
   "id": 105632498962,
   "name": "Analyze (python)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:44Z",
   "completed_at": null,
   "suite": 95751173075
  },
  {
   "id": 105632496774,
   "name": "abrir-pr-de-release",
   "status": "completed",
   "conclusion": "skipped",
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": "2026-09-18T14:15:43Z",
   "suite": 95751174715
  },
  {
   "id": 105632496303,
   "name": "e2e-parte (1)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751175069
  },
  {
   "id": 105632496267,
   "name": "e2e-parte (2)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751175069
  },
  {
   "id": 105632496002,
   "name": "invariants-majors (17)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174887
  },
  {
   "id": 105632495969,
   "name": "e2e-parte (3)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751175069
  },
  {
   "id": 105632495963,
   "name": "verify",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174887
  },
  {
   "id": 105632495924,
   "name": "a-tag-veio-da-main",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174896
  },
  {
   "id": 105632495747,
   "name": "build-and-size",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174957
  },
  {
   "id": 105632495602,
   "name": "invariants-majors (15)",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174887
  },
  {
   "id": 105632495310,
   "name": "cortar-tag",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:15:43Z",
   "completed_at": null,
   "suite": 95751174715
  }
 ],
 "runs:95462bd6e563ea50c029c548f71cc3d82a643aeb": [
  {
   "id": 35356926526,
   "name": "relogio",
   "path": ".github/workflows/relogio.yml",
   "event": "schedule",
   "status": "completed",
   "conclusion": "skipped",
   "created_at": "2026-09-18T14:33:04Z",
   "check_suite_id": 95756230988
  },
  {
   "id": 35355126940,
   "name": "release",
   "path": ".github/workflows/release.yml",
   "event": "push",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:43Z",
   "check_suite_id": 95751174715
  },
  {
   "id": 35355127013,
   "name": "ci",
   "path": ".github/workflows/ci.yml",
   "event": "push",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:43Z",
   "check_suite_id": 95751174887
  },
  {
   "id": 35355127026,
   "name": "perf",
   "path": ".github/workflows/perf.yml",
   "event": "push",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:43Z",
   "check_suite_id": 95751174957
  },
  {
   "id": 35355126996,
   "name": "Publicar imagem Docker (GHCR)",
   "path": ".github/workflows/publish-image.yml",
   "event": "push",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:43Z",
   "check_suite_id": 95751174896
  },
  {
   "id": 35355127057,
   "name": "e2e",
   "path": ".github/workflows/e2e.yml",
   "event": "push",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:43Z",
   "check_suite_id": 95751175069
  },
  {
   "id": 35355126431,
   "name": "Push on main",
   "path": "dynamic/github-code-scanning/codeql",
   "event": "dynamic",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T14:15:42Z",
   "check_suite_id": 95751173075
  }
 ],
 "check_runs:88d44de6bbe2964c2123a859fc59248c877476d0": [
  {
   "id": 105635282344,
   "name": "promover-stable",
   "status": "completed",
   "conclusion": "skipped",
   "started_at": "2026-09-18T14:23:55Z",
   "completed_at": "2026-09-18T14:23:54Z",
   "suite": 95717918952
  },
  {
   "id": 105635281023,
   "name": "imagens-ok",
   "status": "queued",
   "conclusion": null,
   "started_at": "2026-09-18T14:23:54Z",
   "completed_at": null,
   "suite": 95717918952
  },
  {
   "id": 105627177408,
   "name": "e2e",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T14:23:01Z",
   "completed_at": "2026-09-18T14:23:17Z",
   "suite": 95717918985
  },
  {
   "id": 105606741254,
   "name": "imagem-do-app-sobe",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T14:20:01Z",
   "completed_at": "2026-09-18T14:23:54Z",
   "suite": 95717918952
  },
  {
   "id": 105606741251,
   "name": "build-and-push (deskcomm-worker, Dockerfile.worker, DeskcommCRM worker)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T14:03:41Z",
   "completed_at": "2026-09-18T14:06:03Z",
   "suite": 95717918952
  },
  {
   "id": 105606741225,
   "name": "build-and-push (deskcommcrm, Dockerfile, DeskcommCRM)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T14:14:54Z",
   "completed_at": "2026-09-18T14:20:57Z",
   "suite": 95717918952
  },
  {
   "id": 105606741198,
   "name": "build-and-push (deskcomm-scheduler, Dockerfile.scheduler, DeskcommCRM scheduler)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T13:41:08Z",
   "completed_at": "2026-09-18T13:41:29Z",
   "suite": 95717918952
  },
  {
   "id": 105606741157,
   "name": "imagens-de-fundo-sobem",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T13:37:37Z",
   "completed_at": "2026-09-18T13:39:57Z",
   "suite": 95717918952
  },
  {
   "id": 105606437556,
   "name": "invariants",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T14:03:34Z",
   "completed_at": "2026-09-18T14:03:37Z",
   "suite": 95717918500
  },
  {
   "id": 105593758325,
   "name": "acolher",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T12:46:47Z",
   "completed_at": "2026-09-18T12:46:51Z",
   "suite": 95717918466
  },
  {
   "id": 105593757506,
   "name": "a-tag-veio-da-main",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T12:55:56Z",
   "completed_at": "2026-09-18T12:55:59Z",
   "suite": 95717918952
  },
  {
   "id": 105593757427,
   "name": "e2e-parte (3)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T13:31:52Z",
   "completed_at": "2026-09-18T14:00:04Z",
   "suite": 95717918985
  },
  {
   "id": 105593757391,
   "name": "e2e-parte (2)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T13:18:19Z",
   "completed_at": "2026-09-18T13:46:17Z",
   "suite": 95717918985
  },
  {
   "id": 105593757259,
   "name": "build-and-size",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T12:38:05Z",
   "completed_at": "2026-09-18T12:41:09Z",
   "suite": 95717918874
  },
  {
   "id": 105593757114,
   "name": "invariants-majors (15)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T12:47:03Z",
   "completed_at": "2026-09-18T12:54:48Z",
   "suite": 95717918500
  },
  {
   "id": 105593757093,
   "name": "e2e-parte (1)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T13:03:28Z",
   "completed_at": "2026-09-18T13:18:11Z",
   "suite": 95717918985
  },
  {
   "id": 105593756972,
   "name": "verify",
   "status": "completed",
   "conclusion": "cancelled",
   "started_at": "2026-09-18T12:46:35Z",
   "completed_at": "2026-09-18T13:01:50Z",
   "suite": 95717918500
  },
  {
   "id": 105593756555,
   "name": "invariants-majors (17)",
   "status": "completed",
   "conclusion": "success",
   "started_at": "2026-09-18T12:37:46Z",
   "completed_at": "2026-09-18T12:45:36Z",
   "suite": 95717918500
  }
 ],
 "runs:88d44de6bbe2964c2123a859fc59248c877476d0": [
  {
   "id": 35343237145,
   "name": "perf",
   "path": ".github/workflows/perf.yml",
   "event": "pull_request",
   "status": "completed",
   "conclusion": "success",
   "created_at": "2026-09-18T12:10:03Z",
   "check_suite_id": 95717918874
  },
  {
   "id": 35343236989,
   "name": "ci",
   "path": ".github/workflows/ci.yml",
   "event": "pull_request",
   "status": "completed",
   "conclusion": "cancelled",
   "created_at": "2026-09-18T12:10:03Z",
   "check_suite_id": 95717918500
  },
  {
   "id": 35343236976,
   "name": "acolhida",
   "path": ".github/workflows/acolhida.yml",
   "event": "pull_request_target",
   "status": "completed",
   "conclusion": "success",
   "created_at": "2026-09-18T12:10:03Z",
   "check_suite_id": 95717918466
  },
  {
   "id": 35343237176,
   "name": "Publicar imagem Docker (GHCR)",
   "path": ".github/workflows/publish-image.yml",
   "event": "pull_request",
   "status": "queued",
   "conclusion": null,
   "created_at": "2026-09-18T12:10:03Z",
   "check_suite_id": 95717918952
  },
  {
   "id": 35343237188,
   "name": "e2e",
   "path": ".github/workflows/e2e.yml",
   "event": "pull_request",
   "status": "completed",
   "conclusion": "success",
   "created_at": "2026-09-18T12:10:03Z",
   "check_suite_id": 95717918985
  }
 ],
 "workflow:.github/workflows/perf.yml@2026-09-18T12:10:03Z": "name: perf\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\n# O GITHUB_TOKEN deste job serve a `actions/checkout` (clone) e a\n# `actions/setup-node` (manifest de `actions/node-versions`, repo público).\n# `pnpm build` é local e o relatório de tamanho é escrita em arquivo do runner\n# (`>> $GITHUB_STEP_SUMMARY`), sem token. Ver o comentário em ci.yml para o\n# porquê de o bloco ficar no topo.\npermissions:\n  contents: read\n\njobs:\n  build-and-size:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - uses: actions/checkout@v7\n\n      - uses: ./.github/actions/preparar-node\n        # O teto do preâmbulo mora aqui porque passo de composite action não\n        # aceita `timeout-minutes`. Razão e medições: o cabeçalho da action.\n        timeout-minutes: 5\n\n      - name: Build\n        run: pnpm build\n        env:\n          NEXT_TELEMETRY_DISABLED: \"1\"\n          # \"off\", não vazio: vazio seleciona o Sentry DA COMUNIDADE\n          # (lib/sentry/dsn.ts:8-10). A intenção aqui sempre foi desligar, e\n          # com aspas vazias ela não era alcançada — o build seguia com o DSN\n          # padrão embutido.\n          SENTRY_DSN: \"off\"\n\n      - name: Report bundle sizes\n        run: |\n          {\n            echo \"## Build output sizes\"\n            du -sh .next 2>/dev/null || true\n            if [ -d .next/static ]; then du -sh .next/static; fi\n          } >> \"$GITHUB_STEP_SUMMARY\"\n\n      # NOTE: Lighthouse CI + bundle-analyzer thresholds deferred (S-12.05 follow-up).\n",
 "workflow:.github/workflows/ci.yml@2026-09-18T12:10:03Z": "name: ci\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\n# O GITHUB_TOKEN destes jobs serve a `actions/checkout` (clone) e a\n# `actions/setup-node` (manifest de `actions/node-versions`, repo público) —\n# `contents: read` cobre os dois, que é o escopo que o próprio modelo de dados do\n# CodeQL prescreve para essas duas actions. O `cache: pnpm` NÃO usa este token\n# (usa ACTIONS_RUNTIME_TOKEN), `pnpm/action-setup` não tem input de token, e\n# nenhum passo `run:` enxerga o token — ele só entra no ambiente via\n# `secrets.GITHUB_TOKEN`, que não aparece em nenhum destes workflows.\n#\n# No topo, e não por job, porque os dois jobs precisam do mesmo escopo e job\n# acrescentado depois nasce restrito em vez de herdar o default do repositório.\n# Esse default hoje é `read` (medido em actions/permissions/workflow), então isto\n# não corrige vazamento em vigor: tira a garantia de uma configuração que muda\n# fora do repo, num clique, sem PR e sem deixar rastro aqui. `packages: write`\n# continua por job em publish-image.yml — mesma regra, menor escopo que precisa.\npermissions:\n  contents: read\n\njobs:\n  verify:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - uses: actions/checkout@v7\n\n      - uses: ./.github/actions/preparar-node\n        # O teto do preâmbulo mora aqui porque passo de composite action não\n        # aceita `timeout-minutes`. Razão e medições: o cabeçalho da action.\n        timeout-minutes: 5\n\n      - name: Typecheck\n        run: pnpm typecheck\n\n      - name: Lint\n        run: pnpm lint\n\n      # Invariante 1 de docs/doctrine/restricao-de-canal.md: nenhuma feature\n      # nomeia um provider. Step próprio (e não só dentro de `gov:verify`)\n      # porque é o CI que gateia PR — `gov:verify` não é invocado por nenhum\n      # workflow, e mecanismo que não roda no gate não protege nada.\n      - name: Channel provider leak\n        run: pnpm lint:channels\n\n      # Auditoria de autorização e papel: impede comparações diretas de papel\n      # fora de lib/auth/ que contornem o gate de MFA e requireRole().\n      - name: Role rank audit\n        run: pnpm lint:role-rank\n\n      # O merge LIMPO não chama hook nenhum: dois PRs com migrations de nomes\n      # diferentes não conflitam textualmente, o commit nasce sem `pre-commit` e o\n      # número duplicado entra calado (o #804 entrou assim com o `0241`; o `0161` foi\n      # disputado por cinco PRs — issue #285). A régua por número era só hook local:\n      # `core.hooksPath` é configuração local, NÃO versionada, que fork nenhum executa.\n      # Step próprio, como os dois acima, porque é o CI que gateia PR — `gov:verify`\n      # não é invocado por workflow nenhum. Mesma régua do hook do contribuidor,\n      # contra o REMOTO (e o script busca a base: o checkout daqui não traz origin/main).\n      - name: Colisão de número de migration\n        run: pnpm checar:colisao-de-migration\n\n      - name: Unit tests\n        run: pnpm test:unit\n\n\n      # Scripts do kit self-host num repo git descartável (docker/crontab\n      # dublados). É o único gate que exercita o update.sh — um erro aqui\n      # rebobina a instalação de quem hospeda, e nenhum teste TS o veria.\n      #\n      # Desde 2026-08-08 inclui `hostgator-setup-kit/test-validators.sh`, os 163\n      # casos que cobrem o install.sh. Eles existiam e NÃO rodavam em job nenhum\n      # (issue #191): dois defeitos que tornavam a instalação impossível —\n      # escolher OpenRouter travava o instalador num laço, escolher OpenAI\n      # deixava o .env pela metade — passaram por todos os checks verdes. 15s.\n      - name: Kit self-host (bash)\n        run: pnpm test:shell\n\n  # Gate de isolamento multi-tenant + invariantes de governança.\n  # Job separado de propósito: roda em paralelo ao verify (não atrasa o feedback\n  # rápido) e vira um check nomeado, que é o que a doutrina de RLS exige provar.\n  # Cada perna sobe `pgvector/pgvector` na SUA major, aplica supabase/baseline.sql\n  # em modo install (ON_ERROR_STOP=1) e update (idempotência), e roda\n  # tests/invariants/** contra ele. Invocar via `pnpm test:db` é o que põe\n  # node_modules/.bin no PATH que o script herda para chamar `vitest`.\n  #\n  # AS DUAS MAJORS, e por que as duas (issue #454): pg15 é o PISO que dizemos\n  # suportar — o piso real do `baseline.sql`, `security_invoker` em view — e é\n  # onde quem digita `pnpm test:db` na própria máquina cai por padrão; pg17 é a\n  # major de onde o `pg_dump` do baseline saiu e o que o Supabase entrega a\n  # projeto novo. Rodar só no piso mediu uma instalação que não era a do dump;\n  # rodar só na de cima era o estado até a #422. A major entra por\n  # `TEST_DB_IMAGE`, a MESMA variável dos dois scripts, e o PADRÃO dela continua\n  # sendo o piso (`scripts/test-db.sh`) — quem guarda isso é\n  # tests/unit/baseline-no-piso-do-postgres.test.ts.\n  invariants-majors:\n    runs-on: ubuntu-latest\n    # Eram 20 min quando a perna tinha UMA passada; agora ela tem duas\n    # (`test:db` + `test:db:update`) e o teto acompanha. O 30 é DECLARADO, não\n    # medido — não há Docker onde esta matriz foi escrita —, e existe para o\n    # vermelho continuar falando de schema: mantido em 20, a perna morreria por\n    # relógio no meio da segunda passada.\n    timeout-minutes: 30\n    strategy:\n      # `false` e não o padrão (`true`): com `fail-fast`, a primeira major que\n      # reprovasse CANCELARIA a outra e o relatório diria \"cancelled\" em vez de\n      # medir as duas. Cobertura declarada que o CI cancela é o modo de falha\n      # que esta issue existe para fechar.\n      fail-fast: false\n      matrix:\n        pg: [\"15\", \"17\"]\n    env:\n      # Uma variável, os dois scripts: `scripts/test-db.sh` e\n      # `scripts/test-update-com-dados.sh` leem esta para escolher a imagem.\n      TEST_DB_IMAGE: pgvector/pgvector:pg${{ matrix.pg }}\n    steps:\n      - uses: actions/checkout@v7\n\n      - uses: ./.github/actions/preparar-node\n        # O teto do preâmbulo mora aqui porque passo de composite action não\n        # aceita `timeout-minutes`. Razão e medições: o cabeçalho da action.\n        timeout-minutes: 5\n\n      - name: RLS isolation + governance invariants (pg${{ matrix.pg }})\n        run: pnpm test:db\n\n      # O `update.sh` do cliente roda sobre um banco COM DADOS, e nenhum gate\n      # exercitava isso: `test:db` aplica o baseline num banco VAZIO, então\n      # constraint que só quebra com linha existente passava verde (é o achado\n      # que o cabeçalho de scripts/test-update-com-dados.sh mede). O script\n      # existia desde 2026-08-27 e NENHUM workflow o chamava (issue #454): aqui\n      # ele passa a ser gate, nas duas majors, pela mesma variável de imagem.\n      - name: update.sh sobre banco COM DADOS (pg${{ matrix.pg }})\n        run: pnpm test:db:update\n\n  # O NOME DESTE JOB É O CHECK OBRIGATÓRIO, e é por isso que ele existe — mesmo\n  # padrão do `e2e` em e2e.yml e do `imagens-ok` em publish-image.yml.\n  #\n  # A branch protection da `main` exige um check chamado exatamente `invariants`\n  # (a lista dos cinco — `verify, build-and-size, invariants, e2e, imagens-ok` —\n  # está na triagem do CI; token de fork não lê branch protection pela API, e por\n  # isso ela é citada, não remedida aqui). Job de matrix se\n  # chama `invariants-majors (15)` e `invariants-majors (17)`: se a matriz\n  # tivesse ficado com o nome `invariants`, a proteção passaria a esperar para\n  # sempre um check que nenhum run produz, e NENHUM PR mergearia.\n  invariants:\n    if: always()\n    needs: [invariants-majors]\n    runs-on: ubuntu-latest\n    # Nenhuma permissão: este job só lê o resultado de `needs`.\n    permissions: {}\n    steps:\n      - name: Falhar se qualquer major não passou\n        run: |\n          echo \"invariants-majors: ${{ needs.invariants-majors.result }}\"\n          # `success` é o único desfecho aceito: o agregado de uma matrix só é\n          # `success` com TODAS as pernas verdes, então uma major sozinha\n          # reprovando derruba o check obrigatório. `skipped` também reprova, de\n          # propósito — perna pulada não mediu nada.\n          [ \"${{ needs.invariants-majors.result }}\" = \"success\" ]\n",
 "workflow:.github/workflows/acolhida.yml@2026-09-18T12:10:03Z": "# A ACOLHIDA DE CONTRIBUIDOR EXTERNO — SEM DEPENDER DE ALGUÉM ESTAR TRIANDO.\n#\n# ## O gargalo medido\n#\n# A taxa histórica de recusa de PR de fork aqui é ZERO: o que trava não é\n# qualidade, é o tempo até alguém olhar. Três pessoas fecharam o PRÓPRIO PR\n# pedindo desculpa por \"ruído\" (#515, #547, #588) — nenhuma tinha errado, e a\n# última fechou 36 segundos depois de abrir, levando junto um bug de primeira\n# impressão achado com cliente real travado. Antes disso, a única coisa que este\n# projeto dizia a quem contribui era um check `Vercel` vermelho que não é culpa\n# dela.\n#\n# Este workflow diz três coisas, e nada além: os workflows podem ficar parados\n# esperando liberação; o que trava o merge são os checks marcados obrigatórios no\n# próprio PR de quem lê; e quando vem o veredito humano.\n#\n# O PRAZO PROMETIDO NÃO É CHUTE — foi medido antes de entrar no texto.\n#\n# A doutrina proíbe prometer o que não se cumpre, então \"em até um dia útil\"\n# passou pela régua. Medido em 2026-09-05, sobre os PRs de FORK dos últimos 60,\n# contando de `createdAt` até o primeiro comentário humano (descartados\n# vercel[bot], ecc-tools[bot], github-actions e o próprio autor):\n#\n#   n = 13 PRs de fork com resposta humana\n#   mediana 1,0h · p90 7,5h · máximo 10,5h · dentro de 24h: 13/13\n#\n# O pior caso tem folga de mais de 2x contra a promessa. Se um dia essa conta\n# virar, o texto aqui vira mentira ANTES de alguém notar — então refaça a\n# medição em vez de confiar nesta linha:\n#\n#   gh pr list --state all --limit 60 --json number,createdAt,author,headRepositoryOwner\n#   (filtre headRepositoryOwner != melgarafael, pegue o 1º comentário não-bot)\n#\n# veredito. O molde é `triagem/references/resposta-ao-contribuidor.md`, seção\n# \"Acolhida\" — mudar o texto lá sem mudar aqui faz a versão humana e a\n# automática divergirem, e `tests/unit/acolhida-nao-toca-no-fork.test.ts`\n# reprova quando o essencial some daqui.\n#\n# ## Por que a acolhida pode ser automática: ela NÃO AVALIA\n#\n# Ela não pode estar errada sobre o mérito porque não fala do mérito. Um\n# \"parece ótimo!\" postado antes de medir é a semente do carimbo, e carimbo é\n# pior que silêncio — porque lava. Toda frase daqui é sobre o PROCESSO.\n#\n# ## A parte perigosa, e ela decide o desenho inteiro\n#\n# `pull_request_target` roda no contexto da BASE: com os segredos do repositório\n# e com permissão de escrita, num evento disparado por quem é de fora. É um\n# vetor de escalada conhecido, e a única defesa que não depende de vigilância é\n# o job não encostar em nada que venha do PR:\n#\n#   1. ZERO CHECKOUT, e zero `uses:`. Este job não baixa uma linha de código —\n#      nem do fork, nem da base, nem de action de terceiro. Sem `actions/checkout`\n#      não existe `npm install`, não existe script de `package.json`, não existe\n#      `.github/actions/*` do fork: não há bytes do contribuidor no runner.\n#   2. PERMISSÃO MÍNIMA E EXPLÍCITA: `pull-requests: write` e nada mais. Declarar\n#      o bloco zera todo escopo não citado — sem `contents`, sem `actions`, sem\n#      `packages`. O token deste job comenta num PR e não sabe fazer mais nada.\n#      No topo, e não por job, pela mesma razão escrita em `ci.yml`: job\n#      acrescentado depois nasce restrito em vez de herdar o default do\n#      repositório, que muda fora do repo, num clique, sem PR.\n#   3. NENHUM DADO DO PR DENTRO DE `run:`. Título, corpo e nome de branch são\n#      texto escolhido por quem abriu o PR; interpolados por `${{ }}` viram\n#      CÓDIGO no shell antes de o shell existir. O único dado que este job usa é\n#      o login, e ele entra por `env:` e é citado com aspas — nunca por `${{ }}`\n#      no corpo do `run:`.\n#   4. O texto fixo vai por heredoc COM ASPAS (`<<'MD'`). Sem as aspas, as crases\n#      da própria prosa (`` `gh pr checks` ``) viram substituição de comando.\n#\n# ## Por que o texto apresenta `--required` como atalho PARCIAL\n#\n# `gh pr checks <n> --required` mostra os obrigatórios que JÁ REPORTARAM, e só\n# esses: o que ainda não rodou não aparece na saída. Medido em 2026-09-17 — a\n# proteção da `main` exigia cinco contextos, e o comando listou DOIS no PR #1069\n# (`invariants`, `verify`), contra os cinco que a proteção da `main` exigia.\n# Escrever que ele \"mostra os mesmos\" da tela do PR faria alguém ler três verdes\n# no terminal e concluir que passou. Refaça a comparação em vez de confiar nesta\n# linha:\n#\n#   gh api repos/$REPO/branches/main/protection --jq '.required_status_checks.contexts'\n#   gh pr checks <n> --required --json name --jq '[.[].name]'\n#\n# O primeiro só responde a quem administra o repositório, e para quem não\n# administra o GitHub devolve 404 — que lê como \"não há proteção nenhuma\", e não\n# é isso. Medido em `gh api repos/vercel/next.js/branches/canary/protection`.\n#\n# ## O que este workflow DELIBERADAMENTE não faz: liberar o CI sozinho\n#\n# No primeiro PR de quem nunca contribuiu, os runs ficam em `action_required` e\n# `gh pr checks` NÃO os mostra — o PR parece não ter check nenhum. Dava para\n# aprovar sozinho (`POST /actions/runs/{id}/approve`), e isso está RECUSADO:\n#\n#   - aprovar é EXECUTAR o código do fork nos nossos runners; o `verify` roda\n#     `pnpm install` (scripts de ciclo de vida) e a suíte, tudo vindo do PR. O\n#     portão do GitHub existe exatamente para uma pessoa olhar antes disso, e\n#     automatizá-lo o remove justamente para a população que ele protege;\n#   - exigiria `actions: write` aqui dentro, inflando este workflow de \"sabe\n#     comentar\" para \"sabe aprovar e re-executar workflow\" — num arquivo\n#     `pull_request_target`, que já é o alvo mais valioso do repositório;\n#   - e rodaria em `opened`, antes de qualquer humano ter visto o diff.\n#\n# Então a acolhida só AVISA que os runs estão parados. Quem tria libera com:\n#   gh api \"repos/$REPO/actions/runs\" --jq '.workflow_runs[]|select(.status==\"action_required\")|.id'\n#   gh api --method POST \"repos/$REPO/actions/runs/<id>/approve\"\nname: acolhida\n\non:\n  # `opened` e só. `synchronize`/`reopened` re-disparariam a acolhida a cada push\n  # do contribuidor — e a boa-vinda que chega quatro vezes é ruído, não acolhida.\n  pull_request_target:\n    types: [opened]\n\npermissions:\n  pull-requests: write\n\njobs:\n  # `github.repository_owner` guarda o fork: este arquivo vai para a `main` de um\n  # produto self-host, e o fork de cada pessoa herda o workflow. Sem a guarda, um\n  # bot falaria pela gente no repositório de outra pessoa — prometendo o prazo de\n  # mantenedores que nunca concordaram com ele, e apontando um gate de merge que\n  # não é o dela. É `repository_owner` e não `repository` porque renomear o\n  # repositório não deve calar a acolhida; mudar de dono deve.\n  acolher:\n    if: github.repository_owner == 'melgarafael' && github.event.pull_request.head.repo.full_name != github.repository\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - name: Acolher quem abriu o PR\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          REPO: ${{ github.repository }}\n          PR: ${{ github.event.pull_request.number }}\n          LOGIN: ${{ github.event.pull_request.user.login }}\n        run: |\n          set -euo pipefail\n\n          # IDEMPOTÊNCIA REAL, e ela não é sobre re-execução: um humano pode ter\n          # acolhido antes (ou já ter dado o veredito). A triagem carimba TODO\n          # comentário com a âncora invisível `triagem-de-pr:v1:pass=N`; achar\n          # qualquer uma delas significa que a conversa já começou, e a boa-vinda\n          # do robô chegaria depois da pessoa.\n          #\n          # Falha aqui ABORTA de propósito. Se a listagem não respondeu, não\n          # sabemos se já há acolhida — e postar no escuro duplica. Vermelho\n          # visível vale mais que um comentário em dobro.\n          COMENTARIOS=\"$(mktemp)\"\n          gh api --paginate \"repos/${REPO}/issues/${PR}/comments\" --jq '.[].body' > \"${COMENTARIOS}\"\n          if grep -qF 'triagem-de-pr:v1:' \"${COMENTARIOS}\"; then\n            echo \"âncora de triagem já presente no PR #${PR} — a conversa já começou, nada a postar\"\n            exit 0\n          fi\n\n          # O login é o ÚNICO dado do PR que entra no texto, e ele entra como\n          # valor, não como código. O GitHub restringe login a alfanumérico com\n          # hífen interno, no máximo 39 caracteres; qualquer coisa fora disso não\n          # é um login, e vira saudação sem nome em vez de virar conteúdo.\n          SAUDACAO=\"Recebido — obrigado por isto.\"\n          if printf '%s' \"${LOGIN}\" | grep -qE '^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$'; then\n            SAUDACAO=\"Recebido, @${LOGIN} — obrigado por isto.\"\n          fi\n\n          CORPO=\"$(mktemp)\"\n          {\n            printf '<!-- triagem-de-pr:v1:pass=1 -->\\n'\n            printf '%s\\n\\n' \"${SAUDACAO}\"\n            # Heredoc COM aspas: nada aqui dentro é expandido pelo shell, e as\n            # crases da prosa continuam sendo crases.\n            cat <<'MD'\n          Uma coisa vai parecer erro seu e não é:\n\n          - No primeiro PR de quem nunca contribuiu aqui, os workflows ficam **parados esperando\n            liberação** — política do GitHub, não sua. Enquanto isso o PR parece não ter check nenhum\n            (nem o `gh pr checks` mostra os que estão nesse estado). Quem tria libera; você não precisa\n            fazer nada.\n\n          Quando eles rodarem, o que trava o merge são só os checks marcados **Required** aqui no seu\n          próprio PR — é essa lista que vale, não qualquer outro vermelho que apareça. No terminal,\n          `gh pr checks <número> --required` mostra os obrigatórios que **já reportaram**, e só esses:\n          enquanto um deles não rodou, ele não aparece ali. Se um reprovar, a saída dele diz o que\n          falta; se não estiver claro, comente aqui — não feche o PR.\n\n          Um mantenedor vai revisar de verdade — rodando os gates e reproduzindo o comportamento, não só\n          lendo o diff — e responde aqui **em até um dia útil**, com a medição junto, nunca com um \"acho\n          que\".\n\n          Esta mensagem é automática e não diz nada sobre o seu PR: ela é sobre o processo. O que vem\n          depois é pessoa.\n          MD\n          } > \"${CORPO}\"\n\n          gh api --method POST \"repos/${REPO}/issues/${PR}/comments\" \\\n            -F \"body=@${CORPO}\" --jq '.html_url'\n",
 "workflow:.github/workflows/publish-image.yml@2026-09-18T12:10:03Z": "name: Publicar imagem Docker (GHCR)\n\n# Builda as imagens GENÉRICAS que o self-hoster instala e publica no GitHub\n# Container Registry. O leigo puxa no VPS (não builda lá). Roda nos runners do\n# GitHub, então o build pesado nunca cai no VPS do usuário.\n#\n# São TRÊS imagens, e a razão de o worker e o scheduler estarem aqui é um defeito\n# real: o serviço `worker` não tinha `image:` no compose de produção, só `build:`.\n# Serviço build-only é pulado por `docker compose pull` e imune a `up -d` sem\n# `--build` — ele era construído na VPS de cada cliente, no install, e NENHUM\n# update.sh jamais o reconstruiu. O runtime do agente de IA congelava no código\n# do dia da instalação. Doutrina: docs/doctrine/packaging.md, invariante 1.\n\non:\n  push:\n    tags: [\"v*\"]\n    branches: [\"main\"]\n  # NÃO acrescente `release: types: [published]` aqui. Ele já esteve, e o efeito\n  # foi medido na v1.3.0: `gh release create` empurra a tag E publica a release,\n  # então o MESMO commit foi construído DUAS vezes, com 5 minutos de diferença.\n  #\n  #   19:53:04  evento=push     v1.3.0 -> publicou 1.3.0, 1.3 e stable\n  #   19:58:05  evento=release  v1.3.0 -> reconstruiu e MOVEU 1.3.0 e 1.3,\n  #                                       sem mover stable (a condição exige push)\n  #\n  # Duas consequências, e a segunda é a grave:\n  #\n  # 1. `stable` e `1.3.0` ficaram em digests DIFERENTES (mesmo `revision`\n  #    9bd59e9 e mesmo `version`, builds distintos) — quem comparasse os dois\n  #    concluiria, com razão, que são artefatos diferentes.\n  # 2. A tag de VERSÃO foi movida depois de publicada. A doutrina inteira se\n  #    apoia em \"instalação de cliente aponta para número de versão, nunca para\n  #    tag móvel\" (docs/doctrine/packaging.md, invariante 3) — e era o nosso\n  #    próprio workflow movendo o número. Republicar uma release antiga (editar\n  #    e salvar) reconstruiria e moveria aquela versão de novo.\n  #\n  # O gatilho de push da tag já cobre o fluxo real: a tag é sempre empurrada,\n  # com ou sem release. Vigiado por tests/unit/packaging-artefato-do-cliente.test.ts.\n  # Em PR a imagem é CONSTRUÍDA e não publicada. Sem isto, nada no PR consegue\n  # revelar que a mudança quebra a imagem — foi assim que um bump de `next`\n  # passou por `verify`, `build-and-size`, `invariants` e `e2e` (os quatro\n  # obrigatórios) e derrubou o build do Dockerfile na `main`: o `next build`\n  # dentro da imagem não enxerga `tests/` (`.dockerignore`), e a partir do\n  # next 16.3 ele typecheca os `*.test.ts` colocados. O artefato que o\n  # self-hoster instala era o único sem gate.\n  pull_request:\n  workflow_dispatch: {}\n\nenv:\n  REGISTRY: ghcr.io\n\njobs:\n  # Nenhuma tag publica sem estar CONTIDA na `main`.\n  #\n  # `on: push: tags: [\"v*\"]` reage a tag de QUALQUER ramo, e nenhuma das três\n  # condições do canal `stable` (mais abaixo) olha procedência — nem o\n  # `type=semver`, que publica `X.Y.Z` do mesmo jeito. A branch protection não\n  # cobre isto: ela vale para a branch `main`, não para refs de tag.\n  #\n  # E o alcance disso é o parque inteiro: `hostgator-setup-kit/agent.sh` oferece\n  # a MAIOR tag `v*` a toda VPS instalada, sem olhar de onde ela veio. Este repo\n  # já teve na árvore local uma tag `v1.4.0` de um FORK — ela existe hoje\n  # renomeada para `jmpo/v1.4.0` justamente para não colidir com a numeração\n  # daqui.\n  a-tag-veio-da-main:\n    runs-on: ubuntu-latest\n    # Sem o bloco, o token herda o default do REPOSITÓRIO — configuração que vive\n    # fora do repo. Mesma razão do `permissions: {}` de `imagens-ok`.\n    permissions:\n      contents: read\n    steps:\n      - name: A tag está contida na main?\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          set -euo pipefail\n          # Sem `if:` no job, de propósito: job pulado deixa `build-and-push`\n          # pulado também, e `imagens-ok` (que roda com `always()`) lê `skipped`\n          # como reprovação. Em PR e em push de branch este job RODA e sai zero.\n          if [ \"${GITHUB_REF_TYPE}\" != \"tag\" ]; then\n            echo \"não é push de tag (${GITHUB_REF_TYPE}) — nada a conferir\"\n            exit 0\n          fi\n          estado=$(gh api \"repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}\" --jq .status)\n          echo \"compare main...${GITHUB_SHA} → ${estado}\"\n          # `identical` = a tag é o topo da main; `behind` = está atrás dela, ou\n          # seja, contida. `ahead` e `diverged` são commit que a main não tem.\n          case \"$estado\" in\n            identical|behind) echo \"ok: o commit da tag está contido na main\" ;;\n            *)\n              echo \"::error::A tag ${GITHUB_REF_NAME} aponta para um commit que a main NÃO contém (${estado}).\"\n              echo \"::error::Publicar isso entregaria ao parque instalado código que nunca passou pelo CI da main.\"\n              exit 1\n              ;;\n          esac\n\n  build-and-push:\n    needs: [a-tag-veio-da-main]\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    strategy:\n      # fail-fast desligado: se o worker quebrar, ainda quero saber se o app\n      # também quebrou. Com ele ligado, o primeiro erro esconde os outros dois e\n      # o conserto vira uma rodada por imagem.\n      fail-fast: false\n      matrix:\n        include:\n          - name: deskcommcrm\n            dockerfile: Dockerfile\n            title: DeskcommCRM\n          - name: deskcomm-worker\n            dockerfile: Dockerfile.worker\n            title: DeskcommCRM worker\n          - name: deskcomm-scheduler\n            dockerfile: Dockerfile.scheduler\n            title: DeskcommCRM scheduler\n\n    steps:\n      - uses: actions/checkout@v7\n\n      - name: Log in to GHCR\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v4\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      # A versão que a imagem vai reportar em /api/v1/health. Numa tag v1.2.3 é\n      # \"1.2.3\"; fora de tag, o SHA curto — que ainda responde \"o que está\n      # rodando aqui?\", que é a pergunta que o campo existe para responder.\n      - name: Resolver APP_VERSION\n        id: ver\n        run: |\n          if [ \"${GITHUB_REF_TYPE}\" = \"tag\" ]; then\n            echo \"value=${GITHUB_REF_NAME#v}\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"value=${GITHUB_SHA::7}\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Docker metadata (tags/labels)\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.name }}\n          tags: |\n            type=ref,event=branch\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            # `enable={{is_default_branch}}` NÃO basta: o metadata-action o\n            # considera verdadeiro também num push de TAG, então toda release\n            # movia `latest` junto. Isso apagava a distinção que a doutrina\n            # inteira depende — `latest` = topo da main, `stable` = última\n            # release — e fazia `latest` oscilar entre os dois.\n            type=raw,value=latest,enable=${{ github.ref_type == 'branch' && github.ref_name == 'main' }}\n            # `stable` NÃO sai daqui, e a razão é a issue #488. Ele já saiu: era\n            # mais um `type=raw` nesta lista, e por isso CADA imagem movia o\n            # canal sozinha, ao terminar, sem saber se as irmãs conseguiram.\n            # Medido no corte da v1.12.0 — o job do app morreu em `Set up\n            # Buildx`, antes de compilar coisa alguma, enquanto worker e\n            # scheduler seguiram e moveram o ponteiro:\n            #\n            #   deskcommcrm          1.12.0   404\n            #   deskcommcrm          stable   sha256:0235d02b…  ← 1.11.0\n            #   deskcomm-worker      stable   sha256:66c7bde4…  ← 1.12.0\n            #   deskcomm-scheduler   stable   sha256:ac6b87cc…  ← 1.12.0\n            #\n            # Quem instalasse por `stable` naquela janela recebia app 1.11.0 com\n            # worker e scheduler 1.12.0. `fail-fast: true` não resolveria: quando\n            # o app falhou, as irmãs JÁ tinham publicado e JÁ tinham movido o\n            # canal — abortá-las não desfaz o que foi publicado.\n            #\n            # Publicar por NÚMERO é o que continua aqui, e é seguro: `1.12.0`\n            # pode existir para uma imagem e não para outra sem prejuízo, porque\n            # ninguém instala por um número que o instalador não escreveu. Mover\n            # o CANAL é ato do job `promover-stable`, lá embaixo, quando o\n            # conjunto está completo.\n          labels: |\n            org.opencontainers.image.title=${{ matrix.title }}\n\n      - name: Set up Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build e push (imagem genérica — sem segredos)\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ${{ matrix.dockerfile }}\n          # linux/amd64: o VPS HostGator e a imagem do WAHA são amd64.\n          platforms: linux/amd64\n          # PR de fork não tem permissão de escrita no GHCR, e publicar imagem\n          # de código não revisado seria pior que não gatear. Em PR: constrói\n          # (que é o gate) e descarta.\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            APP_VERSION=${{ steps.ver.outputs.value }}\n          # Cache por imagem: as três têm camadas diferentes e um escopo único\n          # faria uma sobrescrever o cache da outra a cada run.\n          cache-from: type=gha,scope=${{ matrix.name }}\n          cache-to: type=gha,mode=max,scope=${{ matrix.name }}\n          # Sem build-args de segredo: a imagem é genérica (NEXT_PUBLIC_* viram\n          # placeholder; valores reais entram em runtime no VPS do usuário).\n\n  # A imagem do app CONSTRÓI e MORRE ao subir? Este job existe porque isso\n  # aconteceu: `next@16.3.1` passou a resolver `@swc/helpers` por ESM, o file\n  # tracing do `output: standalone` levou 2 dos 108 arquivos do pacote, e o\n  # container entrou em crashloop com MODULE_NOT_FOUND. A imagem construiu,\n  # `imagens-ok` ficou verde, ela foi publicada — e o defeito só apareceu no\n  # `docker compose up` da VPS, com a produção fora do ar.\n  #\n  # \"Constrói\" nunca foi prova de \"sobe\". O gate mede o boot: erguer o\n  # container com env de mentira e exigir que o Next chegue a \"Ready\". Um 500\n  # depois disso é esperado (não há Supabase aqui) e não reprova — o que\n  # reprova é o processo não chegar a servir.\n  imagem-do-app-sobe:\n    needs: [a-tag-veio-da-main]\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@v7\n\n      - name: Set up Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build local da imagem do app (sem push)\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: Dockerfile\n          platforms: linux/amd64\n          push: false\n          load: true\n          tags: deskcomm-smoke:pr\n          build-args: |\n            APP_VERSION=smoke\n          # Mesmo escopo de cache do build-and-push: as camadas são idênticas,\n          # então na prática este job reaproveita aquele build em vez de repeti-lo.\n          cache-from: type=gha,scope=deskcommcrm\n\n      - name: O container chega a servir?\n        run: |\n          set -euo pipefail\n          docker run -d --name smoke -p 3000:3000 \\\n            -e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.supabase.co \\\n            -e NEXT_PUBLIC_SUPABASE_ANON_KEY=chave-de-mentira \\\n            -e SUPABASE_SERVICE_ROLE_KEY=chave-de-mentira \\\n            -e SUPABASE_DB_URL=postgres://u:p@127.0.0.1:5432/postgres \\\n            -e SENTRY_DSN=off \\\n            deskcomm-smoke:pr\n\n          # Espera o boot; falha rápido se o processo morreu (crashloop).\n          pronto=nao\n          for _ in $(seq 1 40); do\n            if ! docker ps --filter name=smoke --filter status=running -q | grep -q .; then\n              echo \"::error::o container do app MORREU durante o boot\"\n              docker logs smoke 2>&1 | tail -40\n              exit 1\n            fi\n            if docker logs smoke 2>&1 | grep -qE \"Ready in|started server on\"; then\n              pronto=sim\n              break\n            fi\n            sleep 3\n          done\n\n          if [ \"$pronto\" != \"sim\" ]; then\n            echo \"::error::o app não chegou a servir em 120s\"\n            docker logs smoke 2>&1 | tail -40\n            exit 1\n          fi\n\n          # A assinatura exata do defeito que originou este gate — explícita,\n          # para o log dizer o que houve em vez de só \"não subiu\".\n          if docker logs smoke 2>&1 | grep -q \"MODULE_NOT_FOUND\"; then\n            echo \"::error::falta módulo no standalone (o defeito do @swc/helpers)\"\n            docker logs smoke 2>&1 | grep -A5 MODULE_NOT_FOUND | head -20\n            exit 1\n          fi\n\n          echo \"o app chegou a servir:\"\n          docker logs smoke 2>&1 | grep -E \"Ready in|Next.js\" | head -3\n\n      # Issue #1075. O passo acima prova que o contêiner SOBE; nenhum passo provava\n      # que uma rota que depende de um arquivo FORA do bundle FUNCIONA. Em Node o\n      # pdfjs empacotado aponta o worker dele para o specifier relativo\n      # `\"./pdf.worker.mjs\"`, resolvido a partir do próprio chunk gerado no build — e\n      # o `output: standalone` não levou esse arquivo para a imagem. Toda release\n      # publicada leu PDF de texto como \"sem texto\"/\"não consegui ler\", e o defeito\n      # atravessou as releases porque nenhum job olhava dentro da imagem: o teste de\n      # extração roda pelo `node_modules` do repositório (onde o arquivo existe) e o\n      # gate de boot aceita o app subindo — a extração só quebra quando alguém manda\n      # um PDF de verdade.\n      #\n      # A sonda roda DENTRO da imagem construída acima, sobre a MESMA fixture do teste\n      # de extração, e carrega o módulo EMPACOTADO (chunk + runtime do Turbopack), que\n      # é o caminho da rota. Um `import(\"pdfjs-dist\")` cru mediria outro caminho — na\n      # imagem esse pacote não existe fora do bundle.\n      - name: A imagem extrai texto de PDF?\n        run: |\n          set -euo pipefail\n          docker run --rm \\\n            -v \"$PWD/tests/fixtures/sample-text.pdf:/fixture.pdf:ro\" \\\n            -v \"$PWD/scripts/sonda-pdf-na-imagem.mjs:/sonda-pdf-na-imagem.mjs:ro\" \\\n            -e PDF_ESPERADO=\"DeskcommCRM RAG fixture\" \\\n            deskcomm-smoke:pr node /sonda-pdf-na-imagem.mjs\n\n      - name: Encerrar\n        if: always()\n        run: docker rm -f smoke 2>/dev/null || true\n\n  # O CANAL `stable` MOVE AQUI, E SÓ AQUI — depois que o conjunto está completo.\n  #\n  # Antes, `stable` era mais um `type=raw` na lista de tags da matriz, e por isso\n  # CADA imagem movia o canal sozinha ao terminar, sem saber se as irmãs\n  # conseguiram. Medido no registro público durante o corte da v1.12.0 (issue\n  # #488), com sonda de DIGEST — a ingênua (\"a tag `stable` existe?\") devolve 200\n  # nas três e não prova nada, porque `stable` existe desde a 1.11.0:\n  #\n  #   deskcommcrm          1.12.0   404\n  #   deskcommcrm          stable   sha256:0235d02b…   <- 1.11.0\n  #   deskcomm-worker      stable   sha256:66c7bde4…   <- 1.12.0\n  #   deskcomm-scheduler   stable   sha256:ac6b87cc…   <- 1.12.0\n  #\n  # Quem instalasse por `stable` naquela janela recebia app 1.11.0 com worker e\n  # scheduler 1.12.0. Os três saem do mesmo repositório e compartilham código;\n  # rodar dois numa versão e o terceiro noutra é um estado que nenhum teste cobre\n  # e que ninguém escolheu. O job do app tinha morrido em `Set up Buildx` — antes\n  # de compilar qualquer coisa, portanto por infraestrutura e não por conteúdo.\n  #\n  # `fail-fast: true` na matriz seria o conserto óbvio, e chega tarde: quando o\n  # app falhou, as irmãs JÁ tinham publicado e JÁ tinham movido o canal. Abortar\n  # não desfaz o que foi publicado. O que separa os dois atos é publicar por\n  # NÚMERO sempre (cada imagem, independente — ninguém instala por um número que\n  # o instalador não escreveu) e mover o CANAL num job final.\n  promover-stable:\n    # Sem `always()`, e isso é o ponto: `needs` só libera quando as TRÊS entradas\n    # deram `success`. Com `always()` o canal andaria por cima de uma irmã que\n    # falhou, que é exatamente o defeito de volta com outra roupa.\n    # `imagens-de-fundo-sobem` entrou aqui de propósito (#604): worker ou\n    # scheduler que não sobe/carrega o laço do event_log não pode virar o\n    # `stable` de quem self-hospeda — publicar seria empurrar o defeito da #648\n    # canal abaixo.\n    needs: [build-and-push, imagem-do-app-sobe, imagens-de-fundo-sobem]\n    # As três condições, e cada uma barra um caminho medido. Sem `push`, um\n    # dispatch numa release ANTIGA faria `stable` REGREDIR, e todo self-hoster no\n    # default do compose sofreria downgrade silencioso no próximo `up -d` — app\n    # velho sobre banco já migrado. Sem `tag`, um dispatch numa branch moveria o\n    # canal. Sem o `v`, uma tag de teste o move (o registro já tem uma\n    # `quebrada-teste`, que nenhum push na main consegue produzir).\n    if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')\n    runs-on: ubuntu-latest\n    permissions:\n      # O menor escopo que faz o trabalho: reapontar tag no GHCR. Sem o bloco, o\n      # GITHUB_TOKEN herdaria o default do REPOSITÓRIO — configuração que vive\n      # fora do repo, muda num clique e não passa por PR.\n      packages: write\n    steps:\n      - name: Log in to GHCR\n        uses: docker/login-action@v4\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Apontar `stable` para esta versão, nas três imagens\n        env:\n          VERSAO: ${{ github.ref_name }}\n        run: |\n          set -euo pipefail\n          # `imagetools create` REAPONTA o índice que já foi publicado — não\n          # reconstrói. Reconstruir o mesmo commit dá um digest DIFERENTE (foi o\n          # que fez `stable` e `1.3.0` divergirem com o mesmo `revision`), e aí o\n          # canal deixaria de ser o mesmo manifesto que a versão.\n          dono=$(echo \"${GITHUB_REPOSITORY_OWNER}\" | tr '[:upper:]' '[:lower:]')\n          numero=\"${VERSAO#v}\"\n          for img in deskcommcrm deskcomm-worker deskcomm-scheduler; do\n            echo \"::group::${img}\"\n            docker buildx imagetools create \\\n              --tag \"${REGISTRY}/${dono}/${img}:stable\" \\\n              \"${REGISTRY}/${dono}/${img}:${numero}\"\n            echo \"::endgroup::\"\n          done\n          echo \"stable agora aponta para ${numero} nas três imagens\"\n\n  # As imagens de FUNDO sobem — e o laço do event_log CARREGA? (issue #604)\n  #\n  # `imagem-do-app-sobe` prova a imagem do app. As imagens `deskcomm-worker`\n  # (Dockerfile.worker) e `deskcomm-scheduler` (Dockerfile.scheduler) eram\n  # construídas em `build-and-push` e publicadas sem que NINGUÉM as executasse\n  # antes: o defeito da #648 — um `import` que estoura só sob `tsx`, o\n  # `event_log` parado dez dias e o `/healthz` verde — atravessava build, push,\n  # tag e release sem ser visto por gate nenhum. Um job que só checa `docker ps`\n  # teria ficado verde no dia do defeito; por isso \"contêiner de pé\" não é o\n  # critério aqui.\n  #\n  # O QUE ESTE JOB MEDE:\n  #   1. dentro da imagem do worker, a MESMA função do boot\n  #      (`carregarDepsDoLaco`, via scripts/sonda-do-laco-de-event-log.ts)\n  #      responde `ok:true` — com a marca `event-log drain: laço carregado` no\n  #      log e `carregado:true` na prontidão;\n  #   2. o `/healthz` do worker, o MESMO caminho que a produção consulta,\n  #      publica `event_log_drain.carregado: true` (aceita 200 e 503: sem banco\n  #      no runner não há 200 honesto, e a prontidão do laço não depende do\n  #      banco estar de pé);\n  #   3. a imagem do scheduler entrega o que ela promete: crond de pé (o\n  #      HEALTHCHECK da própria imagem) E a linha `api/v1/cron/event-log-drain`\n  #      instalada no crontab — a segunda via do mesmo drain, que a produção usa\n  #      quando o laço do worker não carregou.\n  #\n  # Sem `if:` neste job, de propósito, como nos irmãos: job pulado vira\n  # `skipped`, e `imagens-ok` (que roda com `always()`) lê `skipped` como\n  # reprovação. Em PR ele roda e constrói as duas imagens de fundo com o mesmo\n  # escopo de cache do `build-and-push`; na tag, roda igual.\n  #\n  # Ele entra POR BAIXO de `imagens-ok`, que é a fachada que a branch protection\n  # já exige: nenhum check obrigatório novo — a proteção da main não passa a\n  # esperar um nome que ninguém produz.\n  imagens-de-fundo-sobem:\n    needs: [a-tag-veio-da-main]\n    runs-on: ubuntu-latest\n    # Só leitura, explícita: sem o bloco o GITHUB_TOKEN herda o default do\n    # REPOSITÓRIO — configuração que vive fora do repo, muda num clique e não\n    # passa por PR. Mesma razão do `permissions:` dos outros jobs.\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@v7\n\n      - name: Set up Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build local da imagem do worker (sem push)\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: Dockerfile.worker\n          platforms: linux/amd64\n          push: false\n          load: true\n          tags: deskcomm-worker:pr\n          # Mesmo escopo de cache do build-and-push, por imagem. Os dois jobs\n          # correm em PARALELO, então o cache costuma estar frio no primeiro run\n          # de um push e quente nos seguintes — \"reaproveita aquele build\" só\n          # vale a partir do segundo. Medido em 16/09/2026 no primeiro run deste\n          # gate: manifesto importado, camadas reconstruídas.\n          cache-from: type=gha,scope=deskcommcrm-worker\n\n      - name: Build local da imagem do scheduler (sem push)\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: Dockerfile.scheduler\n          platforms: linux/amd64\n          push: false\n          load: true\n          tags: deskcomm-scheduler:pr\n          cache-from: type=gha,scope=deskcommcrm-scheduler\n\n      - name: O laço do event_log CARREGA dentro da imagem do worker?\n        run: |\n          set -euo pipefail\n          # Env de mentira, igual à do smoke do app: o runner não tem Supabase.\n          # Montar o admin client não abre conexão — o que se mede aqui é o\n          # CAMINHO DE BOOT (o import sob `tsx`), não o banco.\n          # `--entrypoint pnpm`: a sonda substitui o CMD do worker e prova o\n          # carregamento sem depender de o entrypoint da imagem ser executável\n          # fora do cluster.\n          saida=$(docker run --rm --name worker-laco \\\n            --entrypoint pnpm \\\n            -e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.supabase.co \\\n            -e NEXT_PUBLIC_SUPABASE_ANON_KEY=chave-de-mentira \\\n            -e SUPABASE_SERVICE_ROLE_KEY=chave-de-mentira \\\n            -e SENTRY_DSN=off \\\n            deskcomm-worker:pr \\\n            exec tsx scripts/sonda-do-laco-de-event-log.ts)\n\n          echo \"$saida\"\n          printf '%s' \"$saida\" | tail -1 | grep -q '\"ok\":true' || {\n            echo \"::error::o laço do event_log NÃO carregou dentro da imagem do worker.\"\n            echo \"::error::Este é o defeito da #648: o worker sobe, o /healthz fica verde e o event_log fica parado.\"\n            exit 1\n          }\n          printf '%s' \"$saida\" | grep -q 'event-log drain: laço carregado' || {\n            echo \"::error::a sonda não emitiu a marca 'event-log drain: laço carregado'.\"\n            exit 1\n          }\n\n      - name: O worker BOOTA com o CMD da imagem? (sem banco no runner, o boot para na camada de banco)\n        run: |\n          set -euo pipefail\n          # Por que este passo existe se a sonda já provou o laço: a sonda importa a\n          # cadeia do DRAIN; o boot importa bem mais (playbook-seed, waha, wacalls,\n          # cron, observabilidade). A #648 foi um `import` que estourava só sob `tsx`\n          # — a classe de defeito que só um boot de verdade pega.\n          #\n          # O que NÃO dá para medir aqui é o `/healthz` respondendo `ok`: o runner não\n          # tem Postgres, `startWorker` roda `assertHarnessSchema(pool)` (uma query)\n          # antes de escutar, e o worker sai de propósito. Então este passo\n          # CLASSIFICA a saída em vez de ignorá-la:\n          #   • `event-log drain OFF` no log            → REPROVA (é a #648 na cara);\n          #   • morte com a cadeia de boot quebrada     → REPROVA (import/módulo);\n          #   • de pé sem `event_log_drain` no /healthz → REPROVA;\n          #   • morto por banco ausente (ECONNREFUSED…) → passa com `::notice::`,\n          #     declarando o que este runner não mede;\n          #   • morto por motivo não reconhecido        → REPROVA (morte silenciosa é\n          #     exatamente o que este gate existe para não deixar passar).\n          docker rm -f worker-boot >/dev/null 2>&1 || true\n          docker run -d --name worker-boot \\\n            -p 127.0.0.1:3971:3971 \\\n            -e HEALTH_PORT=3971 \\\n            -e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.supabase.co \\\n            -e NEXT_PUBLIC_SUPABASE_ANON_KEY=chave-de-mentira \\\n            -e SUPABASE_SERVICE_ROLE_KEY=chave-de-mentira \\\n            -e SUPABASE_DB_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres \\\n            -e SENTRY_DSN=off \\\n            deskcomm-worker:pr\n\n          # ESPERA UM SINAL, e não o `docker ps`: o contêiner fica `running` 0,2s\n          # depois do `run -d` — antes de o `tsx` importar a primeira linha. O loop\n          # antigo saía na PRIMEIRA volta, lia logs vazios e um `/healthz` que\n          # ninguém ainda escutava, e reprovava um worker SADIO (medido: run\n          # 35099604595, o passo morreu 0,2s depois do `docker run`). Esperar o\n          # sinal é o que separa \"não bootou\" de \"ainda não bootou\".\n          estado=nao_bootou\n          corpo=\"\"\n          for _ in $(seq 1 30); do          # até 90s: tsx + pool + a query do harness\n            sleep 3\n            if ! docker ps --filter name=worker-boot --filter status=running -q | grep -q .; then\n              estado=saiu\n              break\n            fi\n            logs_ate_agora=$(docker logs worker-boot 2>&1 || true)\n            if printf '%s' \"$logs_ate_agora\" | grep -qE 'agent-engine pronto'; then\n              estado=pronto\n              break\n            fi\n            # Defeito FATAL já escrito no log: não há por que esperar os 90s — a\n            # classificação abaixo reprova por estes mesmos marcadores, então o\n            # veredito não muda; só não fica 90s parado para dizê-lo.\n            if printf '%s' \"$logs_ate_agora\" | grep -qE 'event-log drain OFF|Cannot find module|ERR_MODULE_NOT_FOUND|Cannot use import statement'; then\n              estado=fatal_no_log\n              break\n            fi\n            corpo=$(curl -s -m 2 \"http://127.0.0.1:3971/healthz\" || true)\n            if [ -n \"$corpo\" ]; then\n              estado=servindo\n              break\n            fi\n          done\n\n          docker logs worker-boot > boot.log 2>&1 || true\n          echo \"--- logs do boot (tail) ---\"\n          tail -30 boot.log\n          echo \"estado: $estado\"\n\n          # A volta pode ter parado no LOG de prontidão antes de o `curl`\n          # acontecer; esta última tentativa garante que o caso 4 (worker de pé)\n          # não dependa do caminho por onde o loop saiu.\n          if [ -z \"$corpo\" ]; then\n            corpo=$(curl -s -m 5 \"http://127.0.0.1:3971/healthz\" || true)\n          fi\n\n          # 1) A #648 na cara: o laço desligou no boot.\n          if grep -q 'event-log drain OFF' boot.log; then\n            echo \"::error::o laço do event_log NÃO carregou no boot da imagem — este é o defeito da #648, e a sonda do passo anterior também reprovou.\"\n            exit 1\n          fi\n\n          # 2) Cadeia de boot quebrada (import/módulo) — a classe da #648. Vale nos\n          #    DOIS estados: o contêiner pode ter morrido, ou ter ficado de pé com a\n          #    exceção engolida em outro laço do boot.\n          if grep -qE 'Cannot find module|ERR_MODULE_NOT_FOUND|Cannot use import statement|SyntaxError|is not a function|Failed to resolve' boot.log; then\n            echo \"::error::o worker morreu com a CADEIA DE BOOT quebrada (import/módulo) — a classe da #648.\"\n            exit 1\n          fi\n\n          # 3) Banco ausente: é o desfecho DECLARADO deste runner (não tem Postgres).\n          #    `assertHarnessSchema(pool)` roda uma query ANTES de escutar, então o\n          #    boot para aqui de propósito. O que este passo NÃO mede, por causa\n          #    disso, é o `/healthz` respondendo 200 — isso é medido em produção e\n          #    pelo e2e, que têm banco.\n          if grep -qE 'ECONNREFUSED|ENOTFOUND|getaddrinfo|schema do harness ausente|password authentication|Connection terminated|timeout expired|ECONNRESET' boot.log; then\n            echo \"::notice::o worker parou na camada de banco (este runner não tem Postgres). O laço carregado foi medido pela sonda, dentro da imagem; o /healthz respondendo 200 é medido em produção e no e2e — não aqui.\"\n            exit 0\n          fi\n\n          # 4) De pé e servindo: aí o campo TEM de estar lá — é o sinal que o gate de\n          #    publicação lê antes de deixar a imagem ir para o canal.\n          if [ -n \"$corpo\" ]; then\n            echo \"healthz: $corpo\"\n            printf '%s' \"$corpo\" | grep -q '\"event_log_drain\"' || {\n              echo \"::error::o worker está de pé mas o /healthz não publica event_log_drain (#604).\"\n              exit 1\n            }\n            printf '%s' \"$corpo\" | grep -q '\"event_log_drain\":{\"carregado\":true' || {\n              echo \"::error::o /healthz publica event_log_drain com carregado:false — o laço não carregou.\"\n              exit 1\n            }\n            docker logs worker-boot 2>&1 | tail -20\n            exit 0\n          fi\n\n          # 5) Não bootou, não morreu por banco, não serviu: morte silenciosa ou boot\n          #    que não chega — exatamente o que este gate existe para não deixar\n          #    passar. Se for de outro tipo, o classificador precisa de mais um caso\n          #    escrito, não de um passe livre.\n          echo \"::error::o worker não bootou por um motivo que este gate não reconhece (estado: $estado).\"\n          echo \"::error::Se for esperado, o classificador acima precisa de mais um caso escrito — não de um passe livre.\"\n          exit 1\n      - name: O scheduler sobe E tem o evento do event_log no crontab?\n        run: |\n          set -euo pipefail\n          # `INTERNAL_SECRET` é obrigatório: com ele vazio o entrypoint sai 1, e é\n          # assim mesmo (cron que responderia 401 em silêncio não deve subir).\n          docker run -d --name sched \\\n            --health-interval=3s --health-timeout=3s --health-start-period=3s --health-retries=20 \\\n            -e INTERNAL_SECRET=chave-de-mentira \\\n            -e APP_URL=http://127.0.0.1:3000 \\\n            -e TZ=UTC \\\n            deskcomm-scheduler:pr\n\n          cron=nao\n          for _ in $(seq 1 20); do\n            if ! docker ps --filter name=sched --filter status=running -q | grep -q .; then\n              echo \"::error::o scheduler MORREU durante o boot\"\n              docker logs sched 2>&1 | tail -40\n              exit 1\n            fi\n            docker exec sched sh -lc 'crontab -l 2>/dev/null || cat /etc/crontabs/root 2>/dev/null || cat \"${CRONTAB_PATH:-/etc/crontabs/root}\" 2>/dev/null' > cron.txt 2>/dev/null || true\n            if grep -q 'api/v1/cron/event-log-drain' cron.txt; then\n              cron=sim\n              break\n            fi\n            sleep 3\n          done\n\n          if [ \"$cron\" != \"sim\" ]; then\n            echo \"::error::o crontab do scheduler não tem a linha do event-log-drain\"\n            cat cron.txt || true\n            docker logs sched 2>&1 | tail -40\n            exit 1\n          fi\n          grep -c 'api/v1/cron' cron.txt | sed 's/^/linhas de cron instaladas: /'\n          grep 'event-log-drain' cron.txt\n\n          # E o crond está de pé? O HEALTHCHECK da própria imagem é quem responde\n          # (mesma checagem que o `docker ps` do compose mostra ao self-hoster).\n          saude=nao\n          for _ in $(seq 1 20); do\n            estado=$(docker inspect -f '{{.State.Health.Status}}' sched 2>/dev/null || echo sem-healthcheck)\n            if [ \"$estado\" = \"healthy\" ]; then saude=sim; break; fi\n            [ \"$estado\" = \"sem-healthcheck\" ] && { echo \"::error::a imagem do scheduler perdeu o HEALTHCHECK — o compose mostraria o scheduler sem estado de saúde\"; exit 1; }\n            sleep 3\n          done\n          if [ \"$saude\" != \"sim\" ]; then\n            echo \"::error::o scheduler não chegou a 'healthy' (crond de pé) em 60s\"\n            docker inspect -f '{{json .State.Health}}' sched || true\n            docker logs sched 2>&1 | tail -40\n            exit 1\n          fi\n          docker logs sched 2>&1 | tail -20\n\n  # Job de fachada: dá UM nome estável para a branch protection exigir. Sem ele,\n  # o required check teria que listar as três instâncias da matriz pelo nome\n  # gerado, e acrescentar uma quarta imagem um dia quebraria o gate em silêncio —\n  # o merge voltaria a passar sem que ninguém tivesse decidido isso.\n  imagens-ok:\n    if: always()\n    runs-on: ubuntu-latest\n    # Nenhuma permissão: este job só lê o resultado de `needs` e sai. Sem o bloco,\n    # o GITHUB_TOKEN herdaria o default do REPOSITÓRIO — configuração que vive fora\n    # do repo, muda num clique e não passa por PR.\n    #\n    # A regra e o gate que a impõe vieram do PR #241 (alertas do CodeQL), de um\n    # contribuidor. O teste dele pegou este job: eu o criei sem o bloco, e o defeito\n    # só apareceu quando a `main` entrou naquela branch. É o próprio caso que a\n    # justificativa do teste antecipa — \"um job novo nasce sem bloco e herda o\n    # default de novo\".\n    permissions: {}\n    needs: [build-and-push, imagem-do-app-sobe, imagens-de-fundo-sobem]\n    steps:\n      - name: Falhar se qualquer imagem não construiu — ou se o app não sobe\n        run: |\n          set -euo pipefail\n          echo \"build-and-push:         ${{ needs.build-and-push.result }}\"\n          echo \"imagem-do-app-sobe:     ${{ needs.imagem-do-app-sobe.result }}\"\n          echo \"imagens-de-fundo-sobem: ${{ needs.imagens-de-fundo-sobem.result }}\"\n          # As imagens de fundo entram na mesma exigência: worker ou scheduler que\n          # não sobe (ou que sobe com o laço do event_log morto, #604) reprova o\n          # check que a branch protection já espera — sem check novo obrigatório.\n          [ \"${{ needs.build-and-push.result }}\" = \"success\" ]\n          [ \"${{ needs.imagem-do-app-sobe.result }}\" = \"success\" ]\n          [ \"${{ needs.imagens-de-fundo-sobem.result }}\" = \"success\" ]\n",
 "workflow:.github/workflows/e2e.yml@2026-09-18T12:10:03Z": "name: e2e\n\n# Issue #63: as specs de tests/e2e/ não rodavam em lugar nenhum. Este job fecha o\n# buraco com o subconjunto que não depende de serviço externo.\n#\n# NÃO conte aqui quantas são: o summary do job CONTA em vez de afirmar, e\n# `tests/unit/e2e-cobertura-completa.test.ts` reprova o build quando uma spec do\n# disco não está em nenhuma das três listas abaixo. Um número escrito nesta linha\n# é a quarta fonte da mesma verdade, e as três anteriores já divergiram — este\n# cabeçalho dizia \"29 de 33\" quando o disco tinha 45 e o job rodava 44.\n#\n# A execução é dividida em DUAS invocações do playwright de propósito: o\n# limitador de login do produto é por IP (60/300s) e no CI todos os specs vêm do\n# mesmo 127.0.0.1. Ver o comentário do passo \"E2E — parte 1 de 3\".\n#\n# É CHECK OBRIGATÓRIO. A régua, para reconferir em vez de acreditar nesta linha:\n#   $ gh api repos/melgarafael/DeskcommCRM/branches/main/protection \\\n#       --jq '.required_status_checks.contexts|join(\", \")'\n#   verify, build-and-size, invariants, e2e, imagens-ok   # medido em 2026-08-14\n#\n# São CINCO. A versão anterior deste bloco colava a saída de 2026-08-12, com\n# quatro — `imagens-ok` virou obrigatório em 2026-08-13, um dia depois. Trocar\n# uma afirmação vencida por outra vencida é o mesmo defeito, e o commit\n# bcd88c30 desta branch existe justamente por causa dele: quem lê aqui para\n# saber contra o que medir concluiria que imagem quebrada não segura merge.\n# Este cabeçalho afirmou o contrário (\"NÃO-BLOQUEANTE por ausência… quando N\n# execuções seguidas passarem limpas, adicionar aos obrigatórios\") por tempo\n# demais depois de a promoção ter acontecido — e quem lesse isso mediria o custo\n# de uma mudança contra a régua errada.\n#\n# O job falha honestamente: a primeira versão usava `continue-on-error: true` no\n# job inteiro e reportou VERDE com o `supabase start` quebrado e zero teste\n# executado. Verde que não distingue \"passou\" de \"nem rodou\" é pior que vermelho.\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\n# O GITHUB_TOKEN deste job serve a `actions/checkout` (clone) e a\n# `actions/setup-node` (manifest de `actions/node-versions`, repo público).\n#\n# `supabase/setup-cli@v3` merece a frase inteira, porque a resposta curta está\n# errada: ele é `using: composite` e aninha `oven-sh/setup-bun`, cujo input\n# `token` tem default `${{ github.token }}` — o token É repassado. Com o\n# `.bun-version` do setup-cli fixo em semver exato, o caminho que gasta o token\n# (api.github.com/repos/oven-sh/bun/git/refs/tags) não é tomado; se um dia for, é\n# leitura de repo público e `contents: read` basta. Grep no `action.yml` de topo\n# não enumera consumidor de token quando a action é composite: o consumidor mora\n# um nível abaixo.\n#\n# `actions/upload-artifact@v7` não tem input de token: publica pela Actions\n# Results API, com ACTIONS_RUNTIME_TOKEN + ACTIONS_RESULTS_URL. Nenhum passo\n# `run:` enxerga o GITHUB_TOKEN — ele só entra no ambiente via\n# `secrets.GITHUB_TOKEN`, ausente aqui.\npermissions:\n  contents: read\n\njobs:\n  # AS DUAS PARTES RODAM EM PARALELO, e o motivo é medido, não estético.\n  #\n  # Elas rodavam como dois passos do MESMO job, então somavam contra um único\n  # teto de 30 min. Medido no run 33125422531, o último verde antes deste\n  # conserto: parte 2 = 16,3 min, parte 1 = 8,2 min, setup (build + Supabase +\n  # browser) ≈ 4,5 min — total ≈ 29 min, com 1 min de folga. A folga acabou:\n  # dois runs da própria `main` foram CANCELADOS em 30 min exatos (23:30→00:00\n  # e 23:26→23:56 de 2026-08-27), e um PR de UM arquivo foi cancelado junto.\n  #\n  # Em paralelo, cada parte paga o setup uma vez e tem o teto inteiro para si:\n  # o job mais longo passou a ser ≈ 21 min em vez de ≈ 29.\n  #\n  # ⚠️ ESSA ÚLTIMA FRASE VENCEU. Ela estava certa quando escrita e descreve o\n  # efeito do paralelismo, não o estado: dez dias depois o job mais longo era 26\n  # min e chegou a ser cancelado aos 30. Quem lê \"≈ 21 min\" para saber se cabe\n  # mais spec mede contra a régua errada. O estado de hoje está no bloco\n  # \"REBALANCEAMENTO DE 2026-09-04\" mais abaixo — que também traz o comando que\n  # produz o número, porque número escrito envelhece e comando não.\n  #\n  # E há um ganho que não era o objetivo: a razão de existirem partes separadas é\n  # \"processo novo, contador de login zerado\" — o teto por IP. Runners de\n  # matrix são máquinas diferentes, com IPs diferentes, então a separação\n  # deixa de depender de reiniciar o processo.\n  #\n  # O teto de 30 min FICA. Ele é o que denuncia a suíte crescendo de novo;\n  # subi-lo seria trocar um vermelho honesto por um CI que demora mais a cada\n  # mês sem ninguém perceber.\n  e2e-parte:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    strategy:\n      # Sem fail-fast: se a parte 1 quebrar, ainda quero saber se a 2 também.\n      # Com ele ligado, o primeiro vermelho esconde o outro e o conserto vira\n      # uma rodada por parte.\n      fail-fast: false\n      matrix:\n        parte: [1, 2, 3]\n    # ─────────────────────────────────────────────────────────────────────────\n    # A COBERTURA DEIXA DE SER PROSA DIGITADA À MÃO.\n    #\n    # Antes, as listas viviam nos dois `run:` e a declaração do que NÃO é coberto\n    # era um `echo` escrito à mão. As duas fontes divergiram em silêncio: o texto\n    # dizia \"32 de 33 specs\" quando o disco tinha 39 e o job rodava 36 — e as\n    # ausentes eram justamente as novas, incluindo `agente-papeis-operador`, a\n    # prova de tela do épico dos três papéis, que nunca rodou em job nenhum.\n    #\n    # Cobertura parcial silenciosa se lê como cobertura total. Agora há UMA fonte\n    # por lista, o summary CONTA em vez de afirmar, e\n    # `tests/unit/e2e-cobertura-completa.test.ts` reprova o build quando um arquivo\n    # de spec não está em nenhuma das três listas.\n    #\n    # FORA_DO_CI exige motivo escrito no próprio nome do bloco abaixo — spec fora\n    # do gate sem razão declarada é a dívida voltando pela porta de serviço.\n    # ─────────────────────────────────────────────────────────────────────────\n    #\n    # ═══ `cadastro-sem-confirmacao-de-email`: A PRECONDIÇÃO É DO PROVEDOR ═══\n    #\n    # Ela mede o cadastro com \"Confirm email\" DESLIGADO no provedor de auth — o\n    # estado em que `signUp()` já devolve sessão e não existe link nenhum para\n    # clicar. Isso não é dado que a spec possa semear: é `GOTRUE_MAILER_AUTOCONFIRM`,\n    # fixado quando o Supabase local sobe, e o `supabase/config.toml` do repo\n    # declara `enable_confirmations = true` — que é o que `signup-journey` e\n    # `invite-lifecycle` exercitam. Ligar o autoconfirm por causa desta spec\n    # trocaria o provedor da SUÍTE INTEIRA, e as duas de cima passariam a medir\n    # outro fluxo sem que ninguém percebesse.\n    #\n    # O gate de todo dia é o par de testes de unidade, que roda no `verify`:\n    # `app/actions/auth/signUp.test.ts` (a action DIZ que a sessão veio aberta) e\n    # `tests/unit/cadastro-sem-confirmacao-nao-manda-esperar-email.test.tsx` (a\n    # tela AGE sobre isso). A spec é a prova pela tela de que os dois, juntos,\n    # resolvem a jornada — e ela CONFERE a própria precondição em `beforeAll`,\n    # falhando alto em vez de se auto-pular.\n    #\n    # ═══ O 429 DE `olhar-telas-do-epico`: RESOLVIDO DUAS VEZES, EM CAMADAS DIFERENTES ═══\n    #\n    # A spec reprovava com **429** em TODAS as 7 telas, falha PRÉ-EXISTENTE NA MAIN (run\n    # 31343408488, main, 2026-08-10T00:03, lista byte-idêntica).\n    #\n    # Duas sessões chegaram à MESMA causa raiz em paralelo, sem se ver, e os dois\n    # consertos são complementares — nenhum torna o outro dispensável:\n    #\n    #  - `a60cae2d` (main, PR #220) desligou a telemetria NO AMBIENTE DO CI:\n    #    `SENTRY_DSN: \"off\"` no build e em todas as partes (e no `perf.yml`). Ver o\n    #    comentário do passo \"Build de produção\" abaixo — a armadilha é que vazio NÃO\n    #    desliga, seleciona o Sentry da comunidade.\n    #  - `77b4a486` (este PR) consertou O PRODUTO: o SDK do browser mandava duas SESSÕES\n    #    de release health por NAVEGAÇÃO para o DSN da comunidade — de toda instalação\n    #    self-host, não só do CI. `browserSessionIntegration` é default do SDK e\n    #    `integrations: [x]` SOMA aos defaults, então a política \"no Sentry da comunidade,\n    #    só erro\" estava declarada e não estava em vigor. Detalhe em `lib/sentry/dsn.ts`;\n    #    o gate é `tests/unit/sentry-comunidade-so-erro.test.ts`.\n    #\n    # O ingest respondia `429` com `x-sentry-rate-limits: 60::organization:suspended` —\n    # lista de categorias vazia, isto é, TODAS: a organização estava suspensa por cota, e\n    # cada tentativa barrada virava erro de console, que é o que esta spec cobra.\n    #\n    # FONTE DA VERDADE do ambiente da suíte é `scripts/gerar-env-e2e.sh` (o `.env.e2e`\n    # que o passo \"Publicar\" joga no `$GITHUB_ENV`, e que vale também para quem roda\n    # local). As três linhas `SENTRY_DSN: \"off\"` de nível de passo FICAM como segunda\n    # camada — `env:` de passo vence o `$GITHUB_ENV`, então elas protegem o CI se o\n    # gerador mudar. Duas fontes que CONCORDAM e uma delas declarada como principal;\n    # apagar as do colega para \"limpar duplicação\" seria trocar defesa por estética.\n    #\n    # Três hipóteses minhas caíram por medição, e o custo delas foi maior que o do\n    # conserto. As duas primeiras foram descartadas na época:\n    #\n    #  1. \"a parte 2 está sobrecarregada\" — caiu de 69 para 53 testes, falha byte-idêntica.\n    #  2. \"as specs anteriores exaurem o contador\" — posta em PRIMEIRO lugar, contador\n    #     limpo, as mesmas 7 telas deram 429, inclusive a primeira.\n    #  3. \"é o fallback em memória do limitador\" — foi o que este comentário afirmou como\n    #     \"a pista que sobra\". MEDIDO no job real: 120 quedas para memória, 103 delas no\n    #     bucket `auth:login:ip` contra teto de 1000. O limitador não barrou nada.\n    #\n    # A lição operacional é sobre INSTRUMENTO, não sobre limitador: a mensagem do browser\n    # para requisição barrada é \"Failed to load resource: ... 429\" e não diz QUEM\n    # respondeu; a spec descartava `m.location()` e o relatório não guarda trace, então a\n    # única cópia do endereço morria ali. Três runs foram gastos adivinhando o dono de um\n    # 429 cuja URL o próprio teste tinha em mãos. A spec agora registra a URL.\n    #\n    # `AUTH_RATE_LIMIT_LOGIN_IP` segue existindo pelo motivo dele (o teto por IP no CI é\n    # um runner só) e não teve nada a ver com isto. Afrouxar o limitador do produto para\n    # acomodar teste segue sendo a troca errada — e aqui não foi preciso.\n    #\n    # A ordem das listas voltou ao que era: mudá-la foi tentativa que não se sustentou,\n    # e deixar a mudança de pé com um comentário dizendo \"hipótese\" seria pior — o\n    # próximo leitor herdaria uma decisão tomada por um motivo medido FALSO.\n    #\n    # O rebalanceamento das duas partes FICA, por outro motivo e sem alegar mais do que\n    # faz: 6,5 + 8,1 min em vez de 2,7 + 12,2.\n    #\n    # ═══ REBALANCEAMENTO DE 2026-09-04 — O PRIMEIRO MEDIDO POR ARQUIVO ═══\n    #\n    # A folga acabou de novo. Run 33820766602 (verde), confirmado em seis rodadas:\n    #\n    #   parte 1: 103 testes · 9,1 min de Playwright · job 18 min\n    #   parte 2: 127 testes · 18,0 min de Playwright · job 26 min\n    #   setup (build + Supabase + browser) ≈ 8-9 min, igual nos dois\n    #   as seis: parte 1 = 18 17 15 15 18 14 min · parte 2 = 26 28 26 26 25 25 min\n    #\n    # E não é mais risco: a parte 2 já ESTOUROU o teto — 30 min 17 s num PR, e de\n    # novo no run 33823969191 (deste PR), CANCELADA aos 30 min com 117 dos 133\n    # casos terminados.\n    #\n    # ── Por que contagem de TESTES não serve como régua ─────────────────────\n    #\n    # Este arquivo já registra dois proxies que inverteram o sinal da medição\n    # (ver o bloco de `SPECS_PARTE_1` abaixo). O terceiro proxy tentador é o\n    # número de testes por parte, e ele cai na PRÓPRIA tabela acima:\n    #\n    #   103 testes / 9,1 min  = 5,3 s por teste na parte 1\n    #   127 testes / 18,0 min = 8,5 s por teste na parte 2\n    #\n    # O mesmo \"teste\" custa 60% mais de um lado. E dentro de uma parte a\n    # dispersão é MAIOR: medido por arquivo no run 33823969191,\n    # `agenda-kit-visual` tem 20 casos e custa 21,6 s; `navegacao` tem 9 casos e\n    # custa 239,2 s. Mover o arquivo com MAIS casos desloca **11× menos tempo**\n    # que mover o de menos.\n    #\n    # ── O instrumento, para não haver quarto proxy ──────────────────────────\n    #\n    # O passo de execução roda com `--reporter=list` (o `dot`, default em CI,\n    # despeja os pontos em rajada — 37 numa linha só, cobrindo 4,9 min). Cada\n    # caso vira uma linha carimbada pelo Actions, e o custo de RELÓGIO de um\n    # arquivo é o intervalo entre o último caso dele e o último caso do arquivo\n    # anterior — o que inclui o `beforeAll`, que é onde as specs pagam o\n    # `execFileSync` dos seeds.\n    #\n    # Somar a duração que o reporter imprime NÃO serve: `result.duration` é só o\n    # caso. Medido na parte 1 do 33823969191 — soma das durações 7,14 min contra\n    # 7,95 min de relógio contra 8,1 min do rodapé do Playwright. O relógio\n    # fecha em 98% do rodapé; a soma das durações joga fora 12%, concentrado\n    # justamente nas specs que semeiam.\n    #\n    # Para refazer a medição em qualquer run, em vez de acreditar nos números\n    # desta seção:\n    #\n    #   gh run view --job <id-do-job> --log > /tmp/e2e.log\n    #   grep -E '\\[chromium\\] › tests/e2e/' /tmp/e2e.log\n    #\n    # ── O que mudou, e por que UM arquivo só ────────────────────────────────\n    #\n    # `navegacao.spec.ts` vai da PARTE_2 para a PARTE_1. Medido: 239,2 s\n    # (3,99 min), 23,3% de todo o relógio que a parte 2 chegou a gastar antes do\n    # corte. É o maior item das duas partes depois de `prova-painel-provedores`,\n    # e move sozinho quase exatamente o que falta.\n    #\n    # Um arquivo só, e não três de peso médio, porque as duas partes rodam\n    # contra o MESMO banco sem reset: cada spec movida é uma vizinhança nova.\n    # `navegacao` é a que tem menos aresta — só afirma cromo de navegação\n    # (sidebar, abas, ⌘K, medidas de layout), não lê dado semeado por vizinha\n    # nenhuma, e a precondição dela (`afirmarAdminDeTenantPuro`) é satisfeita em\n    # qualquer parte, porque quem promove `platform_admin` é\n    # `seed-e2e-system-update` e ele promove o `e2e-dono` REVOGANDO o `e2e-admin`.\n    # Alfabeticamente cai entre `mfa-opcional` e `notificacoes-`, isto é, ANTES\n    # de `relogio-http-cron-externo` — a restrição do tick global segue de pé.\n    #\n    # ── PREVISÃO (é previsão; a rodada seguinte deste PR é que mede) ────────\n    #\n    # Aplicada ao run de referência (9,1 + 18,0 = 27,1 min), com os 8 arquivos\n    # que o corte impediu de medir estimados em ~2,4 min (16 casos à média de\n    # 8,8 s/caso da própria parte 2), `navegacao` vale ≈ 3,6 min lá:\n    #\n    #   parte 1 ≈ 12,7 min de Playwright → job ≈ 21,6 min\n    #   parte 2 ≈ 14,4 min de Playwright → job ≈ 22,4 min\n    #   folga contra o teto: ≈ 7,6 min no pior dos dois (era 4 min, e virou −0:17)\n    #\n    # ── 16/09, noite: a previsão acima NÃO se confirmou, e as duas voltam ───\n    #\n    # Medido no run 35160549180 (lote 12, head 2a847f6f0): a parte 1 ESTOUROU o\n    # teto — 30 min cravados, cancelada no caso 141 de 156, ainda passando. A\n    # parte 2 mediu 15 min e a 3, 22 min. Ou seja, a partição prevista em\n    # 21,6/22,4 virou >30 contra 15: a folga está toda na parte 2.\n    #\n    # O que cresceu não foram as specs novas: as três de QA do lote 12 que caíram\n    # na parte 1 somam 5 casos e 31 s. Cresceram os casos acrescentados às specs\n    # que já estavam lá (o arrasto duplo do #919 em `kanban-owner-filter`, entre\n    # outros), que é crescimento que nenhuma linha deste arquivo anuncia.\n    #\n    # Então `prova-painel-provedores` (199,4 s, a alavanca que o bloco abaixo já\n    # nomeava) e `navegacao` (239,2 s) vão as duas para a PARTE_2. Medido nos\n    # logs deste run: a parte 1 gastou 978 s de Playwright em 138 casos\n    # concluídos; tirar 438 s devolve ~7,3 min e põe o job perto de 22 min, com\n    # a parte 2 em ~23 min. Subir `timeout-minutes` não entra: o teto é o que\n    # denuncia o crescimento, e calá-lo devolveria o problema maior mais tarde.\n\n    # ── A próxima alavanca, quando esta acabar ──────────────────────────────\n    #\n    # `prova-painel-provedores` é 199,4 s — 42% de toda a parte 1, num arquivo\n    # de 8 casos a 25 s cada. Se a parte 1 voltar a estourar, é lá que se olha\n    # antes de repartir o resto. E se as duas estourarem juntas, a alavanca\n    # deixa de ser a partição e passa a ser uma terceira parte: o setup de\n    # 8-9 min é pago por parte, então três partes custam ~9 min de runner a mais\n    # e devolvem ~9 min de relógio a cada uma.\n    env:\n      # Provider controlado; preview/assistência executam core, ferramentas e gates reais.\n      INTERNAL_AGENT_RUN_STUB: \"true\"\n      # Única exceção HTTP do laboratório de extensões. O servidor é iniciado\n      # pela própria spec, em loopback, com SQLite e PID exclusivos.\n      EXTENSIONS_LOCAL_CATALOG_ORIGIN: \"http://127.0.0.1:56331\"\n      SPECS_PARTE_1: >-\n        pre-go-live-whatsapp.spec.ts\n        smoke.spec.ts auth.spec.ts error-pages.spec.ts\n        icone-da-marca.spec.ts\n        password-recovery.spec.ts signup-journey.spec.ts\n        primeiro-acesso-sem-organizacao.spec.ts\n        rbac-roles.spec.ts inbox-scope.spec.ts reset-password-mfa.spec.ts\n        degradacao-silenciosa.spec.ts vps-webhook-outbound-ssrf.spec.ts\n        kanban-owner-filter.spec.ts queue-assign.spec.ts\n        conversa-vira-lead.spec.ts contato-salva-email.spec.ts\n        confirmar-dado-do-contato.spec.ts\n        credenciais-de-ia.spec.ts\n        risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts\n        agente-papeis-operador.spec.ts\n        followup-linguagem.spec.ts followup-ramos.spec.ts\n        wizard-do-funcionario.spec.ts mfa-opcional.spec.ts inbox-responder-citando.spec.ts\n        agenda-tela-do-produto.spec.ts\n        notificacoes-diz-o-que-falta.spec.ts\n        relogio-http-cron-externo.spec.ts\n        j20-elegibilidade-respondi.spec.ts j20-elegibilidade-followup.spec.ts\n        j20-elegibilidade-atendimento-manual.spec.ts\n        juntar-contatos-duplicados.spec.ts\n        lote-no-quadro-do-funil.spec.ts\n        motivos-de-perda-do-funil.spec.ts\n        importar-leads-planilha.spec.ts\n        relatorio-de-atividades.spec.ts\n        qa-l12-inbox.spec.ts\n        qa-l12-troca-de-funil.spec.ts\n        qa-l12-agente-ia.spec.ts\n      # ⚠️ A ORDEM DESTA LISTA NÃO DECIDE A ORDEM DE EXECUÇÃO. O Playwright\n      # ordena os arquivos por CAMINHO, não pela ordem em que são passados na\n      # linha de comando. Medido no run 31838253496: `marca-logo.spec.ts` estava\n      # escrita por último aqui e executou em 15º de 23 — a linha de progresso\n      # `···F°°··············` mostra 14 testes completando DEPOIS dela.\n      #\n      # Este comentário afirmava, por três commits, que ela era \"a ÚLTIMA de\n      # propósito\" e que isso limitava contaminação a zero specs. Era falso, e\n      # a consequência foi pior que o comentário errado: creditei a esse\n      # reposicionamento o desaparecimento do React #418 entre dois ciclos.\n      # Não foi ele — a ordem nunca mudou. Foi o `test.afterAll` da própria\n      # spec, que passou a limpar as DUAS camadas de marca e por isso as specs\n      # seguintes deixaram de ver logo gravado.\n      #\n      # O que continua verdade, e a frase precisou ser corrigida em 2026-09-07\n      # porque descrevia o mundo de ANTES da matrix: as specs de UMA MESMA parte\n      # rodam contra o MESMO banco, sem reset entre elas — o que uma spec deixa\n      # gravado, as seguintes daquela parte enxergam. Quem protege contra isso é\n      # a limpeza da spec, não a posição dela nesta lista.\n      #\n      # Entre partes DIFERENTES não há banco compartilhado: cada job da matrix é\n      # um runner próprio que paga o seu setup inteiro (~8,5 min, medidos iguais\n      # em cada um). É por isso que mover spec entre partes é risco: ela troca de\n      # vizinhança, e a vizinhança é que deixou o banco no estado que ela espera.\n      #\n      # ⚠️ Ela esteve no TOPO desta lista por três commits, pela razão errada.\n      # Eu media \"carga de login por parte\" com um regex que contava a PALAVRA\n      # login — e casava comentário, nome de helper e string. O proxy não só era\n      # impreciso: ele INVERTEU o sinal (dizia 162 vs 145; o real, contando\n      # chamadas, é 63 vs 82), então mover a spec \"para a parte mais leve\" a\n      # mudou para a mais carregada. E o critério era irrelevante desde o\n      # começo — o `AUTH_RATE_LIMIT_LOGIN_IP: \"1000\"` mais abaixo desliga o\n      # teto no CI, e esta spec faz 6 logins (eram 3 até a revisão que descobriu\n      # que dois casos rodavam DESLOGADOS: `page` é fixture de escopo de teste,\n      # não de worker — cada caso precisa do seu login).\n      #\n      # E o desfecho: a movida não teve efeito NENHUM, porque a ordem é por\n      # caminho (ver o bloco acima). Duas medições erradas em sequência levaram\n      # a uma mudança inócua — que eu ainda por cima creditei por um resultado\n      # que veio de outra coisa. Fica escrito porque o erro instrutivo não é o\n      # proxy ruim: é ter medido de novo, com outro instrumento ruim, em vez de\n      # perguntar se o mecanismo suposto existia.\n      #\n      # (Este comentário mora FORA do bloco `>-` porque dentro dele `#` não é\n      # comentário: é conteúdo, e cada palavra viraria argumento do playwright.\n      # Medido: com o texto dentro, a variável ia de 23 para 205 tokens.)\n      #\n      # `agente-marca-consulta` entrou aqui em 2026-08-27, e a dívida que a segurava era\n      # explícita: ela estava em FORA_DO_CI com o motivo escrito de que eu não conseguira\n      # EXECUTÁ-LA. Foi executada em ambiente completo (banco do baseline, app buildado),\n      # 3 casos / 3 verdes, e o primeiro run achou um defeito real de servidor — a tela não\n      # dizia de quem era o compromisso. Entra na PARTE_2 e não na 1 porque é AQUI que mora\n      # o molde dela, `agente-organiza-operacao`; o comentário antigo dizia PARTE_1 e estava\n      # errado sobre o próprio vizinho.\n      # `relogio-http-cron-externo` entrou na PARTE_1 em 2026-08-28, e a escolha da\n      # parte é o cuidado que ela exige: o tick que ela dispara é GLOBAL por\n      # natureza — ele roda as tarefas de minuto da instalação inteira, não só as\n      # da fixture dela. Um enrollment de outra spec que esteja vencido naquele\n      # instante É processado.\n      #\n      # Por que isso não quebra ninguém, medido e não presumido: as specs que\n      # criam enrollment e depois conferem `current_node_id` são\n      # `followup-journey`, `followup-queue` e `followup-dossie` (PARTE_2, outro\n      # processo, depois) e `followup-ramos` (PARTE_1). A ordem de execução é por\n      # CAMINHO — ver o bloco acima —, e `followup-ramos` < `relogio-http-...`,\n      # então ela já terminou quando o tick roda. Nenhuma spec posterior da\n      # PARTE_1 depende de enrollment parado.\n      #\n      # Se um dia uma spec de follow-up entrar na PARTE_1 com nome depois de\n      # `relogio-`, é aqui que se olha: ou ela vai para a PARTE_2, ou esta vai.\n      SPECS_PARTE_2: >-\n        agente-marca-consulta.spec.ts\n        agenda-marcar-pela-tela.spec.ts\n        agenda-remarcar-e-cancelar.spec.ts\n        agenda-tipos-de-agendamento.spec.ts\n        cliente-pela-agenda.spec.ts\n        agenda-grade-interativa.spec.ts\n        agenda-papel-de-leitura.spec.ts\n        agenda-busca-cliente-pelo-nome-do-perfil.spec.ts\n        agente-novo-e-uso.spec.ts\n        agente-organiza-operacao.spec.ts\n        central-de-avisos-capacidades.spec.ts\n        escalacao-ciclo.spec.ts\n        followup-builder.spec.ts\n        followup-queue.spec.ts\n        distribuicao-atendimento.spec.ts\n        followup-journey.spec.ts\n        capacidades-do-agente.spec.ts\n        escopo-de-funil-do-agente.spec.ts\n        followup-dossie.spec.ts\n        followup-tempo-adaptativo.spec.ts\n        gatilho-de-etapa.spec.ts\n        gatilho-de-caso.spec.ts\n        agenda-ocupacao-do-google-na-grade.spec.ts\n        agenda-ocupacao-do-google-no-historico.spec.ts\n        followup-publicado-abre-no-construtor.spec.ts\n        automacao-diz-a-verdade.spec.ts\n        acervo-de-conhecimento.spec.ts\n        agenda-kit-visual.spec.ts\n        agenda-conectar-google.spec.ts\n        agenda-painel-cabe-na-tela.spec.ts\n        agenda-ver-na-agenda.spec.ts\n        agenda-escopo-da-organizacao.spec.ts\n        agenda-caminho-ate-os-horarios.spec.ts\n        agenda-google-volta-do-consentimento.spec.ts\n        agenda-google-volta-nao-desloga.spec.ts\n        qa-l12-funil.spec.ts\n        navegacao.spec.ts prova-painel-provedores.spec.ts\n      # `moeda-da-organizacao` entrou na PARTE_2 em 2026-09-04, ao lado de\n      # `navegacao` — mesmo domínio (Configurações) e a mesma ausência de\n      # dependência: sem WAHA, Resend, Nuvemshop ou Redis. Toca a organização\n      # única do banco do CI (troca a moeda e devolve para BRL no afterEach),\n      # o mesmo cuidado que `troca-de-organizacao-tem-volta` já toma.\n      #\n      # `zona-de-perigo` entrou na mesma leva e no mesmo grupo, e por um\n      # cuidado a mais: ela APAGA dados, e por isso NÃO toca a organização\n      # compartilhada do banco do CI — o seed dela cria duas organizações\n      # próprias e descartáveis, e o caso zera uma para provar que a outra\n      # sai inteira. Sem WAHA, Resend, Nuvemshop ou Redis.\n      # Cada item aqui tem o motivo MEDIDO, não presumido — e o gate cobra que a\n      # lista exista e some com as outras duas.\n      #\n      # `vps-fresh-onboarding` fica fora por DUAS razões, e só a primeira estava\n      # escrita — com o número errado, contradito 8 linhas abaixo neste mesmo\n      # arquivo, que já usava 17 como controle positivo:\n      #\n      #   (1) 17 linhas dependem de WAHA/Resend/Nuvemshop/Redis\n      #       (`grep -ciE \"waha|resend|nuvemshop|redis\"` → 17 linhas, 22 ocorrências).\n      #\n      #   (2) `orgRow()` resolvia a organização com `.limit(1).single()` em\n      #       `organizations`, SEM `where` — pressupondo banco de UMA organização\n      #       só. O banco de uma parte é compartilhado entre as specs dela, e\n      #       `signup-journey.spec.ts:32` cria uma segunda org sem cleanup: depois\n      #       que ela roda, já há ≥2 na mesma parte.\n      #\n      #       ⚠️ ESTA LINHA DIZIA QUE \"o `.single()` falha\". É FALSO, e a frase\n      #       errada é o motivo de a classe ter sobrevivido em quatro lugares:\n      #       ela descrevia o desfecho como barulho quando o desfecho é dano.\n      #       Medido em 2026-09-04 contra PostgREST v14.10, três linhas na tabela:\n      #\n      #           .select(...).limit(1).single()  →  error: null + a PRIMEIRA linha\n      #           .select(...).single()           →  PGRST116 (\"Cannot coerce…\")\n      #\n      #       O `.limit(1)` recorta ANTES da checagem de singularidade. Ou seja:\n      #       o `beforeAll` desta suíte — que zera `onboarded_at`, apaga\n      #       `ai_agents` e apaga `channel_sessions` — apagava dados de uma\n      #       organização QUALQUER, em silêncio. Levantado por @Elevstudio-Dev\n      #       (PR #559) depois de acontecer numa instalação de trabalho.\n      #\n      #       A instância está corrigida (a org sai do dono do bootstrap) e a\n      #       classe inteira virou gate: `tests/unit/e2e-nao-escolhe-a-primeira-linha`.\n      #       O que continua valendo aqui é a razão (1): esta suíte não roda no CI\n      #       por dependência externa, não por causa do rig de organização.\n      #\n      # Continua sendo a P0 da doutrina de QA Visual: `e2e` verde NÃO prova a\n      # jornada de instalação fresca, que é o produto que se vende.\n      #\n# As CINCO specs da missão \"Follow-up Vivo\" (`followup-linguagem`,\n      # `followup-ramos`, `followup-dossie`, `followup-tempo-adaptativo`,\n      # `gatilho-de-etapa`) entraram nas listas de execução, e não aqui, porque a\n      # medição não dá motivo para excluí-las: grep de waha/resend/nuvemshop/redis\n      # devolve **0 em todas as cinco** (controle positivo: `vps-fresh-onboarding`\n      # devolve 17). Usam o endpoint de cron, como a `followup-journey` que já roda.\n      #\n      # ⚠️ O QUE NÃO ESTÁ MEDIDO: elas passam LOCALMENTE, e verde local não é verde\n      # de CI. Nenhuma delas rodou num job ainda — foram escritas nesta missão e\n      # ficaram fora de todas as listas, isto é, existiam e nenhum job as invocava.\n      # A primeira execução do `e2e` neste PR é que decide; se alguma reprovar por\n      # ambiente, o lugar dela é aqui embaixo COM o motivo medido, nunca uma\n      # exclusão preventiva \"por garantia\" — que é exatamente a dívida entrando\n      # pela porta de serviço que este bloco existe para barrar.\n      #\n      # `prova-painel-provedores` esteve aqui e SAIU: em 2026-08-08 o caso F3 exigia\n      # mais de 50 modelos da OpenRouter e o catálogo chegava ao CI com 2 linhas — a\n      # spec media a sorte do ambiente. `scripts/seed-e2e-catalogo-openrouter.ts`\n      # (main, 2026-08-09) semeia o catálogo no job, então a condição que a excluía\n      # deixou de existir e ela voltou para a lista de execução.\n      #\n      # `inbox-tempo-real` entrou aqui em 2026-08-26, no dia seguinte ao merge, e o\n      # motivo é medido nos dois eixos que importam — não é \"está flaky, tira\".\n      #\n      #  (a) ELE NÃO DISCRIMINA O CONSERTO QUE DIZ GUARDAR. Bancada local fiel a\n      #      este job (Supabase local na major do `config.toml` — era pg17 em\n      #      2026-08-26, o dia da medição abaixo; quem cobre pg15 E pg17 hoje é a\n      #      matriz do job `invariants`, no ci.yml —, `baseline.sql`, Realtime\n      #      reiniciado depois do baseline, `next build` de produção, os mesmos\n      #      seeds), revertendo SÓ `lib/supabase/browser.ts` para a versão\n      #      anterior ao\n      #      #327 — e conferindo no bundle que o conserto saiu\n      #      (`grep -rc realtime-token .next/static` → 0, controle positivo\n      #      `sb-deskcomm-auth` → 1):\n      #\n      #          com o conserto  → 1 passed\n      #          sem o conserto  → 1 passed\n      #\n      #      Causa provável: `useMessagesRealtime` liga `refetchOnWindowFocus:\n      #      true` e o `execFileSync` que injeta a mensagem devolve o foco à\n      #      janela. Dois caminhos, mesma saída. Quem discrimina de verdade é\n      #      `tests/unit/realtime-token-do-socket.test.ts`, no `verify`.\n      #\n      #  (b) ELE REPROVA PR ALHEIO POR SORTE. Desde o conserto do locator:\n      #      #327 `dd58ec7c` pass · #343 `b508208b` pass · #344 `4e5acb21` FAIL ·\n      #      #344 rerun do MESMO sha FAIL. O #344 não toca uma linha de inbox,\n      #      realtime ou socket.\n      #\n      #      Um gate que reprova por moeda é pior que gate nenhum: ele treina o\n      #      time a reexecutar em vez de olhar, e foi assim que a degradação\n      #      silenciosa passou despercebida da primeira vez.\n      #\n      #      A spec CONTINUA no repositório e continua sendo a cerca de ponta a\n      #      ponta contra o sintoma relatado — roda local. Volta para a lista de\n      #      execução quando a issue #347 entregar entrega determinística (o\n      #      caminho provável é desligar o `refetchOnWindowFocus` durante a\n      #      asserção, para sobrar um caminho só).\n      #\n      # Atualização de 2026-09-16 (issue #347): a spec ganhou o discriminador que\n      # faltava. Ela deixou de asserir só a SAÍDA (o texto na tela, que tem dois\n      # caminhos) e passou a asserir a ENTRADA do caminho realtime — o `phx_join`\n      # com token do USUÁRIO e o frame `postgres_changes` com o corpo da mensagem\n      # (ver o cabeçalho da spec). O item (a) acima caducou como motivo de\n      # exclusão; o que falta para voltar ao gate é fechar a matriz de sabotagem\n      # (reverter só `lib/supabase/browser.ts` e ver VERMELHO) numa rodada do\n      # runner — a bancada exige Docker, ausente na VPS onde a mudança foi escrita.\n      #\n      # ✅ `agenda-marcar-pela-tela` SAIU DAQUI — e o registro é o motivo.\n      # Ela nasceu `skip` pela DECISÃO 21.3: a frente 1 é API e não tem pixel\n      # próprio, então em vez de CITAR num relatório qual spec vai prová-la em\n      # tela, a spec é CRIADA com a condição de saída no cabeçalho.\n      #\n      # ⚠️ E ESSA CONDIÇÃO VENCEU SEM NINGUÉM VOLTAR PARA CONFERIR. Declarar a\n      # condição foi a coisa certa; o que faltou é que ninguém relê condição de\n      # saída. Medido quando alguém foi olhar:\n      #\n      #     grep -n \"^export async function\" app/api/v1/agenda/agendamentos/route.ts\n      #     # GET:95  POST:145  PATCH:149  DELETE:153\n      #\n      # Escrita e executada, ela achou CINCO defeitos que nenhuma leitura de\n      # código tinha achado — entre eles a tela dizendo \"Marcado ✓\" sem criar\n      # linha nenhuma, e um laço de requisições no painel de marcação.\n      #\n      # Entra na PARTE_2 ao lado de `agente-marca-consulta` porque as duas usam\n      # `seed-e2e-agenda.ts` — e cada uma o chama por si, para nenhuma depender\n      # da ordem em que a outra rodou.\n      # para dirigir, e a condição que a exclui deixa de existir. Enquanto isso,\n      # rodá-la no CI custaria um job para exercitar um `skip`.\n      #\n      # `agenda-conectar-google` entra pela MESMA 21.3 e pela frente 3 (Google\n      # Calendar BYO), que também não tem pixel: o botão \"Conectar Google\" e a\n      # faixa de estado moram na tela da Agenda, da frente 2.\n      #\n      # São DUAS condições de saída, e a primeira depende só de tela:\n      #\n      #  (a) o caso SEM CHAVE sai daqui assim que a tela consumir\n      #      `googleEstaConfigurado()` — ele não precisa de Google nenhum, porque\n      #      o que prova é a instalação SEM `GOOGLE_CALENDAR_CLIENT_ID`, que é o\n      #      estado de 100% dos deploys novos (DECISÃO 3.1, envs opcionais). É o\n      #      caso que vem PRIMEIRO no arquivo de propósito: spec que só cobre o\n      #      caminho feliz deixa sem prova justamente a primeira tela que todo\n      #      self-hoster encontra;\n      #\n      #  (b) o caso COM CHAVE precisa, além da tela, de uma conta Google de teste\n      #      com consentimento pré-aprovado. Sem ela o job pararia num\n      #      `accounts.google.com` esperando login humano — e é por isto que ela\n      #      fica FORA do CI em vez de ficar sem existir.\n      # ─── PARTE 3 — nasceu em 2026-09-07, e o teto de 30 min é quem a pediu ───\n      #\n      # A parte 2 foi CANCELADA aos 30 min neste PR (job 101799282999), com 55 dos\n      # 57 arquivos terminados. Não foi defeito de spec: foi a suíte crescendo —\n      # o PR acrescenta 10 specs e todas as 10 caíram na parte 2 (47 → 57).\n      #\n      # Duas partes não davam mais para equilibrar com segurança. Medido pelo\n      # instrumento desta seção no próprio run cancelado: 24,9 min de relógio na\n      # parte 2 contra 13,7 min na parte 1, e o setup custa ~8,5 min IGUAIS em\n      # cada job. Dividir 39,7 min de Playwright em dois daria 28,4 min por job —\n      # 1,6 min abaixo do teto, que é folga que a próxima spec consome. Em três,\n      # dá ~22 min, a mesma folga que a parte 1 tem hoje.\n      #\n      # O corte é o ponto onde o relógio ACUMULADO da parte 2 chega à metade, na\n      # ordem em que o Playwright executa — não por contagem de arquivos, que\n      # esta seção já registra como proxy que inverte o sinal. Ficaram\n      # 32 arquivos (13.3 min) na parte 2 e 25 (11.7 min) aqui.\n      #\n      # A parte 3 é a CAUDA contígua da parte 2, e isso é deliberado: as specs de\n      # uma parte compartilham o banco sem reset, então cada vizinhança rompida é\n      # risco de contaminação. Uma cauda rompe UMA fronteira; um sorteio romperia\n      # todas. Os 3 arquivos que o cancelamento impediu de medir vêm junto, porque\n      # eles são justamente o fim da fila.\n      SPECS_PARTE_3: >-\n        autonomia-assistida.spec.ts\n        roteamento-por-canal.spec.ts\n        central-avisos-destino.spec.ts\n        agenda-presenca-recuperacao.spec.ts\n        agenda-google-sync.spec.ts\n        agenda-google-meet.spec.ts\n        encerramento-atendimento.spec.ts\n        interface-por-vinculo.spec.ts\n        olhar-telas-do-epico.spec.ts\n        pipelines-gestao.spec.ts\n        qa-agente-usa-as-maos.spec.ts\n        qa-selo-no-funil-usado.spec.ts\n        qa-telas-descobertas-w4.spec.ts\n        retorno-anti-morte.spec.ts\n        webhooks.spec.ts\n        marca-logo.spec.ts\n        logo-moldura-no-tema-escuro.spec.ts\n        historico-de-captacao.spec.ts\n        inbox-quem-manda.spec.ts\n        inbox-rotulo-de-origem.spec.ts\n        troca-de-organizacao-tem-volta.spec.ts\n        organizacoes-criacao-convite-e-cache.spec.ts\n        suporte-temporario.spec.ts\n        i18n-espanhol-na-tela.spec.ts\n        inbox-abas-espelham-o-comando.spec.ts\n        moeda-da-organizacao.spec.ts\n        zona-de-perigo-apaga-dados-de-teste.spec.ts\n        central-avisos-resolver-em-lote.spec.ts\n        voz-desligada-por-padrao.spec.ts\n        voz-primeiro-pareamento.spec.ts\n        inbox-busca-e-filtros-dizem-a-verdade.spec.ts\n        qa-titulos-das-telas.spec.ts\n        qa-equipe-pinta-na-hora.spec.ts\n        qa-sugestao-rejeitada-e-motivo.spec.ts\n        qa-l12-agenda-contatos.spec.ts\n        qa-l12-tags-radar.spec.ts\n        extensoes-declarativas.spec.ts\n        extensoes-recuperacao.spec.ts\n        extensoes-versao.spec.ts\n        motivo-do-publicar-na-tela.spec.ts\n        followup-cartoes.spec.ts\n\n      FORA_DO_CI: >-\n        vps-fresh-onboarding.spec.ts\n        inbox-tempo-real.spec.ts\n        cadastro-sem-confirmacao-de-email.spec.ts\n    steps:\n      - uses: actions/checkout@v7\n\n      - uses: ./.github/actions/preparar-node\n        # O teto do preâmbulo mora aqui porque passo de composite action não\n        # aceita `timeout-minutes`. Razão e medições: o cabeçalho da action.\n        timeout-minutes: 5\n\n      # Versão EXATA, e nunca `latest`: é o CLI que escolhe qual stack o e2e\n      # sobe (PostgREST, GoTrue, Storage, Realtime) e o e2e exercita exatamente\n      # esse stack. Com `latest`, o registry no minuto do run decidia o produto\n      # sob teste — release upstream virava vermelho num PR sem relação.\n      # 2.117.0 é o que `latest` apontava em 2026-09-14 (release de 2026-09-07).\n      # Subir o pino é ato deliberado: data e razão junto, no mesmo commit.\n      # A catraca que reprova `latest`/faixa está em\n      # tests/unit/workflows-pinam-o-supabase-cli.test.ts, e roda no job\n      # `verify` (o `pnpm test:unit` do ci.yml).\n      - uses: supabase/setup-cli@v3\n        with:\n          version: 2.117.0\n\n      # Sobe Postgres + Auth (GoTrue) + PostgREST + Storage. Auth de verdade é\n      # requisito: os specs de login exercitam o GoTrue, não um mock.\n      #\n      # A pasta de migrations sai do caminho ANTES do start: a cadeia não sobe\n      # em banco novo (quebra na 0010, `alter table public.contacts` sobre uma\n      # tabela que ainda não existe) — está no CLAUDE.md e foi confirmado neste\n      # CI. O que o self-hoster aplica é o `baseline.sql`, então é ele que o\n      # e2e aplica: o banco do teste fica igual ao de quem instalou o produto.\n      - name: Subir Supabase local (sem a cadeia de migrations)\n        run: |\n          mv supabase/migrations /tmp/migrations-off\n          mkdir -p supabase/migrations\n          supabase start\n\n      # O baseline é um dump `--schema-only`: ele USA os tipos das extensões\n      # (public.vector, citext, gin_trgm_ops) mas não as cria. O stack local do\n      # Supabase traz os binários e não as habilita — mesmo prelúdio que o\n      # scripts/test-db.sh faz para o Postgres cru, só a parte de extensões\n      # (roles e schema auth já vêm prontos aqui).\n      - name: Habilitar extensões exigidas pelo baseline\n        run: |\n          psql \"postgresql://postgres:postgres@127.0.0.1:54322/postgres\" -v ON_ERROR_STOP=1 -q <<'SQL'\n          create schema if not exists extensions;\n          create extension if not exists \"uuid-ossp\" with schema extensions;\n          create extension if not exists pgcrypto with schema extensions;\n          create extension if not exists vector with schema public;\n          create extension if not exists citext with schema public;\n          create extension if not exists pg_trgm with schema public;\n          SQL\n\n      - name: Aplicar o baseline.sql (o mesmo que o install.sh do kit aplica)\n        run: |\n          psql \"postgresql://postgres:postgres@127.0.0.1:54322/postgres\" \\\n            -v ON_ERROR_STOP=1 -q -f supabase/baseline.sql\n\n      # ⚠️ O REALTIME PRECISA REINICIAR DEPOIS DO BASELINE — senão ele assina e\n      # não entrega, em silêncio.\n      #\n      # A ordem acima é `supabase start` (que sobe o Realtime) e SÓ DEPOIS o\n      # baseline, que é quem adiciona `messages`, `conversations`, `crm_leads` e\n      # as demais à publication `supabase_realtime`. O Realtime já subiu com a\n      # publication sem essas tabelas e não as reconhece depois.\n      #\n      # Medido no PR #327, com o defeito atravessando três rodadas: o script de\n      # apoio confirmou a gravação (`[e2e-chega-mensagem] entregue em 6f5fd1f2…`)\n      # e o snapshot da página no mesmo instante mostrava \"Nenhuma mensagem nesta\n      # conversa.\" — gravado no banco, nunca entregue à tela. Canal anônimo e\n      # canal sem publication falham do MESMO jeito: respondem SUBSCRIBED e\n      # calam, que é a razão de isto ter custado três rodadas de 15 minutos.\n      #\n      # Na VPS o problema não existe: lá o `install.sh` aplica o baseline e só\n      # então o compose sobe os serviços. Reiniciar aqui é o que faz o ambiente\n      # do teste refletir o de quem instalou.\n      - name: Reiniciar o Realtime (a publication nasceu no passo anterior)\n        run: |\n          IDS=$(docker ps -q --filter name=supabase_realtime)\n          if [ -z \"$IDS\" ]; then\n            echo \"::error::Nenhum container supabase_realtime — o stack não subiu como esperado.\"\n            exit 1\n          fi\n          docker restart $IDS\n          # Espera ficar saudável: seguir sem isso devolve o mesmo silêncio que\n          # este passo existe para eliminar, só que mais tarde e sem explicação.\n          for i in $(seq 1 30); do\n            estado=$(docker inspect -f '{{.State.Health.Status}}' $IDS 2>/dev/null || echo desconhecido)\n            [ \"$estado\" = \"healthy\" ] && { echo \"realtime saudável em ~$((i*2))s\"; break; }\n            sleep 2\n          done\n          docker inspect -f '{{.State.Health.Status}}' $IDS\n\n      - name: Exportar credenciais do stack local\n        run: |\n          supabase status -o env > /tmp/sb.env\n          {\n            echo \"NEXT_PUBLIC_SUPABASE_URL=$(grep '^API_URL=' /tmp/sb.env | cut -d'\"' -f2)\"\n            echo \"NEXT_PUBLIC_SUPABASE_ANON_KEY=$(grep '^ANON_KEY=' /tmp/sb.env | cut -d'\"' -f2)\"\n            echo \"SUPABASE_SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' /tmp/sb.env | cut -d'\"' -f2)\"\n          } >> \"$GITHUB_ENV\"\n\n      # O `.env.e2e` é o ambiente da suíte, e ele NASCE aqui — não é opcional.\n      #\n      # `playwright.config.ts` passou a exigi-lo e a falhar alto quando falta,\n      # de propósito: sem ele o `next start` carrega o `.env.local`, que num\n      # checkout de trabalho aponta para PRODUÇÃO. O modo de falha que isso\n      # substitui é a suíte escrever organizações e usuários de teste no banco\n      # real, passando verde.\n      #\n      # No CI o `.env.local` aponta para o Supabase local, então o risco não é o\n      # mesmo — mas o contrato é único, e um workflow que contorna o gate seria\n      # o começo de ele não valer para ninguém.\n      - name: Gerar o .env.e2e (ambiente da suíte)\n        run: pnpm e2e:env\n\n      # E publica o arquivo no ambiente do JOB, para que servidor e testes leiam\n      # literalmente a mesma fonte.\n      #\n      # Sem isto havia duas: o `.env.e2e` (que o playwright.config injeta no\n      # `next start`) e um bloco `env:` copiado à mão em cada passo de teste. As\n      # duas divergiram — `INTERNAL_SECRET` valia `e2e-placeholder…` no servidor\n      # e `ci-placeholder…` no processo de teste. Sintoma medido: 401 em toda\n      # chamada a rota interna, 8 specs vermelhas (6 de system-update no\n      # heartbeat, o drain do anti-SSRF, e o invite-lifecycle).\n      #\n      # Medido no Playwright 1.5x: `webServer.env` MESCLA com o ambiente herdado\n      # (uma var só do shell chega ao servidor) e VENCE nas colisões. Ou seja,\n      # copiar valores à mão nunca ia empatar com o arquivo — só uma fonte\n      # empata.\n      #\n      # De brinde, o processo de teste passa a ver as chaves de CIFRA de verdade\n      # (32 bytes, geradas pelo script) em vez do rótulo de 21 caracteres que o\n      # bloco `env:` fixava e que o app recusa.\n      - name: Publicar o .env.e2e no ambiente do job\n        run: grep -vE '^[[:space:]]*(#|$)' .env.e2e >> \"$GITHUB_ENV\"\n\n      # `pnpm e2e:build`, não `pnpm build`: as três `NEXT_PUBLIC_*` são embutidas\n      # no BUNDLE durante o build. Buildar com um env e trocar só no `next start`\n      # deixaria a URL errada dentro do JavaScript que roda no browser — servidor\n      # falando com um banco e cliente com outro, no mesmo teste.\n      - name: Build de produção (com o ambiente do E2E)\n        run: pnpm e2e:build\n        env:\n          NEXT_TELEMETRY_DISABLED: \"1\"\n          # `off`, NÃO vazio. Vazio seleciona o Sentry DA COMUNIDADE — está\n          # escrito em lib/sentry/dsn.ts:8-10, e é o default de propósito num\n          # produto self-host. Só \"off\" desliga.\n          #\n          # Sem isto, o SDK do browser tuneliza por /monitoring (next.config.ts:71)\n          # para o Sentry real do projeto, que devolve 429 por cota. Os 429 caem\n          # no console, e `olhar-telas-do-epico` — a ÚNICA spec que afirma \"não\n          # cospe erro no console\" — reprova nas 7 telas de uma vez.\n          # Medido em 2026-08-10 capturando a URL: /monitoring?o=4509908078559232,\n          # que é o org id do DEFAULT_SENTRY_DSN (lib/sentry/dsn.ts:17).\n          SENTRY_DSN: \"off\"\n\n      - name: Instalar o browser\n        run: pnpm exec playwright install --with-deps chromium\n\n      # `.env.local` é CÓPIA do `.env.e2e`, não uma terceira redação dele.\n      #\n      # O arquivo precisa existir porque alguns seeds leem `.env.local` do disco,\n      # não de `process.env`. Ele era montado à mão aqui, com os valores\n      # redigitados — e foi a terceira fonte do mesmo `INTERNAL_SECRET`, com o\n      # terceiro valor. Copiar não pode divergir; redigitar já divergiu.\n      #\n      # No CI as duas apontam para o mesmo stack local, então a cópia não\n      # reintroduz o risco que o `.env.e2e` existe para evitar (o `.env.local` de\n      # um checkout de trabalho apontar para produção).\n      #\n      # O seed cria 1 org + 4 usuários com os 4 papéis e um TOTP verified de\n      # secret conhecido no admin — sem ele, todo spec que loga fica de fora, que\n      # era a maior parte da suíte.\n      - name: Semear credenciais de teste\n        run: |\n          cp .env.e2e .env.local\n          pnpm exec tsx scripts/seed-e2e-credentials.ts\n\n      # `SUPABASE_DB_URL` no `.env.local` (e não só no `env:` do passo de teste):\n      # `seed-e2e-escalacao.ts` abre um `pg.Pool` direto, e o `.env.local` é a\n      # única fonte que ele lê. Sem a linha, o `pg` cai no default 5432 e morre\n      # com ECONNREFUSED — o stack local do Supabase publica o Postgres na 54322.\n      # Medido: foi exatamente assim que a primeira execução deste passo falhou.\n      #\n      # A maioria das specs roda o próprio seed num `beforeAll`. Estas TRÊS não\n      # — ou o cabeçalho manda rodar à mão, ou a fixture é de outra spec.\n      #\n      # `seed-e2e-followup-agent` (credencial de IA + sessão de canal) é o caso\n      # sutil: `followup-builder` o roda no próprio `beforeAll`, mas\n      # `agente-novo-e-uso` PRECISA das mesmas fixtures e vem ANTES na ordem\n      # alfabética — em banco fresco os comboboxes de credencial e canal abrem\n      # com a opção desabilitada e o clique estoura por timeout. Medido: o\n      # ensaio local passou porque o banco tinha as fixtures de uma rodada\n      # anterior; o CI, fresco, reprovou. Rodar aqui torna a precondição\n      # explícita em vez de depender da ordem dos arquivos.\n      #\n      # `--env-file` só na segunda, e a diferença não é capricho: o seed de\n      # capacidades importa `lib/env.ts` (via lib/ai/embed.ts), que lê\n      # `process.env`; o de escalação parseia o `.env.local` por conta própria.\n      # Sem a flag o de capacidades morre na validação Zod das 3 vars do Supabase.\n      - name: Semear as fixtures que as specs não semeiam sozinhas\n        run: |\n          pnpm exec tsx scripts/seed-e2e-escalacao.ts\n          pnpm exec tsx --env-file=.env.local scripts/seed-e2e-capacidades-ausentes.ts\n          pnpm exec tsx scripts/seed-e2e-followup-agent.ts\n          # O catálogo da OpenRouter não existe em banco fresco (o baseline\n          # semeia só anthropic/openai/google; os demais vêm do cron diário, que\n          # busca na internet). Sem esta linha, `prova-painel-provedores` mede a\n          # sorte do ambiente em vez da tela.\n          pnpm exec tsx scripts/seed-e2e-catalogo-openrouter.ts\n\n      # O que ficou DE FORA (e por quê) é declarado no passo de summary —\n      # cobertura parcial silenciosa se lê como cobertura total.\n      #\n      # `next start` roda com NODE_ENV=production, e aí toda var marcada\n      # `required()` no lib/env.ts vira obrigatória, inclusive as de serviço que\n      # estes specs nem tocam. Os valores abaixo são PLACEHOLDERS FALSOS, só\n      # para o boot passar da validação Zod — nenhum segredo real entra aqui.\n      # DUAS INVOCAÇÕES, e não uma — o motivo é medido, não estético.\n      #\n      # `lib/auth/rate-limit.ts` limita login a 60 por IP a cada 300s. No CI todo\n      # spec loga do mesmo 127.0.0.1, e ao subir a suíte de 15 para 29 specs o\n      # pico passou a ser 69–73 tentativas na janela: a suíte estourava o próprio\n      # teto do produto. O sintoma não é honesto — o login simplesmente não\n      # redireciona, e a spec sorteada pelo momento da saturação é a que falha.\n      # Foi assim que `risk-radar` caiu na main e `rbac-roles` num PR, sem\n      # nenhuma das duas ter mudado.\n      #\n      # Sem Upstash configurado, o limitador cai para memória DO PROCESSO (o log\n      # diz: \"redis incr failed; falling back to in-memory\"), e o\n      # `reuseExistingServer: false` do playwright.config.ts faz cada invocação\n      # subir o SEU `next start`. Duas invocações = dois processos = dois\n      # contadores, e o pico por processo volta para ~40.\n      #\n      # A alternativa seria afrouxar o teto por env — mexer num controle de\n      # segurança para acomodar o teste, que é a troca errada.\n      #\n      # `--workers=1` não é preciosismo: os specs compartilham UMA conta admin\n      # com MFA, e código TOTP é de uso único por janela no GoTrue. Com dois\n      # workers, dois specs gerando o mesmo código no mesmo intervalo de 30s\n      # fazem o segundo ser recusado como replay. Foi o que derrubava a\n      # `reset-password-mfa` (issue #80): ela passa sozinha e falha em paralelo.\n      - name: E2E — parte ${{ matrix.parte }} de 3\n        run: |\n          set -euo pipefail\n          # A lista sai da matrix, e as duas variáveis continuam existindo no\n          # `env:` do job porque é DELAS que `e2e-cobertura-completa.test.ts` lê\n          # a cobertura. Ler por indireção aqui e não duplicar a lista mantém\n          # UMA fonte por parte.\n          case \"${{ matrix.parte }}\" in\n            1) LISTA=\"$SPECS_PARTE_1\" ;;\n            2) LISTA=\"$SPECS_PARTE_2\" ;;\n            3) LISTA=\"$SPECS_PARTE_3\" ;;\n            *) echo \"::error::parte ${{ matrix.parte }} sem lista\"; exit 1 ;;\n          esac\n          # Falha alto se a variável vier vazia: sem isto, um typo no nome faria\n          # o playwright rodar a suíte INTEIRA (ou nenhuma) e o job ficaria\n          # verde medindo outra coisa.\n          [ -n \"$LISTA\" ] || { echo \"::error::lista da parte ${{ matrix.parte }} vazia\"; exit 1; }\n          # `--reporter=list` NÃO é preferência de leitura: é o INSTRUMENTO que\n          # mede o custo de cada spec, e sem ele a partição das listas volta\n          # a ser chutada.\n          #\n          # O reporter default em CI é o `dot`, e ele destrói justamente o dado\n          # que decide o balanceamento: os pontos saem em rajada, esvaziados só\n          # quando outro processo escreve na saída, e o timestamp do Actions é\n          # por LINHA. Medido no run 33820766602 (verde): a parte 1 despejou\n          # **37 pontos numa linha só**, cobrindo 4,9 min — 37 testes que se\n          # tornam indistinguíveis entre si. Reconstruir tempo por spec a partir\n          # disso é o terceiro proxy ruim que este arquivo já viu, e os dois\n          # anteriores INVERTERAM o sinal da medição (ver o bloco de\n          # `SPECS_PARTE_1` abaixo).\n          #\n          # Com o `list`, cada teste vira uma linha com caminho e duração\n          # (`✓ 12 [chromium] › tests/e2e/x.spec.ts:9:5 › título (3.4s)`), e a\n          # soma por arquivo sai de um `gh run view <id> --log`. Custo medido:\n          # ~240 linhas a mais de log, zero segundo de execução.\n          pnpm exec playwright test --workers=1 $LISTA --reporter=list\n        env:\n          # Só o que NÃO vem do `.env.e2e`: isto é ajuste de CI, não config do\n          # produto. CI = 1 IP para todos os specs, e o teto de produção\n          # (60/5min) é irreal aqui. O teto por CONTA (5 falhas) NÃO muda — é\n          # ele que barra brute force.\n          AUTH_RATE_LIMIT_LOGIN_IP: \"1000\"\n          # ver o comentário do passo de build: vazio NÃO desliga.\n          SENTRY_DSN: \"off\"\n\n      - uses: actions/upload-artifact@v7\n        if: failure()\n        with:\n          name: playwright-report-parte-${{ matrix.parte }}\n          path: |\n            playwright-report/\n            test-results/\n          retention-days: 7\n\n  # O NOME DESTE JOB É O CHECK OBRIGATÓRIO, e é por isso que ele existe.\n  #\n  # A branch protection da `main` exige um check chamado exatamente `e2e`\n  # (medido: verify, build-and-size, invariants, e2e, imagens-ok). Jobs de\n  # matrix se chamam `e2e-parte (1)` e `e2e-parte (2)` — se o job das partes\n  # tivesse ficado com o nome `e2e`, a proteção passaria a esperar para sempre\n  # um check que nenhum run produz, e NENHUM PR mergearia. Este agregador é o\n  # mesmo padrão do `imagens-ok` em `publish-image.yml`.\n  e2e:\n    if: always()\n    needs: [e2e-parte]\n    runs-on: ubuntu-latest\n    # Nenhuma permissão: este job só lê o resultado de `needs` e escreve o\n    # summary. Sem o bloco, o token herdaria o default do REPOSITÓRIO, que vive\n    # fora do repo e muda num clique.\n    permissions: {}\n    steps:\n      - name: Falhar se qualquer parte não passou\n        run: |\n          echo \"e2e-parte: ${{ needs.e2e-parte.result }}\"\n          # `success` é o único desfecho aceito. `skipped` também reprova, de\n          # propósito: parte pulada não mediu nada, e ler isso como aprovação é\n          # o modo de falha que o `imagens-ok` documenta.\n          [ \"${{ needs.e2e-parte.result }}\" = \"success\" ]\n\n      - uses: actions/checkout@v7\n        if: always()\n\n      - name: Declarar o que este job ainda NÃO cobre\n        if: always()\n        run: |\n          set -euo pipefail\n          # CONTA, não afirma. A versão anterior deste passo era prosa digitada à\n          # mão e dizia \"32 de 33\" com 39 arquivos no disco — a única coisa que\n          # existia para declarar a lacuna estava, ela mesma, desatualizada.\n          #\n          # As listas saem do PRÓPRIO workflow, com recorte por bloco. Contar com\n          # um `grep` no arquivo inteiro daria número MAIOR: os nomes aparecem\n          # também em comentários, e foi assim que a contagem antiga mentiu.\n          #\n          # ⚠️ O RECORTE PARA DE SER POR RANGE DE `sed` — ele lia COMENTÁRIO.\n          #\n          # A versão anterior era `sed -n \"/^      $1: >-/,/^      [A-Z_0-9]*:/p\"`,\n          # e o `[A-Z_0-9]` incluía DÍGITO de propósito: sem ele o range de\n          # `SPECS_PARTE_1` não parava em `SPECS_PARTE_2` (que tem `2` no nome) e\n          # seguia até `FORA_DO_CI`, devolvendo a união das duas como se fosse a\n          # parte 1. Medido antes daquele conserto: 67 em vez de 29.\n          #\n          # O que aquele conserto não alcançou é que o range NÃO PARA no fim do\n          # bloco `>-`: ele para na próxima CHAVE, e entre uma coisa e outra\n          # moram os comentários — que citam nome de spec o tempo todo. Medido\n          # neste arquivo, contra a lista de verdade:\n          #\n          #   SPECS_PARTE_1  real 30 · sed 31  (extra: `marca-logo`, citada no comentário)\n          #   SPECS_PARTE_2  real 43 · sed 44  (extra: `signup-journey`, idem)\n          #   FORA_DO_CI     real  2 · sed  3  (extra: um exemplo de saída do reporter)\n          #\n          # Nas duas primeiras o erro se anula na CONTA — as extras já estão na\n          # outra lista, e `RODOU` é união. Na terceira, não: `FORA_DO_CI` é a\n          # única lista impressa NOME A NOME, e o range dela vai até o fim do\n          # arquivo (nenhuma chave de 6 espaços vem depois). Qualquer comentário\n          # abaixo dela que cite um `.spec.ts` vira uma linha a mais em \"Não\n          # rodou\", com o motivo alheio colado — a declaração de cobertura\n          # afirmando falso sobre si mesma, que é a classe inteira que este\n          # passo existe para matar.\n          #\n          # O `awk` abaixo recorta o BLOCO: entra na chave, imprime enquanto as\n          # linhas forem continuação (8 espaços), e sai na primeira que não for\n          # — comentário de 6 espaços inclusive.\n          bloco() { awk -v k=\"$1\" '\n                      index($0, \"      \" k \": >-\") == 1 { dentro = 1; next }\n                      dentro && substr($0, 1, 8) == \"        \" && substr($0, 9, 1) != \" \" { print; next }\n                      dentro { exit }\n                    ' .github/workflows/e2e.yml | grep -oE \"[a-z0-9-]+\\.spec\\.ts\" | sort -u; }\n          NO_DISCO=$(ls tests/e2e/*.spec.ts | wc -l | tr -d \" \")\n          RODOU=$( { bloco SPECS_PARTE_1; bloco SPECS_PARTE_2; bloco SPECS_PARTE_3; } | sort -u | wc -l | tr -d \" \")\n          FORA_LISTA=$(bloco FORA_DO_CI)\n          FORA=$(echo \"$FORA_LISTA\" | grep -c . || true)\n          # E o recorte passa a ter CONTROLE, não só um piso.\n          #\n          # `tests/unit/e2e-cobertura-completa.test.ts` já garante que toda spec\n          # do disco está em exatamente uma das três listas, sem duplicata e sem\n          # fantasma. Logo `RODOU + FORA` TEM de dar `NO_DISCO` — e quando não\n          # der, o recorte leu a mais ou a menos. Sem esta linha, ler a mais é\n          # invisível: o passo imprime um número maior e segue verde, que é\n          # exatamente como a contagem antiga mentiu por três versões seguidas.\n          if [ \"$((RODOU + FORA))\" -ne \"$NO_DISCO\" ]; then\n            echo \"::error::recorte das listas divergiu — rodou=$RODOU fora=$FORA disco=$NO_DISCO\"\n            exit 1\n          fi\n          # Falha alto se o recorte parou de casar: listas vazias fariam este\n          # passo anunciar \"0 de N\" como se fosse notícia, em vez de erro.\n          [ \"$RODOU\" -gt 10 ] || { echo \"::error::o recorte das listas do workflow parou de casar\"; exit 1; }\n          {\n            echo \"## E2E — cobertura deste job\"\n            echo \"\"\n            echo \"**Rodou: ${RODOU} de ${NO_DISCO} specs**, em partes paralelas.\"\n            echo \"\"\n            echo \"**Não rodou (${FORA}):**\"\n            for s in $FORA_LISTA; do echo \"- \\`${s}\\` — precisa de WAHA + Redis + Resend + Nuvemshop (P0 da doutrina de QA Visual)\"; done\n            echo \"\"\n            echo \"A soma é conferida mecanicamente por \\`tests/unit/e2e-cobertura-completa.test.ts\\`:\"\n            echo \"spec no disco que não esteja em nenhuma das três listas reprova o build.\"\n          } >> \"$GITHUB_STEP_SUMMARY\"\n"
}