I spent a few weeks converting seven Go microservice repos to Bazel. Not one at a time, like a sane person. All at once, in parallel, bouncing between them like a browser with 40 tabs open.
Here’s what I learned. Some of it is about Bazel. Some of it is about CI. Some of it is about how my brain works.
Pick the right repos first
You’re staring at a spreadsheet of dozens of service repos. Some have been partially Bazelized. Some haven’t been touched. Some run CI 50 times a week. Some run it twice a month. You can’t do them all, and even if you could, you shouldn’t start randomly.
The prioritization criteria that actually worked:
- P90 CI time + CI frequency. A repo with 30-minute CI that runs 40 times a week is burning 20 hours of compute weekly. That’s your biggest win. A repo with 5-minute CI that runs twice a month? Not urgent.
- Has someone already Bazelized the codebase? If a teammate landed a Bazel MR that adds BUILD files and Gazelle config but nobody converted the CI script yet, that’s low-hanging fruit. The hard part is done. You’re just wiring it up.
- Does it impact many users? High-MAU services mean more people waiting on CI. More people annoyed. More motivation to get this right.
- Is there a successful example to mimic? This one is underrated. If two repos in your org already have working Bazel CI scripts, you can copy the pattern. If zero do, you’re inventing the pattern. Start with repos where you can copy.
I ended up sorting by “already Bazelized + has an SCM example to copy from” first, then by CI bottleneck severity. The first repo I converted took a day. The next three took a few hours each. Pattern recognition compounds fast.
The four-step CI conversion
Every repo followed the same pattern. Once I saw it, the whole batch became mechanical.
Step 1: Negotiate with repo owners. You’re changing their build. They need to know. This is the boring human step, but skip it and you’ll get your MR rejected by someone who’s never heard of Bazel and doesn’t appreciate surprise build system changes.
Step 2: Create a parallel build script. Don’t touch the existing build.sh. Create build.bazel.sh next to it. Same directory, different file. This means the old CI still works while you’re iterating on the new one. No risk. No “I broke prod because I was experimenting.”
Step 3: Configure CI to use the new script. In most CI systems, this is a config change — point the build job at build.bazel.sh instead of build.sh. Ideally you can A/B test: run both scripts, compare outputs, gain confidence before switching.
Step 4: Write the actual build script. This is where the real work lives. The pattern that emerged across all seven repos:
#!/bin/bash
# 1. Install bazelisk (if not present)
if [[ $(which bazel) == "" ]]; then
curl -s "https://your-internal-tools/install.sh" | bash -s -- "bazel/tool/bazelisk"
fi
# 2. Update Go dependency repos for Bazel
bazel run //:gazelle-update-go-repos || { echo "gazelle-update-go-repos failed"; exit 1; }
# 3. Regenerate BUILD files from source
bazel run //:gazelle || { echo "gazelle failed"; exit 1; }
# 4. Build the target
bazel build //:my_service || { echo "build failed"; exit 1; }
# 5. Copy binary to output directory
mkdir -p output/bin
cp -rf bazel-bin/my_service_/my_service output/bin/${RUN_NAME}
That’s it. Five commands. Install the tool, sync deps, generate build files, build, copy binary. Every repo is a variation on this skeleton. The variables change — target name, output path, whether you need coverage tooling — but the shape is identical.
Finding the right target name? bazel query 'kind(go_binary, //...)' tells you what go_binary targets exist in the repo. The binary output path follows a predictable pattern: bazel-bin/<target>_/<target>.
The coverage tool gotcha
Some services needed code coverage instrumentation as part of CI. The existing build.sh scripts ran a Go coverage annotation tool that modifies source files in-place — injecting coverage counters into every function and branch.
Sounds simple. In practice, it’s a landmine.
The coverage tool runs go commands internally. In a Bazel build, the Go SDK lives inside Bazel’s output base, not in your system PATH. If the coverage tool picks up the system Go (say, 1.16) while Bazel is using its own managed Go SDK (say, 1.21), you get version mismatches, incompatible generated code, and builds that fail with cryptic errors about import paths.
The fix: use --run_under to prepend Bazel’s Go SDK to the PATH before the coverage tool runs.
WORKSPACE_PATH=$(bazel info workspace)
GO_SDK_PATH=$(bazel info output_base)/external/go_sdk/bin
bazel run --run_under="PATH=${GO_SDK_PATH}:$PATH " \
//:go_coverage -- annotate --work-path ${WORKSPACE_PATH}
bazel info output_base gives you the root of Bazel’s managed external dependencies. The Go SDK lives at external/go_sdk/bin under that. By putting it first in PATH, the coverage tool uses the same Go binary that Bazel uses. No version mismatch.
The other subtlety: the annotation tool modifies .go files in-place. It literally injects coverage counter variables into your source code. After it runs, git diff shows every .go file as modified. This means:
- You need to run
gazelle-update-go-reposandgazelleafter the annotation, because the modified source may have new imports. - You need to handle the dirty git state — either commit the changes,
git checkout .to revert them later, or accept that CI runs on a dirty tree. - If the annotation fails partway through, you have a half-annotated codebase that won’t build in either mode. Fun.
Why it works locally but fails in CI
This came up on almost every repo. Build passes on my machine, fails in CI. Every time, it was one of these:
Toolchain version mismatch. My laptop has Go 1.21. The CI image has Go 1.16. A dependency uses a feature introduced in 1.18. Works locally, breaks in CI. The fix is either pinning the CI image or — better — letting Bazel manage the Go SDK entirely so the system Go doesn’t matter.
Missing or stale lock files. go.sum on my machine includes everything because I’ve run go mod tidy recently. The go.sum in the repo is three months old and missing entries for indirect dependencies that got added since. CI does a fresh checkout, runs go build, and fails because go.sum doesn’t match go.mod. The fix: always commit go.sum after running go mod tidy, and make CI fail loudly if they’re out of sync.
Network access differences. My laptop can reach the public Go module proxy. CI runs in a restricted network that can only reach the internal proxy. A dependency resolves differently — or not at all — through the internal proxy. The fix: configure GOPROXY and GONOSUMCHECK explicitly in the build script, don’t rely on defaults.
Cache differences. I’ve been building this repo for weeks. My local Bazel cache has everything warm. CI starts cold every time (or uses a shared cache that may or may not have the right entries). A build that takes 30 seconds locally takes 10 minutes in CI — and sometimes the cache miss reveals a dependency that’s been silently satisfied by a stale cache entry.
latest semantics. The old build script runs go get tool@latest. On Monday that pulls v1.2.3. On Wednesday it pulls v1.3.0, which has a breaking change. The build is non-deterministic by design. Bazel’s whole philosophy is eliminating this — pin everything, hash everything, never use latest.
The debugging checklist I wish I had on day one:
- What Go version is CI using? (
go version) - Is
go.sumup to date? (go mod tidy && git diff go.sum) - Can CI reach the module proxy? (
curl -s $GOPROXY/module/@v/list) - Is the build using any
latestor floating version? (grep -r "latest" build.sh) - What’s in the Bazel cache? (
bazel info output_baseto check, orbazel cleanto eliminate it as a variable)
The ADHD tax on batch work
Here’s the part that’s not about Bazel.
Seven repos. Each one needs: clone, branch, write build script, test locally, test in CI, debug failures, iterate, submit MR. Multiply by seven. That’s 40+ distinct tasks across seven different contexts.
My brain does not handle this well.
I’d start on repo A, hit a CI failure, context-switch to repo B while waiting, get deep into repo B’s coverage tool issue, forget where I was on repo A, open repo C to “make progress,” realize C needs the same fix as A, go back to A, lose track of C.
The temptation — and I fell for it, repeatedly — was to stop doing the manual work and start building automation. “I should write a script that batch-clones all repos and creates branches.” “I should build a template for the build script so I can just fill in variables.” “I should create a dashboard that tracks which repos are at which stage.”
Every one of those ideas is technically correct and practically a trap. The automation takes two hours to build, saves maybe 30 minutes across seven repos, and — critically — gives my brain the dopamine hit of “building something” while the actual work sits undone.
The honest lesson: just getting the manual work done is often faster than chasing noble optimizations. Especially when the batch is small enough that automation doesn’t compound. Seven repos is not “at scale.” It’s just seven.
What actually helped:
- A simple text file with one line per repo and its status. Not a dashboard. Not a tracker. A text file. “repo A: build script done, CI failing on coverage. repo B: not started. repo C: MR submitted.”
- Doing repos in strict sequential order. Not parallel. Not “whichever one I feel like.” Pick the next one, finish it, move on. The context-switching cost of parallel work across repos is brutal.
- Emacs. Specifically: projectile for fast repo switching, treemacs for visual orientation, dired for file operations without leaving the editor. When you’re jumping between seven repos, the speed of your navigation tools directly affects how much context you lose per switch. A half-second switch preserves context. A ten-second switch — find terminal, cd to directory, remember where you were — destroys it.
I wrote a batch clone script anyway. It took 20 minutes and saved me maybe five:
#!/bin/bash
REPOS=(
"https://git.example.com/team/service_a"
"https://git.example.com/team/service_b"
"https://git.example.com/team/service_c"
"https://git.example.com/team/service_d"
)
BASE_DIR="$HOME/code/batch-migrate"
for REPO in "${REPOS[@]}"; do
REPO_NAME=$(basename $REPO)
git clone "$REPO" "$BASE_DIR/$REPO_NAME"
cd "$BASE_DIR/$REPO_NAME"
git checkout -b "migrate/bazelize"
echo "Ready: $REPO_NAME"
cd $BASE_DIR
done
Was it useful? Barely. Did writing it feel productive? Absolutely. That’s the trap.
The pattern, compressed
If I had to hand this work to someone else, here’s the cheat sheet:
-
Prioritize: Sort repos by (CI time x CI frequency). Filter to ones already Bazelized. Start with repos that have a working example to copy.
-
For each repo:
- Create
build.bazel.shalongside existingbuild.sh - Pattern: install bazelisk -> gazelle-update-repos -> gazelle -> bazel build -> copy binary to output/
- If the repo needs coverage: use
--run_underwith Bazel’s Go SDK path - If the repo has a test recording framework: handle the dirty git state from source annotation
- Create
-
Debug CI failures with: toolchain version check, lock file freshness, network proxy config, cache state,
latestusage search. -
Work sequentially, not in parallel. Track status in a flat list. Resist the urge to automate a seven-item batch.
The actual Bazel conversion is straightforward once you have the pattern. The hard parts are: negotiating with repo owners, debugging environment differences between local and CI, and — honestly — managing your own attention across multiple similar-but-not-identical repos.
Build systems are technical problems with human solutions. The gazelle pipeline is the easy part. The hard part is sitting down and doing the same thing seven times without getting bored and building a meta-tool.