diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 037816f..4f6fa8f 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -33,15 +33,25 @@ jobs: script: | // Titulo estavel, sem data: o cron semanal reabriria uma issue nova // toda segunda para a mesma divergencia nao resolvida. - // Dois titulos: um bloqueante (alvo de producao) e um informativo - // (so o piloto divergiu). O informativo existe porque D-2 manda + // Tres titulos: drift em alvo de producao (bloqueante), + // indisponibilidade (pode ser rede, nao contrato) e divergencia so + // do piloto (informativo). O informativo existe porque D-2 manda // relatar a divergencia do piloto sem reprovar o run; sem issue, // "relatar" viraria uma linha de log num check verde. const status = process.env.DRIFT_STATUS; const informativo = status === "informational"; + // Titulo por NATUREZA do problema, nao um so para tudo. Chamar uma + // indisponibilidade de "drift detectado" ja custou duas issues + // abertas em falso (#4 em 2026-08-03 e #9 em 2026-09-04), as duas + // por soluco de rede do runner com gov.br. Quem abre a issue tem + // que saber, pelo titulo, se o contrato mudou ou se o CI nao + // conseguiu falar com o servidor. + const indisponivel = status === "unreachable" || status === "malformed"; const titulo = informativo ? "Piloto divergiu do contrato vendorado (informativo)" - : "Drift detectado nos contratos oficiais"; + : indisponivel + ? "Contratos oficiais inalcançáveis a partir do CI" + : "Drift detectado nos contratos oficiais"; const dia = new Date().toISOString().slice(0, 10); const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const existentes = await github.rest.issues.listForRepo({ @@ -57,7 +67,19 @@ jobs: }); return; } - const corpo = informativo + const corpoIndisponivel = [ + `O detector não conseguiu ler um contrato de produção em ${dia} (status: ${status}).`, + "", + `Run: ${run} (o log diz qual alvo e qual foi o erro)`, + "", + "**Isto pode não ser problema de contrato.** A rede dos runners do GitHub com hosts gov.br é intermitente, e o mesmo sintoma já apareceu duas vezes em falso: a #4 (2026-08-03) e a #9 (2026-09-04), em runs consecutivos que falharam em alvos diferentes a cada vez. O detector já retenta 5 vezes com espaçamento exponencial (~30s de janela) antes de desistir.", + "", + "Antes de tratar como incidente: rode `pnpm drift-check` de uma rede que alcance os hosts, ou re-rode o workflow. Se passar, isto foi soluço de rede e a issue pode ser fechada como falso positivo. Se o alvo continuar inalcançável em runs sucessivos e também localmente, aí sim o endpoint mudou ou saiu do ar.", + ].join("\n"); + + const corpo = indisponivel + ? corpoIndisponivel + : informativo ? [ `O contrato do piloto divergiu do vendorado (${dia}). O run passou de propósito: o piloto é infraestrutura de teste com janela até 31/12/2026 e mudar antes da produção é o comportamento esperado dele, então isso é aviso, não quebra.`, "", diff --git a/docs/watch-routine.md b/docs/watch-routine.md index 481440e..82a741a 100644 --- a/docs/watch-routine.md +++ b/docs/watch-routine.md @@ -42,7 +42,8 @@ A auditoria de 2026-09-04 respondeu, com evidência, a pergunta que estava em ab - O `drift-check.mjs` afirmava, num comentário, que o OAS que gera os pacotes não tinha fonte pública e que por isso não dava para monitorá-lo (o gap D-4). O `README.md` repetia a mesma coisa. Era falso: https://www.cgibs.gov.br/split-payment serve o zip do OAS sem login e sem mTLS, e o arquivo de lá é byte-idêntico ao vendorado desde julho. - O custo foi medido: o **OpenAPI v1.1.0** saiu em 24/08/2026 e passou **11 dias** sem detecção, junto com o Manual de Integração v1.1.0. Nenhuma das duas publicações apareceu em issue, PR ou entrada de novidades. - Outros quatro artefatos publicados entre 04/08 e 31/08 também passaram batido: NT 2025.002 v1.51, NT 2026.006, IT 2026.001 e o PL 010f. -- O que já funcionava: o detector automático pegou o drift do contrato de produção da Calculadora e abriu a issue #6 em 31/08/2026. Ela ficou 4 dias sem tratamento, o que é falha de resposta, não de detecção. +- O que já funcionava, e melhor do que parecia: o detector pegou o drift do contrato de produção da Calculadora e abriu a issue #6 em **17/08/2026**, avisando de novo em 24/08 e 31/08. Ela ficou **18 dias** sem tratamento. A detecção automática fez o trabalho dela três vezes; o que faltou foi resposta humana. (Registro anterior dizia 31/08 e 4 dias: foi erro de leitura, a data que aparece por padrão no `gh issue list` é a de atualização, não a de criação.) +- **O detector também grita lobo, e isso corrói a resposta.** Duas issues já foram abertas em falso por indisponibilidade de rede do runner, não por drift: a #4 (03/08) e a #9 (04/09). Nos dois runs consecutivos de 04/09 os alvos que falharam foram diferentes a cada vez, o que descarta bloqueio de host específico e aponta intermitência da rota dos runners do GitHub para hosts gov.br. Uma issue chamada "Drift detectado" que na verdade é soluço de rede ensina o mantenedor a não confiar no alarme, e um alarme em que ninguém confia explica 18 dias de silêncio melhor do que desatenção. - Correção aplicada nesta data: quarto alvo no `drift-check.mjs`, apontado para o inventário de artefatos daquela página, com o inventário pinado em `vendor/cgibs-split-payment-artefatos.json` e testes cobrindo o comportamento. Artefato novo publicado ali reprova o run. - **Limite desse alvo, medido no mesmo dia**: `www.cgibs.gov.br` não aceita conexão do runner do GitHub Actions (connect timeout em 443, três tentativas, enquanto `consumo.tributos.gov.br` e `piloto-cbs.tributos.gov.br` respondem do mesmo runner). É bloqueio do host, não rede instável. O alvo então declara `severidadeIndisponivel: "ignore"`: no CI ele reporta e não reprova, e só compara de verdade quando roda de uma rede que alcança o host, como a máquina do mantenedor via `pnpm drift-check`. Drift ali continua reprovando quando alcançável. **Consequência prática: para esse contrato, a checagem semanal manual não é redundância, é a cobertura principal.** Se um dia o bloqueio cair, o CI passa a cobrir sozinho e essa nota sai. diff --git a/scripts/drift-check.mjs b/scripts/drift-check.mjs index d2297d8..39e64ff 100644 --- a/scripts/drift-check.mjs +++ b/scripts/drift-check.mjs @@ -53,13 +53,18 @@ export const TARGETS = [ vendored: "vendor/cgibs-split-payment-artefatos.json", live: "https://www.cgibs.gov.br/split-payment", severity: "fail", - // O www.cgibs.gov.br nao aceita conexao do runner do GitHub: connect - // timeout em 443, tres tentativas, enquanto consumo.tributos.gov.br e - // piloto-cbs.tributos.gov.br respondem do MESMO runner (run 33901733965). - // E bloqueio do host, nao rede instavel. Reprovar por isso deixaria o - // detector vermelho toda semana por uma causa que nao vamos consertar, e - // detector que grita lobo acaba silenciado. Drift ali continua reprovando; - // so a indisponibilidade e ignorada. + // Indisponibilidade aqui nao reprova. A primeira versao deste comentario + // dizia que o www.cgibs.gov.br bloqueava o runner do GitHub, com base num + // unico run em que ele deu timeout e os tres alvos de tributos.gov.br + // responderam (33901733965). O run seguinte desmentiu: o CGIBS respondeu + // MATCH e os outros tres e que deram timeout (33902480218). Nao e bloqueio + // de host nenhum; e a rede do runner com gov.br sendo intermitente, o mesmo + // fenomeno que a issue #4 ja tinha classificado como falso positivo em + // 2026-08-03 e que motivou o retry. + // + // Este alvo e o mais exposto: e o unico cuja indisponibilidade nao diz nada + // sobre um contrato de producao, porque a pagina e so um indice. Drift ali + // continua reprovando. severidadeIndisponivel: "ignore", kind: "inventario-html", }, @@ -240,11 +245,24 @@ export function descreveErroDeRede(err) { // retry, o detector reprova o build por soluco de rede, e detector que grita // lobo acaba silenciado. // +// Em 2026-09-04 isso se repetiu com 3 tentativas espacadas de 2s fixos: dois +// runs seguidos falharam, em alvos DIFERENTES a cada vez. Seis segundos de +// janela nao cobrem a intermitencia observada, entao o espacamento passou a ser +// exponencial (2s, 4s, 8s, 16s), o que da ~30s de janela em 5 tentativas em vez +// de 6s em 3. O timeout por tentativa tambem subiu: o erro observado e connect +// timeout de 10s, e o default do undici nao respeita o AbortSignal na fase de +// conexao. +// // 4xx NAO e retentado de proposito: e resposta definitiva (a URL mudou ou // sumiu), e insistir so atrasa o sinal que queremos receber rapido. -export const TENTATIVAS_PADRAO = 3; +export const TENTATIVAS_PADRAO = 5; export const ESPERA_PADRAO_MS = 2000; +// Espacamento exponencial: a n-esima espera e ESPERA_PADRAO_MS * 2^(n-1). +export function esperaDaTentativa(tentativa, base = ESPERA_PADRAO_MS) { + return base * 2 ** (tentativa - 1); +} + export async function fetchLive(target, fetchImpl = fetch, opts = {}) { const { tentativas = TENTATIVAS_PADRAO, @@ -266,7 +284,7 @@ export async function fetchLive(target, fetchImpl = fetch, opts = {}) { } catch (err) { ultimaFalha = { ok: false, kind: "fetch", error: descreveErroDeRede(err), tentativas: tentativa }; if (tentativa < tentativas) { - await dormir(esperaMs); + await dormir(esperaDaTentativa(tentativa, esperaMs)); continue; } return ultimaFalha; @@ -276,7 +294,7 @@ export async function fetchLive(target, fetchImpl = fetch, opts = {}) { const valeRetentar = res.status >= 500; ultimaFalha = { ok: false, kind: "fetch", error: `HTTP ${res.status}`, tentativas: tentativa }; if (valeRetentar && tentativa < tentativas) { - await dormir(esperaMs); + await dormir(esperaDaTentativa(tentativa, esperaMs)); continue; } return ultimaFalha; diff --git a/scripts/drift-check.test.mjs b/scripts/drift-check.test.mjs index ec4990c..342192a 100644 --- a/scripts/drift-check.test.mjs +++ b/scripts/drift-check.test.mjs @@ -6,7 +6,9 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { TARGETS, + TENTATIVAS_PADRAO, diffSummary, + esperaDaTentativa, extrairInventario, fetchLive, exitCodeFor, @@ -399,3 +401,62 @@ describe("severidade de indisponibilidade separada da de drift", () => { expect(alvo.severidadeIndisponivel).toBe("ignore"); }); }); + +// Duas vezes o mesmo soluco: 2026-08-03 (issue #4) e 2026-09-04 (issue #9), +// esta ultima em dois runs seguidos que falharam em alvos DIFERENTES. Tres +// tentativas de 2s fixos dao 6s de janela, e nao seguraram. +describe("retry com espacamento exponencial", () => { + it("dobra a espera a cada tentativa", () => { + expect([1, 2, 3, 4].map((t) => esperaDaTentativa(t, 2000))).toEqual([2000, 4000, 8000, 16000]); + }); + + it("cinco tentativas cobrem ~30s de janela, contra 6s do esquema antigo", () => { + const janela = [1, 2, 3, 4].reduce((soma, t) => soma + esperaDaTentativa(t, 2000), 0); + expect(TENTATIVAS_PADRAO).toBe(5); + expect(janela).toBe(30_000); + }); + + it("espera de verdade entre as tentativas, na ordem exponencial", async () => { + const esperas = []; + const fake = async () => { + throw new Error("fetch failed"); + }; + await fetchLive({ live: "https://exemplo" }, fake, { + tentativas: 4, + esperaMs: 2000, + dormir: async (ms) => esperas.push(ms), + }); + expect(esperas).toEqual([2000, 4000, 8000]); + }); + + it("uma resposta boa na ultima tentativa ainda vale", async () => { + let n = 0; + const fake = async () => { + n += 1; + if (n < 5) throw new Error("fetch failed"); + return new Response(JSON.stringify({ ok: 1 }), { status: 200 }); + }; + const r = await fetchLive({ live: "https://exemplo" }, fake, { + tentativas: 5, + esperaMs: 0, + dormir: async () => {}, + }); + expect(r.ok).toBe(true); + expect(n).toBe(5); + }); + + it("4xx continua sem retry: e resposta definitiva, nao soluco", async () => { + let n = 0; + const fake = async () => { + n += 1; + return new Response("nao existe", { status: 404 }); + }; + const r = await fetchLive({ live: "https://exemplo" }, fake, { + tentativas: 5, + esperaMs: 0, + dormir: async () => {}, + }); + expect(r.ok).toBe(false); + expect(n).toBe(1); + }); +});