We had a 1,600-line GitHub Actions workflow. Twelve jobs, each one a snowflake of copy-pasted boilerplate. Every job that built a Docker image started with the same 75 lines of ceremony — checkout, VPN, DNS config, buildx setup, ccache, ACR login — before getting to the 15 lines that actually did something.
This week Masatoshi refactored it into ~600 lines plus three reusable workflows. Here’s what the refactoring actually looked like, and why the line count isn’t even the interesting part.
The anatomy of a build job
Our build_yolo_base_and_rocket_containers.yml workflow builds Docker images, copies them across multiple Azure Container Registries, and creates multi-arch manifests. It runs on every push to main and on PRs that touch image-related files.
Here’s what a typical build job looked like before. I’ll annotate the approximate line counts:
build_yolo_image_falcon:
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
environment: falcon-acr
strategy:
matrix:
include:
- arch: amd64
runner: yolo-ubuntu-2404-x64-64c.256g.2040g
- arch: arm64
runner: yolo-ubuntu-2404-arm64-64c.256g.2040g
steps:
- uses: actions/checkout@v5 # ~5 lines
- name: Connect to Tailscale # ~10 lines
- name: Force Docker DNS for tailnet # ~6 lines
- name: Set up Docker Buildx # ~15 lines
- name: Get cache key # ~3 lines
- name: Cache ccache data # ~8 lines
- name: Inject caches into Docker # ~8 lines
- name: Corp docker login # ~6 lines
- name: AZ CLI login # ~5 lines
- name: Install ACR regional endpoints # ~8 lines
- name: Build and push # ~15 lines
Ninety lines. Fifteen of them are the actual build command. The other 75 are infrastructure plumbing that’s identical across every build job — checkout, network setup, cache wiring, auth. Copy this 12 times across the workflow, and you’ve got about 900 lines of pure duplication.
The image copy jobs had their own flavor:
upload_image_manifold:
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Set up Docker Buildx
- name: Login to Falcon ACR (source) # ~5 lines
- name: Install ACR regional endpoints # ~8 lines
- name: Authenticate to Falcon ACR # ~3 lines
- name: Login to Manifold ACR (target) # ~5 lines
- name: Authenticate to Manifold ACR regional # ~5 lines
- name: Copy image # ~8 lines
Forty lines. Eight are the copy command. The rest is auth and setup, duplicated per destination registry.
And the multi-arch merge jobs:
merge_image_falcon_multiarch:
steps:
- name: Set up Docker Buildx # ~15 lines
- name: AZ CLI login # ~5 lines
- name: Install ACR regional endpoints # ~8 lines
- name: Create and push multi-arch manifest # ~10 lines
Same pattern. Setup, auth, then the one thing the job actually does.
The copy-paste tax
This isn’t just ugly. It’s operationally expensive.
A few weeks ago we needed to add ACR regional endpoints to our registry authentication — a change to how az acr login works. That meant touching every single job that logs into an ACR. In a 1,600-line file with 12 jobs, that’s a lot of places to find-and-replace. Miss one, and a specific registry push fails in CI at 3am.
The failure mode is subtle. Everything looks consistent because it’s copy-pasted, so code review eyes glaze over. “Yeah, it’s the same boilerplate, LGTM.” But line 847 has a slightly different --subscription-id than line 1203 because someone edited one instance and not the other three months ago. You don’t catch that in review. You catch it when Manifold uploads start failing on Tuesdays.
This is the real cost of copy-paste in CI workflows. Not the aesthetics — the drift.
Three abstractions
Masatoshi identified three recurring patterns and extracted each into a reusable workflow:
1. bake_image.yml — wraps docker buildx bake with all the ceremony. Checkout, Tailscale VPN, Docker DNS, buildx, ccache, ACR login, build/push. You pass in the bake target and registry details, it handles the rest.
2. copy_image_to_acr.yml — wraps cross-ACR image copies. Handles flexible auth — OIDC or username/password — for both source and target registries. The caller says “copy this image from registry A to registry B” without worrying about the auth dance.
3. create_multiarch_image.yml — wraps multi-arch manifest creation using docker buildx imagetools create. Takes a list of architecture-specific tags and produces a unified manifest.
These aren’t generic “do anything” abstractions. They’re tight wrappers around the three things this workflow actually does: build images, copy images between registries, and merge architecture manifests. The abstraction boundary matches the operation boundary. That’s the whole trick.
The after
Here’s what a build job looks like now:
build_yolo_infinity:
needs: [compute_vars, compute_hash, find_changed]
uses: ./.github/workflows/bake_image.yml
with:
targets: pretraining
git-sha: ${{ needs.compute_vars.outputs.git_sha }}
cache-image: yolo/pretraining/cache
enable-tailscale: true
should-run: ${{ format('{0}', needs.find_changed.outputs.mai_job_x86_changed == 'true' || ...) }}
secrets:
ACR_PUSHER_PASSWORD: ${{ secrets.ACR_PUSHER_PASSWORD }}
Fifteen lines. Entirely declarative. “Build this target, with this tag, using these secrets.” The 75 lines of checkout-VPN-buildx-ccache-auth are inside bake_image.yml, maintained in exactly one place.
Copy jobs:
copy_yolo_to_mango:
needs: [compute_vars, compute_hash, build_yolo_infinity, find_changed]
uses: ./.github/workflows/copy_image_to_acr.yml
with:
image_name: yolo/pretraining
source_acr: inf5acr
source_acr_username: ${{ vars.ACR_PUSHER_USERNAME }}
source_tag: ${{ needs.compute_vars.outputs.git_sha }}
target_acr: ${{ vars.MANGO_ACR }}
target_client_id: ${{ vars.MANGO_CLIENT_ID }}
target_tenant_id: ${{ vars.MANGO_TENANT }}
target_subscription_id: ${{ vars.MANGO_SUBSCRIPTION_ID }}
environment: mai-mango
secrets:
SOURCE_ACR_PASSWORD: ${{ secrets.ACR_PUSHER_PASSWORD }}
Multi-arch merge:
create_yolo_multiarch_falcon:
needs: [compute_vars, copy_yolo_to_falcon_amd64, build_yolo_falcon_arm64, find_changed]
uses: ./.github/workflows/create_multiarch_image.yml
with:
environment: falcon-acr
registry-name: ${{ vars.FALCON_ACR }}
registry-region: ${{ vars.FALCON_ACR_PRIMARY_REGION }}
image-name: yolo/pretraining
source-tags: >-
${{ needs.compute_vars.outputs.git_sha }}-amd64
${{ needs.compute_vars.outputs.git_sha }}-arm64
target-tags: >-
${{ needs.compute_vars.outputs.git_sha }}
yolo-${{ needs.compute_hash.outputs.yolo_hash }}
azure-client-id: ${{ vars.FALCON_CLIENT_ID }}
azure-tenant-id: ${{ vars.FALCON_TENANT }}
azure-subscription-id: ${{ vars.FALCON_SUBSCRIPTION_ID }}
Every job is now a ~15 line uses: call. The main workflow reads like a dependency graph — which is what a CI workflow should look like.
The flow change
The refactoring also changed the build architecture. Before, images were built independently at each destination registry. After, images build once at the primary registry and get copied everywhere else.
Before:
┌─ build at inf5acr (90 lines)
│ └─→ upload to mango (40 lines)
│
PR push ──────┤ build at falcon, amd64 (90 lines) ──┐
│ build at falcon, arm64 (90 lines) ──┤─→ merge multiarch (50 lines)
│ │
│ copy to manifold, amd64 (60 lines) ─┐
│ copy to manifold, arm64 (60 lines) ─┤─→ merge multiarch (50 lines)
After:
┌─ build at inf5acr (15 lines)
│ ├─→ copy to mango (15 lines)
│ ├─→ copy to falcon, amd64 (15 lines)
│ └─→ copy to manifold, amd64 (15 lines)
PR push ──────┤
│ build at falcon, arm64 (15 lines)
│ └─→ copy to manifold, arm64 (15 lines)
│
│ merge multiarch falcon (15 lines)
│ merge multiarch manifold (15 lines)
Build once, copy many. Instead of running docker buildx bake three times for the same image at three registries, you run it once and docker pull/push to distribute. Pull/push is cheap. Builds are expensive.
This is a subtle but meaningful architectural change hidden inside a “just refactoring” PR.
The naming cleanup
Something that falls out naturally when you extract reusable workflows: you have to name things.
The old naming was inconsistent in that way organic codebases always are:
| Old Name | Pattern |
|---|---|
upload_image_to_mango |
upload_image_to_{dest} |
upload_image_manifold |
upload_image_{dest} (no “to”) |
build_yolo_image_falcon |
build_yolo_image_{dest} |
merge_image_falcon_multiarch |
merge_image_{dest}_multiarch |
The new naming follows a consistent {verb}_{product}_{preposition}_{dest}_{arch} pattern:
| New Name | Pattern |
|---|---|
copy_yolo_to_mango |
copy_{what}_to_{dest} |
copy_yolo_to_falcon_amd64 |
copy_{what}_to_{dest}_{arch} |
build_yolo_falcon_arm64 |
build_{what}_{dest}_{arch} |
create_yolo_multiarch_falcon |
create_{what}_multiarch_{dest} |
Not revolutionary. But when you’re staring at a GitHub Actions run with 15 jobs in the sidebar, copy_yolo_to_falcon_amd64 tells you exactly what’s happening. upload_image_manifold makes you think.
Hardcoded GUIDs begone
The old workflow had a top-level env block that read like an Azure service principal directory:
env:
REGISTRY_NAME: "inf5acr"
ACR_PUSHER_USERNAME: "6134e469-de37-43ab-9bf1-04c3376ba36c"
MANGO_CLIENT_ID: "376c348c-3590-4189-a62d-9669ddbb0d1f"
MANGO_SUBSCRIPTION_ID: "4d641501-dbb3-44b9-97e3-946e5cd957c2"
FALCON_CLIENT_ID: "1cb1d607-fa46-4bf6-be4d-c92c7b0e754a"
FALCON_SUBSCRIPTION_ID: "2dec9943-5db1-4aa9-9b4d-5f5fe2e8465e"
# ... 12 more lines of GUIDs
Hardcoded credentials in a YAML file that’s visible to every engineer with repo access. Not secrets (they’re client IDs, not passwords), but still the kind of thing that makes security reviewers twitch.
The refactored version uses GitHub organization variables — vars.MANGO_CLIENT_ID, vars.FALCON_ACR, etc. Same values, but managed through GitHub’s settings UI instead of living in source control. You can rotate a client ID without a PR. You can restrict who sees them. And your 1,600-line YAML file stops looking like a leaked Azure subscription dump.
The GitHub Actions boolean trap
Here’s a gotcha that only surfaces when you extract reusable workflows.
Each job has a should-run parameter — a conditional that determines whether to actually execute or skip. In the old inline version, this was just an if: expression on the job:
if: github.event_name == 'workflow_dispatch' || needs.find_changed.outputs.something == 'true'
Easy. It’s a boolean expression, GitHub evaluates it, the job runs or doesn’t.
In a reusable workflow, you’d expect to pass this as type: boolean:
on:
workflow_call:
inputs:
should-run:
type: boolean
Doesn’t work. GitHub Actions has a known limitation where boolean inputs to reusable workflows get coerced in surprising ways. The string "false" can become true because it’s a non-empty string. Or the boolean false gets stringified and then re-parsed differently depending on context.
The fix is to declare it as type: string and wrap the caller side in format('{0}', ...):
# Caller
with:
should-run: ${{ format('{0}', needs.find_changed.outputs.changed == 'true') }}
# Reusable workflow
inputs:
should-run:
type: string
default: 'true'
Then inside the reusable workflow, check inputs.should-run == 'true' as a string comparison.
This is one of those “you’ll only discover this at 11pm when CI breaks” things. String-typed booleans feel wrong, but they’re the reliable option. GitHub’s own documentation acknowledges the limitation without quite admitting it’s a bug.
The numbers
| Before | After | |
|---|---|---|
| Main workflow | ~1,600 lines | ~600 lines |
| Reusable workflows | 1 file | 3 files (~400 lines total) |
| Net lines | ~1,600 | ~1,000 |
| Files changed | 4 | 4 |
| Unique logic per job | ~15 lines buried in ~90 | ~15 lines, that’s the whole job |
| Places to update ACR auth | ~12 | 3 (one per reusable workflow) |
The net reduction is about 600 lines — not as dramatic as “1,600 to 600” sounds, because the reusable workflows have to exist somewhere. But the main workflow dropped by 63%, and more importantly, the ratio of signal to boilerplate inverted. Before, each job was 83% plumbing and 17% intent. After, each job is 100% intent — the plumbing lives behind the uses: call.
Why this matters beyond aesthetics
A week before this PR, I was adding ACR regional endpoint support. I had to edit 8 different spots in this file to add the same az acr login --suffix argument. Missed one initially. Caught it in CI when the Manifold upload failed.
With the refactored version, that change is a one-line edit inside copy_image_to_acr.yml. Test it once, it’s correct everywhere.
This is the maintenance story. CI workflows grow by accretion — someone adds a job, copies an existing one, tweaks the target. Six months later you have 12 jobs that are 90% identical but differ in ways nobody can articulate without diffing them character by character. The refactoring isn’t about making the file shorter. It’s about making “change how ACR auth works” a 1-line change instead of a 12-line archaeological expedition.
The secondary wins are real too. Build-once-copy-many saves compute time. Consistent naming saves cognitive load. Org variables instead of hardcoded GUIDs saves security conversations. But the core value is simple: when the next infrastructure change comes — and it will — the blast radius is one file instead of twelve places in one very long file.
That’s what CI workflow refactoring actually looks like. Not a framework. Not an abstraction library. Three focused reusable workflows, consistent naming, and the discipline to not copy-paste.