ci(imagem): regra de quais caminhos de PR alcançam as imagens Docker

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkdA9sjQrTUhsbdb2LbYhs
This commit is contained in:
Pessoa
2026-09-18 11:31:59 -03:00
co-authored by Claude Opus 5
parent 2b9a570c41
commit fdd8ec7423
2 changed files with 137 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Lê caminhos (um por linha) na entrada e responde `sim` se ALGUM deles pode
# mudar ou quebrar uma das três imagens (app, worker, scheduler); `nao` só
# quando TODOS estão na lista abaixo. Entrada vazia responde `sim`: não saber o
# que mudou nunca pode virar "não precisa construir".
#
# Usado pelo publish-image.yml em pull_request, para não gastar 3 builds Docker
# num PR que só mexe em documentação ou teste. A lista é de quem NÃO alcança a
# imagem, e não de quem alcança, de propósito: o `Dockerfile.worker` faz
# `COPY . .` e o `next build` do app typecheca todo `**/*.ts` do contexto
# (tsconfig.json), então praticamente qualquer arquivo de código pode quebrar
# uma imagem. Errar para o lado de "construir" custa um build; errar para o
# outro deixa passar imagem quebrada com `imagens-ok` verde.
#
# Cada entrada, e por quê:
# - o que o `.dockerignore` exclui: nem entra no contexto de build, então
# nenhum Dockerfile o enxerga. A lista abaixo repete o `.dockerignore`, e
# tests/unit/pr-mexe-na-imagem.test.ts reprova se as duas divergirem.
# - `.github/**/*.{yml,yaml,md}`, menos o próprio publish-image.yml: entra no
# contexto (o worker o carrega inerte), mas nenhum passo de build lê YAML
# nem markdown. `.ts` sob `.github/` NÃO está aqui — o `next build` o
# typechecaria.
# - `*.md` na RAIZ: inerte pela mesma razão; nenhum módulo importa markdown da
# raiz (o CHANGELOG que a tela mostra chega do agente do host, não do disco
# da imagem). Markdown em subdiretório fica de fora da regra: há código que
# lê `.md` do próprio disco (lib/agent-engine/playbooks/*.md).
set -euo pipefail
algum=nao
while IFS= read -r caminho || [ -n "$caminho" ]; do
[ -z "$caminho" ] && continue
algum=sim
case "$caminho" in
.github/workflows/publish-image.yml) echo sim; exit 0 ;;
# ↓ espelho do .dockerignore — os diretórios. Padrão de .dockerignore sem
# barra casa só na RAIZ do contexto, e `*` de `case` atravessa `/`: por
# isso todo padrão aqui começa pelo nome do diretório da raiz.
node_modules/* | .next/* | .git/* | test-results/* | tests/* | .lina/* \
| docs/* | tasks/* | scratchpad/* | .vercel/* | .claude/* | .agents/* \
| .agent/* | .codex/* | .cursor/* | .opencode/* | .specs/* | .changes/*) ;;
.github/*.yml | .github/*.yaml | .github/*.md) ;;
# Daqui para baixo, só arquivo da RAIZ: qualquer outro caminho com `/` alcança.
*/*) echo sim; exit 0 ;;
# ↓ espelho do .dockerignore — os arquivos da raiz.
.env | .env.* | playwright.config.ts | *.png | *.log) ;;
*.md) ;;
*) echo sim; exit 0 ;;
esac
done
# Nenhuma linha lida: não sei o que mudou, então constrói.
if [ "$algum" = nao ]; then echo sim; else echo nao; fi
+85
View File
@@ -0,0 +1,85 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// scripts/pr-mexe-na-imagem.sh decide se um PR pula os builds Docker. Errar
// para "nao" deixa passar imagem quebrada com `imagens-ok` verde — o check
// obrigatório que existe porque o artefato do self-hoster era o único sem gate.
const SCRIPT = "scripts/pr-mexe-na-imagem.sh";
function responde(caminhos: string[]): string {
return execFileSync("bash", [SCRIPT], { input: caminhos.join("\n") + "\n", encoding: "utf-8" }).trim();
}
describe("pr-mexe-na-imagem", () => {
it.each([
["Dockerfile"],
["Dockerfile.worker"],
["Dockerfile.scheduler"],
[".dockerignore"],
["package.json"],
["pnpm-lock.yaml"],
["patches/algum.patch"],
["next.config.ts"],
["tsconfig.json"],
["lib/qualquer.ts"],
["workers/event-log.ts"],
["docker/scheduler/entrypoint.sh"],
["public/logo.png"],
["lib/agent-engine/playbooks/platform.md"],
["supabase/baseline.sql"],
[".github/workflows/publish-image.yml"],
[".github/algum/script.ts"],
])("%s → constrói", (caminho) => {
expect(responde(["docs/leia.md", caminho])).toBe("sim");
});
it("entrada vazia constrói — não saber o que mudou nunca vira 'pula'", () => {
expect(responde([])).toBe("sim");
});
it("PR só de documentação, teste, fragmento e workflow alheio pula", () => {
expect(
responde([
"docs/runbooks/deploy.md",
"tests/unit/x.test.ts",
"tests/e2e/y.spec.ts",
".changes/fragmento.md",
".github/workflows/ci.yml",
"CLAUDE.md",
"playwright.config.ts",
]),
).toBe("nao");
});
// O espelho: tudo que o `.dockerignore` tira do contexto pode pular. Se alguém
// acrescenta uma linha lá e não aqui, o PR apenas constrói à toa (custa vaga,
// não segurança). Se alguém REMOVE uma linha de lá e esquece o script, o
// caminho passaria a entrar na imagem sem ninguém construir — é esse lado que
// o teste abaixo cobre, pela volta: toda linha do script tem de estar lá.
const ignorados = readFileSync(".dockerignore", "utf-8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
it("toda entrada do .dockerignore pula", () => {
const naoPulam = ignorados.filter((e) => {
const amostra = e.replace(/\*/g, "amostra");
return responde([amostra]) === "sim" && responde([`${amostra}/amostra.txt`]) === "sim";
});
expect(naoPulam).toEqual([]);
});
it("o espelho do .dockerignore no script não tem entrada que o .dockerignore não tenha", () => {
const fonte = readFileSync(SCRIPT, "utf-8");
const bloco = [...fonte.matchAll(/^\s*(?:\|\s*)?((?:[.\w*-]+(?:\/\*)?\s*\|\s*)*[.\w*-]+(?:\/\*)?)\s*(?:\\|\) ;;)$/gm)]
.flatMap((m) => m[1].split("|").map((s) => s.trim()))
.filter(Boolean);
const doEspelho = bloco
.map((p) => p.replace(/\/\*$/, ""))
.filter((p) => !p.startsWith(".github") && p !== "*.md");
expect(doEspelho.length).toBeGreaterThan(15);
expect(doEspelho.filter((p) => !ignorados.includes(p))).toEqual([]);
});
});