Alright, let's settle this. Everyone's got opinions on CI/CD migrations, usually about "developer experience" or "ecosystem synergy" or whatever buzzword is floating around this week. I don't trust feelings. I trust milliseconds and dollars.
So when the directive came down from on high to move our 20-odd service pipelines from TeamCity to GitHub Actions, my team groaned. Another "modernization" project. I decided if we were going to waste engineering cycles on this, we were at least going to measure *everything*. Built a simple dashboard to track the only metrics that matter: build duration, queue time, and compute cost per pipeline, before and after the cutover.
The setup was straightforward, if tedious. A scrappy Postgres table to store timestamped pipeline run data, scraped from both platforms' APIs. A couple of views to normalize the data. Grafana on top. The real pain wasn't the dashboard—it was the translation.
Here's a taste of what "pipeline translation" actually meant. Taking a declarative, mature TeamCity configuration and turning it into a YAML soup of `actions/checkout@v4` and third-party actions that might break tomorrow.
**TeamCity (The sane way):**
```kotlin
steps {
script {
name = "Build and Test"
scriptContent = """
docker build -t myapp:${build.number} .
docker run --rm myapp:${build.number} pytest /app/tests
""".trimIndent()
}
dockerCommand {
name = "Push"
commandType = push {
namesAndTags = "myregistry.com/myapp:${build.number}"
}
}
}
```
**GitHub Actions (The YAML carnival):**
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and Test
run: |
docker build -t myapp:${{ github.run_number }} .
docker run --rm myapp:${{ github.run_number }} pytest /app/tests
- name: Push to Registry
uses: docker/build-push-action@v5
with:
push: true
tags: myregistry.com/myapp:${{ github.run_number }}
```
Looks simpler? Wait until you need to manage shared secrets across 20 repos, or implement a coherent rollback strategy, or debug why your job is stuck in queue for 10 minutes because the `ubuntu-latest` runner is oversubscribed.
The results after a month of running both systems in parallel? Inconclusive, which is its own kind of result. For 14 of the 20 services, the average build time was within +/- 5%. Three got slower (mostly due to colder caches and longer queue times on the GitHub runners). Three got faster (lightweight Node.js services that benefited from the simpler setup). The real cost delta was in the ops overhead—managing our own TeamCity agents versus paying for GitHub's minutes.
The moral of the story isn't that one platform is better. It's that a migration like this is rarely about performance. It's about consolidating the vendor list, or following the herd, or making a manager's slide deck look good. If you're going to do it, at least measure the actual impact. Otherwise, you're just trading one set of quirks for another, newer, shinier set.
Dashboard snippet for the curious: simple table with service name, 7-day avg build time (old), 7-day avg build time (new), delta, and delta percentage. The most dramatic change was a 12% increase for our monolithic API service. Took us a week to tune the caching strategy to get it back to baseline.
-- old salt
Exactly, this is the core problem everyone glosses over. The translation cost from a mature system to a YAML-based workflow is enormous and never reflected in those "5% faster build" benchmarks. You're not just moving configs, you're rebuilding institutional knowledge as code.
Your example is about to show a clean, versioned Kotlin DSL, and the GHA equivalent will be 50 lines of brittle steps. Did you track the metric that matters most: pipeline config maintenance time? My team found that for complex pipelines, GHA increased the time to implement a change by a factor of three, because you're debugging opaque action behaviors instead of logic you own.
The dashboard is good, but I'd add a layer for tracking infrastructure drift. With TeamCity, you control the agents. With GitHub, you're at the mercy of their runner images and network. I've seen builds break because a default Python version changed on `ubuntu-latest` overnight.
—davidr
Yep, the hidden maintenance cost is real. But you're still missing the biggest line item: the bill.
> With TeamCity, you control the agents. With GitHub, you're at the mercy...
You're at the mercy of their pricing, too. Self-hosted runners are free, but now that's your hardware and ops. The managed runners? The minute you need a bigger machine or longer runtime, the cost balloons. Those "free" minutes disappear fast. Did your team factor that "infrastructure drift" into actual dollars, or just stability?
You're right to point out the bill, but it's more predictable than you think if you treat it like any other vendor contract. The real trap isn't the cost ballooning, it's the auto-scaling. You set up a matrix build or accidentally trigger a workflow on a branch push, and you've burned through a month of minutes in an afternoon because the platform happily lets you.
Your procurement team should be negotiating commit tiers with upfront pricing, not paying as-you-go. Otherwise, you're right, you're just trading hardware ops for financial surprise ops.
Show me the data
> I trust milliseconds and dollars.
Good. That dashboard is the only way to prove the ROI, or lack of it. Everyone else just argues in the abstract.
What was the data pattern? For us, queue time dropped to near zero with GHA's scale, but average build duration increased by 15% due to cold starts and less predictable agent hardware. The cost per build looked better on paper until we added the engineering months spent rewriting and debugging those YAML workflows.
Did you see a clear winner, or just a different set of trade-offs?