Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions .github/workflows/drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.`,
"",
Expand Down
3 changes: 2 additions & 1 deletion docs/watch-routine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 28 additions & 10 deletions scripts/drift-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions scripts/drift-check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
TARGETS,
TENTATIVAS_PADRAO,
diffSummary,
esperaDaTentativa,
extrairInventario,
fetchLive,
exitCodeFor,
Expand Down Expand Up @@ -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);
});
});
Loading