O commit único — quando o botão de liberar parou de mentir sobre o estado do blog
LifeLog·

O commit único — quando o botão de liberar parou de mentir sobre o estado do blog

10 min de leitura← Voltar para timeline

O painel que sabia demais

No dia 8 de setembro, apertei o botão de liberar no painel de posts ocultos. A API flipou o hidden: true para hidden: false nos dois arquivos do par (português e inglês), fez o commit, o push, o deploy. Alguns minutos depois, recarreguei o painel — e ele continuava listando o post que eu tinha acabado de liberar. Do lado de fora, a página pública do post já respondia 200.

Nenhuma das duas fontes estava errada. É que elas descreviam instantes diferentes do mesmo processo.

O problema não era o painel estar desatualizado — era ele estar desatualizado de propósito, por arquitetura. A liberação era coreografada em duas transações independentes:

transação 1: flip hidden:true -> false em <par PT+EN>.mdx  -> commit -> push
transação 2: regenerar api/ocultos-data.mjs sem o par      -> commit -> push

Entre a transação 1 e a transação 2 existe uma janela. Dentro dela, o repositório diz uma coisa (post liberado, hidden: false nos mdx) e o índice gerado diz outra (post ainda na lista de ocultos). O painel de produção lê o índice do bundle deployado — que só troca no próximo deploy. Se a segunda transação empurrar o commit depois do deploy da primeira, o painel congela no estado velho até o deploy seguinte.

Chamei o sintoma de “painel listando post já público” e tratei como bug do painel. Corrigir o sintoma era possível: regenerar o índice no fim do fluxo, refazer o commit, disparar deploy de novo. Foi o que fiz no 067da21 — e era um curativo num corte que a cirurgia ia reabrir a cada liberação.

O diagnóstico que doeu

A pergunta certa não era “por que o painel mente?” e sim “por que a liberação são dois commits?”.

A resposta honesta: porque cada commit sozinho é fácil. Flipar frontmatter é uma troca de string. Regenerar um índice é ler um diretório e serializar JSON. Duas operações simples emendadas com dois commits — cada passo com ponto de falha próprio, cada falha no meio deixando o repositório num estado que só é verdade pela metade.

O índice api/ocultos-data.mjs é derivado dos frontmatters. Duas fontes de verdade derivadas da mesma origem, publicadas em instantes diferentes. Isso não é um bug de sincronização — é uma garantia quebrada por desenho.

A correção que valeu a pena não era sincronizar melhor os dois commits. Era cometer os dois em um só.

A coreografia do commit atômico

A API REST de conteúdo do GitHub (PUT /contents) troca um arquivo por chamada. Não existe endpoint “commita três arquivos de uma vez” nessa rota. Quem resolve isso é a Git Data API — a camada de baixo nível onde um commit é um objeto com árvore, e uma árvore é um conjunto de referências a blobs.

A coreografia, em cinco chamadas:

1. GET  /git/ref/heads/main            -> sha do commit atual (base)
2. GET  /git/commits/<base>            -> sha da árvore base
3. POST /git/blobs                      (um por arquivo: mdx PT, mdx EN, índice)
4. POST /git/trees                      (base_tree + os 3 blobs -> nova árvore)
5. POST /git/commits  (tree, parents=[base]) -> PATCH /git/refs/heads/main

O passo 4 é o que faz a mágica: base_tree diz “parta da árvore atual e mude só estes caminhos”. O commit resultante carrega o repositório inteiro e a mudança de três arquivos — e não existe momento intermediário em que o main veja metade da liberação.

No scripts/ocultos-core.mjs, o trecho que fecha a coreografia:

const treeSha = await createTree(baseTree, blobs);
const commit = await gh(`/repos/${owner}/${repo}/git/commits`, {
  method: 'POST',
  body: JSON.stringify({ message, tree: treeSha, parents: [baseSha] }),
});
if (commit.status !== 201) throw Object.assign(new Error(`commit HTTP ${commit.status}`), { status: commit.status });

const patch = await gh(`/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
  method: 'PATCH',
  body: JSON.stringify({ sha: commit.data.sha, force: false }),
});
if (patch.status !== 200) {
  throw Object.assign(new Error(`ref update HTTP ${patch.status}`), { status: patch.status, moved: patch.status === 422 });
}

Dois detalhes ali carregam mais design do que parece.

O force: false no PATCH é o seguro contra o overwrite cego. Se outra sessão empurrou algo no main entre a minha leitura do ref e o meu PATCH, o GitHub responde 422 — a ref não é fast-forward. O erro não é tratado como falha genérica: ele vem com uma marca (moved: true) e o chamador entra num retry que re-lê todos os conteúdos frescos do repositório antes de refazer a coreografia. Até três tentativas. A cada volta, o estado novo do mundo entra na conta — nunca o meu snapshot velho por cima de trabalho alheio.

E a regra de ouro: as fronteiras entre as chamadas não são pontos onde o repositório fica “meio liberado”. Elas são onde uma liberação não visível vira uma liberação única e completa.

Byte-idêntico ou nada

Comitar o índice junto dos mdx exige que o índice regenerado na liberação seja exatamente igual ao que o build gera. Se o formato divergir por um byte — um espaço, uma quebra de linha, ordem de chave no JSON — todo release vira um diff falso, e o histórico do arquivo vira ruído.

A resposta foi uma fonte única de formato. O serializeOcultos e o parseOcultos vivem no mesmo módulo que o gerador do build consome; o endpoint de liberação importa os mesmos objetos. A propriedade é testável e o teste diz exatamente isso:

it('parse → serialize = bytes idênticos', () => {
  const text = serializeOcultos(samplePosts);
  const parsed = parseOcultos(text);
  expect(serializeOcultos(parsed)).toBe(text);
});

Quinze testes cobrem o contrato: header gerado igual ao formato atual, parse aceita arquivo real com o ponto-e-vírgula final, parse falha alto (e não engole) quando o marcador export default some, flip preserva line endings LF e CRLF como estão, flip num arquivo já liberado é idempotente e devolve bytes intactos, remoção do par tira PT e EN e preserva os outros, e as variantes de nomenclatura do EN (en/<slug> versus en/en-<slug>, um legado honesto das primeiras semanas) colidem e deduplicam sem quebrar.

O detalhe que quase passou: o flip de hidden: true para hidden: false não pode normalizar line endings. O repositório tinha arquivos em CRLF e LF convivendo, e um flip que padronizasse tudo viraria um diff de arquivo inteiro — o commit atômico carregando renames invisíveis. O flipHidden preserva o que está lá:

export function flipHidden(raw) {
  const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!fmMatch) return { raw, flipped: false, already: false, missing: true };
  const fm = fmMatch[1];
  const updated = fm.replace(/^(\s*hidden:\s*)true(\s*)$/m, '$1false$2');
  if (updated !== fm) return { raw: raw.replace(fm, updated), flipped: true, already: false };
  if (/^(\s*hidden:\s*)false(\s*)$/m.test(fm)) return { raw, flipped: false, already: true };
  return { raw, flipped: false, already: false, missing: true };
}

A regex do frontmatter aceita \r?\n nas bordas e preserva cada byte que não é o true alvo. Um clique, um diff de uma palavra por arquivo.

A liberação como transação

Depois do merge, a liberação passou a ser isto:

Antes Depois
2 commits por liberação 1 commit único
Janela com estado inconsistente no main Nenhuma — o main nunca vê metade
Painel podia listar post já público Índice e frontmatter saem juntos do mesmo commit
Conflito com push concorrente = overwrite ou abort 422 detectado, conteúdo re-lido, retry até 3x
Formato do índice em dois lugares Uma fonte, byte-idêntica por teste
Deploy do CI podia commitar self-heal extra Deploy do CI só cura drift de qualquer origem

O deploy também ganhou uma rede: se o build rodar num estado em que o índice diverge do HEAD, o próprio workflow commita o alinhamento e segue. Não é para isso acontecer — o commit atômico garante que não acontece no caminho normal — mas um sistema que só funciona quando tudo dá certo é um sistema que mentiu para você uma vez e está esperando a próxima.

O que ficou de aprendizado não é a Git Data API em si — a documentação dela é boa e a coreografia está em qualquer guia de commits multi-arquivo. O aprendizado é a ordem das perguntas. Eu tinha dois commits porque dois commits eram fáceis, e o estado inconsistente entre eles era “temporário”, “só alguns segundos”, “ninguém clica nesse meio-tempo”. Estados temporários não são inofensivos: são garantias em aguardente. Quando duas fontes de verdade derivam da mesma origem, elas precisam sair juntas — no mesmo commit, no mesmo deploy, no mesmo instante. Ou viram uma só.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$