T-Shirt Sized GitHub Actions Runners — Fixing runs-on Sprawl

T-Shirt Sized GitHub Actions Runners: A Method for Fixing runs-on Sprawl

Mandy Singh

Mandy Singh

July 23, 2026 · // 8 min read

Open ten repositories in a mid-size engineering org and look at the runs-on: lines in their workflow files. You’ll find ubuntu-latest next to self-hosted-large next to gpu-builder-v2 next to something a departed engineer typed once and nobody has touched since. No two repos size a lint job the same way. Nobody can tell finance which team is burning runner minutes on what. The fleet can’t be capacity-planned because it isn’t a fleet, it’s a pile of one-off decisions made by whoever wrote the workflow that week.

This is the runner sprawl problem, and it has a known fix: t-shirt sizing.

What is t-shirt sized runner allocation?

T-shirt sized runner allocation means mapping every runs-on: value in a GitHub Actions workflow to one of a small, fixed set of size labels, typically runner-s, runner-m, runner-l, and runner-xl, each tied to a known instance type. A lint job runs on runner-s. An end-to-end suite runs on runner-xl. The label tells you the workload class before you read a single line of the job itself.

The naming convention is borrowed from clothing sizes for the same reason it works in retail: four buckets are easy to reason about, easy to autoscale against, and easy to bill. Once every job carries a size label, the platform team can autoscale capacity per size instead of guessing at aggregate demand, and finance can attribute spend to the size tier instead of an opaque instance ID.

Why runs-on labels turn into a junk drawer

Nobody sets out to create runner sprawl. It accumulates one workflow at a time. An engineer copies a working CI file from another repo and inherits whatever label was in it. Someone provisions a bigger self-hosted runner for a slow build and names it after the problem it solved that day, self-hosted-builder, big-box-2. A GPU job gets added for a one-off benchmark and the label sticks around after the benchmark is deleted.

None of these decisions are wrong in isolation. The cost shows up in aggregate. Without a shared sizing convention, the platform team can’t answer basic questions: how much compute goes to test jobs versus build jobs, which teams are over-provisioned, where the fleet needs to grow next quarter. Every answer requires someone to manually audit hundreds of workflow files across repos they don’t own, then chase every workflow author to confirm what a label like gpu-builder-v2 actually still needs. The audit alone eats a sprint before a single fix ships.

The sizing method: job-class mapping

The fix is a repeatable method: map job class to runner size, not job name to runner size.

Instead of asking “what does this specific job need,” ask “what class of work is this job doing,” and let the class determine the size. Four classes cover most CI workloads:

Lint, formatting, typecheck, and doc-build jobs are lightweight and short. They map to runner-s. Unit tests and light integration tests need more memory and time but nothing exotic. They map to runner-m. Image builds using docker buildx, heavier integration suites, and monorepo builds need real compute. They map to runner-l. End-to-end test suites and multi-arch image builds are the heaviest, longest-running jobs in most pipelines. They map to runner-xl.

Two rules keep the method from over-reaching. First, specialized runners stay specialized: GPU labels, Windows labels, macOS labels, and ARM labels are left alone unless a team explicitly opts a workload into the standard sizing scheme. Second, any workflow author can pin a job out of the migration entirely with a # runner-pin comment directly above the line that needs to keep its current label. The method standardizes the common case without breaking the exceptions.

A copy-paste sizing rulebook you can run today

A sizing rulebook only works if it’s specific enough to apply without a meeting. Here’s a starting rulebook a platform team can adapt and use directly:

Sizing rules (customize before running):
- Lint, formatting, typecheck, doc builds → runner-s
- Unit tests, light integration tests → runner-m
- Image builds (docker buildx), heavy integration tests, monorepo builds → runner-l
- End-to-end test suites, multi-arch image builds → runner-xl

Mapping heuristics for ambiguous cases:
- ubuntu-latest on a lint job → runner-s
- self-hosted-large / self-hosted-builder on an image job → runner-l
- Anything ambiguous → runner-m (the safe default)

Leave alone: gpu-*, windows-*, macos-*, ARM-specific labels, and anything
preceded by a # runner-pin comment.

Before-and-after, the change looks like this in a single workflow file:

 jobs:
   lint:
-    runs-on: ubuntu-latest
+    runs-on: runner-s
     steps: [...]
   build:
-    runs-on: self-hosted-large
+    runs-on: runner-l
     steps: [...]

Two details matter beyond the top-level jobs. Matrix builds set runner labels through strategy.matrix.runner or similar keys, and those entries need the same mapping applied. Reusable workflows are trickier: if a caller sets runs-on: through an input, the input wiring stays untouched and only the default value gets updated, so callers that already override the runner keep working exactly as they did.

What standardizing costs if you do it by hand

The sizing rulebook above takes an afternoon to write. Applying it is the expensive part. Every .github/workflows/*.yml file in every repository needs to be found, read, and mapped, including top-level runs-on: keys, matrix entries, and reusable workflow overrides. A platform engineer doing this manually across a few hundred repos is looking at weeks of repetitive, error-prone work: open a repo, grep for runs-on:, decide the mapping, open a PR, repeat. Miss a matrix entry or a reusable workflow default and the fleet stays partially sized, which means the cost attribution and capacity planning benefits never fully materialize.

This is the same pattern that shows up across most configuration standardization work. The rule is easy. The rollout across hundreds of repositories is where the effort actually goes.

Where Tidra fits: executing the migration across every repo

The sizing rules are defined. The mapping logic is clear. The only missing piece is applying it consistently across every workflow file in every repository without a platform engineer doing it by hand.

Tidra takes the rulebook above as a natural-language initiative and runs it across the org. It scans .github/workflows/*.yml in every targeted repo for runs-on: references, including matrix entries and reusable workflow overrides, and maps each one to the correct size using the rules provided. It leaves GPU, Windows, macOS, and ARM-specific runners untouched unless a repo explicitly opts in, and it honors every # runner-pin comment. For each repo, Tidra opens one PR with a before-and-after sizing table, so the review is a five-minute diff check instead of a manual audit. Nothing merges until the team reviews and approves it.

What this is worth to a platform team

A standardized runner fleet turns a guessing game into a budget line. Once every job carries a size label, the platform team can forecast capacity by size tier instead of by anecdote, and finance can see exactly what each size tier costs across the org. The alternative is staying in the current state: a junk drawer of labels that nobody can plan against, reviewed one workflow at a time, forever behind whatever the fleet actually needs next quarter.

Run this migration across your repos: tidra.ai/initiatives/tshirt-sized-gha-runners

Frequently asked questions

What are t-shirt sized GitHub Actions runners? T-shirt sized runners are a small, fixed set of runner labels (typically runner-s, runner-m, runner-l, runner-xl) that replace ad hoc runs-on: values like ubuntu-latest or self-hosted-large, each tied to a known instance type for consistent capacity planning and cost attribution.

How do I decide which size a CI job should use? Map by job class, not by job name. Lint, formatting, and doc jobs map to runner-s. Unit and light integration tests map to runner-m. Image builds and heavy integration tests map to runner-l. End-to-end and multi-arch builds map to runner-xl. Ambiguous jobs default to runner-m.

Do GPU, Windows, and macOS runners need to be resized too? No. Specialized runners (GPU, Windows, macOS, ARM) are left on their existing labels unless a team explicitly opts them into the standard sizing scheme.

How do I exclude a specific job from the runner size migration? Add a # runner-pin comment directly above the runs-on: line that needs to keep its current label. Any migration tool or script should respect that comment and leave the line unmodified.

What’s the hardest part of migrating to t-shirt sized runners? Not the sizing decision, the rollout. Every workflow file across every repository needs to be found and updated, including matrix entries and reusable workflow defaults, which makes manual migration slow at any real scale.