Gabriel Fialho / software engineer
Where
tecnoversus.com, my own
Stack
Python, GitHub Actions, WordPress REST
Scope
Only engineer

A publishing pipeline I do not have to watch

Getting a model to write an article is the easy half. The half that took the time is what stands between it and the live site.

The constraint

A content site needed to keep publishing without me writing each article, and without quality dropping enough to put an affiliate network approval at risk. Everything below follows from holding both at once.

$0.00 Per article, all five stages
0 Articles published
0 CI workflows
0 Invariants pinned by tests

How it is arranged

Five stages: research, outline, write, edit, seo. The article body travels one path, which sounds obvious until someone adds a shortcut for a special case.

The provider is behind an interface

DeepSeek runs in production because it is cheap. Anthropic is wired behind the same interface. Switching is an environment variable, because nothing else in the codebase knows which provider is there. The version of this decision made after a price change is a rewrite instead.

Links are resolved in code, not by the prompt

Affiliate and internal links never come out of the model. It emits a token like [[aff:slug]] and code resolves it against a registry. An unregistered slug degrades to plain text.

A wrong affiliate link is the most expensive thing this system can produce, and asking a model not to invent a URL is a request rather than a guarantee.

The generator has no credentials

CI opens a pull request, I merge it, the merge publishes. The process that writes never holds a WordPress credential, so the worst case from a bad generation is a bad pull request.

Five generation stages feed a quality gate. Below the gate, a dashed line marks where publishing credentials stop. Pull request, human merge and publication all sit below that line.
Everything that can publish lives below the dashed line. The generator never crosses it.

The gate has no model in it

Between generation and publishing sits a check written in plain Python with zero model calls. Two rules govern what goes in:

  • Every rule matches a defect that actually reached production. Nothing goes in as a precaution, because a preventive rule with no incident behind it becomes a false positive, and a check people learn to ignore is worse than no check.
  • Fix the prompt, not the article. Editing one article's markdown fixes one article.

It costs nothing to run, so it runs every time. That is the argument against reviewing a model with another model: a reviewer that sometimes approves the defect is not a gate, and one that costs money per run ends up sampled rather than applied.

Pinned so far: truncation mid sentence, duplicate H1, broken links, missing affiliate links, invented hands-on experience, prices with no date, expired years, duplicates.

Not measured

I do not know how many articles the gate has blocked. It is in the run history and I have not pulled it. It is the number that would turn the gate from a design argument into a measured one.

I do not have traffic figures. I can tell you what 26 articles cost to produce, not what they earn in impressions.

The one that got through

Seven articles published with a clean body and a meta description starting with "We tested", on a site that tests nothing.

The gate was reading the article body. A meta description is not the body, and it is also the whole search result, the card on the home page and the share preview. The check passed while the exact claim I had forbidden went out on the surface most people see.

Two more checks closed it, on metadata and on the FAQ block, the second because it becomes FAQPage structured data and can be lifted into a search result with no context around it. The real correction was to the definition: the surface of a defect is everything a reader can see, and mine had been defined over part of it.