Plumbing
The TofuBofu GitHub integration: we open a pull request, and we never merge it
By Arnav Mukherjee, founder of TofuBofu · September 3, 2026
On the last day of August, our GitHub channel opened its first pull request into a customer's repository. Their existing posts carry fifteen frontmatter keys. We filled five, and one of the five was wrong.
The category field came back holding a buying question. Not a label a human would ever type, a search string, sitting in the taxonomy slot on a live repository belonging to somebody who pays us. Here's the line, copied out of the diff:
I'll come back to why that happened and what closed it. Start with the decision the whole integration is built on, because it's the one nobody else in this category will make.
Nothing here merges anything, ever
Every AI visibility tool that publishes treats the word publish as a button that makes something appear on your website. Ours can't do that to a repository, and the restraint is deliberate rather than a limitation we're dressing up. Asking for a published post runs the identical code path as asking for a draft. The adapter reports back "draft" whichever you requested, and appends one sentence: publishing to a repository always goes through review.
You'll read the same promise on the connect card before you've typed anything. We never push to your main branch and we never merge. A machine holding write access to your source code, refusing to use it until a human clicks the green button, is an odd thing to build. Your developer will understand it in about four seconds, which matters more than anything we could say to your marketing lead.
The pull request body carries the whole review surface. It names the file path, tells you which of your existing posts the format was copied from, prints the meta description, lists every key we held no value for, says where the FAQ schema went or why it didn't go anywhere, and closes on this: merging this publishes the post, nothing is live until you do.
There's a real cost and I'd rather state it than let you discover it. If you bought done-for-you, a repository site still needs a click from your side on every article. I agreed that pull-request-only scope with a customer's developer in August and I haven't reopened it. Nobody else's operator model survives contact with a git repository either, they just don't tell you where the boundary sits.
We copy your newest post. We don't invent a format
WordPress has one REST contract for every install on earth, so an adapter can be written once against a documented API. A repository has none of that. Your frontmatter is whatever your generator and your last developer agreed on, and the only honest specification available to us is the content already sitting there.
So the newest post in the folder you pick is the spec. We read it at connect time and copy the delimiter, whether you fence with three dashes or the TOML plus signs, whether keys separate on a colon or an equals sign, which keys you quote and which you leave bare, whether your tags are an inline array or a block list, and which of twenty date formats you write. Then we write only the keys your sample actually carries, and only where we know what belongs in them.
The other adapters post rendered HTML. This one writes body_markdown, because HTML pasted into a .md file works by accident and reads as machine output in your own diff. Your hero image gets prepended as a markdown image only when your frontmatter has no hero key ending in Url, so the picture never lands on the post twice.
Schema follows the file extension rather than a preference. For .md and .markdown the FAQ JSON-LD goes in as a script block. For .mdx it never does, because a raw script block is JSX and those JSON braces get read as expressions. Dropping your schema is a bad outcome, breaking your build is a worse one, so the pull request hands the JSON-LD to your developer instead and says why.
One draft, one branch, one pull request
Back to the field we got wrong
Fifteen keys in their posts, five written by us, one of the five carrying a search query. The root cause was ugly and small: each role bound to one key on a first-come basis, so title claimed the slot ahead of seoTitle, excerpt beat metaDescription, and categories beat tags. Three keys we could have filled came out empty.
Then the pull request reported those three under the heading we use for fields we skipped to avoid guessing. We weren't guessing. We held every one of those values and dropped them on the floor. Honest about the outcome, wrong about the cause, which is the kind of message that costs you a developer's trust in one reading.
Three things closed it on 1 September. Roles now split by meaning rather than racing for a key, so an SEO title and a page title are different jobs. Taxonomy became learned-or-empty, with the draft's own tags removed as a source entirely, because the only way to fill a category without asking you is to invent a label. And a ratchet test now runs across every registered publisher, so a fourth adapter can't repeat this quietly next quarter.
I'm publishing that because a tool willing to say "I don't know what belongs here" is rarer than one that fills the field. An unrecognised date format means we write no date at all. A bare image or heroImage or cover key stays empty, since we can't tell whether your project wants a remote URL, a repository path or a bundler import. Every unknown key stays empty and gets named in the pull request. Guessing breaks your build as surely as omitting does, and only one of those two is honest.
Connecting it, and the door most people need
Set the brand's domain first, because a connection belongs to a website rather than to an account. Then Integrations, GitHub, Connect. You'll see two doors, and the second one matters more than the first.
Installing a GitHub App on an organisation is owner-only. GitHub answers everyone else with "this action must be performed by an organization owner", which we measured against a live customer org rather than reading in a doc. So there's a second door labelled "Already installed? Link it", and that one authorises you as a person, which any member can do, since GitHub's installations endpoint answers on membership rather than ownership. Get an owner to install the app once, then link it yourself and carry on.
After that you pick the one repository your site builds from, then the folder your posts live in, chosen from a dropdown built by walking your tree for markdown files with the known conventions ranked first. Connect mints a token, reads the repo, checks the installation's own permissions and learns the shape. We store no GitHub write credential at any point: the installation token lasts about an hour, gets minted per operation, and is thrown away.
The authorisation story is worth one paragraph, because an installation id isn't a secret and it arrives from a redirect. Our first build accepted whatever integer came back, which meant a verified user could name somebody else's installation and open pull requests in their repository through our app key. A review prescribed signing the pair into a receipt. Right shape, measurably not enough, since an attacker can read their own signed state and hand-request the callback with a victim's installation id. So the callback proves the claim before it signs it: we exchange GitHub's one-time code for a user token, ask GitHub whether that person actually holds the installation, and discard the token. Two guards then run at connect, and the conflict check fails closed on a read error.
Where it refuses, and what it says
An empty folder
The newest post in your chosen folder is the whole specification, so an empty folder or frontmatter we cannot parse stops the connect with that sentence. There is no fallback shape, because a fallback shape is a guess wearing a default's clothes. Publish one post by hand and we will match it.
A repository too large for one tree read
GitHub caps a recursive tree listing and returns a truncated flag. We surface it rather than presenting a partial list as complete, because a customer who cannot see their folder concludes it does not exist. Every refusal names an action the screen can actually offer.
An installation already in use
One GitHub installation belongs to one TofuBofu account. The second account to claim it is told so plainly instead of quietly sharing write access to somebody else's repository.
A check that cannot be checked
Looking for an existing post returns three answers: a file on the default branch means published, an open pull request on our branch means a previous publish is waiting, and a clean look finding neither means absent. Anything else returns none, meaning we could not check, never it is not there. Conflating an unreachable API with an absent item is how a false failure becomes a duplicate live page.
A permission check we deleted
A second check used to read the repository object's push permission. Measured against a live installation while a paying customer sat blocked: contents write, pull requests write, and a permissions block reading false on a repo we had just read through that very token. That block describes a user's role and an installation token has no user, so reading it refused a working connection and cost an afternoon of round trips with their org owner.
GitHub is a category, not a platform
One integration reaches every site that stores its content as files in a repository the host rebuilds from. The connect card names Next.js, Astro, Hugo and Jekyll, and the mechanism doesn't care which of them you run, since we're reading your posts rather than detecting your generator. Vercel and its peers are the wrong target for this: they build and host, they don't store your content.
That audience keeps growing and the publishing tools haven't followed. Every AEO product I've looked at assumes your content sits behind a CMS API, which leaves a whole class of fast, statically generated, perfectly crawlable sites with nothing to connect to and a copy-paste workflow that nobody keeps up for a month.
Worth being straight about what the plumbing buys you. Ahrefs compared 1,885 pages that gained schema against 4,000 matched controls between August 2025 and March 2026 and found ChatGPT citations up 2.2%, AI Mode up 2.4% and AI Overviews down 4.6%. Read the selection criterion before you quote that at anyone: they picked pages already carrying 100+ AI Overview citations, so it says nothing about pages the engines can't see yet. Their author's own caveat is the honest read, that schema might still matter for pages nobody is picking up. Our customers are that excluded population, and of 39 readable site crawls we measured on 12 August, 26 carried no FAQ schema at all. Every one of those 39 sites ran a scan because somebody already suspected they were missing, so read it as a sample selected on the problem.
Find out what the engines say about you first
A free scan runs your buying questions across six AI engines and ranks the gaps worth writing for.
Get your free auditFrequently asked questions
Does TofuBofu push to my main branch?
No. Every publish writes one markdown file to a new branch named tofubofu/{slug} and opens a pull request against your base branch. Asking for status published takes the identical code path as asking for a draft, and the adapter reports the result as a draft either way. Merging is your click, on your side, always.
Which site frameworks does the GitHub integration support?
Any site built from files in a repository. The connect card names Next.js, Astro, Hugo and Jekyll, and the mechanism is framework-agnostic because we do not know your generator: we read your newest existing post and copy its frontmatter format. Vercel and similar hosts are the wrong target, because they host the build and do not store the content.
What GitHub permissions does TofuBofu need?
A GitHub App installed on the single repository your site builds from, with Contents write and Pull requests write. Nothing else. We store no GitHub write credential: an installation token valid for about an hour is minted for each operation from the app key and then discarded, so the connection row holds an installation id, a repository, a folder and a base branch.
I am not an organisation owner. Can I still connect?
Yes, through the Link it door. Installing a GitHub App on an organisation is owner-only, and GitHub answers everybody else with a refusal. Linking authorises you as a person instead, which any org member can do, because GitHub answers the installations endpoint on membership rather than ownership. Ask an owner to install the app once, then link it yourself.
What happens if my posts folder is empty?
Connect fails and says so. The newest existing post in the folder you pick is the specification for everything we write, so an empty folder or unparseable frontmatter leaves us nothing to copy and there is no fallback shape. Publish one post by hand first and we will match it.
Does the pull request include FAQ schema?
For .md and .markdown files, yes: the FAQ JSON-LD is appended as a script block in the body. For .mdx, never. A raw script block is JSX inside MDX and the JSON braces are read as expressions, which breaks your build rather than merely dropping the schema. In that case the pull request body carries the JSON-LD for your developer to place in the layout.
Can TofuBofu take a published post back?
Before you merge, yes. Unpublish closes the pull request and deletes the branch on a best-effort basis, and a missing pull request is treated as success because the repository already sits in the state we wanted. After you merge, the file is yours: removing it is a commit on your side.
Sources and further reading
- GitHub: authenticating as an app installation: installation access tokens expire after one hour, which is why we mint one per operation and store none.
- GitHub REST: app installations: the endpoint that answers on membership, which is what makes the link door work for non-owners.
- What is MDX: curly braces are expressions in MDX, which is why a raw JSON-LD block breaks an MDX build.
- Ahrefs: does schema markup help AI citations?: 1,885 treated pages against 4,000 controls, selected on pages already holding 100+ AI Overview citations.