Pular para conteúdo

Contribuindo

Tudo o que você precisa para desenvolver, testar e lançar esta biblioteca.

Veja também: Uso · Referência da API · o CONTRIBUTING.md na raiz do repositório contém a política autoritativa de branch/PR e de mensagem de commit.


Preparando o ambiente de desenvolvimento

O projeto traz um Makefile e um tasks.sh equivalente, então use o que couber na sua máquina — make init ou bash tasks.sh init quando make não estiver disponível (por exemplo, num shell padrão do Windows).

make init        # cria o virtualenv do Poetry + instala deps + instala hooks de pre-commit
# ou, sem make:
bash tasks.sh init

init compõe venv (cria o virtualenv do Poetry e instala todas as dependências, inclusive dev + docs) e precommit (instala os git hooks). O Poetry é instalado automaticamente se estiver faltando.

Testes e lint

make unit_tests          # poetry run pytest tests/unit/
make integration_tests   # poetry run pytest tests/integration/
make lint                # ruff + mypy + codespell + pydocstyle + gates de shell/sql/yaml/actions

A CI roda os mesmos gates em cada pull request; mantenha-os verdes localmente antes de dar push.

⚠️ O gate de workflows é o actionlint, não o yamllint. Os dois respondem perguntas diferentes: o yamllint diz se o arquivo é YAML bem-formado; o actionlint diz se ele é um workflow que o GitHub roda. Um on: com evento inexistente passa no primeiro e faz o GitHub rejeitar o arquivo inteiro — o workflow não roda de forma alguma, e o sintoma (PR sem rótulo, sem comentário do gate) parece lentidão, não morte. O actionlint é opcional localmente: make lint avisa e segue se ele não estiver instalado, mas na CI a ausência falha o job, de propósito. Para tê-lo aqui, baixe o binário da página de releases (https://github.com/rhysd/actionlint/releases) — ele não entra como dev-dep do Poetry: o actionlint-py é sdist-only e baixa o binário em tempo de build, o que colocaria um download dentro de todo poetry install, nas três matrizes de SO.

Servindo a documentação localmente

make docs_server   # mkdocs serve em http://0.0.0.0:8000

Documentação versionada (mike + GitHub Pages)

O site é versionado com mike: cada release publica a sua própria versão navegável na branch gh-pages, e o leitor alterna entre versões por um dropdown no cabeçalho. O alias latest sempre aponta para a release mais recente, então a URL raiz do site cai na versão mais nova.

Como adicionar uma página: crie o .md em docs/ e registre-o no nav: do mkdocs.yml no mesmo commit. A referência da API é um diretório (docs/api/): a entrada de um novo leitor ou serializador vai em docs/api/reference.md, nunca em um docs/api.md. As páginas obrigatórias estão em docs/.docs-skeleton.yaml e são verificadas por bin/check_docs_sections.py.

Como cada peça se encaixa:

  • Build-check (CI): o workflow Docs - Build Check roda mkdocs build --strict a cada push e PR — sem publicar. É só a garantia de que a doc sempre compila; um release nunca descobre uma árvore de docs quebrada na hora de publicar.
  • Deploy (CI): o job Deploy Versioned Docs (mike) do Release to PyPI roda depois do publish no PyPI (só documenta versão que efetivamente subiu): mike deploy --push X.Y latest + mike set-default --push latest, empurrando para gh-pages com o GITHUB_TOKEN embutido (contents: write) — sem token externo. A granularidade é minor (X.Y): um patch herda o slot da sua minor em vez de multiplicar entradas. Prereleases (rc1, b1, …) não movem latest (o job é pulado para elas).

Configuração do mantenedor — uma única vez, com admin no repositório: apontar o GitHub Pages para a branch gh-pages (o modelo mike), em vez da fonte GitHub Actions. O workflow não faz isso: o GITHUB_TOKEN é um token de GitHub App que não altera as configurações do Pages.

make enable_pages          # ou: bash tasks.sh enable_pages

Já roda dentro de make init / bash tasks.sh init. É idempotente e não-bloqueante, e — para nunca deixar o site em 404 — só troca a fonte para gh-pages depois que essa branch existir (o primeiro mike deploy do release a cria). Se rodar antes disso, ele avisa e não mexe no Pages; basta rodar make enable_pages de novo após o primeiro release. Alternativa manual: Settings → Pages → Build and deployment → Source: Deploy from a branch → gh-pages / (root).

Semear a versão atual imediatamente (opcional): se quiser publicar a versão já lançada sem esperar o próximo release, rode uma vez, com a branch limpa:

git config user.name "<você>" && git config user.email "<seu-email>"
poetry run mike deploy --push --update-aliases 0.18 latest
poetry run mike set-default --push latest
make enable_pages          # agora que gh-pages existe, aponta o Pages para ela

Verificando o pacote construído

Antes de abrir um PR de release, confirme que a wheel realmente constrói e importa — isso pega erros de empacotamento (um __init__ faltando, um subpacote _internal/ não incluído) que os testes na árvore de fontes nunca revelam:

make install_dist_locally    # python -m build → instala → smoke-import → reporta a wheel

Pull requests

  1. Crie a branch a partir da branch padrão seguindo a política de prefixo (feat/…, fix/…, …).
  2. Preencha o template de PR por completo.
  3. Garanta que os checks de CI (testes, lint, build da documentação) passem — eles são o gate de merge.

O ruleset pr-quality-gate (revisão automática e proteção da branch)

A branch padrão é protegida por um branch ruleset chamado pr-quality-gate, provisionado inteiramente por código — nenhuma caixinha marcada à mão:

make enable_repo_rules     # ou: bash tasks.sh enable_repo_rules

Já roda dentro de make init / bash tasks.sh init. É idempotente (um ruleset existente é atualizado no lugar, não duplicado) e não-bloqueante (sem gh, sem auth ou sem permissão de admin, ele avisa e sai com 0 — o init completa). Exige gh autenticado com direitos de admin no repositório: o GITHUB_TOKEN do CI não consegue escrever rulesets, por isso isto é um passo do mantenedor, não um job de workflow.

O que o ruleset aplica:

Regra Efeito
pull_request Toda mudança passa por PR. 0 aprovações exigidas — o GitHub não deixa o autor aprovar o próprio PR, então exigir ≥1 travaria um mantenedor solo. As conversas precisam estar resolvidas (required_review_thread_resolution), o que torna vinculante todo thread de review inline — na prática, hoje, os do CodeRabbit (medido: foi essa regra que bloqueou os PRs #207 e #210 até as threads serem resolvidas). ⚠️ Não os do Copilot: a regra copilot_code_review está inerte neste repo (ver abaixo — é limitação de plano de conta), então ele não abre thread nenhuma.
required_status_checks Os testes (Run Automated Tests, nos 3 SOs) e o build da documentação bloqueiam o merge de fato.
code_scanning CodeQL: alertas de segurança high_or_higher e alertas de erro bloqueiam o merge.
copilot_code_review Revisão automática do Copilot em todo PR, revisando cada novo push (review_on_push). Grátis em repositórios públicos.
non_fast_forward + deletion Sem force-push e sem deletar a branch padrão.

Automático × manual — a fronteira é repositório × conta. Tudo que é configuração do repositório é gravável pela REST API e já vem no script — inclusive a revisão do Copilot, que é um rule type próprio (copilot_code_review), e não um parâmetro de pull_request (essa grafia devolve HTTP 422, o que faz a regra parecer exclusiva da UI). Nenhuma caixinha precisa ser marcada à mão. O outro pré-requisito de repositório — habilitar o CodeQL (default setup), sem o qual a regra code_scanning não tem ferramenta para checar — também é API:

gh api -X PATCH repos/:owner/:repo/code-scanning/default-setup -f state=configured

O que não dá para automatizar daqui é o direito de uso na conta, não no repositório: a regra copilot_code_review só dispara "se o autor tiver acesso ao Copilot code review" — e o code review não está incluído no Copilot Free (a própria tela de planos lista "AI reviews" como recurso de upgrade). Sem um plano que o inclua (Pro / Pro+ / Business), a regra fica configurada e correta, porém inerte: nenhuma revisão aparece e nada dá erro — o silêncio é a armadilha.

Como obter revisão automática de fato:

  • Copilot Pro gratuito — é gratuito para estudantes, professores e mantenedores de projetos open-source populares (verificação em github.com/settings/billing).
  • Sem plano nenhum — um workflow de pull_request chamando um LLM de tier gratuito (ex.: Gemini Flash) para comentar o PR. Independe do Copilot.

Regra geral: configuração de repositório → script (é o que este helper faz, 100%). Direito de uso de conta → depende do plano, e não se resolve do repositório. As demais regras do gate (PR obrigatório, CI verde, CodeQL limpo) funcionam independentemente do Copilot.

O painel de Quality Gate e o auto-merge

Todo PR recebe, automaticamente (workflow pr-gate.yaml → bin/pr_gate.py):

  • Rótulos: risk:* (classe de risco pelos caminhos alterados), size:* (XS…XL, pelo volume do diff) e gate:passing / gate:pending / gate:failing — pending não é failing: um check ainda rodando não diz nada sobre o resultado.
  • Um comentário fixo (atualizado no lugar, nunca empilhado) com a tabela por eixo — testes nos 3 SOs, build da documentação, CodeQL.

Idioma. O bot escreve em inglês, como todo o resto voltado a quem contribui (código, mensagens de commit, saída de CI). Só a documentação publicada segue o idioma do site (mkdocs.yml → theme.language, pt-BR). A fronteira é o público, não o repositório.

Auto-merge — por caminho, nunca por tamanho. Um diff pequeno não é um diff seguro aqui: a falha real desta biblioteca é semântica (uma coluna de FileContract aterrada errado, um date_ref pegando a partição errada). Uma mudança de um caractere em _internal/config/contracts/ é minúscula e catastrófica — e todos os testes passam, porque os testes afirmam o contrato que foi escrito. Por isso a classificação é por caminho:

Classe Caminhos Auto-merge?
docs docs/**, *.md, mkdocs.yml ✅
ci .github/**, bin/**, Makefile, tasks.sh, configs de lint ✅
deps poetry.lock, pyproject.toml ✅ (os testes são o gate)
tests tests/** ❌ — definem o que "passar" significa
src src/opendata_cvm/** ❌ nunca, em nenhum tamanho
other qualquer outro ❌ (desconhecido = inseguro)

Numa classe auto-fundível, o auto-merge é opt-out: a classificação é o consentimento, então nenhum rótulo é preciso para armá-lo — as bumps semanais do Dependabot fundem sozinhas. O rótulo do-not-merge é a válvula de escape que segura um PR específico.

Um diff XL também não funde — exceto quando o único arquivo alterado é o poetry.lock. O veto por tamanho pergunta "esse diff é grande o bastante para um humano olhar?"; num lockfile regenerado a pergunta não significa nada, porque o tamanho acompanha quantos hashes de dependência se moveram, não quanto risco chegou — uma bump de 3 ferramentas de dev deu 579 linhas (XL, vetada) enquanto uma de 2 pacotes deu menos de 500 (L, fundida). O pyproject.toml não entra na exceção: é lá, num range editado à mão, que o risco de dependência de fato mora.

E ele não burla nada: usa o auto-merge nativo do GitHub, que segura o merge até todos os checks obrigatórios do ruleset ficarem verdes. O script decide apenas elegibilidade; quem decide se passou continua sendo o ruleset. (Auto-aprovação seria inútil aqui: o ruleset exige 0 aprovações, então uma aprovação de bot não destravaria nada.)

Quando o merge é entregue — no fim do run, nunca no começo. O gate não arma o auto-merge ao abrir o PR. O GitHub recusa enablePullRequestAutoMerge num PR que já poderia ser fundido ("Pull request is in clean status"), e anuncia a recusa com HTTP 200 + errors — não com um status de erro. Armar no opened cai justamente no pior instante: nenhum check registrou ainda e nenhuma thread de review existe, então o PR parece fundível, a mutação é recusada, e o silêncio passa por sucesso. Foi esse o defeito do #214, e ele ficou invisível porque o log dizia auto_merge=True.

Então a entrega acontece depois do poll: o gate tenta fundir direto (PUT /pulls/:n/merge) e cai no auto-merge se a tentativa não tiver sucesso — o caso esperado é a recusa do GitHub enquanto algo ainda bloqueia (405), mas a queda vale para qualquer falha da chamada, inclusive um erro de rede que sobreviva às retentativas. Nesse ponto armar o auto-merge é exatamente o comportamento certo: se algo bloqueia, ele é aceito e o GitHub assume a espera; se a falha foi de rede, ele é a rede de segurança para um PR que talvez já pudesse fundir. E se o próprio armar for recusado, a razão vai para o log — a recusa do GraphQL chega com HTTP 200 + errors, então o gate lê o corpo, nunca o status. Nada disso burla o ruleset: os dois caminhos são validados do lado do servidor.

⚠️ Não existe gatilho para "resolveram a thread". pull_request_review_thread não é aceito no on: — existe só como evento de webhook, e colocá-lo lá faz o GitHub rejeitar o arquivo inteiro ("failed because of a workflow file issue"): o gate não roda de forma alguma, e o PR fica sem rótulo e sem comentário, o que parece um gate apenas lento. pull_request_review e pull_request_review_comment, por outro lado, são gatilhos válidos — só que nenhum deles dispara ao resolver uma thread, então não servem de substituto. E não precisa de nenhum: quem espera o último bloqueio cair — check obrigatório ou conversa não resolvida — é o próprio auto-merge nativo, sem nenhum run de workflow envolvido.

Duas regras da UI ficam deliberadamente desligadas, para não criar uma segunda fonte de verdade: Require code quality results (severidade subjetiva de IA no caminho do merge — ruff, mypy e os gates de bin/check_*.py já cobrem qualidade de forma determinística) e Restrict code coverage (o piso já é single-source em .coveragerc, aplicado por pre-commit + CI).

Auto-merge e commit empilhado — o push que chega depois do merge é perdido

Numa classe auto-fundível (docs/ci/deps, sem arquivo de teste), o PR funde no instante em que os checks ficam verdes, em segundos. Acrescentar um commit a um PR já aberto vira uma corrida — e quando ela é perdida, o commit fica órfão. Medido em 3 ocorrências numa sessão (#221, #223, #225): em dois casos o push teve sucesso e origin/<branch> avançou, mas o PR já estava mergeado — o trabalho ficou na branch e em lugar nenhum além dela. Nada falha: o PR está verde e fundido, e o commit simplesmente não está no main.

⚠️ É consequência de o auto-merge funcionar, não um defeito dele. Quanto melhor ele funciona, mais estreita a janela. E a required_review_thread_resolution não protege: ela bloqueia thread existente e não resolvida, não espera um revisor chegar (o #223 fundiu com o CodeRabbit ainda "processando novas mudanças"). A política de auto-merge não muda — o que muda é o seu hábito.

Como evitar:

  • Conclua o trabalho antes de abrir o PR numa classe auto-fundível.
  • Se precisar continuar depois de aberto, aplique o rótulo do-not-merge antes do push seguinte — não depois.
  • Ou aceite abrir um segundo PR para o que vier depois.

Como detectar. Depois de todo push num PR já aberto, compare o PR com o HEAD local:

gh pr view <N> --json state,headRefOid
git rev-parse HEAD

state=MERGED com headRefOid diferente do HEAD local significa commit órfão.

Como recuperar. Crie uma branch nova a partir do main atualizado, traga o commit com git cherry-pick <sha> e abra um PR novo:

git fetch origin
git switch -c <nova-branch> origin/main
git cherry-pick <sha-orfao>

Higiene de branches — apagar depois do merge, arquivar as de incidente

Depois do merge, a branch vai embora. Só o main e a gh-pages vivem no remoto.

⚠️ Ancestralidade não diz nada aqui — o repo faz squash-merge. Um squash cria um commit novo, então os commits originais de uma branch nunca são ancestrais do main, por mais que o conteúdo tenha entrado por completo. Portanto git branch -d sempre recusa, git log main..branch sempre mostra "N commits à frente" e o git cherry sempre marca + (o squash muda o patch-id). Nenhum dos três é evidência de perda. Os testes honestos são: a PR dela mergeou? (gh pr list --head <branch> --state all) e a comparação de conteúdo (o blob de cada arquivo tocado está no main?).

Branch de incidente vira tag de arquivo. Se a branch tem história que vale preservar — um merge parcial, commits órfãos, uma recuperação — não é preciso escolher entre guardar o registro e limpar a lista:

  1. git tag archive/<issue>-<slug> <sha-do-tip> e publique a tag;
  2. só então apague a branch.

A tag deixa os commits alcançáveis para sempre (imunes ao GC) e a lista de branches limpa. Depois, git log archive/<issue>-<slug> devolve tudo. Precedente: archive/49-retry-strategy (o incidente do

49 — mergeou com 1 de 3 commits; o resto foi recuperado pelo #50). Custa ~10 segundos e transforma

"posso apagar essa branch?" de decisão corajosa em decisão trivial.

⚠️ Uma PR auto-fundida não apaga a própria branch nem fecha a issue linkada — o merge é feito pelo bot, e ações do GITHUB_TOKEN não cascateiam para as automações a jusante. Até isso ser resolvido, depois de toda PR auto-fundida: feche a issue (gh issue close <N>) e apague a ref (gh api -X DELETE repos/:owner/:repo/git/refs/heads/<branch>). Sem o fechamento da issue, o card nunca chega a "Done" — o workflow nativo do Projects depende dele.

ℹ️ git push origin --delete <branch> é bloqueado pelo hook local de proteção de branch quando o main está em checkout (falso positivo — apagar a ref de outra branch não escreve no main). Use a API do gh, como acima.

Lançando

Os releases são dirigidos por tag quando o projeto está conectado a um remoto no GitHub:

  • A versão é a tag git (via poetry-dynamic-versioning); o pyproject.toml guarda um placeholder 0.0.0. Não edite à mão. Dispare um release pela aba Actions (Release to PyPI / Release to Test PyPI, workflow_dispatch com a versão) ou empurrando uma tag vX.Y.Z.
  • O workflow de release roda a suíte completa de testes como gate rígido, constrói com python -m build e publica via trusted publishing (OIDC) (pypa/gh-action-pypi-publish) — sem PYPI_TOKEN armazenado.
  • O changelog é regenerado a partir das tags no momento do build (make changelog localmente); a CI nunca faz commit de CHANGELOG.md de volta na branch padrão protegida.

Configuração do mantenedor — trusted publisher (uma vez, antes do primeiro release)

Registre um trusted publisher tanto em pypi.org quanto em test.pypi.org. Cada claim precisa bater exatamente com o workflow, ou o upload falha com um invalid-publisher opaco:

Claim Valor
Owner / repository seu <owner> / <repo> no GitHub
Workflow filename release-pypi.yaml (PyPI) / release-test-pypi.yaml (Test PyPI)
Environment release-pypi / release-test-pypi
PyPI Project Name deve ser igual ao nome de distribuição (name no pyproject.toml)

Para o primeiro upload o projeto ainda não existe — registre um pending publisher no nível da conta (não dentro das configurações de um projeto existente). Publicar de um laptop em vez da CI é o único caso que ainda precisa de um API token; OIDC só funciona a partir do GitHub Actions.