# I Let an AI Re-Platform My CI Pipeline. Here's What Broke.

> Source: <https://dev.to/tomaszwostal/i-let-an-ai-re-platform-my-ci-pipeline-heres-what-broke-26i8>
> Published: 2026-08-02 18:32:45+00:00

*Originally published at wostal.eu.*

TL;DR: I handed a CI re-platform — GitHub Actions → Argo Workflows, GitHub → Codeberg — to an AI agent. The rewrite looked correct and passed lint, but it didn't account for one thing: the runtime environment had changed completely. The old runner was an external VM; the new one is a pod inside the cluster, where Tailscale IPs aren't routable. Nobody ran the pipeline end-to-end, so every difference stayed as a landmine — six of them. The worst part wasn't the AI's mistake. It was that I spent hours blaming the network when the real culprit was a single missing newline in an SSH key.

This is the companion to a different incident. While I was migrating my `homelab`

k3s control plane [from SQLite to etcd](https://wostal.eu/blog/homelab-grows-up-sqlite-to-etcd/), the pipeline that *ran* that migration turned out to be broken in ways that taught me more than the migration itself.

In this post I'll cover:

The entire CI had just been migrated from **GitHub Actions to Argo Workflows**, and from **GitHub to Codeberg** — and that migration was handed to an AI agent 🙂.

The re-platforming rewrote the logic roughly 1:1, but **it didn't catch that the runtime environment had changed fundamentally**. The old runner (GitHub Actions / ARC) behaved one way; the new one is a **pod in the cluster** — from which Tailscale IPs aren't routable, but private IPs are. Nobody (human or AI) ran the pipeline end-to-end, so all of those differences stayed as landmines.

That's why there were **six latent bugs** and the hang I'm about to describe — not random faults, but **traces of a GHA→Argo translation that was never validated in the new environment.**

After the migration, it turned out the deploy pipeline (CI running in the cluster) had never passed end-to-end — `ansible-configure`

was **hanging on gather_facts**. I spent hours on it and

`hostNetwork`

pod hung too.Every one of those tests pointed at the network. Every one of them was lying to me.

Each test was **contaminated by a single bug**: in the test pods, I mounted the SSH key with `cp`

from a secret — **without a trailing newline**. That produces `error in libcrypto`

→ the key never loads → `ssh`

behaves randomly (sometimes a hang, sometimes "permission denied"). I was chasing phantoms, because **the test rig was broken, not the network.**

The breakthrough only came when I did it **properly**: `tcpdump`

+ `conntrack`

on the node, and the key written **exactly as CI writes it** (`printf '%s\n'`

). Then the picture was clean and consistent: from a pod, **the private IP (10.0.1.x) works** (ssh + sftp + full `ansible -m setup`

), while the **Tailscale IP (100.x) hangs** — because the pod has no route into the tailnet. The fix: CI uses the private IP (the firewall doesn't filter the private network anyway). It worked instantly.

Lesson #1: before you blame the network/CNI/MTU, prove that your test rig isn't broken.One missing newline in a key cost me hours chasing a non-existent CNI problem. Reproduceidentically to production(here: exactly how CI writes the key), and for "packets vanish midway" use`tcpdump`

/`conntrack`

, not guesses. "Works from my laptop, not from a pod" isn't always the network — sometimes it's a test-rig artifact.

Here's the second trap. The deploy pipeline never reached apply+ansible, so each subsequent bug only surfaced once the previous one started working. There were **six** in total:

`openssh`

in the runner image`python3`

(ansible needs it on the control node)`duration:0`

that didn't actually gate anything`semantic-release`

Lesson #2: a pipeline that fails early masks every downstream bug.You fix one, run again, and the next one — previously unreachable — finally executes and fails. The first green end-to-end run is the moment everything hidden surfaces at once. Plan for that run; don't assume "it lints, so it works."

Lesson #3: re-platforming CI (GHA→Argo, GitHub→Codeberg) requires an end-to-end run — whether a human or an AI did it.A migration rewritten "1:1" looks correct and passes lint, but it loses theruntime-environment differences: an external runner vs an in-cluster pod, the GitHub API vs Forgejo. Those differences won't show up in any static review — only in one full pass. If you delegate such a migration (to an AI agent 🙂), plan asmoke-run of the whole pathimmediately, or you'll discover the delta months later, at 3am.

This isn't an argument against using AI for migrations. The rewrite *was* mostly correct, and it saved real time. It's an argument about where the human stays in the loop: not in re-typing the YAML, but in **validating the thing actually runs in its new home**. The AI translated the language; it couldn't translate the environment, because it never executed there. Neither did I — until it was breaking.

After the fixes, the pipeline runs the way it should: merge to main → sensor → deploy pipeline → `plan → [approve: suspend] → apply → ansible → release`

, with a manual gate (`argo resume`

= "after approval"). I validated it on a live merge — it cut release `v1.4.1`

cleanly. Control plane on etcd, DR (local + S3), config via GitOps with a human gate in the middle.

Two incidents, one weekend. One taught me that defaults become landmines when a lab grows up. This one taught me that a migration isn't done until something has actually run end-to-end in its new environment — no matter how confident the thing that wrote it.
