My agent published a page before its image existed
The GitHub contents endpoint is one file per commit, one commit per deploy. Publish a page and its image that way and they race. The Git Data API lands the whole change as a single commit, or none of it.
An agent of mine published an article to a live site last week, and for about a minute the page was up while its hero image returned a 404. The text had shipped in one commit and the image in another. Vercel deployed the first push, the article went live, and the image was still in flight. Two commits, two deploys, one ugly gap in the middle.
This is the thing nobody tells you about letting an agent publish by committing to git: every separate write is a separate commit, and on a host that auto-deploys, every commit is a separate deploy. If your article and its image go up in two commits, there is a window where the article is live and broken.
Here is how I closed that window.
The setup
I publish SEO guides for Aldeia, one of my projects, through a review hub. A skill writes the guide files locally, runs the tests and the build, then POSTs a draft to the hub. When I approve it in the hub, the hub calls back to an endpoint inside the Aldeia app, and that endpoint commits the files to main over the GitHub API. Vercel sees the push and deploys. No manual git push anywhere in the loop.
Each guide is more than one file. A new page, an append to a data file that lists every guide, and a bump to that file’s test:
app/guia/<slug>/page.jsx (new page)
src/lib/seoGuides.js (append the guide entry)
src/lib/seoGuides.test.js (bump the count)
Then I added hero images. Now it is four files, and one of them is binary.
The naive way, and why it bites
The obvious way to commit a file over the GitHub API is the contents endpoint: PUT /repos/{owner}/{repo}/contents/{path}. One call, one file, one commit. It is the example in every tutorial.
Four files means four of those calls. Four commits. On Vercel, four deploys racing each other, and no guarantee the page lands after the image it points at. The first time I shipped a guide with a hero, the page deploy won the race and the image 404’d until the next build caught up.
You cannot fix this by ordering the calls carefully. The deploys are async and you are not in control of which finishes first. The only real fix is to stop making four commits.
One commit with the Git Data API
GitHub has a lower-level API that builds a commit out of its parts: blobs, a tree, then the commit object. You assemble everything, then move the branch once. The whole multi-file change lands as a single commit, which means a single deploy.
The shape is: create a blob for each file, build a tree that references all the blobs on top of the current tree, create a commit pointing at that tree, then patch the branch ref to the new 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)
// move the branch once — this is the only write the deploy reacts to
await ghPatch(`/git/refs/heads/${BRANCH}`, { sha: newCommit.sha }, token)
return newCommit.sha
}
Four files, one ref move, one deploy. The article and its image go live in the same instant or not at all.
The binary file in the same tree
The hero image is bytes, not text, and it has to ride in the same tree as the markup or you are back to two commits. The blob endpoint takes an encoding field that is either utf-8 or base64. The trick is to make it per-file instead of hardcoding it, so text and binary can sit in one tree call.
That is the encoding = 'utf-8' default in the snippet above. Text files pass nothing and get UTF-8. The image passes its base64 and the right encoding:
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)
The hub sends the image as hero_image_b64 plus a hero_image_path, the endpoint decodes nothing (base64 is what the blob API wants), and the image lands in public/ in the same commit as the page that displays it.
The second thing that broke: trust the data less
There is a sharper lesson hiding under this one. The first guide I shipped this way crashed the build, and not on the image.
The data file entry is generated by the skill and inserted by the endpoint. The generated entry already ended with a comma. My insert added another. The result was },, in the array, which JavaScript reads as an undefined array element. The sitemap prerender then walked the array and threw a TypeError reaching for .url on the hole that the double comma left.
The fix was one line, but the point is the habit:
// the generated entry may or may not carry a trailing comma — strip it,
// then add exactly one
const normalizedEntry = seoEntry.replace(/,\s*$/, '')
const updatedGuides = guidesContent.replace(MARKER, `\n ${normalizedEntry},${MARKER}`)
When an agent generates a fragment and your code splices it into a real file, treat that fragment as untrusted input even though you wrote the agent. You do not control its exact shape across runs. Normalize before you concatenate. A stray comma you would never type by hand is exactly the kind of thing a generator hands you on run three.
What I used
- GitHub Git Data API:
/git/blobs,/git/trees,/git/commits,/git/refsfor one atomic commit. - A
base64blob encoding per file so the binary hero rides in the same tree as the markup. - A normalize step on every generated fragment before splicing it into source.
Takeaway
If an agent publishes by committing to git, make the whole change one commit. The contents endpoint is one file per commit and one commit per deploy, which is how you get a live page pointing at an image that is not there yet. The Git Data API costs a few more calls and buys you the only thing that matters here: the page and everything it needs land together, or nothing lands. And whatever the agent generated, normalize it before it touches a real file. You wrote the generator, you did not write its output.
Drop your email and I'll ping you when the next one goes up, with the tools and workflows I actually use. No spam.
More tutorials ↗