← Tutoriais
BUILD · AGENTS

Meu agente publicou uma página antes de a imagem existir

O endpoint de contents do GitHub é um arquivo por commit, um commit por deploy. Publique uma página e a imagem assim e elas competem. A Git Data API entrega a mudança inteira como um único commit, ou nada.

Para
Quem tem um agente que publica conteúdo fazendo commits num repo que faz deploy sozinho
Precisa
Um token do GitHub, um host que faz deploy sozinho (Vercel) e um agente que escreve arquivos
Tempo
Uma tarde

Um agente meu publicou um artigo num site em produção semana passada e, por cerca de um minuto, a página ficou no ar enquanto a imagem de capa devolvia um 404. O texto tinha subido num commit e a imagem em outro. A Vercel fez deploy do primeiro push, o artigo ficou no ar, e a imagem ainda estava a caminho. Dois commits, dois deploys, um buraco feio no meio.

É isso que ninguém te conta quando você deixa um agente publicar fazendo commits no git: cada escrita separada é um commit separado, e num host que faz deploy sozinho, cada commit é um deploy separado. Se o seu artigo e a imagem sobem em dois commits, existe uma janela em que o artigo está no ar e quebrado.

Foi assim que fechei essa janela.

A montagem

Eu publico guias de SEO para a Aldeia, um dos meus projetos, através de um hub de revisão. Uma skill escreve os arquivos do guia localmente, roda os testes e o build, e então faz POST de um rascunho para o hub. Quando eu aprovo no hub, o hub chama de volta um endpoint dentro do app da Aldeia, e esse endpoint faz commit dos arquivos para a main pela API do GitHub. A Vercel vê o push e faz deploy. Nenhum git push manual em todo o fluxo.

Cada guia é mais de um arquivo. Uma página nova, um append num arquivo de dados que lista todos os guias, e um ajuste no teste desse arquivo:

app/guia/<slug>/page.jsx      (página nova)
src/lib/seoGuides.js          (adicionar a entrada do guia)
src/lib/seoGuides.test.js     (subir a contagem)

Depois adicionei imagens de capa. Agora são quatro arquivos, e um deles é binário.

O jeito óbvio, e por que ele morde

O jeito óbvio de fazer commit de um arquivo pela API do GitHub é o endpoint de contents: PUT /repos/{owner}/{repo}/contents/{path}. Uma chamada, um arquivo, um commit. É o exemplo de qualquer tutorial.

Quatro arquivos são quatro dessas chamadas. Quatro commits. Na Vercel, quatro deploys competindo entre si, e nenhuma garantia de que a página caia depois da imagem para a qual ela aponta. A primeira vez que publiquei um guia com capa, o deploy da página ganhou a corrida e a imagem deu 404 até o build seguinte se acertar.

Você não resolve isso ordenando as chamadas com cuidado. Os deploys são assíncronos e você não controla qual termina primeiro. O único conserto real é parar de fazer quatro commits.

Um commit com a Git Data API

O GitHub tem uma API de mais baixo nível que monta um commit a partir das suas peças: blobs, uma árvore, e então o objeto commit. Você monta tudo e depois move o branch uma única vez. A mudança inteira de vários arquivos cai como um único commit, o que significa um único deploy.

O formato é: criar um blob para cada arquivo, construir uma árvore que referencia todos os blobs sobre a árvore atual, criar um commit apontando para essa árvore, e então fazer patch no ref do branch para o novo commit.

async function commitFiles(token, files, message) {
  const ref = await ghGet(`/git/refs/heads/${BRANCH}`, token)
  const baseSha = ref.object.sha
  const baseCommit = await ghGet(`/git/commits/${baseSha}`, token)

  const treeItems = await Promise.all(files.map(async ({ path, content, encoding = 'utf-8' }) => {
    const blob = await ghPost('/git/blobs', { content, encoding }, token)
    return { path, mode: '100644', type: 'blob', sha: blob.sha }
  }))

  const newTree = await ghPost('/git/trees',
    { base_tree: baseCommit.tree.sha, tree: treeItems }, token)
  const newCommit = await ghPost('/git/commits',
    { message, tree: newTree.sha, parents: [baseSha] }, token)

  // mover o branch uma vez — é a única escrita à qual o deploy reage
  await ghPatch(`/git/refs/heads/${BRANCH}`, { sha: newCommit.sha }, token)
  return newCommit.sha
}

Quatro arquivos, um movimento de ref, um deploy. O artigo e a imagem ficam no ar no mesmo instante, ou não ficam.

O arquivo binário na mesma árvore

A imagem de capa são bytes, não texto, e tem que viajar na mesma árvore que o markup ou você volta a ter dois commits. O endpoint de blobs aceita um campo encoding que é utf-8 ou base64. O truque é fazer isso por arquivo em vez de fixar, para que texto e binário caibam numa só chamada de árvore.

É o encoding = 'utf-8' padrão do trecho acima. Os arquivos de texto não passam nada e recebem UTF-8. A imagem passa o seu base64 e a codificação certa:

const filesToCommit = [
  { path: jsxPath,                 content: jsxContent },
  { path: 'src/lib/seoGuides.js',  content: updatedGuides },
  { path: 'src/lib/seoGuides.test.js', content: updatedTest },
]

if (heroImageB64 && heroImagePath) {
  const cleanPath = heroImagePath.replace(/^\//, '')
  filesToCommit.push({
    path: `public/${cleanPath}`,
    content: heroImageB64,
    encoding: 'base64',
  })
}

await commitFiles(token, filesToCommit, message)

O hub manda a imagem como hero_image_b64 mais um hero_image_path, o endpoint não decodifica nada (base64 é exatamente o que a API de blobs quer), e a imagem aterrissa em public/ no mesmo commit que a página que a exibe.

A segunda coisa que quebrou: confie menos nos dados

Tem uma lição mais afiada escondida embaixo desta. O primeiro guia que publiquei assim quebrou o build, e não por causa da imagem.

A entrada do arquivo de dados é gerada pela skill e inserida pelo endpoint. A entrada gerada já terminava em vírgula. Minha inserção adicionou outra. O resultado foi },, no array, que o JavaScript lê como um elemento de array indefinido. O prerender do sitemap percorreu o array e lançou um TypeError ao buscar .url no buraco que a vírgula dupla deixou.

O conserto foi uma linha, mas o que importa é o hábito:

// a entrada gerada pode ou não trazer uma vírgula final — remova-a,
// e então adicione exatamente uma
const normalizedEntry = seoEntry.replace(/,\s*$/, '')
const updatedGuides = guidesContent.replace(MARKER, `\n  ${normalizedEntry},${MARKER}`)

Quando um agente gera um fragmento e o seu código o emenda dentro de um arquivo real, trate esse fragmento como entrada não confiável mesmo tendo escrito o agente. Você não controla o formato exato dele entre execuções. Normalize antes de concatenar. Uma vírgula perdida que você nunca escreveria à mão é justamente o tipo de coisa que um gerador te entrega na terceira execução.

O que usei

Para levar

Se um agente publica fazendo commits no git, faça a mudança inteira virar um único commit. O endpoint de contents é um arquivo por commit e um commit por deploy, que é exatamente como você termina com uma página no ar apontando para uma imagem que ainda não está lá. A Git Data API custa algumas chamadas a mais e compra a única coisa que importa aqui: a página e tudo de que ela precisa aterrissam juntos, ou nada aterrissa. E seja lá o que o agente gerou, normalize antes que toque um arquivo real. Você escreveu o gerador, não escreveu a saída dele.

ACHOU ÚTIL? RECEBA O PRÓXIMO

Deixe seu e-mail e eu te aviso quando o próximo sair, com as ferramentas e fluxos que eu de fato uso. Sem spam.

Mais tutoriais ↗