Changelog

What changed, release by release.

Every entry here is a version that actually reached production. The page is written by the release pipeline, from the commit messages — nobody edits it by hand.

8 releases · the most recent on 02 Sep 2026

Site API

Site API 0.6.0

Novidades

  • #1332 — Tres imagens por artigo, e fotografias para os artigos antigos

Decisao do dono a 02/09: tres imagens sempre, e substituir por fotografias as dez capas em SVG que trazem o titulo pintado em portugues dentro da imagem — o que estraga a pagina inglesa, porque a traducao reutiliza a fotografia da portuguesa.

O numero de imagens estava escrito a mao em dois sitios (a prosa da instrucao ao escritor e uma lista fixa no editor), e a variavel de configuracao que parecia controla-lo so era lida pela estimativa de custo. Ou seja, muda-la sozinha nao geraria mais imagem nenhuma: faria a estimativa mentir. Passa a mandar nos tres sitios. O leitor aceita as respostas antigas de duas imagens sem rebentar, e um artigo retido continua a comprar so a capa.

DEFEITO ENCONTRADO AO FAZER ISTO, e que custava dinheiro em silencio: o modelo do artigo tinha um campo para a imagem de corpo e NENHUM renderizador o usava. Todos os artigos que o motor escreveu desde 01/09 pagaram uma segunda fotografia que nunca chegou a uma pagina. Passa a haver uma lista de imagens de corpo, e o renderizador coloca-as ao longo do texto sem mexer nas posicoes dos anuncios.

O comando novo gera fotografias para artigos que ja existem. O plano e derivado do artigo guardado e nao de uma lista, portanto uma segunda corrida planeia zero; os bytes vao para disco antes de qualquer coisa poder falhar; o custo e estimado e o saldo verificado antes de gastar; e so uma opcao explicita compra. A direccao artistica e importada da instrucao do escritor, com um teste que falha se alguem a copiar — se divergirem, as dez fotografias antigas nao combinam com as que o motor gera semanalmente e nota-se no indice.

Duas premissas minhas que estavam erradas e foram corrigidas em vez de contornadas: o campo de lista de imagens nao existia, e os dez artigos nao tem zero imagens de corpo — tem uma cada, em SVG, dentro do proprio texto. Por isso a substituicao troca o endereco no sitio onde ele esta, e a fotografia fica onde o autor a pos. So as capas tem titulo pintado; os SVG de corpo nao tem texto.

Verificado sem gastar creditos: ensaio contra a API a serio com os 18 artigos semeados da exactamente 10 artigos, 20 imagens, 170 creditos; o caminho completo num artigo com gerador de substituicao troca a capa e a figura no lugar, mantem a data de publicacao e o estado, e regenera as paginas; a segunda corrida planeia zero. O briefing real de um artigo passou pelo modelo verdadeiro e produziu dois prompts validos, com legendas em portugues europeu e cenas genuinamente diferentes, a cerca de 0,70 USD por artigo.

267 -> 288 testes verdes no motor; 398 -> 408 no site-api.

  • Servir as fontes tipograficas da nossa origem, em vez da Google

Fecha o ultimo caminho por onde a Google via o IP do visitante antes de ele responder ao aviso de cookies. O WI #1329 tirou a medicao; faltavam as fontes, que saiam em todas as paginas.

Sairam tambem as duas ligacoes antecipadas para os dominios de fontes: sem isso o IP continuava a ir para la, mesmo sem pedido visivel na aba de rede.

Sao os MESMOS ficheiros, nao equivalentes: comparados por SHA-256 com o que a gstatic servia, byte a byte identicos. A Fraunces e o ficheiro variavel com o eixo optico intacto, confirmado com fontTools, para os titulos grandes manterem o corte de display. Os intervalos de peso declarados sao os que a Google declarava, para um pedido de peso mais pesado cortar da mesma maneira.

Inventariados os 34 caracteres nao-ASCII das 39 paginas: todos cabem no subconjunto latino. O latin-ext vai na mesma como seguro — com unicode-range custa zero bytes ate uma pagina ter um carater desses.

Amostra de glifos: zero pixeis diferentes em 5,18 milhoes, com acentos, cedilha, aspas tipograficas, guilhemets e simbolos, em romano e italico, nas cinco familias. Cinco das seis paginas ficaram pixel-identicas; a sexta difere em 101 pixeis, mas duas capturas do MESMO servidor "antes" diferem em 4059 na mesma zona — e a animacao do painel, nao as fontes.

O QUE OS NUMEROS NAO CONFIRMAM, e vai dito porque a decisao foi tomada com o argumento de que seria mais rapido: em rede muito lenta a home paga cerca de 270 ms de atraso a pintar o primeiro texto. A causa foi isolada com uma terceira variante — e o custo e do PRELOAD, nao de servir as fontes daqui: sem preload, o depois empata com o antes. O que o preload compra e a fonte da marca a entrar um segundo mais cedo nos artigos em rede lenta. Nos artigos vale claramente a pena; na home e marginal. Ficou so na Inter, e e o botao reversivel deste trabalho.

O ganho incondicional e outro: medidos em producao, 437,7 ms de mediana gastos so a abrir ligacoes aos dois dominios da Google, e em serie — o browser so sabe o endereco do segundo depois de o primeiro responder. Isso desaparece. Origens de terceiros na home: 8 para 6; na pagina 404, de 2 para zero.

  • #1335 — Traduzir para ingles na publicacao, com o portugues como fonte

Decisao do dono a 02/09: em vez de escrever artigos em ingles, o artigo portugues e a fonte unica e a versao inglesa e traduzida, gerando paginas reais e indexaveis. Rejeitou explicitamente a traducao no browser, que nao cria paginas que a Google indexe, e o abandono do ingles.

Traduz na PUBLICACAO e nao na aprovacao, por tres razoes, por ordem de peso. O texto portugues so e final quando publica: entre aprovar e sair, o dono ainda pode edita-lo, e traduzir na aprovacao traduziria uma versao que nunca existiu. Uma falha nunca pode atrasar o portugues, e aqui nao pode porque o artigo ja esta no ar quando a rotina comeca. E nao ha fila de trabalhos: o que falta e derivado do armazem em cada execucao, por isso uma execucao que morre nao deixa nada por limpar, e os 11 artigos antigos escoam pelo mesmo caminho que os de amanha. Custo assumido: a pagina inglesa aparece ate 24h depois da portuguesa.

O dono NAO aprova a versao inglesa. Ele ja aprovou o artigo; le-lo outra vez noutra lingua e a mesma revisao duas vezes sem nenhuma decisao editorial nova. E um "nao" nunca poderia despublicar o portugues — so poderia significar "nao espelhes este", que e o que reter a pagina inglesa no backoffice ja faz, depois e em segundos. O que substitui a aprovacao sao verificacoes que recusam ANTES de a API ser chamada (referencia perdida, marca [n] perdida, seoTitle cortado, slug em colisao), um aviso com o link no momento em que sai, e uma variavel que estaciona tudo em retido — uma variavel, nao um lancamento.

Gravar uma traducao liga os dois lados NUMA SO operacao na API. O renderizador e o sitemap ja sabiam emitir alternates, mas so quando ambos os documentos declaram o par: um chamador que tenha de se lembrar da segunda escrita publica, mais cedo ou mais tarde, um par com metade.

Duas impressoes digitais respondem sozinhas ao "e se o dono editar o portugues depois de traduzido": a do original diz que a traducao ficou desactualizada e refaz; a da propria pagina inglesa diz que alguem lhe mexeu a mao — e nesse caso NAO reescreve, avisa.

As 4 paginas inglesas que ja existem sao traducoes de 4 artigos portugueses e ja estao ligadas nos dois sentidos. Nao ha nada a migrar. E ficam intocaveis: substituir uma traducao humana por uma automatica e uma despromocao.

Custo medido nos 14 artigos: cerca de 6350 tokens de entrada e 2500 de saida por artigo, e ZERO creditos de imagem — a pagina inglesa reutiliza a fotografia da portuguesa, porque uma imagem nao tem lingua. Limpar o atraso dos 11 sao ~100k tokens de entrada; em regime, ~9k por semana.

Verificado com uma traducao a serio do artigo mais dificil do blog (literacia financeira, 8275 caracteres, 8 referencias, 7 percentagens), que passou o portao com zero desvios numericos. As citacoes de Klapper e Lusardi foram buscadas a fonte original em ingles, e isso virou regra do guia: retraduzir de volta inventaria uma terceira versao de palavras com o nome de uma pessoa ao lado.

Corrigido pelo caminho: a seccao aparecia em portugues na pagina inglesa, no caminho de navegacao e no cartao do indice. O rotulo ingles ja existia e a API nao o usava. Nao e campo para dar a um tradutor — e comparado letra a letra, e um "quase certo" faz o cartao desaparecer do indice.

398 -> 441 testes verdes no site-api; 267 -> 311 no motor. Guarda da CSP verde.

Correções

  • #1329 — Nao contactar a medicao da Google antes do consentimento

Decisao do dono a 02/09. Ate agora, quem abria o site e ainda nao tinha respondido ao aviso de cookies gerava dois pedidos para a Google: o proprio gtag.js e um /g/collect com gcs=G100. Nao gravava cookies nem identificava ninguem, mas o pedido saia, com o IP.

Isso e o Consent Mode v2, o desenho recomendado pela Google, e existe para permitir a modelacao de conversoes. O dono aceita perde-la. O argumento que decidiu: a medicao nao registou nada de Maio a 01/09, portanto nao ha serie historica para partir ao meio, e mudar o mecanismo nunca sera mais barato do que hoje.

O tag da Google deixa de existir no HTML e passa a ser criado por codigo, so quando o consentimento e "all". Sairam tambem das 35 paginas o preconnect para o googletagmanager e o dns-prefetch para o google-analytics: nao aparecem como pedidos na aba de rede, mas o preconnect abre mesmo ligacao TCP e TLS a Google antes de haver resposta ao aviso.

O identificador de medicao vivia no endereco do tag da Google. Passa a ser um atributo do proprio ficheiro de analytics — continua a haver uma copia por pagina, continua a ser atributo de um script externo, portanto nenhum hash da politica de seguranca pode ser invalidado, e o script que reescreve os tokens no CI continua a apanha-lo.

Ao aceitar, a visita em curso conta sem recarregar a pagina. Isso importa mais do que parece: obrigar a recarregar perderia a primeira visita de toda a gente, que e justamente quando o leitor chega de uma pesquisa.

Medido no browser contra a imagem de producao, nao em ambiente local. Sem resposta ao aviso: zero pedidos ao googletagmanager e zero ao google-analytics, contra um de cada antes. Aceite, sem recarregar: gcs=G111 e um page_view. Recusado, na pagina seguinte: zero.

Uma guarda nova chumba o build se alguma pagina voltar a trazer um script, preconnect, dns-prefetch, imagem ou iframe para um host de medicao ou publicidade da Google, ou se algum dos carregadores perder a verificacao de consentimento. Testada com a regressao reintroduzida de proposito. Mais 13 testes sobre o portao, sem dependencias novas — o site continua sem cadeia de build.

O QUE ISTO NAO FAZ, e ficou documentado para nao ser mal lido: continuam a sair pedidos para hosts da Google sem consentimento — as fontes (fonts.googleapis.com e fonts.gstatic.com) em todas as paginas, e um logotipo servido a partir do Google Cloud Storage nas duas homepages. Nao medem nada, mas veem o IP. Auto-aloja-los tem custo real de desempenho e e decisao separada. Esta na lista de excepcoes da guarda para que "a guarda esta verde" nunca seja lido como "nenhum host da Google e contactado".

398 testes verdes no site-api, antes e depois. Guarda da CSP verde.

Site API

Site API 0.5.1

Correções

  • #1328 — Nao propor integracao de trabalho que ja esta em main

O passo que cria automaticamente o pedido de integracao criava pedidos para trabalho ja fundido. Aconteceu tres vezes na noite de 01-02/09 e gastou tres corridas completas do agente de CI, que e partilhado com a producao.

A causa nao e falta de deduplicacao: o Azure DevOps ja recusa um segundo pedido activo para o mesmo par origem/destino. O que acontece e que o branch sobrevive ao merge — o apagar automatico nem sempre pega — e, com o pedido anterior ja fechado, a corrida seguinte encontra o caminho livre e cria um novo. Um force-push depois de um rebase alimenta o mesmo ciclo.

A guarda conta os commits do branch que ainda nao estao em main e, se forem zero, sai com codigo 0 e a dizer que nao ha nada a propor. Nao haver nada a propor e um resultado normal, nao uma falha. Tres linhas que cobrem os tres casos sem depender de perceber quando e que o apagar do branch falha.

A main e obtida para uma ref propria, nunca lida do FETCH_HEAD: num espaco de trabalho persistente o FETCH_HEAD da corrida anterior sobrevive e a contagem daria um numero errado. E o padrao que o repo do site adoptou no #1327. Se o fetch falhar, a guarda ABRE — prefere-se um pedido a mais a bloquear trabalho legitimo.

Sao sete pipelines e nao seis: alem dos seis repositorios, ha um setimo passo de criacao de pedido dentro do repo do site, o da pipeline da edge API, com exactamente o mesmo defeito.

Verificado fora do pipeline, com o bloco extraido verbatim do YAML e corrido contra repositorios git reais em sete cenarios: branch com commits a frente deixa passar; branch ja fundido trava; branch nova com clone raso deixa passar; FETCH_HEAD velho de uma corrida anterior trava, quando a mesma contagem via FETCH_HEAD daria um pedido fantasma; force-push pos-rebase trava; fetch impossivel abre. Correram as tres variantes do bloco, incluindo sob o set -euo pipefail que o passo do mobile tem.

Limite conhecido e assumido: com squash merge o commit do branch nao fica em main e a contagem continua acima de zero, portanto a guarda nao apanha. Nao afecta o caso real — estes repositorios fundem com merge commit, e ai o topo do branch e antepassado de main.

  • #1331 — Tabela por converter, titulo duplicado e buracos de publicidade

O primeiro artigo escrito de ponta a ponta pelo motor foi publicado a 02/09 e estava mal formatado. Tres defeitos independentes, mais dois que apareceram ao corrigi-los.

A tabela saia como um paragrafo de barras verticais. O BodyRenderer usa a Markdig, que suporta tabelas, mas o pipeline tinha apenas DisableHtml e UseAutoLinks. Ligada so a extensao das tabelas, e nao o pacote avancado inteiro: este renderizador processa texto gerado por IA e nao ha razao para lhe alargar a superficie.

A Markdig emite a table a cabeca da linha, sem contentor, e uma table nao desloca sozinha sem display:block, que a deixaria de ser tabela. Fica envolvida num div focavel com role=region: uma zona que desloca tem de ser alcancavel sem rato, senao as colunas da direita ficam inacessiveis a quem navega por teclado.

Isso obrigou a uma correccao que nao estava prevista: o InjectInArticleAds injecta anuncios no inicio dos blocos de topo e a sua expressao incluia "table". Como a table tambem comeca na coluna zero dentro do novo contentor, um anuncio ia parar entre o div e a tabela. Retirada da expressao, com um teste que falha se voltar.

O titulo da pagina vinha duplicado ("... | Blog AtivaMoney | Blog AtivaMoney"). A origem no motor nao era o contrato JSON, como eu supunha, mas a politica editorial que o prompt injecta na integra e que mandava terminar o titulo em "| Blog AtivaMoney". O escritor cumpriu a instrucao. Corrigida a politica, que e a fonte real, e o renderizador passa a remover um sufixo de marca repetido — em ciclo, e nas variantes PT e EN. Um seoTitle que seja so a marca fica intacto: um titulo vazio e pior do que um titulo redundante.

Os cerca de 1700 px de buracos cinzentos vinham de altura minima fixa nos espacos de publicidade, com o AdSense ainda em revisao. A reserva passa a acontecer no momento do PEDIDO e nao no da renderizacao: o main.js marca o espaco como pedido antes de sequer descarregar o script, e o CSS so lhe da altura a partir dai. Quando a conta for aprovada, o anuncio aterra numa caixa ja do tamanho certo e a pagina nao salta; se vier vazio, ou se nao vier resposta em 4 segundos, o espaco volta a zero. Nao ha passo manual no dia da aprovacao.

Dois achados que nao estavam no pedido:

O identificador da conta de publicidade no renderizador dos artigos era ca-pub-5063379807576127. O verdadeiro e ca-pub-8640715225546644 — esta no ads.txt, que e o ficheiro que a Google le para saber quem e dono do inventario, e em mais 126 sitios do repositorio. Os dois blocos de anuncio de TODOS os artigos do motor nomeavam uma conta que nao e dona do inventario e nunca serviriam.

O template dos artigos EN usa "| Blog AtivaMoney" enquanto as paginas EN escritas a mao e o feed EN usam "| AtivaMoney Blog". Nao alterado de proposito: mudaria o titulo de artigos EN ja publicados, o que e uma decisao de SEO do dono.

388 -> 398 testes verdes no site-api. Guarda da CSP verde. Motor sem regressoes (as 2 falhas do cli.e2e sao pre-existentes e reproduzem-se noutras branches).

Medido no browser a 1280 e 390 px: a tabela existe e desloca na propria caixa, a pagina nao ganha barra horizontal, e a altura de espacos vazios passa de 1726 px para 0 no desktop e de 1126 px para 0 no telemovel.

Site API

Site API 0.5.0

Novidades

  • #1295 — Publicar o changelog como pagina do site

O pipeline de RELEASE gerava o CHANGELOG.md, fazia commit e empurrava directamente para main, contornando a politica de branches — sem PR e sem work item ligado. Havia 34 commits desses no historico. Passa a gerar o mesmo conteudo e a publica-lo como pagina, por API, em runtime. Nada entra no repositorio.

Isso acaba de uma vez com quatro coisas: o contorno da politica, a corrida entre lancamentos simultaneos, a desactualizacao de todos os worktrees a cada release, e uma classe inteira de conflitos de merge.

Reaproveita a maquina do motor de conteudo (#1281): um changelog e exactamente o tipo de conteudo que ele ja sabe publicar sem deploy.

Vive em /pt/changelog/ e /en/changelog/. Duas moradas por serem dois idiomas, nao sete por serem sete pipelines — o selector PT/EN do cabecalho apontaria a um 404 se so existisse uma.

A loja guarda uma entrada por versao, com ancora propria (#backend-1.49.2) para se colar num work item. Mas o pipeline envia o historico TODO pos-cutover a cada release, nao so a versao de agora. Custa cerca de 300 KB e compra duas propriedades que um envio de uma entrada nao da: uma publicacao falhada cura-se sozinha na release seguinte — que e o que torna o continueOnError honesto e nao um encolher de ombros — e a pagina povoou-se sem migracao nenhuma.

A publicacao nunca bloqueia uma release. Um changelog por publicar e um contratempo; uma release bloqueada e uma paragem.

Sem chave nova: usa a variavel secreta que ja existe desde o #1281, e a chamada vai por dentro, pelo nginx. O /api/internal/* continua inalcancavel da internet.

Os CHANGELOG.md ficam onde estao, e o guard do CI que bloqueia edicoes manuais fica tambem — passa a proteger um ficheiro congelado, o que continua a ser correcto.

Duas coisas que so a verificacao mostrou, e valem o registo:

O filtro das 119 entradas de ruido auto-referencial (docs(changelog): backend-v1.49.0 seguido do marcador de skip, que iriam parar a uma pagina publica) nao funcionou a primeira. Alargar o scope no cliff.toml nao chega — a regra continua a exigir a palavra a seguir aos dois pontos. Descoberto ao verificar contra assuntos de commit reais em vez de ler a configuracao.

A pagina foi medida num browser, nao lida em HTML, e apanhou sete defeitos que nenhum teste apanharia: titulo a 58 px, texto branco sobre cartao branco (a pele v3-noite a ganhar as regras novas — o mesmo defeito do #1287), links a 1.4:1 porque a pele remapeia --primary para lima, o realce da ancora a apagar o cartao para onde o leitor tinha acabado de saltar, e 348 px de deslocamento horizontal num telemovel de 390 por causa de blocos de codigo nos corpos dos commits. Contrastes agora entre 4.8:1 e 19.4:1.

388 testes verdes. Smoke com a API a serio e o conteudo real dos sete CHANGELOG.md — 135 lancamentos.

Como se desfaz: tirar os dois mounts do docker-compose.prod.yml e subir o site (o endereco volta a 404, os dados ficam no disco), e git revert do commit em cada repo (a geracao nunca mudou, volta a escrever o ficheiro). Nada a apagar.

NOTA DE INTEGRACAO (rebase sobre a main ja com o #1289)

Os templates do changelog foram escritos antes do #1289 fundir, portanto traziam o gtag em linha — exactamente o que esse WI eliminou do resto do site. Depois do rebase, o guard da CSP que o #1289 ligou ao CI chumbou: "2 inline script(s) would be blocked".

A correccao nao foi acrescentar os hashes a politica, que era voltar a armar a armadilha que custou quatro meses de GA4. Foi alinhar os dois templates com o resto: analytics.js externo, as duas tags defer pela ordem certa.

Vale a pena registar que foi o guard a apanhar isto, e nao uma revisao. Sem ele, duas paginas novas entrariam em producao com scripts bloqueados, exactamente pelo mesmo mecanismo da avaria de Maio, e por uma razao que ninguem podia adivinhar: dois trabalhos correctos, feitos em paralelo, cujo conflito nao e textual e o git nao ve.

388 testes verdes sobre esta base; o guard passa com 2 hashes distintos e nenhuma entrada morta.

  • #1299 — Estados do artigo, a fundacao da curadoria humana

O dono decidiu aprovar todos os artigos antes de publicarem. Ate aqui um artigo tinha dois estados: nao existe, ou esta publicado. Passa a ter cinco: rascunho, retido, aprovado, abandonado e publicado, mais data prevista de publicacao e historico de comentarios.

A regra inegociavel - retido e abandonado NUNCA chegam ao publico - esta testada de quatro angulos e para os quatro estados nao-publicados: a pagina no disco, a listagem, o feed e o sitemap. O teste verifica ainda que um artigo publicado ao lado continua la, para o filtro nao passar por estar simplesmente a esconder tudo.

O RISCO MAIS ALTO era a compatibilidade: os 18 artigos em producao nao tem campo de estado. Assumir "rascunho" por omissao fazia o blog inteiro desaparecer na proxima regeneracao. Regra: ausencia do bloco = publicado. E, separadamente, bloco presente mas ilegivel = retido, porque ai nao e legado, e corrupcao, e corrupcao nunca deve publicar. Dai Held = 0 no enum: qualquer campo por preencher falha para o lado que nao magoa. Provado com uma copia byte-a-byte de um artigo que esta mesmo em producao.

O estado vive dentro do JSON do artigo, e nao num ficheiro a parte, por atomicidade: com dois ficheiros existe uma janela em que o artigo esta gravado e o estado nao, e um artigo sem estado tem de ser lido como publicado - o que publicaria exactamente o que nao pode ser publicado.

O rerender passa a renderizar so os publicados E a apagar do disco os que deixaram de o ser. Reutilizar o unpublish nao servia: ele apaga tambem a fonte, e reter um artigo nao pode destrui-lo. O unpublish fica como travao de emergencia; o caminho reversivel e a mudanca de estado.

Fechadas duas portas laterais: o catalogo que alimenta as redes sociais passa a devolver so publicados (o LinkedIn e mais uma forma de publicar), e o health publico conta so publicados - contar rascunhos mentia sobre o blog e denunciava artigos por aprovar.

Contrato escrito em docs/editorial/contrato-api-fila-conteudo.md, para o backoffice e o motor construirem contra nomes reais e nao inventados.

243 testes, 70 novos, zero warnings.

Correções

  • #1289 — Tirar o gtag de dentro do HTML e por o guard da CSP no CI

A CSP autoriza scripts em linha por hash sha256, e um hash de conteudo parte-se em silencio a cada byte que muda. Foi assim que o GA4 esteve a registar zero desde Maio ate 01/09 (#1288) sem ninguem dar por isso. A correccao do #1288 acrescentou mais hashes, ou seja deixou a armadilha armada.

O arranque do analytics passa para assets/js/analytics.js, autorizado por 'self'. Um ficheiro externo e autorizado por origem, nao por conteudo: deixa de haver o que se partir. Seis hashes passam a dois — os quatro do gtag desaparecem e o script de tema, que existia em duas variantes com o mesmo codigo e bytes diferentes, fica unificado.

As duas tags ficam defer e por ordem. Isso e o que garante que os defaults "tudo negado" entram no dataLayer antes de a tag da Google os ler; em async havia uma corrida em que a tag corria sem defaults nenhuns. E sai um bloco em linha do caminho critico, portanto e melhor para o LCP, nao pior.

O scripts/check-csp-hashes.mjs passa a correr no CI, depois do passo que reescreve os tokens de analytics e antes do build de Docker — nessa ordem de proposito, porque era exactamente o set-analytics-tokens.sh a reescrever bytes dentro de um bloco em linha que armava a avaria sozinho. Verificado dos dois lados: reescrito o GA4_ID como o CI o faz, o guard fica verde; partido um hash de proposito, chumba com exit 1.

Dois defeitos no proprio guard, encontrados ao liga-lo: contava <script> dentro de comentarios HTML, e varria api/bin/, onde uma copia velha de template podia fazer um hash morto parecer vivo. Essa segunda e a direccao perigosa, porque suprime o aviso em vez de o criar.

O JS_HASH passa a cobrir os dois ficheiros de javascript, derivado dos bytes concatenados, porque as paginas carimbam os dois urls com o mesmo token e ele tem de mexer quando qualquer um deles mexer. A regra vive no script de build; o AssetFingerprints nao precisa de saber que ficheiros entraram.

Duas coisas que ficam POR decidir e nao sao regressao desta alteracao:

O GA4 usa Consent Mode v2 — a gtag.js carrega SEMPRE e os sinais comecam todos em denied. Sem consentimento saem na mesma dois pedidos a Google, a propria tag e um g/collect com gcs=G100. Medido tambem na imagem anterior: comporta-se exactamente igual. Fechar isto implica nao carregar a gtag.js ate haver consentimento, e perder a modelacao de conversoes. E decisao do dono. O AdSense, esse, esta mesmo travado: o script nunca chega a ser pedido sem consentimento.

A CSP bloqueia tres coisas, iguais antes e depois: stats.g.doubleclick.net, www.google.pt/ads/ga-audiences (a politica so tem .com) e googletagmanager.com/td. Sao sinais de remarketing do Ads; nenhum afecta a medicao do GA4. Pre-existentes, merecem WI proprio.

  • #1296 — Meta tag do AdSense tambem nas listagens do blog

A verificacao de propriedade do AdSense procura a meta tag em cada pagina. Ficaram de fora as duas listagens (/pt/blog/ e /en/blog/), porque o script que acrescentou a tag as 24 paginas em falta excluiu a pasta api/ - e e la que vivem os templates de onde essas duas paginas sao geradas.

Detalhe que so se percebe conhecendo o #1281: as paginas do blog no repositorio sao a copia de seguranca dentro da imagem. O que o visitante ve vem do volume, gerado a partir destes templates. Editar o HTML do repo nao chega - tem de ser aqui.

Chega a producao com uma tag site-api-v*, que dispara a regeneracao.

Site API

Site API 0.3.1

Correções

  • #1293 — Trocar a conta AdSense e por a meta tag de verificacao em todas as paginas

O dono gerou o primeiro bloco de anuncios e o codigo veio com um identificador de editor DIFERENTE do que estava no site: ca-pub-8640715225546644 contra ca-pub-5063379807576127. Sao contas diferentes, e um espaco de uma conta nao funciona anunciado por outra. Confirmado com ele que a boa e a nova.

Trocado em 16 ficheiros, no ads.txt e nos dois templates do motor.

Segundo problema, que faria a verificacao de propriedade FALHAR: a meta tag google-adsense-account so existia nas 11 paginas de artigo. Nao estava na pagina inicial, nem em /pt/, /en/, /pt/blog/ ou /pt/precos/. O Google verifica o site, e a instrucao do painel diz explicitamente "cada pagina". Acrescentada as 24 que faltavam: 35 de 35.

Primeiro slot real ligado: IN_ARTICLE = 4118604714. Faltam tres, todos do tipo Display, e o dono cria-os no painel.

Nota para quem vier a seguir: o loader adsbygoogle.js continua deliberadamente FORA do <head>, ao contrario do que o painel do AdSense manda colar. No <head> ele corre antes de a pessoa responder ao aviso de cookies e poe cookies de publicidade sem consentimento (#1283). O main.js injecta-o so com consentimento total e pelo menos um slot preenchido.

Site API

Site API 0.3.0

Novidades

  • #1282 — Motor editorial autonomo, com travoes e vigia

O motor sai do portatil. Passa a correr como container isolado na VPS de producao, com limites de CPU e memoria, e a publicar sozinho pela API da Fase 1. Node 22, zero dependencias npm, 120 testes.

Nao ha revisao humana antes de publicar, por decisao do dono. Por isso o desenho e todo em torno de parar depressa e de gritar quando falha:

  • Quatro travoes independentes, qualquer um sozinho para tudo: ficheiro STOP, limite diario, arrefecimento apos 3 falhas seguidas, e o interruptor do lado da API.
  • Tres regras que falham em vez de degradarem: sem fotografia nao publica, sem guia editorial recusa escrever, e sem que as fontes abram nao publica.
  • Vigia diaria escrita a partir das falhas reais deste projecto, nao de um template: silencio prolongado, falhas seguidas, numero de artigos a diminuir, motor sem dar sinal, interruptor esquecido desligado. Mais um sinal de vida para servico externo, que e a unica coisa que da por um motor morto.
  • O motor RECUSA arrancar sem canal de avisos configurado. Foi o silencio que custou 19 dias sem ninguem dar por nada.

Nao e cron dentro do container: cron perde o ambiente, escreve para fora do docker logs e nao da sinal quando o trabalho nunca arranca. Foi assim que se perderam 8 execucoes. O tick de um minuto regista cada decisao e e ele proprio o sinal de vida.

Fecha dois buracos da Fase 1, em ficheiros novos da API: PUT de imagem (o motor nao tem repositorio nem deploy, logo nao tinha como por uma capa no site) e GET da lista de artigos (as redes sociais precisam dos 18 que nao escreveram).

Correções

  • #1290 — O CI corria 6 testes e reportava verde; passam a correr os 173

O passo de construcao so compila o projecto da aplicacao. O dotnet test a seguir tinha --no-build, portanto corria a biblioteca de testes que estivesse em disco. Como o agente e a propria VPS e reutiliza a pasta de trabalho entre execucoes, corria uma biblioteca OBSOLETA.

Prova, build 2903: Passed: 6, Total: 6. Localmente, sem --no-build: 173.

  • #1283 — Desbloquear o AdSense e fechar o portao de consentimento

Nenhum anuncio aparecia no blog e nao era regressao: as paginas escritas a mao ja eram assim. Sao bloqueios independentes e resolver um so nao chega.

Um quarto bloqueio, nao previsto e mais grave que os outros: o loader do AdSense estava no <head>, logo executava em todas as visitas ANTES de o visitante responder ao aviso de cookies. Isso anulava o portao de consentimento do #943 e punha cookies de publicidade sem consentimento, com o RGPD por cima. Verificado no browser: a biblioteca executou com o consentimento por responder e o aviso ainda no ecra. Corrigir so a politica de seguranca teria activado isto em producao em todas as paginas do blog.

  • ads.txt: linha real do IAB, com explicacao campo a campo.
  • main.js: os identificadores dos espacos passam a viver num unico bloco no topo do ficheiro; o HTML so nomeia a posicao. O loader passa a ser injectado por codigo, e so com consentimento total E pelo menos um identificador preenchido.
  • 11 artigos + os dois templates do motor: loader retirado do <head>. O meta que identifica o editor fica, e e esse que o AdSense usa para verificar.
  • scripts/check-csp-hashes.mjs: guarda que compara os hashes da politica de seguranca com os scripts embutidos reais e falha quando divergem. Contra o nginx.conf actual apanha o bug do GA4 (#1288). Fica por ligar ao pipeline, que e trabalho do #1289.

Ao dono faltam apenas os quatro numeros do painel AdSense. Ate la o blog fica sem anuncios por decisao explicita, nao por defeito.

Site API

Site API 0.2.0

Novidades

  • #1281 — Migrate the published articles to the canonical json

Os 11 artigos publicados (7 PT + 4 EN) passam a existir no formato canonico. Isto e critico: assim que o volume servir o blog, tudo o que nao estiver no armazenamento desaparece do site.

Conversao HTML->markdown feita a medida, nao com biblioteca generica. Os corpos usam um conjunto pequeno e conhecido de tags (p, h2, h3, ul, ol, li, strong, em, a, code, figure, img) e um conversor que so conhece essas e muito mais previsivel.

Verificacao de round-trip (scripts/content/verify-migration.mjs): compara o texto visivel do original com o do re-renderizado e reporta o que se perdeu. Resultado: 0,00% de perda nos 11 artigos, ~9.700 palavras. Sitemap com 27 URLs (16 institucionais + 11 artigos), 7 cartoes PT e 4 EN, referencias e FAQ intactas em todos.

Tres defeitos reais corrigidos por causa desta tarefa:

  1. 413 na publicacao. O limite de corpo da API sao 16 KiB, dimensionados para o formulario de contacto; os artigos tem 16-17 KB e um foi recusado. O limite passa a ser levantado SO no caminho autenticado do conteudo - o contacto e a lista de espera mantem o limite apertado que existe por anti-abuso. Ha teste para os dois lados.

  2. Extensao da capa assumida. O renderizador acrescentava ".jpg" ao identificador, mas metade das capas publicadas sao .svg (o fallback que o pipeline antigo produzia). Metade das imagens ia dar 404. O identificador passa a incluir a extensao e o MIME deriva dela.

  3. hreflang nao reciproco (SEO). O artigo EN data-security declara a traducao PT, mas o PT nao declarava a EN. O Google ignora a anotacao por completo quando nao e bidireccional, por isso aquele par estava a perder o beneficio. A migracao repara a reciprocidade e avisa quando uma traducao aponta para algo que nao existe.

151 testes.

  • #1281 — Publish orchestration and the internal content endpoints

Publicar passa a ser uma chamada HTTP autenticada. O orquestrador valida, guarda o JSON canonico e reconstroi tudo o que dele deriva.

Duas decisoes que valem nota:

Nada e escrito quando a validacao falha, por isso um artigo recusado nao deixa rasto no site vivo - so e guardado depois de passar.

Cada publicacao re-renderiza TODAS as paginas, nao so a nova. Um artigo novo muda as ligacoes "continua a ler" dos vizinhos e a listagem das duas linguas, e re-renderizar tudo e a unica forma de isso ficar coerente. O conjunto sao dezenas, custa milissegundos, e a alternativa e uma deriva lenta que ninguem nota.

Seguranca da chave do motor: separada do X-Internal-Secret do Core para poder ser revogada sozinha sem derrubar o formulario de contacto; comparacao em tempo constante; e sem chave configurada os endpoints respondem 503 em vez de ficarem abertos - um deploy que se esqueca do segredo falha fechado. Ha teste para os tres casos.

Acrescentado tambem o interruptor geral (/api/internal/kill-switch) e o /api/public/blog/health sem chave, para um vigia de fora conseguir ver se o blog ainda esta a ser actualizado mesmo quando o motor e que esta em baixo.

144 testes.

  • #1281 — Content store, blog listing and the closed category taxonomy

Armazenamento no volume (FileContentStore): escritas atomicas, ficheiro temporario e move, porque o nginx serve esta mesma pasta e uma escrita directa deixaria um leitor apanhar meia pagina. O slug chega por HTTP, por isso ha uma guarda que recusa caminhos que saiam da raiz - com teste para ../ e para um slug malicioso.

Listagem do blog: sem isto um artigo novo nao aparecia em /pt/blog/. Descobri duas coisas ao extrair o template dessa pagina:

  • Ela tem dados estruturados Blog com a lista de todos os artigos, que ficam desactualizados assim que se acrescenta um cartao. Passam a ser gerados do mesmo conjunto que os cartoes.
  • As categorias sao uma taxonomia FECHADA de 5, com slugs partilhados entre PT e EN, e nao sao slugificacao do rotulo ("Dicas de Utilizacao" -> "dicas-utilizacao", sem o "de"). Um cartao com categoria fora da lista desaparecia mal o leitor carregasse num filtro, por isso o validador passa a recusar categorias inventadas.

Corrigido um defeito real apanhado por teste: WebUtility.HtmlEncode converte acentos em entidades numericas, e as paginas iam encher-se de "Seguran&#231;a". Substituido por HtmlText.Escape, que so escapa os cinco caracteres que mudam a marcacao.

121 testes.

  • #1281 — Generate the rss feed and the sitemap from the published set

O feed mantem a identidade do canal ja publicado (titulo, descricao, atom:link, imagem) para os subscritores existentes nao verem uma quebra. O titulo do item e o titulo do artigo, byte a byte, porque a distribuicao a jusante liga o post ao artigo por esse campo.

Data em RFC 822 com desvio numerico. Nao uso ToString("R") porque converte para GMT e perde o desvio da publicacao.

Descoberta ao construir o sitemap: ele NAO e so do blog. Das 29 entradas, 16 sao a homepage, precos, roadmap e as paginas legais, que nao sao do motor. Um gerador que so conhecesse artigos tirava-as do indice na primeira publicacao. Ficam num fragmento versionado (Templates/sitemap-static.xml), extraido pelo mesmo script e reemitido tal e qual. Ha teste que falha se desaparecerem.

Alternates hreflang so sao declarados quando existe traducao a serio. Apontar hreflang para uma pagina que nao e traducao e pior do que nao declarar nada.

91 testes.

  • #1281 — Render the article page from a template extracted from production

O template E a pagina publicada com as zonas variaveis trocadas por {{TOKENS}}, para uma pagina renderizada bater com o que os leitores ja veem em vez de ser um palpite novo sobre a marcacao.

A extraccao e feita por script (scripts/content/extract-template.mjs) e nao a mao, com ancoras que falham alto se a marcacao mudar. O script verifica o resultado: recusa se faltar um token, se aparecer um token desconhecido, ou se sobrar conteudo especifico do artigo de origem.

O renderizador produz as 23 zonas, incluindo dados estruturados Article, BreadcrumbList e FAQPage. Dois detalhes que so aparecem ao construir:

  • O projecto compila com InvariantGlobalization, por isso nao ha nomes de mes por cultura em runtime. As abreviaturas sao explicitas.
  • O JSON-LD vive dentro de <script>, por isso o codificador escapa < e >. Um titulo com </script> nao consegue fechar o bloco - ha teste para isso.

Novo campo translationSlug: o artigo EN de onde extrai o template tem traducao PT, e o seletor de idioma e os alternates hreflang mudam de forma conforme exista ou nao. Sem traducao apontam para o indice do blog, que e o que as paginas por traduzir ja fazem.

77 testes.

  • #1281 — Align the article contract with the pages already live

Ao extrair o template do artigo publicado encontrei campos que o contrato da Task 1 nao cobria, e preferi corrigir o modelo a forcar um desenho que perde informacao:

  • seoTitle separado de title. O <title> da pagina viva tem 43 caracteres e o <h1> tem 54, e sao textos diferentes de proposito: o separador do browser e o resultado de pesquisa cortam, o titulo na pagina nao.
  • subtitle, o paragrafo de entrada em .blog-post-subtitle.
  • faq, que alimenta a seccao de perguntas e os dados estruturados FAQPage. Entradas com metade preenchida sao recusadas, porque isso enviaria FAQPage partido para a Google.

Simplificacao deliberada: as paginas vivas tem tres descricoes distintas (meta, og, twitter). Isso e um acidente de escrita a mao, nao um requisito, por isso o contrato guarda duas e mapeia-as - description vai para meta e twitter, subtitle vai para og.

58 testes.

  • #1281 — Render article body from markdown with raw HTML disabled

O corpo do artigo passa a ser markdown convertido no servidor. HTML cru fica desligado de proposito: o motor publica sem revisao humana, por isso nao pode injectar markup nem um script numa pagina ja no ar. Ligacoes absolutas ganham target=_blank e rel=noopener.

Markdig 1.3.2 fixado. Verificado sem vulnerabilidades nem depreciacoes (dotnet list package --vulnerable --include-transitive / --deprecated), por causa da regra DEP004 do CI.

6 testes.

  • #1281 — Article contract and server-side acceptance rules

O artigo passa a ter uma forma canonica (ArticleDocument) e regras de aceitacao validadas no servidor. Duas delas existem por causa de como o pipeline antigo falhou: sem referencias com url https nao publica, e sem foto de capa nao publica - isto mata a degradacao silenciosa para capas SVG que produziu 3 artigos fora da norma editorial.

9 testes.

Correções

  • #1281 — Rerender no pipeline certo e pela rede interna

Dois erros meus, encontrados a inspeccionar a VPS em vez de assumir.

  1. O passo estava no pipeline do SITE, mas o template viaja na imagem do SITE-API (api/Templates/** e Content no csproj). Uma mudanca de template deploya com o site-api, por isso e ai que o rerender tem de correr. Movido.

  2. Assumi que o site-api era alcancavel em 127.0.0.1:8085. Nao e: escuta em 8083 e NAO publica porta nenhuma para o host. O passo passa a ir pela rede interna, via o container do nginx (esta em site-edge e tem busybox wget). Validei o comando na VPS antes de o escrever.

Fica tambem respondida a questao de seguranca que eu tinha deixado em aberto, e a resposta e boa: /api/internal/* NAO e alcancavel da internet. O vhost ativamoney.com declara so quatro rotas, todas por correspondencia exacta. O location /api/ generico que existe no ficheiro pertence ao vhost app.ativa.money e aponta para o BFF da app.

Efeito lateral disso: o /api/public/blog/health tambem nao esta exposto, e ele existia precisamente para vigilancia de fora. Para servir esse proposito falta uma location no vhost do site, no ativamoney-infra. Fica registado como item proprio, nao corrigido as escondidas aqui.

  • #1281 — Restrict link schemes in the article body (XSS)

Uma revisao de seguranca do commit anterior apontou o BodyRenderer. Confirmei a falha com testes de exploracao antes de mexer no codigo: x renderizava <a href="javascript:alert(1)"> e i renderizava um img com esquema data:. Desligar HTML cru nao cobre isto - o destino de uma ligacao markdown e um caminho separado e legitimo ate um href.

Importa porque o motor editorial publica em producao sem revisao humana e o texto que escreve e influenciado pelas fontes que le, por isso uma injeccao numa fonte citada chegava a uma pagina no ar.

Correccao em duas camadas:

  • BodyRenderer so aceita http, https e mailto, ou destinos relativos. Tudo o resto e neutralizado. A comparacao normaliza espacos e caracteres de controlo primeiro, porque o browser tambem os ignora e e assim que "java<tab>script:" passa por um StartsWith ingenuo.
  • ArticleValidator recusa o artigo inteiro quando o corpo traz um destino com esquema nao permitido, para nunca chegar a renderizacao.

O que a revisao NAO apanhou e eu confirmei estar bem: <script>, <img onerror>, <svg onload> e <iframe> sao escapados correctamente, e a aspa no title de uma ligacao e escapada para ". Quatro asercoes minhas estavam mal escritas - davam como falha a palavra "onerror" a aparecer dentro de texto ja escapado. Corrigidas para testar a propriedade real.

54 testes.

Site API

Site API 0.1.1

Correções

  • #1067 — 1a correcao nao chegou -- apertar caps e medir em bytes
  • #1067 — Auto-PR do CI falhava em branches novas (description >4000)

Descoberto ao tentar abrir o PR desta propria branch: o passo "PR: auto-create to main + link Work Items" (azure-pipelines.yml e api/azure-pipelines.yml) calcula os commits/Work Items exclusivos da branch com "git log FETCH_HEAD..HEAD". Nestes dois pipelines o checkout: self nao tinha fetchDepth definido — fica com o fetch raso por omissao do agente. Numa branch nunca vista antes no agente self-hosted persistente (workspace reutilizado entre builds, clean: "false"), esse intervalo resolveu mal e varreu quase todo o historico do repo: "Work Items detetados: #0, #1, #2...#050608, #657580" — a description do PR passou dos 4000 caracteres que a API do Azure DevOps aceita (HTTP 400), e o PR nem chegou a ser criado.

ativamoney-infra/azure-pipelines.yml ja fazia checkout: self com fetchDepth: 0 (historico completo) exactamente por esta razao — so faltava aplicar o mesmo padrao aqui.

Duas camadas de protecao, nao uma:

  1. fetchDepth: 0 no checkout — corrige a causa raiz.
  2. Commits limitados a 50, Work Items a 40, e a description truncada a 3900 caracteres como rede de seguranca dura — protege mesmo que o intervalo git volte a resolver mal por qualquer outra razao no futuro (agente diferente, bug diferente).

Testado isoladamente em bash (nao consigo correr o pipeline real): sintaxe valida, os dois caps disparam correctamente com dados sinteticos (80 commits, 80 WIs -> "+30 commits omitidos" / "+40 omitidos"), description final bem dentro do limite.

  • #1067 — Implementar rota de campanhas no site-api (404 em producao)

GET https://ativamoney.com/api/public/campaigns/current devolvia 404 em producao. Causa raiz confirmada por investigacao exaustiva: nao e' um problema de deploy nem de roteamento — o backend ja tem o endpoint (release backend-v1.32.0) e o nginx.prod.conf ja tem o passthrough (deploy infra-v0.20.0), ambos ja em producao. O elo em falta era o site-api (que vive dentro deste repo, em api/ — decisao do WI #933): nunca teve nenhuma rota de campanhas mapeada, em nenhum branch. O pedido chegava ao site-api, nao encontrava rota, e o proprio ASP.NET Core devolvia 404 antes de sequer contactar o backend.

CampaignsEndpoints.cs espelha PlansEndpoints.cs (cache de curta duracao via IDistributedCache, TTL configuravel, fallback 502 se o Core estiver em baixo). ICoreApiClient.GetCurrentCampaignAsync devolve null quando o Core responde 204 (sem campanha ativa) — distinto de "nao esta em cache", que usa um sentinel de string vazia.

8/8 testes do site-api (6 pre-existentes + 2 novos: payload verbatim e passthrough do 204).

Site API

Site API 0.1.0

Novidades

  • #937 — CI auto-PR vincula todos os WIs dos commits e detalha corpo

O passo de auto-criacao de PR de cada pipeline passa a:

  • recolher os commits da branch (fora de main) e todos os #NNN referenciados neles + o do nome da branch, deduplicados;
  • escrever no corpo do PR a lista de commits e os Work Items vinculados;
  • criar o link formal PR<->WI (ArtifactLink "Pull Request") em cada WI, que satisfaz a branch policy "Work item linking".

Escaping do corpo via jq (fallback python3). infra: checkout none->self (fetchDepth 0) para conseguir listar os commits no stage isolado.

Refactor

  • #933 — Move a edge API do site para dentro do repo (api/) — sem repo site-api separado

Decisao do PO: as APIs do site vivem no repo do proprio site. Move o projeto .NET standalone para ativamoney-site/api/. O site estatico (pt/ en/ assets/) mantem-se zero-dependency; api/ e a edge .NET net10.0 com build/test/pipelines proprios. Mesma imagem brenonico/ativamoney:site-api-latest. .gitignore ignora api/bin, api/obj. Build 0 erros, 6/6 testes.

See the product in motion.

This is what we have been building. Create your Free account in less than 3 minutes.

Start now for free →