diff --git a/Dockerfile b/Dockerfile index 4b380cb7..d1052931 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,15 +40,19 @@ ENV NEXT_TELEMETRY_DISABLED=1 # This ensures the production image always has the S3ClientUploadHandler entry, # eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73). # The dummy values are safe because Payload's generator doesn't connect to S3. +# Shell prefix assignments bind to a single command, so each side of the `&&` +# declares its own env: the generator runs through `pnpm generate:importmap` +# (which owns the `--conditions=react-server` the Payload CLI needs to import +# server-only modules), and `next build` carries the standalone flag itself. RUN --mount=type=secret,id=database_url,env=DATABASE_URL \ --mount=type=secret,id=payload_secret,env=PAYLOAD_SECRET \ S3_BUCKET=build-dummy \ S3_ENDPOINT=http://127.0.0.1:3900 \ S3_ACCESS_KEY_ID=build-dummy \ S3_SECRET_ACCESS_KEY=build-dummy \ + pnpm generate:importmap && \ NEXT_OUTPUT_STANDALONE=1 \ NODE_OPTIONS="--no-deprecation --max-old-space-size=8000" \ - pnpm generate:importmap && \ pnpm exec next build # Production image, copy all the files and run next diff --git a/docs/changelog/2026-09-12-deploy-build-importmap.md b/docs/changelog/2026-09-12-deploy-build-importmap.md new file mode 100644 index 00000000..e8b023ab --- /dev/null +++ b/docs/changelog/2026-09-12-deploy-build-importmap.md @@ -0,0 +1 @@ +**Deploy destravado — envs do standalone no `next build` e importmap canônico no build da imagem (2026-09-12):** nenhum build de imagem completava desde o OPS99 (commit `493f4ba9`, 27/08 19:05 UTC; prod ficou no build anterior, `4d6533c04`) e o "sucesso" das 05:22 foi stale run saindo 0 em falso verde (main avançou durante o verify). O dispatch das 13:34 (run 34696817841) falhou no `pnpm exec payload generate:importmap` com `Error: This module cannot be imported from a Client Component module` (`server-only`): o OPS99 encadeou o gerador inline sem o `--conditions=react-server` dos scripts donos (`package.json` → `generate:importmap`/`migrate`/`build`, `scripts/predev-importmap.mjs:41`) — o `server-only` de `src/utilities/access/campaignStaffAdvisors.ts:5` (via `src/payload.config.ts:49` → `utilities/campaignAccess.ts`) resolve para o módulo que lança. O #953 (commit `52a2ef6d`) trocou o gerador por `pnpm generate:importmap` e tornou o stale run `fatal`; o deploy seguinte (run 34701266297, 15:07 UTC) passou o importmap mas expôs a segunda falha latente do mesmo RUN: prefixos de shell (`VAR=x cmd1 && cmd2`) valem só para o primeiro comando, então `NEXT_OUTPUT_STANDALONE=1` (`next.config.mjs:8`) e `NODE_OPTIONS` ficaram presos no gerador, o `next build` completou (399s) sem `.next/standalone` e o stage `runner` morreu em `COPY --from=builder /app/.next/standalone ./` → `"/app/.next/standalone": not found` (Dockerfile:73). Este PR (#952, sobre o #953) move `NEXT_OUTPUT_STANDALONE=1`/`NODE_OPTIONS` para o comando do `next build` e estende o guard de invariantes em `tests/unit/deployScript.unit.spec.ts` (script canônico + flags no lado do `next build`, RED com o Dockerfile do OPS99 e com o do #953). Verificação: produção-equivalente local gera o importMap com `S3ClientUploadHandler` e `.next/standalone/server.js`; `pnpm gate:fast` verde (lint + typecheck + unit) e gate:ci local verde (int 78 files / 712 tests); post-mortem completo em `docs/postmortems/2026-09-12-deploy-build-importmap.md`. Sem migration, sem access/Consent. Deploy de prod segue manual — dispatch do humano após o merge, confirmação pendente. diff --git a/docs/postmortems/2026-09-12-deploy-build-importmap.md b/docs/postmortems/2026-09-12-deploy-build-importmap.md new file mode 100644 index 00000000..8a14d935 --- /dev/null +++ b/docs/postmortems/2026-09-12-deploy-build-importmap.md @@ -0,0 +1,91 @@ +# Post-mortem: build da imagem mudo desde o OPS99 — importmap sem `react-server` e envs presos no prefixo + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ------------------------------------------------------------------------------------------------------ | +| Data do post-mortem | 2026-09-12 | +| Severidade | alta (nenhum build de imagem completou desde o OPS99; nenhum dado/PII afetado; prod no build anterior) | +| Ambiente | prod (build da imagem no homeserver, via `deploy.yml`) | +| Issue(s) | sem Issue (fluxo `/bug-fix`) | +| PR do fix | #953 (importmap + stale run) e #952 (envs do standalone + guards + docs) | +| Detectado por | humano (resultado do deploy manual) + log do run 34696817841 | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | 2026-08-27 16:05 -0300 | commit `493f4ba9` (OPS99) encadeia o gerador no RUN do builder sem `--conditions=react-server` e deixa os prefixos de env presos no primeiro comando da cadeia | +| Detecção | 2026-09-12 13:34–14:00 UTC | run 34696817841 do `deploy.yml`: `verify` verde (16m56s), `deploy homeserver (teqo-1313)` falha em 9m34s no `docker build` — `Error: This module cannot be imported from a Client Component module` no passo do importmap | +| Correção parcial mergeada | 2026-09-12 14:50 UTC | PR #953 (commit `52a2ef6d`): o gerador passa a usar `pnpm generate:importmap` e o guard de stale run deixa de sair 0 em falso verde | +| Segunda falha confirmada | 2026-09-12 15:07–15:33 UTC | run 34701266297 (dispatch pós-#953): importmap passa, `next build` completa (399s) sem `.next/standalone` e o stage `runner` morre em `COPY --from=builder /app/.next/standalone ./` → `"/app/.next/standalone": not found` (Dockerfile:73) | +| Correção completa | pendente | PR #952 — envs do standalone no comando do `next build`, guards de invariantes e este documento; CI em andamento | +| Deploy | pendente | dispatch manual do `deploy.yml` pelo humano após o merge (o deploy é manual; nenhum merge publica sozinho) | +| Verificado em prod | pendente | confirmação do humano após o deploy | + +Antes do OPS99, o último build real de imagem que passou foi o run 33074305879 (2026-08-27 12:56 UTC, prod no commit `4d6533c04` conforme o commit do #953). O "sucesso" das 05:22 (run 34675436246) foi falso verde do guard de stale run: main avançou durante o verify (`087635d0` vs `ccc8d6ea`) e o script saiu 0 sem deployar — comportamento corrigido no #953. Desde o OPS99 nenhum build de imagem completou, e o primeiro rebuild real (dispatch das 13:34) expôs os dois defeitos do mesmo RUN. + +## O bug + +O deploy manual de 2026-09-12 13:34 UTC não chegou ao fim: o job `deploy homeserver (teqo-1313)` falhou no `docker build` (target `builder`), no passo `pnpm exec payload generate:importmap`, com `Error: This module cannot be imported from a Client Component module` (`node_modules/server-only/index.js`). Produção continuou no build anterior; nenhum dado, PII ou migration foi tocado. **Sintoma — não a causa.** + +A correção do #953 destravou o primeiro obstáculo e desarmou o falso verde do stale run, mas deixou o segundo defeito latente no mesmo RUN: `NEXT_OUTPUT_STANDALONE=1` e `NODE_OPTIONS` continuavam prefixando o primeiro comando da cadeia (o gerador), então o `next build` sairia sem `output: 'standalone'` e o stage `runner` falharia no `COPY /app/.next/standalone` — o deploy seguiria sem publicar. O run 34701266297 (dispatch pós-#953) confirmou a segunda falha em produção: o importmap passou, o `next build` completou (399s) sem gerar `.next/standalone` e o build morreu no `COPY --from=builder /app/.next/standalone ./` → `"/app/.next/standalone": not found` (Dockerfile:73). + +## Causa-raiz + +Os dois defeitos têm a mesma raiz: o RUN do builder executa o gerador e o `next build` na **mesma cadeia `&&`**, e o OPS99 (commit `493f4ba9`) montou essa cadeia reimplementando a invocação do CLI no Dockerfile em vez de chamar o script dono (`package.json` → `generate:importmap`), sem atentar para a semântica de prefixos do shell. Cada defeito é uma face disso: + +1. **Importmap sem `react-server` (corrigido no #953):** o script do repo define `NODE_OPTIONS="--no-deprecation --conditions=react-server"` (`package.json`; mesmo contrato em `scripts/predev-importmap.mjs:41`), mas o Dockerfile chamou `pnpm exec payload generate:importmap` direto. Sem a condição, `server-only` resolve para o módulo que lança; com ela, para o `empty.js`. O CLI carrega o `payload.config.ts` inteiro — que importa `src/utilities/campaignAccess.ts` → `src/utilities/access/campaignStaffAdvisors.ts:5` (`import 'server-only'`) — e o build morria na avaliação do config. +2. **Envs presos no prefixo (corrigido neste PR):** em shell POSIX, atribuições-prefixo (`VAR=x cmd1 && cmd2`) valem **só para o primeiro comando** (reproduzido: `sh -c 'FOO=1 sh -c "echo first=\$FOO" && sh -c "echo second=\$FOO"'` → `first=1`, `second=`). Ao prepender o gerador, o OPS99 deixou `NEXT_OUTPUT_STANDALONE=1` (lido em `next.config.mjs:8`) e `NODE_OPTIONS="--max-old-space-size=8000"` aplicados ao gerador; o `next build` rodaria sem standalone. + +5-whys: + +1. **Por que o deploy falhou?** O `generate:importmap` do Docker build estourou num `import 'server-only'` do grafo do `payload.config.ts`. +2. **Por que estourou?** O Payload CLI rodou sem `--conditions=react-server`, a condição que faz `server-only` virar módulo vazio. +3. **Por que rodou sem a condição?** O Dockerfile re-escreveu a chamada do CLI em vez de usar o script `pnpm generate:importmap`, que é o dono da flag — divergiu do contrato sem ninguém notar. +4. **Por que o PR CI não pegou?** O CI de PR não builda a imagem Docker; o único lugar que exerce o stage `builder` é o deploy manual, e os dispatches pós-OPS99 não tinham feito rebuild real (o das 05:22 foi stale skip). +5. **Por que a divergência ficou 2 semanas escondida?** O contrato do RUN (comando canônico + escopo dos envs) vivia só na cabeça de quem escreveu o OPS99: nada pinava o Dockerfile aos scripts donos nem cobria o encadeamento do build da imagem em teste. + +**Evidência:** falha de produção do importmap reproduzida localmente em 2026-09-12 com o comando exato do Dockerfile (mesma stack, `server-only/index.js:1`); a mesma invocação via script (`pnpm generate:importmap`) passa. A semântica de prefixo foi reproduzida com o one-liner acima; o build de produção local sem `NEXT_OUTPUT_STANDALONE=1` deixa de gerar `.next/standalone`, e o run 34701266297 confirmou a falha no `COPY` do stage `runner`. + +## Correção + +- **#953 (commit `52a2ef6d`, já em main):** o Dockerfile passa a chamar `pnpm generate:importmap` (script dono da flag `react-server`, o mesmo do `predev`); o guard de stale run passa de `say … exit 0` para `fatal` nos dois pontos, sem falso verde. +- **#952 (este PR):** cada lado da cadeia declara seu próprio env — `pnpm generate:importmap && NEXT_OUTPUT_STANDALONE=1 NODE_OPTIONS="--no-deprecation --max-old-space-size=8000" pnpm exec next build`. O `next build` volta a produzir `.next/standalone` para o stage `runner` e mantém o teto de memória. +- **Guards (`tests/unit/deployScript.unit.spec.ts`):** (a) o script `generate:importmap` do `package.json` contém `--conditions=react-server` e o Dockerfile usa o script, não o CLI inline (#953, estendido com o pin do script); (b) `NEXT_OUTPUT_STANDALONE=1`/`max-old-space-size=8000` ficam no lado do `next build` e não no lado do gerador (#952); (c) os dois pontos de stale run são `fatal` (#953). + +Resolve a causa: o build da imagem volta a usar a invocação canônica do CLI e não depende mais de onde o `&&` corta os envs. Sem migration, sem mudança de access ou Consent. + +## Verificação + +- Teste de regressão do importmap (#953): `Dockerfile: generates the importMap via the canonical script` — **falha** com o Dockerfile do OPS99 restaurado (mutação, 1 failed / 11 passed) e **passa** com o fix +- Teste de regressão dos envs (#952): `Dockerfile: NEXT_OUTPUT_STANDALONE and NODE_OPTIONS reach next build (shell prefix scoping)` — **falha** com o Dockerfile do #953 (main atual, 1 failed / 11 passed) e **passa** com o fix (12 passed) +- Reprodução: comando exato do OPS99 falha localmente (mesma stack `server-only`); `pnpm generate:importmap` passa; one-liner de shell prova a semântica de prefixo +- Produção-equivalente local: `pnpm generate:importmap` (S3 dummy) gera o importMap com o `S3ClientUploadHandler` (entrada que o OPS99 protegia) e `NEXT_OUTPUT_STANDALONE=1 NODE_OPTIONS="--no-deprecation --max-old-space-size=8000" pnpm exec next build` completa e produz `.next/standalone/server.js` +- Suíte: `pnpm gate:fast` verde (lint + typecheck + unit); `pnpm push` verde no gate:ci local (int 78 files / 712 tests, docs guards) +- CI: pendente — PR #952 +- Prod: run 34701266297 (pós-#953) confirmou a segunda falha — `"/app/.next/standalone": not found` no stage `runner` (Dockerfile:73); deploy verificado pendente — dispatch manual do `deploy.yml` pelo humano após o merge deste PR, confirmação do humano pendente + +## Prevenção + +| Estratégia | Custo | Estado | +| -------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------- | +| Gerador via script canônico (`pnpm generate:importmap`) e stale run fatal, pinados por unit | barata | implementada no #953 | +| Guard unit pinando o escopo dos envs no lado do `next build` da cadeia | barata | implementada agora (PR #952) | +| Job de CI que builda o stage `builder` da imagem (Docker + DB migrado + secrets) a cada mudança de Dockerfile/script | cara | documentada — não implementada | +| Lint que proíbe invocação inline de CLI Payload em Dockerfile (fora dos scripts donos) | cara | documentada — candidata a Issue futura | +| Destrackear `src/app/(payload)/admin/importMap.js` (segue tracked no git apesar do `.gitignore` da OPS99) | barata | documentada — fora deste fluxo | + +**Estratégia implementada:** guards unitários dos invariantes do caminho de build Docker (comando canônico do importmap, escopo dos envs no `next build`, stale run fatal) — a classe "o Dockerfile divergiu do contrato dos scripts" vira falha de teste no PR, não falha de deploy em produção. + +**Estratégia documentada (cara):** job de CI que executa o build da imagem (ou ao menos `docker build --target builder`) a cada mudança de Dockerfile/scripts, e lint que bane invocação inline do CLI Payload no Dockerfile. Exigem runner com Docker + banco migrado + secrets de build — candidatas a Issue futura. O destracking do `importMap.js` também fica registrado como limpeza fora do escopo. + +## Lições + +- **Uma causa, duas falhas latentes:** o mesmo `&&` do OPS99 quebrou o importmap e roubou os envs do `next build`. Corrigir a primeira (importmap) **não** zerou o incidente — o run seguinte mostrou que metade do problema continuava armada. Um fix completo remove a causa (o encadeamento mal montado), não só o sintoma visível. +- **Cadeia com `&&` muda o dono dos prefixos de env:** no shell, `VAR=x cmd1 && cmd2` dá `VAR` só ao `cmd1`. Flags de build devem ficar explícitas no comando que as consome. +- **"Deploy verde" pode não ter deployado:** o stale run das 05:22 saiu 0 em 19s e o GitHub mostrou sucesso — prod ficou duas semanas num build antigo. Falso verde de pipeline é pior que falha explícita; o #953 corrigiu o stale run e o run pós-#953 comprovou a segunda falha em vez de escondê-la. +- **Reimplementar a invocação em vez de chamar o script dono é uma twin barata de nascer e cara de pagar:** o `predev-importmap.mjs` já usava a condição `react-server`; o Dockerfile duplicou o comando e perdeu a flag. Chamar o script teria custado zero. +- **O único lugar que exercita o Dockerfile não era o PR CI:** guards de invariantes do arquivo de build são o mínimo para compensar a ausência de um job de imagem no CI. diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 51861cc0..abc33a4d 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -97,6 +97,25 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { expect(script).not.toContain('--password "$REGISTRY_PASSWORD"') }) + it('Dockerfile: NEXT_OUTPUT_STANDALONE and NODE_OPTIONS reach next build (shell prefix scoping)', () => { + // Shell prefix assignments (`VAR=x cmd1 && cmd2`) bind to only the first + // command. When OPS99 chained the importmap generator before next build, + // the standalone/memory flags silently stayed on the generator side — next + // build would produce no standalone output for the runner stage. + const dockerfile = readFileSync(join(repoRoot, 'Dockerfile'), 'utf8') + const builderStage = dockerfile + .slice(dockerfile.indexOf('AS builder'), dockerfile.indexOf('AS runner')) + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .join('\n') + const [importMapCommand, nextBuildCommand] = builderStage.split('&&') + expect(importMapCommand).not.toContain('NEXT_OUTPUT_STANDALONE') + expect(importMapCommand).not.toContain('max-old-space-size') + expect(nextBuildCommand).toContain('next build') + expect(nextBuildCommand).toContain('NEXT_OUTPUT_STANDALONE=1') + expect(nextBuildCommand).toContain('max-old-space-size=8000') + }) + it('Dockerfile: the migrator stage never runs `next build` (it builds against the old schema)', () => { // The invariant OPS66 depends on: the migrator image can always be built, // even before the new migrations exist in the DB. @@ -117,6 +136,11 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { // `server-only` then throws and every Docker build failed (2026-09-12 // incident — no deploy landed after OPS99). The package script owns the // flags. + const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record + } + expect(pkg.scripts['generate:importmap']).toContain('--conditions=react-server') + const dockerfile = readFileSync(join(repoRoot, 'Dockerfile'), 'utf8') const builderStage = dockerfile.slice(dockerfile.indexOf('AS builder')) expect(builderStage).toContain('pnpm generate:importmap')