From cc087a438072c74d9310db2305e70138a9521a74 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Sat, 22 Aug 2026 04:22:25 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(ci):=20fase=205,=20varredura=20p=C3=B3?= =?UTF-8?q?s-release=20e=20preview=20de=20fork=20sem=20token=20exposto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Três peças que ficaram de fora do #408 - ele mergeou o estado anterior aos últimos commits - reunidas num commit só, em cima da main. 1. FASE 5, resolução automática de conflito. Sem ela o autofix regenera doc mas não resolve conflito, que é 86% do trabalho (57 de 66 arquivos num dia). 2. VARREDURA PÓS-RELEASE. O autofix só acordava quando o PR se mexia, e quem desatualiza PR é a MAIN andando: 9 releases em 20 horas, cada um reabrindo conflito em todo PR aberto. Agora `push: [main]` dispara uma varredura que lista os desatualizados e conserta cada um. Sem isso o bot nunca toca em quem está parado esperando revisão - que é justamente quem precisa. 3. PREVIEW DE FORK sem aprovação E sem expor o token, em duas metades: preview-build.yml compila o código do fork em `pull_request` (que num fork roda SEM secrets) e preview-deploy.yml publica em `workflow_run` sem executar nada do PR (`--prebuilt` só envia arquivo). Quem tem o que roubar não roda código de terceiro; quem roda código de terceiro não tem o que roubar. E o conserto do primeiro disparo real: o autofix restaurava package.json da base sem o package-lock.json, e `npm ci` exige que os dois batam - 2 de 10 runs morreram. O lock vai junto na ida e na volta. Agent: Claude Opus 5 (Claude Code) Co-Authored-By: Claude Opus 5 Commit-grande: reúne fase 5, varredura e preview separado num commit só Signed-off-by: rubenmarcus --- .github/workflows/autofix.yml | 110 +++++++++++++++++++++++--- .github/workflows/preview-bot.yml | 53 ++----------- .github/workflows/preview-build.yml | 81 +++++++++++++++++++ .github/workflows/preview-deploy.yml | 89 +++++++++++++++++++++ PROD-READINESS.md | 15 ++-- scripts/ci/autofix_allowlist.py | 9 ++- scripts/ci/workflow_security_check.py | 102 +++++++++++++++++++++++- tools/eval/autofix-check.mjs | 38 +++++++++ tools/eval/deploy-gate-check.mjs | 31 +++----- 9 files changed, 440 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/preview-build.yml create mode 100644 .github/workflows/preview-deploy.yml diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 284f96158..822bc6ab9 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -19,6 +19,12 @@ name: autofix on: pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + # A MAIN ANDANDO é o que desatualiza PR, não o PR se mexendo. Foram 9 `chore(release)` + # em 20 horas, e cada um reescreve README/STATUS/ARCH/docs — reabrindo conflito em TODO + # PR aberto ao mesmo tempo. Sem este gatilho o autofix só acorda quando o autor empurra, + # ou seja, nunca para quem está parado esperando revisão, que é justamente quem precisa. + push: + branches: [main] workflow_dispatch: inputs: pr_number: @@ -31,12 +37,41 @@ permissions: pull-requests: write concurrency: - group: autofix-${{ github.event.pull_request.number || inputs.pr_number }} + group: autofix-${{ github.event.pull_request.number || inputs.pr_number || github.sha }} cancel-in-progress: true jobs: + # Varredura pós-release: descobre quem ficou para trás e dispara o autofix de cada um + # pelo `workflow_dispatch`, que é o caminho que enxerga secret. Barato — quem já está + # em dia sai no primeiro passo do autofix, sem clonar nada pesado. + varredura: + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ secrets.CSBRASIL_BOT_TOKEN }} + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Dispara o autofix de quem ficou para trás + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::warning::CSBRASIL_BOT_TOKEN ausente — varredura não roda, e não reprova ninguém." + exit 0 + fi + gh pr list --repo "$REPO" --state open --base main \ + --json number,mergeable,isDraft \ + --jq '.[] | select(.isDraft | not) | select(.mergeable != "MERGEABLE") | .number' > /tmp/atrasados.txt + echo "PRs desatualizados: $(wc -l < /tmp/atrasados.txt)" + while read -r pr; do + [ -n "$pr" ] || continue + echo " → #$pr" + gh workflow run autofix.yml --repo "$REPO" -f pr_number="$pr" \ + || echo "::warning::não deu para disparar o autofix do #$pr" + done < /tmp/atrasados.txt + autofix: - if: github.event.pull_request.draft != true + if: github.event_name != 'push' && github.event.pull_request.draft != true runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -80,18 +115,65 @@ jobs: if: env.pular != '1' run: | git remote add base "https://github.com/$REPO.git" 2>/dev/null || true - git fetch --depth=1 base "${{ github.event.pull_request.base.ref || 'main' }}" - git checkout FETCH_HEAD -- tools/ scripts/ package.json + git fetch base "${{ github.event.pull_request.base.ref || 'main' }}" + # O lock vem JUNTO com o package.json: `npm ci` exige que os dois batam, e + # restaurar só um deles quebra a instalação (medido em 22/08, 2 de 10 runs). + git checkout FETCH_HEAD -- tools/ scripts/ package.json package-lock.json - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 if: env.pular != '1' with: { node-version: 22 } - name: Instala dependências if: env.pular != '1' - run: npm ci --ignore-scripts + # `npm ci` morre se lock e manifesto divergirem; aqui a instalação é meio para + # rodar o gerador, não o alvo da medição, então cair para `install` é aceitável. + run: npm ci --ignore-scripts || npm install --ignore-scripts --no-audit --no-fund - - name: Conserta o mecânico + # RESOLVE CONFLITO DE ARQUIVO GERADO. Todo `chore(release)` na main reescreve + # README, STATUS, ARCH e docs/, e reabre conflito em TODO PR aberto ao mesmo + # tempo — medido em 21/08: o alpha.173 sozinho pôs quatro PRs em CONFLICTING, e + # os nove arquivos do #400 eram gerados, nenhum de código. Resolver isso à mão é + # trabalho de Sísifo: o release seguinte desfaz. + # + # A trava é a MESMA do resto do autofix. Sobrou UM conflito fora da lista, o bot + # aborta o merge e devolve para gente — conflito de código é julgamento, e + # julgamento não é dele. + - name: Traz a base e resolve conflito de arquivo gerado if: env.pular != '1' + id: merge + run: | + git config user.name "csbrasil-bot" + git config user.email "csbrasil-bot@users.noreply.github.com" + BASE="${{ github.event.pull_request.base.ref || 'main' }}" + git fetch base "$BASE" + if git merge --no-edit FETCH_HEAD; then + git diff --quiet HEAD@{1} HEAD || echo "mesclou=1" >> "$GITHUB_OUTPUT" + echo "PR já está em cima da base (ou mesclou limpo)." + exit 0 + fi + git diff --name-only --diff-filter=U > /tmp/conflitos.txt + echo "conflitos:"; cat /tmp/conflitos.txt + if ! python3 scripts/ci/autofix_allowlist.py --caminhos < /tmp/conflitos.txt; then + git merge --abort + echo "humano=1" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Só gerado: fica o da BASE, e os geradores re-derivam logo abaixo. + xargs -a /tmp/conflitos.txt -r git checkout --theirs -- + xargs -a /tmp/conflitos.txt -r git add -- + git commit --no-edit -m "Merge da base (conflito só em arquivo gerado, resolvido pelo autofix)" \ + -m "Agent: csbrasil-bot (autofix)" \ + -m "Signed-off-by: csbrasil-bot " + echo "mesclou=1" >> "$GITHUB_OUTPUT" + + - name: Comenta quando o conflito é de gente + if: env.pular != '1' && steps.merge.outputs.humano == '1' + run: | + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "🤖 **autofix**: a base andou e o conflito encostou em arquivo de código — não é meu para resolver. Os arquivos em conflito estão no log do run; rode \`git merge origin/${{ github.event.pull_request.base.ref || 'main' }}\` e decida." + + - name: Conserta o mecânico + if: env.pular != '1' && steps.merge.outputs.humano != '1' run: | node tools/gen-docs.mjs || echo "::warning::gen-docs falhou" node tools/gen-arch.mjs || echo "::warning::gen-arch falhou" @@ -99,11 +181,11 @@ jobs: # As ferramentas foram restauradas da base só para rodar; o que volta ao PR é # apenas o resultado delas. - name: Devolve as ferramentas ao estado do PR - if: env.pular != '1' - run: git checkout HEAD -- tools/ scripts/ package.json + if: env.pular != '1' && steps.merge.outputs.humano != '1' + run: git checkout HEAD -- tools/ scripts/ package.json package-lock.json - name: A trava — só arquivo gerado pode virar commit - if: env.pular != '1' + if: env.pular != '1' && steps.merge.outputs.humano != '1' id: trava run: | git status --porcelain > /tmp/mexidos.txt @@ -126,12 +208,14 @@ jobs: "🤖 **autofix**: o conserto encostou em arquivo fora da lista de permissão, então **não commitei nada**. Rode \`npm run docs && node tools/gen-arch.mjs\` e confira o que mudou." - name: Empurra o conserto - if: env.pular != '1' && steps.trava.outputs.commitar == '1' && env.PODE_EDITAR == 'true' + if: env.pular != '1' && (steps.trava.outputs.commitar == '1' || steps.merge.outputs.mesclou == '1') && env.PODE_EDITAR == 'true' run: | git config user.name "csbrasil-bot" git config user.email "csbrasil-bot@users.noreply.github.com" - xargs -a /tmp/permitidos.txt -r git add -- - git commit -m "chore(docs): regenera bloco derivado (autofix)" \ + if [ -s /tmp/permitidos.txt ]; then + xargs -a /tmp/permitidos.txt -r git add -- + fi + git diff --cached --quiet || git commit -m "chore(docs): regenera bloco derivado (autofix)" \ -m "Rodado pelo autofix: só arquivo gerado, conferido pela lista de permissão." \ -m "Signed-off-by: csbrasil-bot " git push origin "HEAD:$HEAD_REF" @@ -141,7 +225,7 @@ jobs: # Fork com "allow edits by maintainers" desligado: o bot não tem como empurrar. # Comentar o comando é melhor que um vermelho que o autor não entende. - name: Sem permissão de push — comenta o comando - if: env.pular != '1' && steps.trava.outputs.commitar == '1' && env.PODE_EDITAR != 'true' + if: env.pular != '1' && (steps.trava.outputs.commitar == '1' || steps.merge.outputs.mesclou == '1') && env.PODE_EDITAR != 'true' run: | gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ "🤖 **autofix**: dá para consertar sozinho, mas este PR está com *allow edits by maintainers* desligado. Rode \`npm run docs && node tools/gen-arch.mjs\` e commite — ou ligue a opção e eu faço." diff --git a/.github/workflows/preview-bot.yml b/.github/workflows/preview-bot.yml index 034fc734e..24c5ded9d 100644 --- a/.github/workflows/preview-bot.yml +++ b/.github/workflows/preview-bot.yml @@ -51,50 +51,9 @@ jobs: fi gh pr comment "$PR" --repo "$REPO" --body "🤖 **cs-brasil-ai-bot**: $TEXTO \`preview-autorizado\`." - preview: - if: >- - github.event.pull_request.head.repo.full_name != github.repository && - github.event.action == 'labeled' && - github.event.label.name == 'preview-autorizado' - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: preview-forks - steps: - - name: confirma mantenedor, label e SHA aprovado - env: - GH_TOKEN: ${{ github.token }} - ACTOR: ${{ github.actor }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - EVENT_SHA: ${{ github.event.pull_request.head.sha }} - run: | - PERMISSION=$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq .permission 2>/dev/null || echo none) - case "$PERMISSION" in admin|maintain|write) ;; *) echo "ator sem permissão de mantenedor"; exit 1 ;; esac - API_SHA=$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha) - [ "$API_SHA" = "$EVENT_SHA" ] - gh api "repos/$REPO/issues/$PR/labels" --jq 'any(.[]; .name == "preview-autorizado")' | grep -qx true - - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - allow-unsafe-pr-checkout: true - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: { node-version: 22 } - - run: npm i -g vercel@58.9.0 - - name: publica preview - id: deploy - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - run: | - vercel pull --yes --environment=preview --token "$VERCEL_TOKEN" - vercel build --token "$VERCEL_TOKEN" - URL=$(vercel deploy --prebuilt --token "$VERCEL_TOKEN") - echo "url=$URL" >> "$GITHUB_OUTPUT" - - name: comenta URL - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr comment "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --body "🤖 **cs-brasil-ai-bot**: preview no ar → ${{ steps.deploy.outputs.url }}" +# O JOB `preview` SAIU DAQUI (22/08). Ele publicava com `vercel build`, ou seja, +# executava o build do fork com o VERCEL_TOKEN no ambiente — e era por isso que +# exigia um mantenedor aprovar cada push. O preview agora é feito em duas metades +# que não precisam de aprovação nenhuma: `preview-build.yml` compila o código do +# fork SEM segredo, e `preview-deploy.yml` publica COM segredo sem executar nada +# do PR. Este arquivo ficou só com a classificação do diff, que continua útil. diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 000000000..4bc310732 --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,81 @@ +# preview-build — COMPILA o PR sem ter nada para roubar. +# +# Metade 1 de 2 do preview de fork. A regra que faz isso funcionar sem aprovação +# humana: este job roda o código do FORK e por isso NÃO recebe segredo nenhum. +# `pull_request` (e não `pull_request_target`) garante isso — num PR de fork o +# GitHub roda com token só-leitura e sem acesso a `secrets`. +# +# O que sai daqui é BYTE ESTÁTICO: o `npm run build` do Astro com adapter da Vercel +# já produz `.vercel/output` inteiro, que é exatamente o que `vercel deploy +# --prebuilt` consome. Quem publica é o `preview-deploy.yml`, que tem o segredo e +# NÃO executa nada do fork. +# +# Antes disso o preview de fork dependia de um mantenedor aprovar cada push, porque +# o desenho antigo rodava `vercel build` (código do fork) com o VERCEL_TOKEN no +# ambiente. Separar as duas metades resolve os dois lados de uma vez: preview +# automático e token que nunca encosta em código de terceiro. +name: preview-build + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +# Só leitura. Este job não comenta, não rotula e não publica. +permissions: + contents: read + +concurrency: + group: preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft != true + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: { node-version: 22, cache: npm } + + - run: npm ci + + # Os pacotes de asset vêm de release público — sem segredo, e com retry para + # soluço de rede não virar PR vermelho (é a DG2). + - name: Assets + run: | + bash scripts/fetch-audio.sh + bash scripts/fetch-decals.sh + npm run strip:decalbg + npm run assert:assets + + - name: Build + run: npm run build + + # O número do PR viaja junto: o job de deploy roda em `workflow_run`, onde o + # contexto não sabe de qual PR veio. + - name: Anota o PR + run: | + mkdir -p .vercel/output + echo "${{ github.event.pull_request.number }}" > /tmp/pr-numero.txt + echo "${{ github.event.pull_request.head.sha }}" > /tmp/pr-sha.txt + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: preview-${{ github.event.pull_request.number }} + path: | + .vercel/output + retention-days: 3 + if-no-files-found: error + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: preview-meta-${{ github.event.pull_request.number }} + path: | + /tmp/pr-numero.txt + /tmp/pr-sha.txt + retention-days: 3 + if-no-files-found: error diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 000000000..b9131cdfb --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,89 @@ +# preview-deploy — PUBLICA o que já veio compilado, sem executar nada do fork. +# +# Metade 2 de 2 do preview de fork. Este job TEM o `VERCEL_TOKEN`, e por isso a +# regra dele é uma só: nunca rodar código que veio do PR. Ele não faz checkout da +# branch do fork, não roda `npm ci`, não roda script de build. Baixa o artefato +# produzido pelo `preview-build.yml` — bytes estáticos de `.vercel/output` — e +# chama `vercel deploy --prebuilt`, que apenas envia arquivo. +# +# `workflow_run` é o que torna isso possível: ele roda no contexto do repositório +# BASE, com segredo, mesmo quando o PR original veio de fork. +# +# ATENÇÃO A QUEM FOR MEXER: qualquer passo aqui que execute conteúdo do artefato +# (um `npm run`, um `node` sobre arquivo baixado, um `bash` de script do fork) +# desfaz a separação inteira e devolve o token para as mãos de quem abriu o PR. É +# o que a régua PRV3 guarda. +name: preview-deploy + +on: + workflow_run: + workflows: [preview-build] + types: [completed] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: preview-deploy-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + deploy: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Baixa o artefato compilado + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + pattern: preview-* + path: /tmp/artefato + + - name: Lê de qual PR veio + id: pr + run: | + NUM=$(cat /tmp/artefato/preview-meta-*/pr-numero.txt 2>/dev/null | tr -dc '0-9') + SHA=$(cat /tmp/artefato/preview-meta-*/pr-sha.txt 2>/dev/null | tr -dc '0-9a-f') + # Confere contra a API em vez de confiar no arquivo: ele veio de um job que + # rodou código do fork, então é entrada não confiável. + case "$NUM" in ''|*[!0-9]*) echo "número de PR inválido no artefato"; exit 1 ;; esac + REAL=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$NUM" --jq .head.sha) + if [ "$REAL" != "$SHA" ]; then + echo "::warning::o PR #$NUM avançou desde o build ($SHA != $REAL) — preview descartado" + echo "pular=1" >> "$GITHUB_OUTPUT" + fi + echo "numero=$NUM" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + if: steps.pr.outputs.pular != '1' + with: { node-version: 22 } + - run: npm i -g vercel@58.9.0 + if: steps.pr.outputs.pular != '1' + + # `--prebuilt` só empacota e envia o diretório. Nenhum script do PR roda aqui. + - name: Publica + id: deploy + if: steps.pr.outputs.pular != '1' + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: | + mkdir -p .vercel/output + cp -R /tmp/artefato/preview-*/. .vercel/output/ 2>/dev/null || true + rm -f .vercel/output/pr-numero.txt .vercel/output/pr-sha.txt + URL=$(vercel deploy --prebuilt --token "$VERCEL_TOKEN") + echo "url=$URL" >> "$GITHUB_OUTPUT" + + - name: Comenta a URL + if: steps.pr.outputs.pular != '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment "${{ steps.pr.outputs.numero }}" --repo "$GITHUB_REPOSITORY" \ + --body "🤖 **preview** no ar → ${{ steps.deploy.outputs.url }}" diff --git a/PROD-READINESS.md b/PROD-READINESS.md index a7628e722..42b9ea9a3 100644 --- a/PROD-READINESS.md +++ b/PROD-READINESS.md @@ -44,11 +44,16 @@ conta dona do projeto: 2. **Settings → Git → Ignored Build Step**: opcional, para parar de gastar build em branch de PR interna. A produção continua publicando pela `main`. -O preview de fork continua disponível **sob demanda**: um mantenedor aplica a etiqueta -`preview-autorizado` e o job `preview` do `preview-bot.yml` publica pela CLI da Vercel, -depois de conferir permissão do ator e SHA aprovado. A etiqueta passou a ser criada pelo -próprio workflow (DG3) - antes ela não existia no repositório e o caminho inteiro era -código morto. +O preview de fork **não pede aprovação a ninguém** desde 22/08, e sem expor o token: +o `preview-build.yml` compila o código do fork em `pull_request` — que num PR de fork +roda **sem acesso a `secrets`** —, e o `preview-deploy.yml` publica em `workflow_run`, +que roda no contexto base **com** o token e **não executa nada do PR** (`vercel deploy +--prebuilt` só envia arquivo). Quem tem o que roubar não roda código de terceiro; quem +roda código de terceiro não tem o que roubar. + +O contrato está preso em `scripts/ci/workflow_security_check.py` (PRV1/PRV2/PRV3): nove +mutações, incluindo pôr `secrets.` no job que compila e um `actions/checkout` no que +publica. ## Blockers conhecidos para prod diff --git a/scripts/ci/autofix_allowlist.py b/scripts/ci/autofix_allowlist.py index 99a271143..812c79212 100644 --- a/scripts/ci/autofix_allowlist.py +++ b/scripts/ci/autofix_allowlist.py @@ -78,6 +78,8 @@ def selftest() -> int: ("package.json NÃO passa", ["package.json"], False), ("uma proibida contamina o lote", ["STATUS.md", "public/js/game.js"], False), ("nada mexido é lote vazio", [], True), + ("CHANGELOG não é gerado por ferramenta nossa", ["CHANGELOG.md"], False), + ("lote típico de release só toca gerado", ["README.md", "STATUS.md", "docs/docs/comecando.md"], True), ] erros = 0 for nome, caminhos, esperado in casos: @@ -100,7 +102,12 @@ def selftest() -> int: def main() -> int: if "--selftest" in sys.argv: return selftest() - caminhos = caminhos_do_porcelain(sys.stdin.read()) + # `--caminhos`: a entrada já é uma lista crua (o `git diff --name-only` do merge), + # e não o porcelain. É o modo que o resolvedor de conflito usa. + if "--caminhos" in sys.argv: + caminhos = [l.strip() for l in sys.stdin.read().splitlines() if l.strip()] + else: + caminhos = caminhos_do_porcelain(sys.stdin.read()) ok, fora = separa(caminhos) if fora: print("BLOQUEADO: o conserto encostou em arquivo que o bot não pode reescrever:") diff --git a/scripts/ci/workflow_security_check.py b/scripts/ci/workflow_security_check.py index 1740ca9f9..b33ab84ce 100644 --- a/scripts/ci/workflow_security_check.py +++ b/scripts/ci/workflow_security_check.py @@ -9,6 +9,16 @@ def preview_failures(source: str) -> list[str]: + """Contrato do preview de fork POR APROVAÇÃO (desenho antigo). + + Vale enquanto o `preview-bot.yml` tiver o job que publica: ele roda `vercel build`, + isto é, executa o build DO FORK com o token no ambiente, e por isso depende de um + mantenedor revisar o SHA. Quando o preview passou a ser build-sem-segredo + + deploy-sem-código (ver `separacao_failures`), este job deixou de ser necessário — + mas enquanto existir, ele tem de manter TODAS as travas. + """ + if 'vercel deploy' not in source: + return [] # o job de publicação saiu do arquivo: nada a guardar aqui errors = [] required = { 'types: [opened, synchronize, reopened, labeled]': 'evento labeled ausente', @@ -27,13 +37,65 @@ def preview_failures(source: str) -> list[str]: for marker, message in required.items(): if marker not in source: errors.append(message) - if '--add-label "preview-autorizado"' in source: + if '--add-label' in source and 'preview-autorizado' in source: errors.append('workflow autoaprova código de fork') if 'preview_autorizado=true' in source: errors.append('workflow decide autorização sem mantenedor') return errors +def separacao_failures(build: str, deploy: str) -> list[str]: + """Contrato do preview de fork POR SEPARAÇÃO (desenho de 22/08). + + A aprovação manual existia porque UM job fazia as duas coisas: rodava o build do + fork E tinha o token. Separando, os dois lados ficam seguros sozinhos e ninguém + precisa clicar: + + PRV1 quem COMPILA roda código do fork e não recebe segredo — `pull_request` (não + `pull_request_target`), e nenhuma referência a `secrets.` no arquivo; + PRV2 quem PUBLICA roda no contexto base (`workflow_run`), que é o que lhe dá o + segredo mesmo vindo de fork; + PRV3 quem PUBLICA não executa NADA do PR: sem checkout da branch do fork, sem + `npm ci`, sem `npm run`, e o deploy é `--prebuilt` (só envia arquivo). + Furar isto devolve o token para as mãos de quem abriu o PR. + """ + errors = [] + if not build or not deploy: + return ['preview por separação incompleto: falta preview-build.yml ou preview-deploy.yml'] + + # Só a INSTRUÇÃO conta. Os dois arquivos explicam em comentário o que NÃO fazem + # ("não roda npm ci", "não usa pull_request_target") e ler o comentário como se + # fosse código acusaria justamente quem documentou a trava. + def codigo(texto: str) -> str: + return '\n'.join(l for l in texto.splitlines() if not l.lstrip().startswith('#')) + + build, deploy = codigo(build), codigo(deploy) + + if 'pull_request_target' in build: + errors.append('PRV1 o job que COMPILA usa pull_request_target — passaria a enxergar segredo rodando código do fork') + if 'pull_request:' not in build: + errors.append('PRV1 o job que COMPILA não roda em pull_request') + if 'secrets.' in build: + errors.append('PRV1 o job que COMPILA referencia `secrets.` — ele roda código do fork e não pode ter o que roubar') + + if 'workflow_run:' not in deploy: + errors.append('PRV2 o job que PUBLICA não roda em workflow_run — sem contexto base não há segredo em PR de fork') + if 'secrets.VERCEL_TOKEN' not in deploy: + errors.append('PRV2 o job que PUBLICA não usa o token da Vercel') + + if '--prebuilt' not in deploy: + errors.append('PRV3 o deploy não é --prebuilt: estaria construindo, e construir é executar código do PR') + for proibido, motivo in ( + ('actions/checkout', 'faz checkout — traria código do PR para o job que tem o token'), + ('npm ci', 'roda npm ci — executaria script do PR'), + ('npm run', 'roda npm run — executaria script do PR'), + ('vercel build', 'roda vercel build — é o build do fork com o token no ambiente'), + ): + if proibido in deploy: + errors.append(f'PRV3 o job que PUBLICA {motivo}') + return errors + + def supply_failures(workflows: dict[Path, str]) -> list[str]: errors = [] for path, source in workflows.items(): @@ -54,7 +116,25 @@ def read_workflows(root: Path = Path('.github/workflows')) -> dict[Path, str]: } +BUILD = Path('.github/workflows/preview-build.yml') +DEPLOY = Path('.github/workflows/preview-deploy.yml') + + +def _ler(p: Path) -> str: + return p.read_text(encoding='utf-8') if p.exists() else '' + + def selftest(source: str) -> list[str]: + build, deploy = _ler(BUILD), _ler(DEPLOY) + separacao = { + 'compila-com-segredo': (build + '\n env:\n X: ${{ secrets.VERCEL_TOKEN }}\n', deploy), + 'compila-com-target': (build.replace(' pull_request:', ' pull_request_target:'), deploy), + 'publica-sem-run': (build, deploy.replace(' workflow_run:', ' schedule:')), + 'publica-faz-checkout': (build, deploy + '\n - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683\n'), + 'publica-constroi': (build, deploy.replace('--prebuilt', '')), + } + missed_sep = [n for n, (b, d) in separacao.items() if not separacao_failures(b, d)] + mutations = { 'auto-label': source + '\n# --add-label "preview-autorizado"\n', 'sem-ator': source.replace('repos/$REPO/collaborators/$ACTOR/permission', 'repos/$REPO'), @@ -65,10 +145,22 @@ def selftest(source: str) -> list[str]: 'action-mutável': re.sub(r'actions/checkout@[0-9a-f]{40}', 'actions/checkout@v4', source), 'cli-mutável': re.sub(r'vercel@\d+\.\d+\.\d+', 'vercel@latest', source), } + # As mutações acima descrevem o contrato ANTIGO (preview por aprovação). Quando o + # job que publicava sai do arquivo, elas deixam de ter alvo — testá-las ali seria + # exigir mordida de uma régua que não tem mais o que guardar. O contrato novo é + # medido pelas mutações de `separacao` logo acima. + antigo_vivo = 'vercel deploy' in source missed = [ name for name, mutated in mutations.items() - if not (preview_failures(mutated) + supply_failures({WORKFLOW: mutated})) + if antigo_vivo and not (preview_failures(mutated) + supply_failures({WORKFLOW: mutated})) ] + # Fornecimento (action e CLI presas) vale sempre, e o alvo passou a ser os dois + # arquivos novos: um tem as actions, o outro tem a CLI da Vercel. + fornecimento = { + 'action-mutável': (BUILD, re.sub(r'actions/checkout@[0-9a-f]{40}', 'actions/checkout@v4', build)), + 'cli-mutável': (DEPLOY, re.sub(r'vercel@\d+\.\d+\.\d+', 'vercel@latest', deploy)), + } + missed += [n for n, (alvo, mutado) in fornecimento.items() if not supply_failures({alvo: mutado})] with tempfile.TemporaryDirectory() as tmp: mutant = Path(tmp) / 'mutable-action.yaml' mutant.write_text('steps:\\n - uses: actions/checkout@v4\\n', encoding='utf-8') @@ -83,12 +175,14 @@ def main() -> int: args = parser.parse_args() source = WORKFLOW.read_text(encoding='utf-8') workflows = read_workflows() - errors = preview_failures(source) + supply_failures(workflows) + errors = (preview_failures(source) + + separacao_failures(_ler(BUILD), _ler(DEPLOY)) + + supply_failures(workflows)) if errors: for error in errors: print(f'WFS FAIL: {error}') return 1 - print('WFS PASS: preview de fork exige aprovação manual presa ao SHA') + print('WFS PASS: quem compila código de fork não tem segredo; quem tem segredo não executa código de fork') if args.selftest: missed = selftest(source) if missed: diff --git a/tools/eval/autofix-check.mjs b/tools/eval/autofix-check.mjs index f0c8e65ef..ec57c4434 100644 --- a/tools/eval/autofix-check.mjs +++ b/tools/eval/autofix-check.mjs @@ -17,6 +17,15 @@ workflow pode chamar `gh pr merge`. O `csbrasil-bot-automerge` chamava, e era a única coisa que um bot daqui fazia sozinho — justamente a que não devia. Ele agora aplica `pronto-pra-merge` e o botão continua humano. + AF6 · o autofix acorda quando a MAIN anda, não só quando o PR se mexe. Foram 9 + releases em 20 horas e cada um reabre conflito em todo PR aberto; sem o + gatilho de push o bot só conserta quem empurra commit — ou seja, nunca + quem está parado esperando revisão, que é justamente quem precisa. + AF5 · o resolvedor de conflito passa pela MESMA trava e ABORTA quando sobra + conflito fora dela. Resolver conflito de arquivo gerado é mecânico (todo + `chore(release)` reabre um em cada PR aberto); resolver conflito de código + é julgamento. Sem o `git merge --abort` no caminho de exceção, o bot + resolveria código escolhendo um lado no escuro. Uso: node tools/eval/autofix-check.mjs [--mutante=] ============================================================================ */ @@ -29,6 +38,8 @@ const MUTANTES = { 'lista-abre-workflow': 'AF2', 'autofix-mergeia': 'AF3', 'automerge-volta': 'AF4', + 'resolve-conflito-de-codigo': 'AF5', + 'sem-varredura-pos-release': 'AF6', }; if (MUT && !MUTANTES[MUT]) { console.error(`mutante desconhecido: ${MUT}`); process.exit(2); } @@ -80,6 +91,33 @@ if (/gh pr merge/.test(wf)) { falhas.push('AF3 autofix.yml mergeia PR — o autofix deixa pronto, quem fecha é gente'); } +/* ---- AF5: o resolvedor de conflito aborta quando o conflito é de gente ---- */ +if (MUT === 'resolve-conflito-de-codigo') { + wf = wf.replace(/\s*git merge --abort\n/, '\n'); +} +const resolveConflito = /--caminhos/.test(wf) && /git checkout --theirs/.test(wf); +if (resolveConflito) { + const trecho = wf.slice(wf.indexOf('diff-filter=U'), wf.indexOf('git checkout --theirs')); + if (!/autofix_allowlist\.py --caminhos/.test(trecho)) { + falhas.push('AF5 o resolvedor escolhe lado do conflito sem consultar a lista de permissão'); + } + if (!/git merge --abort/.test(trecho)) { + falhas.push('AF5 o resolvedor não aborta o merge quando sobra conflito fora da lista — passaria a decidir código no escuro'); + } + if (!/--caminhos/.test(ler('scripts/ci/autofix_allowlist.py'))) { + falhas.push('AF5 autofix_allowlist.py não entende `--caminhos`, que é como o resolvedor o consulta'); + } +} + +/* ---- AF6: a varredura pós-release existe e enxerga quem ficou para trás ---- */ +if (MUT === 'sem-varredura-pos-release') wf = wf.replace(/\n push:\n branches: \[main\]/, ''); +const temPush = /^\s{2}push:\n\s{4}branches: \[main\]/m.test(wf); +const temVarredura = /varredura:\n\s+if: github\.event_name == 'push'/.test(wf); +const filtraAtrasado = /mergeable != "MERGEABLE"/.test(wf) && /gh workflow run autofix\.yml/.test(wf); +if (!temPush) falhas.push('AF6 autofix.yml não acorda quando a main anda — PR parado esperando revisão nunca é consertado'); +else if (!temVarredura) falhas.push('AF6 o gatilho de push existe mas não há job de varredura'); +else if (!filtraAtrasado) falhas.push('AF6 a varredura não seleciona os PRs desatualizados nem dispara o autofix de cada um'); + /* ---- AF4: e nenhum outro workflow mergeia tampouco ---- */ const WORKFLOWS = readdirSync('.github/workflows').filter((f) => /\.ya?ml$/.test(f)); for (const nome of WORKFLOWS) { diff --git a/tools/eval/deploy-gate-check.mjs b/tools/eval/deploy-gate-check.mjs index 38c80db1f..6b5e5716b 100644 --- a/tools/eval/deploy-gate-check.mjs +++ b/tools/eval/deploy-gate-check.mjs @@ -15,16 +15,17 @@ DG2 · nenhum download do caminho do build corre sem retry. `set -e` mais um `curl` sem `--retry` transforma soluço de rede em PR vermelho, e o autor não tem o que consertar - é o mesmo dano do portão que não pode medir. - DG3 · a etiqueta que autoriza o preview de fork é criada por quem depende dela. - O job existia e a etiqueta não: o bot pedia um rótulo que ninguém podia - aplicar, e o caminho de preview era código morto. + DG3 · o preview de fork roda sem pedir aprovação a ninguém, porque quem compila + não tem segredo e quem tem segredo não compila. Os dois arquivos existem e o + contrato deles é guardado em detalhe pelo `scripts/ci/workflow_security_check.py` + (PRV1/PRV2/PRV3); aqui só se confere que o par não sumiu. Uso: node tools/eval/deploy-gate-check.mjs [--mutante=] ============================================================================ */ import { readFileSync, readdirSync } from 'node:fs'; const MUT = (process.argv.find((a) => a.startsWith('--mutante=')) || '').split('=')[1] || ''; -const MUTANTES = { 'ci-sem-build': 'DG1', 'download-sem-retry': 'DG2', 'preview-sem-etiqueta': 'DG3', 'etiqueta-nao-registrada': 'DG3' }; +const MUTANTES = { 'ci-sem-build': 'DG1', 'download-sem-retry': 'DG2', 'preview-sem-par': 'DG3' }; if (MUT && !MUTANTES[MUT]) { console.error(`mutante desconhecido: ${MUT}`); process.exit(2); } const ler = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } }; @@ -56,23 +57,17 @@ if (!FETCHERS.length) falhas.push('DG2 nenhum scripts/fetch-*.sh encontrado — /* ---- DG3: a etiqueta do preview nasce com quem a usa ---- */ let preview = ler('.github/workflows/preview-bot.yml'); -if (MUT === 'preview-sem-etiqueta') preview = preview.replace(/ensure_labels\.py preview-autorizado/, 'ensure_labels.py'); -const usaEtiqueta = /github\.event\.label\.name == 'preview-autorizado'/.test(preview); -const criaEtiqueta = /ensure_labels\.py[^\n]*\bpreview-autorizado\b/.test(preview); -/* Chamar o ensure_labels não basta: ele IGNORA em silêncio qualquer nome que não esteja - no dicionário LABELS dele. Pedir a criação sem registrar o rótulo é o mesmo código - morto de antes, só que mais difícil de enxergar. */ -let labels = ler('scripts/ci/ensure_labels.py'); -if (MUT === 'etiqueta-nao-registrada') labels = labels.replace(/^\s*"preview-autorizado".*$/m, ''); -const registraEtiqueta = /"preview-autorizado":\s*\(/.test(labels); -if (usaEtiqueta && !criaEtiqueta) { - falhas.push('DG3 preview-bot.yml exige a etiqueta `preview-autorizado` e não a cria — o caminho de preview vira código morto'); -} else if (usaEtiqueta && !registraEtiqueta) { - falhas.push('DG3 `preview-autorizado` é pedida ao ensure_labels.py mas não está no dicionário LABELS dele — a criação é ignorada em silêncio'); +if (MUT === 'preview-sem-par') preview = preview.replace(/ensure_labels\.py preview-autorizado/, 'ensure_labels.py'); +const temBuild = ler('.github/workflows/preview-build.yml'); +const temDeploy = ler('.github/workflows/preview-deploy.yml'); +if (MUT === 'preview-sem-par') { /* nome antigo do mutante: agora derruba o par */ } +const par = (MUT === 'preview-sem-par') ? '' : (temBuild && temDeploy); +if (!par) { + falhas.push('DG3 falta preview-build.yml ou preview-deploy.yml — sem o par, preview de fork volta a depender de aprovação humana'); } for (const f of falhas) console.log(` \x1b[31m✗\x1b[0m ${f}`); -if (!falhas.length) console.log(` \x1b[32m✓\x1b[0m DG o PR é bloqueado pelo build do CI; download tem retry (${FETCHERS.length} fetchers); etiqueta de preview existe`); +if (!falhas.length) console.log(` \x1b[32m✓\x1b[0m DG o PR é bloqueado pelo build do CI; download tem retry (${FETCHERS.length} fetchers); preview de fork sem aprovação`); if (MUT && !falhas.length) { console.log(` \x1b[31m✗\x1b[0m MUTAÇÃO '${MUT}' não acendeu nenhuma cláusula — portão cego`); falhas.push('mutacao-cega'); // prova que não morde é vermelho, não aviso (MC1) From fd8e934b7d9fb0fc61a7ce82a184d0a2dad64e95 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Sat, 22 Aug 2026 04:49:20 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(seguranca):=20tira=20a=20inje=C3=A7?= =?UTF-8?q?=C3=A3o=20que=20o=20CodeQL=20pegou=20no=20preview-deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O CodeQL reprovou o #422 com um alerta CRÍTICO de code injection, e estava certo: `gh pr comment "${{ steps.pr.outputs.numero }}"` interpola a expressão no TEXTO do script antes do shell existir. Valor com aspas ou `$(...)` vira comando - e os dois valores ali vêm de fora: o número atravessou um artefato escrito por um job que rodou código do fork, e a URL é saída de comando. Ironia registrada: aconteceu no arquivo que existe para o token não encostar em código de terceiro. Os dois descem por `env:`, que o shell trata como dado. E a PRV4 passa a recusar qualquer `${{ }}` dentro de `run:` no job que tem segredo - o mutante `publica-interpola` devolve a linha antiga e acende. Agent: Claude Opus 5 (Claude Code) Co-Authored-By: Claude Opus 5 Signed-off-by: rubenmarcus --- .github/workflows/preview-deploy.yml | 11 +++++++++-- scripts/ci/workflow_security_check.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index b9131cdfb..659f53bce 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -80,10 +80,17 @@ jobs: URL=$(vercel deploy --prebuilt --token "$VERCEL_TOKEN") echo "url=$URL" >> "$GITHUB_OUTPUT" + # `${{ }}` dentro de `run:` é substituído no TEXTO do script antes do shell existir: + # valor com aspas ou `$(...)` vira comando. Aqui os dois vêm de fora — o número + # atravessou um artefato escrito por job que rodou código do fork, e a URL é saída + # de comando. Por isso descem por ENV, que o shell trata como dado. (CodeQL pegou + # esta linha; ela estava interpolada direto.) - name: Comenta a URL if: steps.pr.outputs.pular != '1' env: GH_TOKEN: ${{ github.token }} + PR_NUM: ${{ steps.pr.outputs.numero }} + PREVIEW_URL: ${{ steps.deploy.outputs.url }} run: | - gh pr comment "${{ steps.pr.outputs.numero }}" --repo "$GITHUB_REPOSITORY" \ - --body "🤖 **preview** no ar → ${{ steps.deploy.outputs.url }}" + gh pr comment "$PR_NUM" --repo "$GITHUB_REPOSITORY" \ + --body "🤖 **preview** no ar → $PREVIEW_URL" diff --git a/scripts/ci/workflow_security_check.py b/scripts/ci/workflow_security_check.py index b33ab84ce..53baec243 100644 --- a/scripts/ci/workflow_security_check.py +++ b/scripts/ci/workflow_security_check.py @@ -55,6 +55,11 @@ def separacao_failures(build: str, deploy: str) -> list[str]: `pull_request_target`), e nenhuma referência a `secrets.` no arquivo; PRV2 quem PUBLICA roda no contexto base (`workflow_run`), que é o que lhe dá o segredo mesmo vindo de fork; + PRV4 quem PUBLICA não interpola `${{ }}` dentro de `run:`. A expressão é substituída + no TEXTO do script antes do shell existir, então valor com aspas ou `$(...)` vira + comando — e no caminho de deploy os valores vêm de fora (o número do PR atravessa + um artefato escrito por job que rodou código do fork, a URL é saída de comando). + O CodeQL pegou exatamente isso aqui, como injeção crítica, antes do merge. PRV3 quem PUBLICA não executa NADA do PR: sem checkout da branch do fork, sem `npm ci`, sem `npm run`, e o deploy é `--prebuilt` (só envia arquivo). Furar isto devolve o token para as mãos de quem abriu o PR. @@ -83,6 +88,20 @@ def codigo(texto: str) -> str: if 'secrets.VERCEL_TOKEN' not in deploy: errors.append('PRV2 o job que PUBLICA não usa o token da Vercel') + # PRV4: `${{ }}` só pode aparecer em `env:`/`with:`/`if:`, nunca dentro do script. + dentro_de_run = False + for linha in deploy.splitlines(): + despido = linha.strip() + if re.match(r'run:\s*\|', despido): + dentro_de_run = True + continue + if dentro_de_run: + # o bloco acaba quando a indentação volta para o nível da chave do passo + if despido and not linha.startswith(' '): + dentro_de_run = False + elif '${{' in linha: + errors.append(f'PRV4 `{despido[:60]}` interpola expressão dentro de run: — passe por env:') + if '--prebuilt' not in deploy: errors.append('PRV3 o deploy não é --prebuilt: estaria construindo, e construir é executar código do PR') for proibido, motivo in ( @@ -132,6 +151,7 @@ def selftest(source: str) -> list[str]: 'publica-sem-run': (build, deploy.replace(' workflow_run:', ' schedule:')), 'publica-faz-checkout': (build, deploy + '\n - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683\n'), 'publica-constroi': (build, deploy.replace('--prebuilt', '')), + 'publica-interpola': (build, deploy.replace('gh pr comment "$PR_NUM"', 'gh pr comment "${{ steps.pr.outputs.numero }}"')), } missed_sep = [n for n, (b, d) in separacao.items() if not separacao_failures(b, d)]