fix(deploy): OPS99 — envs do next build fora do prefixo da cadeia (standalone)

O RUN do builder encadeia gerador de importMap e next build com '&&', mas
prefixos de shell (VAR=x cmd1 && cmd2) valem só para o primeiro comando.
Com o OPS99, NEXT_OUTPUT_STANDALONE=1 e NODE_OPTIONS ficaram presos no
gerador: o next build rodou sem output standalone e o stage runner falhou
no COPY de /app/.next/standalone (run 34701266297, pós-#953 — o #953 já
corrigiu o importmap sem react-server e o falso verde do stale run).

Cada comando passa a declarar seu próprio env, e o guard de invariantes do
Dockerfile em tests/unit/deployScript.unit.spec.ts ganha o pin do escopo
(flags no lado do next build). Inclui post-mortem e changelog.
This commit is contained in:
Francisco Solla 2026-09-12 12:36:28 -03:00
parent 52a2ef6dd1
commit c42bd486de
4 changed files with 121 additions and 1 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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<string, string>
}
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')