## WHAT
- `AGENTS.md` e `docs/agents/issue-tracker.md`: todo trabalho e todo PR precisam de issue Linear.
- SDLC automático: trabalho feito → testes feitos ou atualizados → pre-commit verde → commit → PR, sem perguntar.
- Templates de PR idênticos no Gitea (`.gitea/PULL_REQUEST_TEMPLATE.md`) e no GitHub (`.github/pull_request_template.md`) com WHAT / WHY / HOW / Linear Issue / Comments.
## WHY
Agentes estavam soltos no tracker e paravam pra perguntar se podiam commitar. O contrato precisa ser o ciclo completo, com issue Linear e template de PR.
## HOW
Docs e templates only. Sem mudança de runtime. Pre-commit (`composer pint:check` + `composer phpstan`) passou.
## Linear Issue
- [MAN-132](https://linear.app/maneco-workspace/issue/MAN-132/exigir-issue-linear-em-todo-trabalho-e-pr)
- [MAN-133](https://linear.app/maneco-workspace/issue/MAN-133/adicionar-pr-template-no-gitea-e-no-github)
## Comments
GitHub é o mirror legado; o PR canônico é este no Gitea.
Reviewed-on: #4
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
Remove job/reusable workflow do CI. Staging volta a ser workflow
separado após CI; 1.24 não implementava o trigger.
Co-authored-by: Cursor <cursoragent@cursor.com>
capacity>1 exige rede isolada por job e containers aninhados via DNS,
sem publish de host ports que colidem entre jobs.
Co-authored-by: Cursor <cursoragent@cursor.com>
act_runner em VPS compartilhada falhava com port already
allocated; jobs usam service postgres na rede do job.
Co-authored-by: Cursor <cursoragent@cursor.com>
Gitea rejeita nomes de secret reservados GITEA_*; workflows
e runbook passam a REGISTRY_PAT / REGISTRY_USER.
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf: cortar 157 KiB do caminho crítico e medir o LCP de forma reprodutível (MAN-109)
A auditoria do PR #34 mediu LCP acima da meta de 2,5 s da SPEC §6.6 em todas
as páginas no mobile, mas ficou registrada apenas como tabela num comentário
do Linear — `storage/app/lighthouse` é gitignored, então não havia artefato
para comparar depois. Esta entrega mede de novo, encontra a causa dominante e
corta o que dava para cortar.
## Fontes servidas em dobro (88 KiB)
Bunny entrega cada peso de EB Garamond em woff2 e woff, e o plugin de fontes
emitia uma regra `@font-face` para cada, woff2 primeiro e woff depois. Duas
regras com a mesma família, peso, estilo e unicode-range fazem a última
vencer: o navegador renderizava a partir dos woff e descartava os woff2
pré-carregados.
O log de rede da home prova: 3 woff em prioridade VeryHigh (88 KiB), a mais
alta da página e à frente do elemento de LCP, mais 3 woff2 em High (74 KiB)
baixados só por causa do `<link rel="preload">`. 162 KiB de tráfego para
74 KiB de fonte útil.
woff2 é suportado por todo navegador que este site atende desde 2016, então as
regras woff eram peso morto, não fallback. O plugin `amare:fonts-woff2-only`
remove as regras do CSS e do manifest e tira os arquivos do bundle. É o que
derruba o FCP de 1,51 s para 0,91 s em todas as páginas.
## Ativos de marca reencodados (102 KiB) — absorve MAN-122
O logotipo era servido a 512 px de largura para renderizar em 48 px (lockup,
cabeçalho e rodapé de toda página) e 32 px (mark, home), com `loading="eager"`.
Reencodados a 3× do maior render: lockup 149×144 (84 → 14 KiB) e mark 191×96
(42 → 10 KiB), mesmos nomes de arquivo para não invalidar cache. As variantes
`on-dark` foram reencodadas junto por consistência; nenhuma view as usa hoje.
Os 16 baselines visuais foram regenerados no runner Linux e o diff é
imperceptível a 2× de zoom — mesma forma, mesma cor, menos bytes.
## Efeito medido
Mobile, mediana de 3 execuções por página: home 4,58 → 3,39 s; portfolio
3,98 → 1,58 s; portfolio-detalhe 3,98 → 3,01 s; servicos 3,68 → 2,63 s;
sobre 3,01 → 1,58 s; contato 2,55 → 1,51 s. Desktop passa com folga em todas
(máximo 0,79 s). Acessibilidade, boas práticas e SEO seguem 100, CLS 0,000 e
TBT 0 ms.
`contato`, `portfolio` e `sobre` entraram na meta. `servicos`,
`portfolio-detalhe` e `home` continuam fora, e o que falta está identificado:
o elemento de LCP da home é a imagem do hero em JPEG q82 (143 KiB), e
`Improve image delivery` estima 0,90 s de ganho restante. O lever é variante
WebP em ResponsiveImage com `<picture>` — fora deste escopo porque mexe no
pipeline de mídia que o CMS usa para upload.
## Ferramental
- `scripts/perf/lighthouse.sh` ganha `/portfolio/{slug}`, passada desktop,
mediana de 3 execuções e uma guarda de HTTP 200 antes de auditar. Uma rota
em 404 produz relatório com score alto: a rota mais pesada do site
apareceria como ótima e ninguém notaria.
- `scripts/perf/summarize-lighthouse.mjs` extrai do JSON o elemento de LCP, a
decomposição em fases e as requests até o LCP.
- `docker/ci-runner.Dockerfile` e `scripts/test/visual-update-ci.sh` versionam
a receita de regeneração de baselines, que existia só como checklist.
- `CLAUDE.md` corrigido: Pest Browser serve a aplicação de um servidor Amp
in-process, não do FrankenPHP. A paridade que importa nos baselines é
Linux vs macOS, não o runtime HTTP.
- Sem gate de performance no CI (§14.1 e §22). No lugar, orçamento de bytes
para os ativos de marca e asserção de que o build só emite woff2.
Staging não foi medido: a origem responde 303 para blocked.teams.cloudflare.com
a partir da rede corporativa, inclusive em `/up`. Duas hipóteses seguem não
validadas por dependerem de `FILESYSTEM_DISK=r2` — os ~4 round trips a R2 por
imagem que `x-media.image` faz sem cache, e a falta de `preconnect` para a
origem cross-origin de mídia. Ambas documentadas em
docs/evidence/lighthouse/README.md.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* perf: servir imagens de conteúdo em webp e descrever sizes corretamente (MAN-109)
Fecha a meta de LCP ≤ 2,5 s da SPEC §6.6 em todas as páginas, nos dois presets.
Continuação direta do commit anterior: com fontes e marca resolvidas, o elemento
de LCP de toda página no mobile passou a ser a imagem do hero, e o Load Delay de
1,88 s era contenção de banda pura.
## Variantes webp
`ResponsiveImage::generate()` escreve uma variante `.webp` ao lado de cada
variante no formato original, e `x-media.image` a oferece num
`<source type="image/webp">`. O `<img>` continua apontando para o formato
original: o `<source>` é preferência, não substituição, então nada quebra em
quem não decodifica webp, e mídia antiga sem irmãos webp renderiza `<img>` puro
como antes.
O `<picture>` recebe `display: contents` porque os chamadores estilizam o `<img>`
com classes como `h-full w-full object-cover` que resolvem contra o pai grid ou
flex — um wrapper inline quebraria isso.
O nome do arquivo acrescenta a extensão em vez de trocá-la
(`photo-720.jpg.webp`): uploads usam UUID, então colisão já era improvável, mas
`photo-720.webp` colidiria com a variante webp de um `photo.png`.
## sizes que descreve a realidade
Nenhuma imagem do site ocupa a viewport inteira — todas ficam dentro de
`container-amare`, que reserva 1,5rem de padding de cada lado. Declarar `100vw`
fazia uma viewport de 412 px em DPR 1,75 pedir 721 px e pular para a variante de
960 para desenhar uma caixa de 637 px. Errar por um pixel custava um terço a mais
de bytes em toda página.
Com `calc(100vw - 3rem)` a home passa a usar a variante de 720: 74 KiB, contra
143 KiB no início da investigação. Foi também por isso que 720 entrou em
`ResponsiveImage::WIDTHS` — sem ela o salto de 480 para 960 é grande demais para
a viewport mobile mais comum.
## Efeito medido
Mobile, mediana de 3 execuções: home 3,39 → 2,49 s; portfolio-detalhe
3,01 → 2,18 s; servicos 2,63 → 2,03 s; portfolio 1,58 s; sobre 1,58 s;
contato 1,50 s. Desktop no máximo 0,65 s. Peso total da home 798 → 347 KiB.
Contra o início da investigação (`2e43fde`): home 4,58 → 2,49 s com score de
performance 83 → 98.
A home passa **em cima da linha** — a pior das três execuções deu 2,57 s. Está
documentado como aprovada por margem, não com folga, e os levers restantes estão
listados em docs/evidence/lighthouse/README.md em ordem de custo.
Os 16 baselines visuais não mudaram: nenhum seeder gera variantes e
`media:generate-variants` não roda no runner visual, então naquele ambiente
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
cobertura do caminho com variantes fica em MediaImageComponentTest e
ResponsiveImageTest, não nos baselines — anotado como lacuna conhecida.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: registrar a dependência do deploy e corrigir os commits nas evidências
Três correções de honestidade nos artefatos de medição.
O campo `Commit:` era gravado com o HEAD do momento da coleta, que é sempre
anterior ao commit que contém as mudanças medidas — as duas passadas pós-correção
diziam `2e43fde` e `65919d4` quando as árvores medidas viraram `65919d4` e
`9ab2beb`. Corrigido à mão nos dois arquivos, e o script passa a marcar
`+alterações não commitadas` quando a árvore está suja, para o artefato não voltar
a afirmar o que não é.
O ganho das imagens não aparece em staging nem em produção antes de
`media:generate-variants` rodar: lá o disco é `r2` e as variantes webp e a largura
720 ainda não existem para a mídia publicada. O serviço `migrate` do
docker-compose.deploy.yml roda o comando sem condição a cada deploy e ele
regenera mesmo quando já há variantes, então o primeiro deploy resolve — mas
estava implícito e agora está escrito.
E o custo que as variantes webp introduzem no lado servidor está registrado com o
número certo: `x-media.image` faz 8 `exists()` por imagem no lugar de 3, o que em
`r2` são ~9 round trips remotos por imagem e ~36 na home. Isso agrava a hipótese
não validada de TTFB em staging em vez de melhorá-la, e promove o cache desses
metadados a item mais urgente da lista.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* fix: cache responsive image metadata for R2
* test: atualizar baselines visuais Linux
---------
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
A cidade de atuação é São Paulo, garantida por teste em quatro lugares e
exigida por openspec/specs/site-settings/spec.md. O identificador de
timezone, porém, era America/Fortaleza. O identificador passa a
acompanhar o negócio.
A mudança não altera comportamento. America/Sao_Paulo e
America/Fortaleza são UTC-3 o ano inteiro desde que o horário de verão
brasileiro foi extinto — verificado para janeiro, março e dezembro de
2026, idênticos ao segundo. Nada renderizado muda, o relógio congelado
dos testes visuais usa offset absoluto (-03:00) e os baselines seguem
válidos.
O motivo de mexer é outro: a divergência entre o timezone e a cidade
custou tempo real. Uma sessão anterior a interpretou como drift e
"corrigiu" a SPEC no sentido errado, mudando o documento normativo para
Fortaleza em vez de olhar o que o negócio é. Com os dois valores dizendo
São Paulo, não há mais o que interpretar.
Escopo: config/app.php, .env.example, os dois pontos do ci.yml,
SPEC.md (§0, §13.5, §15.4), README.md, docs/deployment/dokploy.md,
CLAUDE.md, openspec/config.yaml, openspec/specs/visual-regression/spec.md
e o withTimezone do VisualRegressionTest.
Intocados de propósito: as asserções que garantem que Fortaleza não
aparece como cidade de operação, em PublicPagesTest, SiteSettingsTest,
ContentSeederProductionGatingTest e openspec/specs/site-settings. Essas
tratam de cidade, não de fuso, e continuam corretas.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
* fix(seed): impedir ContentSeeder de sobrescrever conteúdo em produção
O migrate do Dokploy roda `db:seed --class=ContentSeeder --force` em todo
deploy, tanto em staging quanto em produção (mesmo compose, só o .env
muda). Como ContentSeeder usa updateOrCreate/delete incondicionais, cada
deploy em produção revertia edições feitas pela dona no Filament
(SiteSetting, Services, PortfolioCases), republicava os 3 casos e
serviços fictícios, apagava fotos reais da galeria e reenviava as
fixtures de imagem para o bucket R2 de produção — além de publicar os
depoimentos reais via TestimonialsSeeder, sem autorização explícita.
Adiciona uma guarda de ambiente no início de ContentSeeder::run(),
seguindo o mesmo padrão já usado em AppServiceProvider::
freezeClockWhenConfigured(): em APP_ENV=production o método retorna
sem efeito colateral (exit 0), preservando o passo migrate&&seed&&...
do docker-compose.deploy.yml. TestimonialsSeeder não é tocado — ele
continua sendo o único caminho de publicação de depoimentos em
produção, documentado como passo manual em docs/deployment/dokploy.md.
Staging (APP_ENV=staging) continua recebendo conteúdo de demonstração
normalmente, preservando a revisão visual.
Atualiza docs/deployment/dokploy.md, que afirmava (incorretamente,
desde o commit 10a1f0d) que os workflows de deploy eram "migrate-only"
e que ContentSeeder nunca rodava em staging/produção.
Adiciona teste de regressão cobrindo: banco vazio em produção (nenhum
registro criado, nenhuma fixture enviada ao disco), banco com conteúdo
editado pela dona em produção (seeding duas vezes não altera nada) e
comportamento inalterado fora de produção (semeia o conteúdo de demo
normalmente).
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* fix(seed): trocar guarda de produção do ContentSeeder para allow-list
O guard anterior (`App::environment('production')`) é uma comparação
exata e sensível a maiúsculas contra o valor literal de APP_ENV, que é
digitado manualmente no editor de texto livre do Dokploy sem validação,
enum ou valor padrão garantido por este repositório. Se esse valor
algum dia ficar vazio, vier com case diferente ('Production',
'PRODUCTION'), espaço em branco ou for simplesmente digitado errado, o
guard falha aberto: o ContentSeeder roda seu caminho destrutivo em
produção sem nenhuma outra checagem no caminho de deploy que pegasse o
erro — exatamente o cenário que esta guarda deveria evitar.
Inverte a lógica para uma allow-list dos ambientes conhecidos e
seguros ('local', 'staging', 'testing'), então qualquer valor não
reconhecido de APP_ENV — incluindo 'production', vazio, mal digitado
ou com case diferente — falha fechado (no-op) em vez de falhar aberto
(sobrescrever dados reais). 'testing' entra na lista porque é o valor
de APP_ENV usado pelo job `feature` do CI (ver .github/workflows/ci.yml),
que roda testes que chamam ContentSeeder diretamente — sem esse valor
a suíte quebraria em CI mesmo passando localmente (APP_ENV=local via
.env).
Adiciona cobertura de regressão via data provider cobrindo tanto os
três ambientes permitidos quanto uma lista de valores não permitidos
('production', string vazia, 'Production', 'PRODUCTION', espaço à
direita, valor arbitrário desconhecido), para que a guarda seja testada
como allow-list e não apenas contra o literal 'production'.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs(deploy): corrigir descrição da guarda do ContentSeeder no runbook
O texto descrevia a guarda antiga (deny-list de 'production') e ainda
afirmava, de forma incorreta, que a publicação dos cinco depoimentos
reais era "um passo manual e deliberado em todo ambiente (incluindo
staging)". Isso nunca foi verdade: o ContentSeeder chama o
TestimonialsSeeder internamente e só pula essa chamada quando a guarda
não casa com o ambiente atual — em staging a guarda casa, então os
depoimentos são publicados/republicados automaticamente a cada deploy,
sem nenhum passo manual envolvido (o próprio teste
test_content_seeder_still_seeds_demo_content_outside_production já
provava isso).
Atualiza as duas passagens para descrever a allow-list
('local'/'staging'/'testing') introduzida no commit anterior e para
deixar explícito que staging publica depoimentos automaticamente,
reservando o comando manual apenas para produção. Também documenta o
trade-off do fail-closed: um APP_ENV de staging digitado errado agora
faz o conteúdo de demonstração parar de ser resemeado silenciosamente.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
---------
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
* docs: adicionar runbook de atualização de conteúdo (MAN-106 AC4)
A dona do negócio precisa editar textos, fotos, casos de portfólio,
depoimentos e dados de contato pelo painel admin sem depender de
engenharia. Documenta as regras que mais confundem (publicação não é
agendamento; home exige publicado + destaque, exceto depoimentos),
os campos editáveis por tela, requisitos de imagem/alt text e o que
não deve ser mexido sem apoio técnico.
Também esclarece o status do depoimentos.md: é material bruto
histórico que nunca foi lido por código (confirmado por busca no
repo), então recebe uma nota de status no topo do arquivo em vez de
ser movido ou apagado, já que ainda é citado por documentos técnicos
antigos como origem do conteúdo.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: reforçar avisos sobre resumo institucional e publicação em Configurações
Ajusta o runbook de atualização de conteúdo: deixa explícito que o
resumo institucional pode ser salvo em branco sem erro (produzindo um
parágrafo vazio silencioso na página Sobre) e que a tela Configurações
não tem passo de publicação — toda alteração entra no ar assim que é
salva.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: corrigir regra de imagem opcional para capa de caso e galeria
A regra geral "sem imagem, texto alternativo pode ficar em branco"
(seção Imagens) estava sendo lida como "a imagem em si é opcional" para
todo campo do painel. Isso é falso para a capa de um caso de portfólio
e para a imagem de um item de galeria: as colunas cover_image_path,
cover_image_alt (portfolio_cases) e path, alt_text (portfolio_images)
são NOT NULL no banco, mas o formulário do Filament nunca chama
->required() nesses uploads. Quem seguisse a doc e tentasse salvar sem
imagem passaria pela validação do formulário e só então bateria num
erro de banco não tratado, em vez da mensagem amigável de campo
obrigatório usada para título, resumo etc. Confirmado em
database/migrations/2026_07_28_230932_create_portfolio_cases_table.php,
database/migrations/2026_07_28_230933_create_portfolio_images_table.php
e app/Support/PublicImageUploadRules.php — os demais campos de imagem
do painel (logo, foto de depoimento, imagem da página Sobre, imagem de
SEO, capa de serviço) são de fato nullable no banco, então a ressalva
fica restrita a portfólio.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: avisar que Excluir apaga registros para sempre, sem lixeira
O runbook só ensinava publicar/despublicar (preencher ou apagar
"Publicado em") como forma de mostrar/esconder conteúdo em Portfólio,
Depoimentos e Serviços, e nunca mencionava que cada linha dessas
tabelas também tem um botão "Excluir" (e ação de exclusão em massa)
bem ao lado do de editar. Nenhum model usa SoftDeletes (confirmado em
app/Models) e as tabelas de Portfólio, Depoimentos e Serviços usam
DeleteAction/DeleteBulkAction (app/Filament/Resources/{PortfolioCases,
Testimonials,Services}/Tables/*.php e ImagesRelationManager.php), ou
seja, um clique em Excluir apaga o registro para sempre, sem
possibilidade de recuperação — inclusive apagando em cascata a galeria
inteira de um caso de portfólio (cascadeOnDelete em
portfolio_images.portfolio_case_id). Quem seguisse só a instrução de
"apagar o Publicado em" não tinha como saber que existia esse outro
controle, irreversível, na mesma linha.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: explicar como converter foto HEIC do iPhone para o formato aceito
A instrução para formato de imagem recusado dizia só "converta a
imagem antes de enviar", sem dizer como. HEIC é o formato padrão de
fotos do iPhone e está listado no próprio texto como um dos formatos
recusados — é exatamente o erro que uma pessoa não técnica enviando
foto do celular vai encontrar primeiro, e o guia não pressupõe em
nenhum outro lugar que ela saiba converter formato de imagem. Adiciona
duas instruções concretas e não técnicas: ajustar o iPhone para salvar
fotos novas em JPEG, e usar o WhatsApp para converter fotos que já
estão em HEIC.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: avisar que o e-mail de Contato recebe os briefings do site
O runbook descrevia o campo E-mail de Configurações → Contato só como
algo que vira link clicável no rodapé e na página /contato. Não
mencionava que esse mesmo endereço é o destino de todo envio do
formulário de contato: ContactController::dispatchEmails() (app/Http/
Controllers/PublicSite/ContactController.php) chama
Mail::to($settings->email)->send(new ContactBriefing(...)) para cada
briefing enviado por um visitante. Alguém lendo só esta doc poderia
trocar esse valor por um e-mail apenas decorativo e parar
silenciosamente de receber notificações de leads, sem nenhum erro
visível no admin.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs: documentar a tela Usuários para quem realmente pode acessá-la
O runbook só citava "Usuários" na negativa ("se você não é
Administradora, peça a uma Administradora"), mas toda política de
autorização do painel (PortfolioCasePolicy, ServicePolicy,
TestimonialPolicy, SiteSettingPolicy, UserPolicy — todas checando
$user->isAdmin()) garante que quem consegue seguir qualquer outra
seção deste guia é, por construção, uma Administradora com acesso
total a Usuários. A doc nunca explicava os campos da tela (Nome,
E-mail, Papel, Ativo, Senha) nem como usá-la para dar acesso a alguém,
apesar de seções anteriores (Como entrar no painel) já dependerem de a
leitora saber fazer exatamente isso. Adiciona uma seção "Usuários"
ligando os campos ao comportamento já descrito em outras seções (Papel
Assistente remove todo acesso; Ativo desligado bloqueia login via
User::canAccessPanel()) e avisa sobre um risco real e não coberto:
como UserPolicy não impede autoedição, uma Administradora pode trocar
o próprio Papel ou desligar o próprio Ativo e ficar travada fora do
painel sem nenhuma forma de recuperação self-service. Também reescreve
o bullet de Usuários em "O que NÃO mexer" para não instruir a mesma
leitora a evitar uma tela que, pela própria lógica de acesso do
sistema, é dela.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
---------
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
* feat: adicionar cadência editorial à home
* fix: reconcile pr23 ci with current main
* test: atualizar baselines visuais da home para cadência editorial
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.
Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.
Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.
Co-authored-by: Cursor <cursoragent@cursor.com>
Publish immutable FrankenPHP images to GHCR, auto-deploy staging after CI,
and promote the same digest to production with smoke, backup, and rollback docs.
Co-authored-by: Cursor <cursoragent@cursor.com>