Multi-Repo to Monorepo: How I Automated 6 Go Microservice Releases and Then Made It 15x Faster
DEV Community

Multi-Repo to Monorepo: How I Automated 6 Go Microservice Releases and Then Made It 15x Faster

Last month I spent more than an hour cutting a release across six Go microservice repos. Tag log, wait for CI. Update sdk's go.mod to point at the new log SHA, push, wait for CI. Repeat for utils. Then do api, cli, and worker in parallel - except I forgot to bump cli's dependency and the build broke at 11pm. That was the last manual release I did. This is the story of automating that entire workflow with Jenkins + Python + GitLab, then realizing the multi-repo architecture was the real problem, and collapsing everything into a Go monorepo that's 15x faster at cutting releases. The full setup runs on my laptop. You can fork it and try it yourself. Table of Contents - The Six Modules - The Stack - Phase 1: Multi-Repo Automation - Phase 2: The Monorepo Pivot - The Unified CI Pipeline - Real Numbers - Caveats and Gotchas - Try It Yourself The Six Modules The project simulates a real production system with six Go modules that have strict dependency ordering: | Module | Role | Tag Scheme | Depends On | |---|---|---|---| log | Logger (leaf, no deps) | v0.x.0 | - | sdk | API client | v0.x.0 | log | utils | Shared utilities | v0.x.0 | log, sdk | api/backend | Backend | APP-x.y.z | log, utils | cli | CLI | cli-x.y.z | log, sdk | worker | Background | v0.x.0 | log, utils | The first three modules are sequential - sdk can't tag until log is tagged, utils can't tag until sdk is tagged. The last three are terminal - they can process in parallel once the sequential chain is done. Every module lives on three long-lived branches: develop β†’ release β†’ master . A release means moving code through all three, in all six repos, in the right order. That's the problem. Do it manually and you're juggling 6 repos Γ— 3 branches Γ— dependency ordering. One forgotten go mod tidy and you're debugging at midnight. The Stack Everything runs on a MacBook. No cloud CI, no SaaS - just local tools wired together. MacBook GitLab.com +-------------------+ ngrok tunnel +------------------------+ | Jenkins LTS | | Webhooks (push / MR) | | (brew service) | | Commit status API | | :8080 | ================> | 6 repos (multi-repo) | | | git push, | - or - | | Python 3 + Go | API calls | 1 repo (monorepo) | +-------------------+ +------------------------+ The key pieces: - Jenkins LTS via brew install jenkins-lts , running as a launched service - ngrok exposing localhost:8080 β†’https://enclosure-version-moocher.ngrok-free.dev/ so GitLab integration/webhook can reach Jenkins - GitLab.com hosting the repos - Python ( python-gitlab +GitPython ) for the automation engine - Go 1.24 with workspace support ( go.work ) Jenkins ↔ GitLab Integration Configured using GitLab’s native Jenkins Integration combined with ngrok for local tunneling: - GitLab β†’ Jenkins: Native integration automatically triggers local Jenkins pipelines on push and MR events. - Jenkins β†’ GitLab: Reports build progress and status back via API, displaying green/red status checks directly on commits and merge requests. Phase 1: Multi-Repo Automation The Setup: 6 Repos, 7 Jenkins Jobs In the multi-repo world, each module is its own GitLab repo under the multirepo-release/ group: multirepo-release/ β”œβ”€β”€ log/ β†’ gitlab.com:multirepo-release/log.git β”œβ”€β”€ sdk/ β†’ gitlab.com:multirepo-release/sdk.git β”œβ”€β”€ utils/ β†’ gitlab.com:multirepo-release/utils.git β”œβ”€β”€ api/ β†’ gitlab.com:multirepo-release/api.git β”œβ”€β”€ cli/ β†’ gitlab.com:multirepo-release/cli.git β”œβ”€β”€ worker/ β†’ gitlab.com:multirepo-release/worker.git └── pipelines/ β†’ gitlab.com:multirepo-release/pipelines.git Each repo gets its own Jenkins CI job (6 jobs), plus one orchestration job that runs the automation Python script. That's 7 Jenkins jobs to maintain. The Automation Engine: 1,800 Lines of Python The pipelines/ repo contains the brain: a Python script (poc_automation.py ) that talks to the GitLab API and performs git operations. One Jenkins job, five actions: | Action | What it does | |---|---| release_cut | Merges develop β†’ release for all 6 repos, in dependency order | backmerge | Merges release β†’ develop (or master β†’ release) | rc_create | Merges release β†’ master, tags each module, bumps go.mod versions | version_bump | Bumps version constants in api and cli on develop | compute_versions | Calculates next semver tags based on existing tags | How a Release Cut Actually Works When you trigger release_cut , the script: - Sequential phase - processes log, sdk, utils one at a time: - Creates an intermediate branch from release - Merges develop into it (handling go.mod conflicts automatically) - Creates a GitLab MR - Waits for approval (or auto-merges if configured) - Records the merged SHA - the next repo needs it for go mod tidy - Creates an intermediate branch from - Parallel phase - processes api, cli, worker simultaneously: - Same merge flow, but all three run concurrently since they're terminal nodes The hardest part: cross-repo dependency tracking. When log merges to release, sdk needs to run go get gitlab.com/multirepo-release/log@ to pin to the exact commit. The script tracks these SHAs in self.merged_target_shas and passes them forward through the chain. Conflict Handling When code conflicts happen (not just go.mod), the pipeline doesn't crash. It: - Creates a conflict branch cut from the source (so GitLab renders the real diff) - Opens an MR with [CONFLICT] in the title - Polls every 30 seconds for manual resolution + approval - Auto-merges once both conditions are met - Continues the pipeline The HTML Report Every automation run generates a styled HTML report archived in Jenkins. It logs every action taken: branches created, MRs opened, approvals received, tags applied. Phase 2: The Monorepo Pivot The multi-repo automation worked. But maintaining it was its own job: - 7 Jenkins jobs to configure and monitor - 6 Jenkinsfile.ci files (103 lines each, identical except for the module name) - 1,800 lines of Python managing 6 separate GitLab projects, tracking SHAs across repos, handling per-repo cloning - 6 MRs per release cut - one per repo, each needing approval The question became: what if all six modules lived in one repo? go.work: The Go Workspace Go 1.18 introduced workspaces (go.work ), which let multiple modules in a single repo resolve each other locally during development while still being independently importable by external consumers. // go.work - the workspace file at the repo root go 1.26 use ( ./api/backend ./cli ./log ./sdk ./utils ./worker ) replace ( gitlab.com/singhamandeep007-group/monorepo/log v0.3.0 => ./log gitlab.com/singhamandeep007-group/monorepo/sdk v0.3.0 => ./sdk gitlab.com/singhamandeep007-group/monorepo/utils v0.3.0 => ./utils ) The replace directives are the key insight: during local development and CI, Go resolves internal dependencies from the local directory. But when an external project runs go get gitlab.com/.../monorepo/log@v0.3.0 , Go fetches the tagged version from GitLab. Best of both worlds. Per-Module Tagging in a Monorepo Go's module system supports directory-prefixed tags. In a monorepo, each module gets its own tag with a path prefix: # Each module tagged independently, on the same commit git tag log/v0.3.0 git tag sdk/v0.3.0 git tag utils/v0.3.0 git tag api/backend/APP-1.3.0 git tag cli/cli-1.3.0 git tag worker/v0.3.0 When someone runs go get gitlab.com/singhamandeep007-group/monorepo/sdk@v0.3.0 , Go looks for the tag sdk/v0.3.0 - the module path prefix matches the tag prefix. Each module can version independently while living in the same repo. What Simplified The monorepo automation (monorepo-pipelines/poc_automation.py ) dropped from 1,762 to 1,143 lines - a 35% reduction. Here's what went away: | Multi-repo complexity | Monorepo equivalent | |---|---| | 6 separate GitLab project objects | 1 project object | | Clone + manage 6 local git repos | No local cloning needed | Track merged_target_shas across repos | Not needed - same repo, same SHA | go get module@SHA + go mod tidy per repo | GitLab file API to edit go.mod directly | | 6 MRs per release cut | 1 MR | sync_dependencies action | Not needed | Third-Party Consumption An external project can import any module from the monorepo individually: // third-party project's go.mod module gitlab.com/singhamandeep007-group go 1.26 require gitlab.com/singhamandeep007-group/monorepo/log v0.3.0 // main.go package main import "gitlab.com/singhamandeep007-group/monorepo/log" func main() { logger := log.NewLogger() logger.Info("Using log from the monorepo") } The consumer doesn't need the entire monorepo - Go fetches only the log module at the tagged version. The Unified CI Pipeline One Pipeline, All Modules, In Parallel The monorepo CI (jenkins/ci.Jenkinsfile , 324 lines) replaces 6 identical per-repo Jenkinsfile.ci files (636 lines total). It fans out all six modules in parallel: For each module, in parallel: go vet ./... - go test -count=1 -race -covermode=atomic β†’ JUnit XML + coverage HTML - govulncheck β†’ JSON + human-readable - go build (only forapi/backend andcli ) β†’ multi-arch binaries + SHA256SUMS stage("All Packages") { parallel pkgStages // 6 modules run simultaneously } All six run with failFast: false - a failure in one module doesn't mask problems in others. The pipeline produces an aggregated ci-report.html showing every module's test results, coverage percentage, and vulnerability count in one page. Multi-Arch Builds Terminal binaries (api/backend , cli ) build for three targets: # CGO_ENABLED=0 for cross-compilation (no C dependencies) GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o bin/api-darwin-arm64 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/api-linux-amd64 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/api-linux-arm64 # SHA256 checksums for verification shasum -a 256 bin/* > bin/SHA256SUMS The Release Automation Pipeline The release automation lives in a separate repo (monorepo-pipelines ) - keeping release tooling isolated from the application code. Same five actions as before, but operating on a single repo: After an RC, t

Comments

No comments yet. Start the discussion.