← Tutoriales
BUILD · AGENTS

Mi agente publicó una página antes de que existiera su imagen

El endpoint de contents de GitHub es un archivo por commit, un commit por despliegue. Publica una página y su imagen así y compiten. La Git Data API deja todo el cambio como un único commit, o nada.

Para
Cualquiera cuyo agente publique contenido haciendo commits a un repo que despliega solo
Necesitas
Un token de GitHub, un host que despliega solo (Vercel) y un agente que escribe archivos
Tiempo
Una tarde

Un agente mío publicó un artículo en un sitio en producción la semana pasada y, durante cerca de un minuto, la página estuvo arriba mientras su imagen de portada devolvía un 404. El texto se había subido en un commit y la imagen en otro. Vercel desplegó el primer push, el artículo quedó en vivo, y la imagen todavía iba en camino. Dos commits, dos despliegues, un hueco feo en el medio.

Esto es lo que nadie te cuenta cuando dejas que un agente publique haciendo commits a git: cada escritura por separado es un commit por separado, y en un host que despliega solo, cada commit es un despliegue por separado. Si tu artículo y su imagen suben en dos commits, hay una ventana en la que el artículo está en vivo y roto.

Así cerré esa ventana.

El montaje

Publico guías SEO para Aldeia, uno de mis proyectos, a través de un hub de revisión. Una skill escribe los archivos de la guía en local, corre los tests y el build, y luego hace POST de un borrador al hub. Cuando lo apruebo en el hub, el hub llama de vuelta a un endpoint dentro de la app de Aldeia, y ese endpoint hace commit de los archivos a main por la API de GitHub. Vercel ve el push y despliega. Ningún git push manual en todo el flujo.

Cada guía es más de un archivo. Una página nueva, un append a un archivo de datos que lista todas las guías, y un ajuste al test de ese archivo:

app/guia/<slug>/page.jsx      (página nueva)
src/lib/seoGuides.js          (añadir la entrada de la guía)
src/lib/seoGuides.test.js     (subir el conteo)

Después añadí imágenes de portada. Ahora son cuatro archivos, y uno de ellos es binario.

La forma obvia, y por qué muerde

La forma obvia de hacer commit de un archivo por la API de GitHub es el endpoint de contents: PUT /repos/{owner}/{repo}/contents/{path}. Una llamada, un archivo, un commit. Es el ejemplo de cualquier tutorial.

Cuatro archivos son cuatro de esas llamadas. Cuatro commits. En Vercel, cuatro despliegues compitiendo entre sí, y ninguna garantía de que la página caiga después de la imagen a la que apunta. La primera vez que publiqué una guía con portada, el despliegue de la página ganó la carrera y la imagen dio 404 hasta que el siguiente build se puso al día.

No lo arreglas ordenando las llamadas con cuidado. Los despliegues son asíncronos y no controlas cuál termina primero. El único arreglo real es dejar de hacer cuatro commits.

Un commit con la Git Data API

GitHub tiene una API de más bajo nivel que arma un commit a partir de sus piezas: blobs, un árbol, y luego el objeto commit. Ensamblas todo y después mueves la rama una sola vez. El cambio completo de varios archivos cae como un único commit, lo que significa un único despliegue.

La forma es: crear un blob por cada archivo, construir un árbol que referencie todos los blobs sobre el árbol actual, crear un commit que apunte a ese árbol, y luego hacer patch al ref de la rama hacia el nuevo 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 la rama una vez — es la única escritura a la que reacciona el despliegue
  await ghPatch(`/git/refs/heads/${BRANCH}`, { sha: newCommit.sha }, token)
  return newCommit.sha
}

Cuatro archivos, un movimiento de ref, un despliegue. El artículo y su imagen quedan en vivo en el mismo instante, o no quedan.

El archivo binario en el mismo árbol

La imagen de portada son bytes, no texto, y tiene que viajar en el mismo árbol que el markup o vuelves a tener dos commits. El endpoint de blobs acepta un campo encoding que es utf-8 o base64. El truco es hacerlo por archivo en vez de fijarlo, para que texto y binario quepan en una sola llamada de árbol.

Eso es el encoding = 'utf-8' por defecto del snippet de arriba. Los archivos de texto no pasan nada y reciben UTF-8. La imagen pasa su base64 y la codificación correcta:

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)

El hub manda la imagen como hero_image_b64 más un hero_image_path, el endpoint no decodifica nada (base64 es justo lo que quiere la API de blobs), y la imagen aterriza en public/ en el mismo commit que la página que la muestra.

Lo segundo que se rompió: confía menos en los datos

Hay una lección más afilada escondida bajo esta. La primera guía que publiqué así rompió el build, y no por la imagen.

La entrada del archivo de datos la genera la skill y la inserta el endpoint. La entrada generada ya terminaba en coma. Mi inserción añadió otra. El resultado fue },, en el array, que JavaScript lee como un elemento de array indefinido. El prerender del sitemap recorrió el array y lanzó un TypeError al buscar .url sobre el hueco que dejó la coma doble.

El arreglo fue una línea, pero lo que importa es el hábito:

// la entrada generada puede traer o no una coma final — quítala,
// y luego añade exactamente una
const normalizedEntry = seoEntry.replace(/,\s*$/, '')
const updatedGuides = guidesContent.replace(MARKER, `\n  ${normalizedEntry},${MARKER}`)

Cuando un agente genera un fragmento y tu código lo empalma dentro de un archivo real, trata ese fragmento como entrada no confiable aunque tú hayas escrito el agente. No controlas su forma exacta entre ejecuciones. Normaliza antes de concatenar. Una coma perdida que nunca escribirías a mano es justo el tipo de cosa que un generador te entrega en la tercera ejecución.

Lo que usé

Para llevar

Si un agente publica haciendo commits a git, haz que todo el cambio sea un solo commit. El endpoint de contents es un archivo por commit y un commit por despliegue, que es justo como terminas con una página en vivo apuntando a una imagen que todavía no está. La Git Data API cuesta unas llamadas más y compra lo único que importa aquí: la página y todo lo que necesita aterrizan juntos, o no aterriza nada. Y sea lo que sea que generó el agente, normalízalo antes de que toque un archivo real. Escribiste el generador, no escribiste su salida.

¿TE SIRVIÓ? RECIBE EL PRÓXIMO

Déjame tu correo y te aviso cuando publique el próximo, con las herramientas y flujos que de verdad uso. Sin spam.

Más tutoriales ↗