This blog's first commit is dated 31 March 2026 and its message reads: feat: initial blog platform with full automation. Today is 23 September. Between them sit 176 days, 3,300 commits and 1,276 posts in each language — 27 of them a 2024-dated archive carried over from my old blog. The one thing I have learned is how optimistic the phrase "full automation" was in that first message.
Whoever builds the automation does not know it. The person who knows it is the one who counts the places it broke. So I sat down and counted.
First, the tally: 99 PRs, 67 of them repairs
I wrote this section twice, and the second time was embarrassing.
The first time I grepped git log for the pattern (#123), found 71 merges, saw that 48 of them were fix, and titled the piece accordingly. Then I noticed the repository uses a second merge style: for a stretch I used real merge commits instead of squashes, and their messages read Merge pull request #57 from .... My grep had never seen those 28 merges.
git log --format='%s' | grep -cE '\(#[0-9]+\)' # 71
git log --format='%s' | grep -cE '^Merge pull request #' # 28
The two sets do not overlap at all. Here is the real table:
| Type | Count |
|---|---|
fix |
67 |
feat |
21 |
chore / test / docs / other |
10 |
security |
1 |
| Total | 99 |
For a piece that is about measuring your own records wrongly, I could not have asked for a better opening. And the set I missed contained one of the incidents I am about to describe: the pace collapse of 9 July is PR #57, and my first count did not include it.
One more warning about the numbers: the repository moved to an enterprise account on 14 September and the PR counters reset. Most of the incidents below carry numbers from the old repository; where one comes from after the move, I say so.
Whatever the total, the ratio holds: roughly two thirds of the work was not adding a capability but keeping an existing one alive. What surprised me was not that ratio but where the repairs clustered. Among the 48 fix commits that carry a scope label the split is: content 11, ci 8, cron 7, i18n 5, social 4, editorial 3, ai 3, the rest one-offs. (The 19 repairs that arrived as merge commits carry no scope label, so this distribution describes only the labelled half.)
Read that list carefully. The ai scope appears just three times, and all three are about the identity of the model I called turning out to be dead, not about the quality of its prose. Here is where I have to be honest: that does not mean the agent's writing was never a problem. Some of the repairs under content were exactly about text quality — a bulk repair of truncated posts on 17 May (#12), and the next day the deletion of 36 half-written posts plus a three-layer generation gate (#14). The largest cleanup of all comes later, in the closing section.
Still, the direction is clear. The overwhelming majority of repairs were not in the prose but in the machinery around it: ordering, locking, state files, queues. Automation turned out not to be hiring a writer. It was running a printing press.
I have published an earlier assessment on this blog about how autonomous agents really are. That post asked what agents can do. The question here is different and less appealing: while doing what they can do, where exactly do they fall over.
One JSON file, two writers, zero locks
The first serious wound opened in May. The pipeline's memory was a single file, scripts/content-calendar.json: which topic had been written, which was waiting, when the last generation ran — all of it there. A classic "keep it simple" decision, and for the first seven weeks it genuinely was simple.
Then I increased the publishing frequency. Two workflows woke at the same time, both read the same file, both appended their own line, and the second overwrote what the first had written. The commit messages tell the rest: on 21 May, calendar.json race condition + atomic write + concurrency (#22); the same day, atomic write + workflow concurrency (race condition kalici koruma) (#23); still the same day, lastGeneratedAt AYRI dosyaya (calendar.json corrupt root cause) (#24); a third follow-up patch still that same day (#25); and on 24 May, calendar.json git rebase corrupt — JSON-aware re-checkout (#26).
Four PRs in a single day, the fifth three days later. All because of one file. And not one of them was a "I wrote the wrong code" mistake; every one was a "I failed to account for two things happening at once" mistake.
Part of the right answer was sitting in GitHub's own documentation: give a workflow a concurrency key and the second run in the same group is held as pending. The fine print matters — the default behaviour is to cancel the pending run and put the new one in its place: "By default, any existing pending job or workflow in the same concurrency group will be canceled and the new queued job or workflow will take its place." If you want the order preserved, you have to set the queue property to max explicitly. Until I learned that, I assumed the cancelled runs had simply never started.
The lesson: if state lives in a file, you are the one who must say who writes to it and when. Git is a version control system, not a database that arbitrates concurrent writes.
The other half of the same family lived in the scheduler. GitHub's documentation is explicit about the schedule event: "The schedule event can be delayed during periods of high loads of GitHub Actions workflow runs." Before reading that I had set the cron to the top of every hour and treated the drift as lost runs; the fix on 18 May tightened the intervals from 30 minutes to 15 to absorb the slippage (#19). You cannot tell a scheduler "exactly at this moment." You can only tell it "no later than this."
When the queue clogs, it isn't production that stops — it's distribution
The second family was sneakier. Posts were being generated, the site was updating, but the social sharing queue had stopped.
13 July: platform-basina bagimsiz kuyruk — LinkedIn backlog Bluesky/X/Devto ac birakmasin (#61). When a single platform's authentication broke, the job at the head of the queue could not advance and every platform behind it waited too. In networking this is called head-of-line blocking; I learned the name after I found the cause.
13 August: draft post kuyrugu kilitliyor — draft filtresi + gate skip (#84). This time the plug was a draft post. The queue tried to share it, the gate rejected it, the job started over, forever.
23 August: yayin kilitlenmesini kir — bas-tikanmasi + arama havuzu + itiraz hakki (#95). The same illness on the production side.
Three separate PRs, three separate places, one structural mistake: I had built a single-file queue and never considered that the job at its head could fail permanently. When we write retry logic we always picture the transient failure — the network dropped, a 503 came back, it will clear shortly. Permanent failure is a different animal: it never clears no matter how often you retry, and every retry holds the ones behind it hostage.
Now, when I design a queue, I ask myself one question: if the element at the head never succeeds, how long until I notice? If the answer is "I don't know," then what I built is a trap.
Provider rot: yesterday's call is dead today
In the first week of August, production fell over again. The cause was not elegant: the model identifier I was using had been removed on the provider's side. On 10 August, olu fallback zincirlerini onar + retry backoff + kosu-ici devre kesici (#80); on 11 August, olu Gemini model slug'i + kota israfi + sessiz alarm (#81); the same day, one more repair for that patch's regression (#82).
Then 21 August arrived and the same rot repeated on the search side: the 30-day cache of topic-research verdicts had been poisoned with junk results, hence zehirlenmis konu-arastirma verdiktlerini temizle (#90). Topics on the calendar had been unfairly stamped "rejected"; the next PR (#93) reversed those stamps one by one.
What I learned here was mental more than technical. When you depend on an external model, your system's behaviour can change while your code does not. No version number, no warning, and if you are not in the habit of reading changelogs, no news either. Your own repository's history is solid; the identity of the service you call is not.
The practical translation: record every external call together with its version, put a circuit breaker that makes noise when the whole chain dies, and never choose "silently return empty" over "raise an error."
A self-updating runner and a self-consuming alarm
July's pair is my favourite, because both were things I had built myself, and both had been written to protect me.
The pipeline runs on self-hosted runners on my own server, and they are ephemeral — a clean container per job. GitHub's documentation recommends this pattern, because "GitHub only assigns one job to a runner." But the same page carries a warning: if you use ephemeral runners in containers, you will hit repeated software updates whenever a new version ships. That is exactly what happened. The runner would start updating itself, the container would die when the job finished, the update would be left half-done, and the next container would fall over with Runner.Listener: No such file. 25 July: self-update crash loop — --disableupdate + aylık imaj tazeleme (#69).
The genuinely funny part is the sibling incident from earlier the same month. Let me state my evidence boundary plainly here: the trace for this second one is not in this repository's git record but in a monitoring script on the server, so unlike everything above I cannot pin it to a commit. The alert system I had built to notice a crashed runner embedded a constantly changing phrase — "X minutes ago" — inside the message. The hash I took to avoid resending the same message therefore changed every minute too. The result: one email per minute. The alarm meant to wake me up made itself invisible by flooding my inbox. The permanent fix for the mail flood only landed on 9 September, when I retired the old cron that triggered it (#97).
Prometheus's alerting guide sums the trap up in one sentence: "Aim to have as few alerts as possible, by alerting on symptoms that are associated with end-user pain rather than trying to catch every possible way that pain could be caused." I had alerted on causes — container state, process name, exit code. Yet the reader feels only one symptom: no new posts are arriving. I had described that distinction in theory in an earlier piece on symptom-based alert design; applying it to my own pipeline took another month.
A second language is a second surface area
The blog publishes in two languages: every Turkish post has an English counterpart. When I made that decision I thought, "translation is one more step, how bad can it be." The five PRs tagged fix(i18n) are the price of that sentence.
On 16 May, within a single day, I patched five separate corners of the same surface: English sharing was being skipped because the cover filename did not match the expected -en.png (#4); English covers had been generated with the Turkish title overlaid, so I regenerated all of them (#6); there were cover residues copied from Turkish and alt-text overrides being clobbered (#8); duplicate <figure> blocks and broken import lines had accumulated in English posts (#10); finally I added a three-layer English validation gate (#11). All five sit in the same day's record, and all five belong to the old repository's numbering.
The most expensive one surfaced two months later. The translatedFrom field in translation files did not carry the category prefix; it needed category/slug rather than just slug. The result is recorded in the commit message of 25 July: 32 English posts were issuing a 301 redirect to themselves — the page calling itself, the browser cutting the loop, the reader seeing a blank screen (#70). A follow-up opened the same day carried the canonical rule into the inline translation path as well (#71).
This family's lesson differs from the others. These were not race conditions or dead endpoints; they all came from the same truth being derived separately in two places. There were three different functions producing the slug and none of them knew about the others. If you compute one value in two places, the two will eventually diverge — the question is not "will they diverge" but "who notices when they do."
The answer is usually depressing: the reader notices. You don't.
The calendar didn't lie; I measured the wrong thing
Now for the most uncomfortable finding.
I scanned the publishDate field of every post and counted how many days between 31 March and 23 September had no publication at all. The answer: three days. 12 April, 26 April and 21 August. The first two are Sundays; the third is the poisoned-rejection day itself.
Three days. Ninety-eight per cent "success." A number worth putting on a dashboard and bragging about.
Yet the same git record documents at least six separate stoppages: on 7 May, pipeline durduran YAML cift-escape bug'i; on 28 May, a 7.5-hour window with no publication (#27); on 25 June, a preventive commit stating the blog had been down for roughly a day; on 9 July, a collapse in daily pace caused by article truncation; on 20 August, a gate-rejection deadlock (#86); on 23 August, a publishing deadlock (#95).
The gap between the two numbers comes from a flaw in what I measured. When the line stops, the catch-up run closes the deficit within the same day and publishDate shows the day as full. In other words, my metric was one that hides outages. The number was right; the question I asked was wrong.
After realising this I built two separate counters. The agent that does the writing consults the live site's feed.json for its daily lock — that is, not the system but the face the system shows the outside world. On 15 September I added a repository-side guarantee next to it: an hourly job counts the new-post commits that landed on main that day and emails me if the count passes four (#5, opened after the move). The two deliberately look from different places. Ask a single source and the system and its measurement will be wrong together.
And the last thing I learned: speeding up is not the goal
The monthly production curve runs like this: April 368, May 273, June 232, July 200, August 97, and 74 in the first 23 days of September.
Not all of that decline is failure. Most of it is decision. On 15 September I added a check capping the line at four posts per day (#5). Because that figure of 368 in April was not a success indicator; it was a receipt for quality debt. The bill came due on 30 June. In a run of commits that day, first a pilot of 8 posts, then four batches of 140, 136, 115 and 55 — 454 in total — went through a cleanup for "fake numbers, fake output, fake anecdotes." A second wave the same day touched 187 more. I paid for fast production by hand, two months after the fact.
The automation chapter of Google's SRE book wrote this years before I lived it: automation is a force multiplier, but it does not improve the accuracy of its application. Automate the wrong thing and you will do the wrong thing faster and more often. The second danger the same chapter warns about felt familiar too: when automation breaks, humans become unable to operate the system by hand. My version of it is a commit dated 15 June: docs(agents): deploy modelini gercege gore duzelt (systemd timer → deploy-only.yml) (#33). My own documentation had been describing the wrong publishing model.
A short checklist for whoever builds one
I can compress six months of invoices into four questions. If you are building a new autonomous pipeline, answer these before writing code:
- Where does state live, and who can write to it at the same time? If the answer is "a JSON file," add the lock and the atomic write on day one.
- What happens if the job at the head of the queue fails permanently? If everything behind it waits, you have built a trap, not a queue.
- Will you know if an external dependency's identity changes? Model name, endpoint, version — record all of them, and make noise when the chain runs out.
- Does your alarm watch the symptom or the cause? The reader feels exactly one symptom: nothing is coming out. Measure that.
None of this is clever; all of it is obvious once it has broken. That is automation's character: building it feels like being an architect, keeping it alive reminds you that you are a plumber.
There is a fifth question, but I ask it of myself rather than of you: what did I not automate? Two things. The first is pace — the fact that the line can produce as much as it likes does not mean it should; the four-posts-a-day ceiling is a deliberate brake, added after I saw the bill for April's 368. The second is the decision to publish: every post goes through an independent review pass after generation, and a "do not publish" verdict there can stop the line. That pass is also why the number in this piece's title is 67 rather than 48.
The agent has been writing for six months and is more consistent at it than I am. I repaired things 67 times, and each time I learned the same lesson a little more sharply: in automation the real product is not the output, it is the stretch of time the output never stopped. You do not learn that while building the line. You learn it while counting the moments it went quiet.
Official Sources
-
GitHub Actions — Events that trigger workflows — that the
scheduleevent can be delayed under heavy load, and that the shortest interval is five minutes. -
GitHub Actions — Workflow syntax:
concurrency— the pending run being cancelled by default, and preserving order with thequeueproperty. -
GitHub Docs — Self-hosted runners reference — the ephemeral runner pattern, disabling automatic updates with
--disableupdate, and the 30-day version obligation. - Google SRE Book — Automation at Google — automation as a force multiplier that does not improve accuracy, and the atrophy of manual operation when automation breaks.
- Prometheus — Alerting best practices — alerting on symptoms rather than causes, and keeping the number of alerts as low as possible.
Top comments (0)