Artigo

Bucket grátis no GitHub Releases

Como o SunCVE serve 130 MB de SQLite sem servidor: GitHub Releases como storage de objetos, bootstrap local, processamento incremental no Actions, retenção de snapshots e o banco carregado no browser via sql.js.

O SunCVE é um dashboard para pesquisar e analisar CVEs. A parte interessante não é o front, é o problema de dados por trás dele: são mais de 300 mil CVEs, do CVE-1999-0001 até o registro mais recente, enriquecidas com repositórios, advisories, PoCs e metadados de pacotes. Isso vira um SQLite de cerca de 130 MB.

E o objetivo do projeto sempre foi radical: rodar 100% no navegador, publicado no GitHub Pages, sem um único servidor de backend para manter. Nada de banco gerenciado, nada de API, nada de fatura no fim do mês.

O problema técnico é direto: servir esses 130 MB de dados sem infra própria, usando o GitHub Releases como storage de objetos. E, de quebra, manter a história git limpa.

O problema original: versionar dados no git

A primeira versão do pipeline fazia a coisa mais óbvia possível: todo dado processado era escrito em JSON, zipado e commitado no repositório. O front baixava esses JSONs, descomprimia no navegador e rodava as queries ali mesmo, tudo local e rápido (o SQLite que roda hoje veio só depois; a ideia de processar tudo no cliente é anterior a ele). Cada rodada de processamento gerava arquivos novos, e eles iam para o git junto com o código.

Funcionou por umas semanas. Depois o problema apareceu:

Repositório de dados é uma armadilha

Dados versionados no git não são apagados quando você deleta o arquivo. Eles ficam para sempre na história git. Cada rodada de processamento adicionava megabytes, e como os JSONs mudavam a cada execução, o git não conseguia comprimir quase nada entre as versões. O .git cresceu para a casa das centenas de MB.

O efeito prático foi o pior possível para um projeto aberto: clonar o repositório ficou lento e pesado. Como exemplo concreto, o git clone demorava demais e, algumas vezes, chegava a travar a própria interface web do GitHub na hora de renderizar o repositório. Quem quisesse contribuir precisava puxar todo o lixo de dados histórico só para mexer em um botão da interface. A barreira para colaborar subiu, contra o ponto inteiro de ser open source.

Faxina: reescrevendo a história git

O primeiro passo foi aceitar uma coisa simples, que vale como regra geral: se você está usando o git como backup de dados, de informação processada, de arquivos gerados, provavelmente está fazendo errado e é hora de rever os conceitos, parça. Versionador de código não é storage de dados.

Então tiramos os arquivos de dados do versionamento, colocamos os diretórios no .gitignore e, mais importante, reescrevemos a história git para remover os blobs antigos. Deletar o arquivo no HEAD não resolve nada quando o peso está no passado: é preciso reescrever a história git para o repositório emagrecer de verdade.

Com a faxina feita, o repositório voltou a ser só código: scripts de processamento, componentes de UI, configuração. O git log voltou a registrar só o que importa, features e correções, e não despejos de dados.

Mas isso reabriu a pergunta que a gente tinha empurrado com a barriga: se os dados não moram no git, onde eles moram?

A pergunta certa: onde guardar os dados sem servidor?

As opções na mesa eram as de sempre:

  • Um servidor com banco de dados. Resolve, mas cria infra para manter, custo mensal e um ponto único de falha. Contra tudo que o projeto queria ser.
  • Um bucket de storage (S3, R2, etc). Mais barato, mas ainda é uma conta a mais, credenciais para gerenciar e mais uma dependência externa.
  • Não ter servidor nenhum. O projeto já rodava direto no navegador desde os tempos dos JSONs zipados. Com o SQLite isso só ficou mais robusto: o banco vai inteiro para o cliente e as queries acontecem ali, via WebAssembly. Se os dados já viajavam para o browser, por que precisaríamos de um backend?

A terceira opção era claramente a certa em espírito. Faltava só um lugar para hospedar o arquivo de banco, um lugar que:

  1. Não fosse o git (para não sujar a história git de novo).
  2. Não fosse infra nova para manter.
  3. Fosse gratuito e já estivesse ali, junto do projeto.

Primeira tentativa: processar tudo no Actions

A ideia inicial foi mover o pipeline inteiro para o GitHub Actions. O Actions processaria os dados e cuspiria os arquivos estáticos que o GitHub Pages precisava, incluindo o SQLite que roda no browser. Elegante no papel: o mesmo lugar que hospeda o código também gera e publica os dados.

O problema apareceu na conta de tempo. Processar desde o primeiro CVE da história até hoje, com todo o enriquecimento (bater em advisory database, verificar repositórios, PoCs, metadados de npm e Packagist), leva quase uma semana de execução.

O teto do runner gratuito

Um job no GitHub Actions gratuito tem limite de tempo de execução (na casa das 6 horas por job). Um pipeline que precisa de dias para processar tudo do zero simplesmente estoura o timeout e é morto no meio do caminho. Reprocessar o mundo inteiro toda vez nunca ia funcionar.

A lição é a de sempre em processamento de dados: você não reprocessa tudo a cada execução. Você processa incrementalmente, do último ponto conhecido para frente. Só que, para partir do último ponto, você precisa ter guardado esse ponto em algum lugar. E aí voltamos para a pergunta do storage.

A sacada: GitHub Releases como bucket

A hipótese foi: e se o GitHub Releases for esse lugar? Releases aceitam assets binários arbitrários, de tamanho generoso, servidos por uma URL estável, sem custo, e sem tocar na história git. Isso é, na prática, um bucket de objetos grudado no repositório.

A ideia funcionou. O desenho final tem cinco etapas:

  1. Bootstrap local. A maior parte dos dados é processada na máquina local, onde não existe teto de 6h. O snapshot final vira um único arquivo e sobe para o release.
  2. Snapshot no release. Um release de tag fixa db-snapshots guarda o SQLite comprimido como asset. Esse é o bucket.
  3. Job incremental diário. Um schedule no Actions baixa o último snapshot, processa só as CVEs novas a partir dali e sobe um snapshot atualizado.
  4. Retenção. Mantemos os últimos snapshots como backup e apagamos os antigos, para o release não crescer sem controle.
  5. Deploy no Pages. O deploy puxa o snapshot mais recente do release, embute no build estático e publica no GitHub Pages.

O ponto que amarra tudo: o job do Actions nunca começa do zero. Ele começa do último snapshot que ele mesmo publicou. O trabalho de uma execução deixa de ser “processar 27 anos de CVEs” e passa a ser “processar o que apareceu desde ontem”. Isso cabe folgado dentro do limite do runner.

Um release, vários snapshots

Um detalhe de design que vale destacar: não criamos um release novo a cada rodada. Existe um único release, de tag fixa db-snapshots, e os snapshots são assets dentro dele. O nome de cada asset carrega o intervalo de CVEs e um timestamp:

snapshot-cve-1999-0001-to-cve-2026-28296-20260227T031447Z.tar.gz

Para “ler o bucket”, basta listar os assets do release e pegar o mais recente por data de criação. Essa primitiva aparece igualzinha em todo lugar do projeto. No script de setup local, por exemplo, é um gh com um filtro jq:

# scripts/setup-db.sh
LATEST_ASSET="$(gh release view "$RELEASE_TAG" \
  --repo "$REPO_SLUG" \
  --json assets \
  --jq '.assets
    | map(select(.name | test("^snapshot-.*\\.tar\\.gz$")))
    | sort_by(.createdAt)
    | reverse
    | .[0].name // ""')"

gh release download "$RELEASE_TAG" \
  --repo "$REPO_SLUG" \
  --pattern "$LATEST_ASSET" \
  --output "$TMP_FILE"

tar -xzf "$TMP_FILE" -C .

Filtra assets que batem com snapshot-*.tar.gz, ordena por createdAt, inverte e pega o primeiro. É o GET latest do nosso bucket, escrito em três linhas de shell.

O bootstrap local

O carregamento inicial, aquele que levaria uma semana no Actions, roda na máquina local pelo scripts/manual-db-snapshot.sh. Ele reproduz o mesmo pipeline do workflow, mas sem teto de tempo, e publica o resultado com os comandos diretos do gh:

# scripts/manual-db-snapshot.sh (trechos)

# cria o release-bucket se ainda não existir
gh release create "$SNAPSHOT_TAG" \
  --repo "$REPO_SLUG" \
  --title "DB Snapshots" \
  --notes "Rolling snapshots for incremental CVE DB state"

# ... processa os dados ...

# sobe o snapshot como asset
gh release upload "$SNAPSHOT_TAG" "$SNAPSHOT_NAME" --repo "$REPO_SLUG"

Depois do primeiro snapshot pesado no ar, o Actions assume a manutenção. O humano só volta a rodar isso se precisar de um reprocessamento grande, tipo um backfill que não cabe no incremental.

O job incremental, passo a passo

O coração do sistema é o workflow db-snapshots.yml. Ele dispara todo dia por schedule e também pode ser rodado à mão:

# .github/workflows/db-snapshots.yml
on:
  workflow_dispatch:
    # ... inputs manuais ...
  schedule:
    - cron: "0 7 * * *"   # todo dia às 07:00 UTC

permissions:
  contents: write   # precisa para escrever no release
  actions: write    # precisa para disparar o deploy no fim

O primeiro passo do job garante que o release existe (cria se for a primeira vez) e descobre qual é o asset mais recente. Depois vem o passo que torna tudo incremental, o restore do último snapshot:

- name: Restore Latest Snapshot
  if: steps.release.outputs.latest_asset_id != ''
  run: |
    mkdir -p data public/db
    curl -L \
      -H "Authorization: Bearer ${GITHUB_TOKEN}" \
      -H "Accept: application/octet-stream" \
      "https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.release.outputs.latest_asset_id }}" \
      -o /tmp/latest-snapshot.tar.gz
    tar -xzf /tmp/latest-snapshot.tar.gz -C .

Um detalhe que fecha o design com elegância: o cursor de retomada mora dentro do próprio SQLite. O banco tem uma tabela sources que guarda, entre outras coisas, qual foi a última release de deltas do cvelistV5 já processada. Quando o job restaura o snapshot, ele não precisa de nenhum estado externo para saber de onde continuar: o ponto de retomada veio dentro do arquivo. O snapshot é, ao mesmo tempo, os dados e o marcador de progresso.

Com o SQLite de ontem restaurado, o pipeline roda a atualização incremental. Cada passo é uma etapa de enriquecimento que só processa o que faltava:

- name: Incremental CVE Update
  run: python scripts/create-manifest.py cves --year-auto

- name: Enrich from GitHub Advisory Database
  run: python scripts/create-manifest.py advisories

- name: Enrich POCs from PoC-in-GitHub
  run: python scripts/create-manifest.py pocs

- name: Incremental Repository Verification
  run: python scripts/create-manifest.py repos --batch-size "${REPO_BATCH_SIZE}"

# ... npm, packagist, wordpress, osv, recálculo de fixes ...

No fim, ele reconstrói os artefatos que o browser consome (build-db-artifacts.sh gera o SQLite comprimido e um manifest.json), e antes de publicar passa por uma trava de sanidade que evita subir um banco quebrado:

- name: Validate Snapshot DB Size
  env:
    MIN_CVE_COUNT: "1000"
  run: |
    total=$(sqlite3 data/source.sqlite "SELECT COUNT(*) FROM cves;")
    if [ "${total}" -lt "${MIN_CVE_COUNT}" ]; then
      echo "[ERROR] Snapshot aborted: only ${total} CVEs. Likely stale/incomplete DB state."
      exit 1
    fi

Essa guarda é importante quando o storage é escrito por automação: uma falha de rede no meio do processamento poderia gerar um banco quase vazio, e sem essa checagem ele sobreporia o snapshot bom. A regra é simples: um snapshot suspeito não vira o novo estado.

Anatomia de um snapshot

O que vai para o release não é o SQLite cru. O passo Pack Snapshot empacota dois arquivos:

tar -czf "${SNAPSHOT_NAME}" \
  public/db/manifest.json \
  public/db/source_com_repositorios.sqlite.gz

O source_com_repositorios.sqlite.gz é o banco comprimido com gzip (nível 9), e o manifest.json é um índice pequeno com metadados: versão, URL do gzip, tamanho, sha256 para checar integridade, e o intervalo de CVEs que aquele snapshot cobre. O manifest é o que o front lê primeiro para saber o que baixar e como validar.

Retenção: os últimos como backup

Um bucket sem política de retenção vira lixão. Como cada rodada sobe um snapshot novo, sem limpeza o release cresceria para sempre. O passo final de manutenção mantém só os últimos e apaga o resto:

- name: Prune Old Snapshots (Keep 3)
  uses: actions/github-script@v7
  with:
    script: |
      const keep = 3;
      const snapshots = (release.data.assets || [])
        .filter(a => /^snapshot-.*\.tar\.gz$/.test(a.name))
        .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));

      for (const asset of snapshots.slice(keep)) {
        await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
      }

Manter os três últimos dá algo que um bucket comum não te dá de graça: backup versionado embutido. Se uma rodada gerar dados ruins que passem pela trava de sanidade, os snapshots anteriores ainda estão lá para rollback manual. É storage e backup na mesma estrutura.

Do release ao navegador

Ter os dados no release é metade da história. A outra metade é como eles chegam no usuário sem nunca passar por um servidor de aplicação.

O deploy puxa o snapshot

Quando o job incremental termina, ele dispara o workflow de deploy. O deploy faz o mesmo movimento de “ler o bucket”: encontra o snapshot mais recente e o restaura para dentro do build:

# .github/workflows/deploy.yml (trecho)
- name: Restore DB Files from Release Snapshot
  run: |
    curl -L \
      -H "Authorization: Bearer ${GITHUB_TOKEN}" \
      -H "Accept: application/octet-stream" \
      "https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.snapshot.outputs.asset_id }}" \
      -o /tmp/latest-db-snapshot.tar.gz
    tar -xzf /tmp/latest-db-snapshot.tar.gz -C .
    test -f public/db/manifest.json
    test -f public/db/source_com_repositorios.sqlite.gz

Aí o Next roda em modo de export estático, com o SQLite comprimido já dentro de public/db/, e o resultado sobe para o GitHub Pages. Do ponto de vista do Pages, o banco é só mais um arquivo estático servido junto com o HTML e o JS. Antes de buildar, ainda tem outra checagem de sanidade que descomprime o banco e conta as CVEs, para não publicar um Pages com dados quebrados.

O SQLite roda no browser

No cliente, não existe API para consultar. O navegador baixa o banco inteiro e roda SQL localmente via sql.js (SQLite compilado para WebAssembly). O fluxo é:

  1. Ler o manifest.json para descobrir a URL do gzip, o tamanho e o sha256.
  2. Baixar o .sqlite.gz e descomprimir em streaming, no próprio browser, com a API nativa DecompressionStream.
  3. Carregar o SQLite resultante no sql.js e rodar as queries direto na memória do cliente.

A descompressão em streaming evita ter que carregar o arquivo comprimido inteiro na memória antes de descomprimir:

// src/lib/sqlite/sqlite-loader.ts (trecho)
if (encoding && encoding !== 'identity') {
  stream = stream.pipeThrough(
    new DecompressionStream(encoding as CompressionFormat)
  );
}

O resultado é guardado no armazenamento local do navegador (OPFS) para não rebaixar tudo a cada visita. A partir daí, buscar, filtrar e agregar CVEs é tudo local, sem round-trip de rede. O usuário está, na prática, rodando um banco de dados de 300 mil registros dentro da própria aba.

O que a gente ganhou

Somando tudo, um desenho sem nenhum servidor entrega o projeto inteiro:

  • Zero infra e zero custo. Sem servidor, sem banco gerenciado, sem bucket pago. Tudo mora no GitHub, junto do código.
  • Git limpo. A história git volta a ser só de features e correções. Clonar o repositório é leve de novo, e a barreira para contribuir cai.
  • Processamento que cabe no runner. O incremental parte do último snapshot, então nenhuma rodada estoura o limite de tempo do Actions.
  • Backup embutido. A retenção dos últimos snapshots dá rollback de graça, algo que um bucket cru não oferece.
  • Roda no navegador. O banco viaja para o cliente e as queries acontecem ali, via WebAssembly. Nada para escalar do lado do servidor, porque não existe servidor.

O truque conceitual é enxergar o GitHub Releases como um bucket de objetos versionado, com uma primitiva de leitura (“pegue o asset mais recente”) e uma de escrita (“suba um asset novo e apague os antigos”). Duas operações simples, mais uma trava de sanidade, e você tem storage de dados grande grudado no seu repositório de código.

Ressalvas: até onde vai essa abordagem

Não é bala de prata. Vale saber onde o modelo aperta:

Quando o Releases-como-bucket faz sentido

Ele brilha para dados grandes, versionados por snapshot e majoritariamente de leitura, onde uma latência de atualização de horas é aceitável. Não é para dados quentes, gravações concorrentes ou consulta parcial: você baixa o banco inteiro, não uma fatia. Também vale ficar de olho nos limites de tamanho de asset e nos custos de banda em repositórios muito populares.

Para o SunCVE o encaixe é perfeito: os dados de CVE são um dataset grande que só cresce, a atualização diária é mais que suficiente, e o cliente sempre quis o banco local mesmo. Fora desse perfil, um bucket de verdade ou um banco continuam sendo a escolha certa.

Fechando

A restrição, “não quero manter servidor”, foi o que forçou o design bom. Sem um lugar para jogar os dados, a gente foi obrigado a olhar para o que já estava ali: o GitHub Releases, que ninguém pensa como storage, mas que faz o papel de bucket muito bem quando o acesso é por snapshot.

No fim, três decisões carregam o projeto: tirar os dados do git para a história git respirar, processar incrementalmente a partir do último snapshot para caber no runner, e guardar esse snapshot no Releases para fechar o ciclo sem infra. O resultado é um banco de 300 mil CVEs que qualquer pessoa abre no navegador, servido por um repositório que continua sendo, essencialmente, só código.

SunCVE GitHub Actions SQLite sql.js arquitetura serverless