Automating Go Monorepo Releases with Release Please and GoReleaser
Introduction
Managing releases in a monorepo can be a daunting task. With multiple packages and applications to version and release independently, manual release management becomes error-prone and time-consuming. In this post, I’ll share how I implemented Release Please and GoReleaser to automate the entire release pipeline for the mono-repo-release project—a Go monorepo with multiple packages (lib1, lib2, and an app).
The Challenge
Our monorepo structure looks like this:
mono-repo-release/
├── cmd/
│ └── app/ # Main application
├── pkg/
│ ├── lib1/ # Package 1 with CLI tool
│ └── lib2/ # Package 2 with CLI tool
The key challenges were:
- Universal Versioning: All packages and binaries share a single version tag for each release
- Single Changelog Management: Maintain a single root CHANGELOG for the entire repository
- Binary Distribution: Build and distribute CLI tools for multiple platforms
- Semantic Versioning: Automatically determine version bumps based on commit messages
- Release Tagging: Create a single root git tag for each release
Solution Architecture
We adopted a two-tool approach:
- Release Please: Handles versioning, changelog generation, and release PR creation
- GoReleaser: Builds binaries and creates platform-specific archives for distribution
Part 1: Release Please - Semantic Release Management
What is Release Please?
Release Please is a Google-backed tool that automates the release process by:
- Analyzing commit messages using Conventional Commits specification
- Automatically determining semantic version bumps
- Updating CHANGELOGs
- Creating release PRs
- Creating GitHub releases and git tags
Configuration
We created a centralized release-please-config.json file:
{
"release-type": "go",
"package-name": "mono-repo-release",
"include-component-in-tag": false,
"include-v-in-tag": true,
"versioning": "default",
"changelog-sections": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "refactor",
"section": "Code Refactoring",
"hidden": false
},
{
"type": "docs",
"section": "Documentation",
"hidden": false
}
],
"extra-files": [
{
"type": "go",
"path": "pkg/lib1/version.go",
"jsonpath": "$.version"
},
{
"type": "go",
"path": "pkg/lib2/version.go",
"jsonpath": "$.version"
}
]
}
Key Configuration Points:
release-type: "go": Specifies this is a Go projectinclude-v-in-tag: true: Include ‘v’ prefix in version tags (e.g.,v1.2.3)changelog-sections: Define which commit types appear in the changelogextra-files: Update version files in each package when releasing
Conventional Commits
To enable Release Please to work effectively, we enforce Conventional Commits:
# Bug fix (creates patch release)
git commit -m "fix(lib1): correct network timeout"
# New feature (creates minor release)
git commit -m "feat(lib2): add new API endpoint"
# Breaking change (creates major release)
git commit -m "feat!: redesign API structure"
The format is: type(scope): description
Scope is optional but highly recommended in monorepos to indicate which package is affected.
The Release Workflow
Here’s how Release Please automates our releases:
- Commits are pushed to the main branch with conventional commit messages
- Release Please runs (via GitHub Actions)
- Release PR is created with:
- Updated root
CHANGELOG.mdfor the repository - Version bump based on commit types
- A summary of changes
- Developer reviews the release PR
- PR is merged to main
- Release Please creates:
- GitHub release pages
- Root repository tag (e.g.,
v2.3.0)
Tag and Changelog Strategy
We use a single root semantic version tag for the entire repository. Every time a release is made, a tag like vX.Y.Z (e.g., v2.3.0) is created at the root. All binaries and packages released in that cycle are built and published with this same version.
This means:
- All binaries and packages share the same version: For example, if the root is tagged
v2.3.0, thenlib1,lib2, andappbinaries for that release are all versioned asv2.3.0. - Single changelog: All release notes and changes are tracked in the root
CHANGELOG.md. - No per-package tags or changelogs: We do not use tags like
lib1:v1.2.3orapp:v1.2.3, nor do we maintain separate changelogs for each package. Everything is tied to the single root semver and changelog. - Simplicity and traceability: This approach makes it easy to see which versions of all binaries were released together, and ensures all artifacts are always in sync with the root changelog and release notes.
This strategy is reflected in our release automation and GitHub Actions: GoReleaser is triggered for each binary, but always using the root tag as the version for all outputs, and changelog updates are centralized.
Part 2: GoReleaser - Binary Building and Distribution
What is GoReleaser?
GoReleaser is a release automation tool specifically designed for Go projects. It:
- Builds binaries for multiple platforms (Linux, macOS, Windows, etc.)
- Creates distribution archives (tar.gz, zip, etc.)
- Generates checksums
- Creates GitHub releases with artifacts
- Optionally publishes to package managers
Per-Package and Per-App Configuration
We use a root .goreleaser.yml configuration, but each package and the main app can have their own .goreleaser.yml file for custom build settings. However, all releases are coordinated under the single root version and changelog.
For example, the app has its own config at cmd/app/.goreleaser.yml:
version: 2
project_name: app
before:
hooks:
- go mod tidy
- go test ./...
builds:
- id: app
main: ./main.go
binary: app
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{.Version}}
archives:
- id: default
formats: [tar.gz]
files:
- README.md
- LICENSE*
- CHANGELOG.md
checksum:
name_template: "checksums.txt"
Similarly, each package (like pkg/lib1 and pkg/lib2) has its own .goreleaser.yml for maximum flexibility and clarity.
Per-Package Configuration: pkg/lib1/.goreleaser.yml
Each package can have its own GoReleaser configuration for custom build settings, but all releases are tied to the root version and changelog:
version: 2
project_name: lib1
before:
hooks:
- go mod tidy
- go test ./...
builds:
- id: lib1-cli
main: ./pkg/lib1/cmd/main.go
binary: lib1-cli
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{.Version}}
archives:
- id: default
formats: [tar.gz]
files:
- README.md
- LICENSE*
- CHANGELOG.md
checksum:
name_template: "checksums.txt"
Key Configuration Details:
before.hooks: Ensure dependencies are tidy and tests pass before buildingbuilds.goosandgoarch: Target platforms (Linux x86_64/ARM64, macOS x86_64/ARM64)ldflags: Embed version information into the binary at build timeCGO_ENABLED=0: Build static binaries (no C dependencies)archives: Create tar.gz archives with documentationchecksum: Generate SHA256 checksums for verification
Benefits of Separate Configurations
By maintaining a .goreleaser.yml in each package directory, we get:
- Package-Specific Builds: Each package can have custom build configurations
- Clear Ownership: Developers can see exactly what gets built for their package
- Scalability: Easy to add new packages with their own configurations
- Centralized Versioning and Changelog: All packages are released together under a single version and changelog
Integration: From Commit to Release
Here’s the complete flow from a developer’s perspective:
1. Developer Makes Changes
# Create a feature branch
git checkout -b feat/new-api
# Make changes to lib2
# ... edit files ...
# Commit with conventional message
git commit -m "feat(lib2): add webhook support"
# Push to GitHub
git push origin feat/new-api
# Create Pull Request
# (Open PR on GitHub)
2. Release Please Detects Changes
When the PR is merged to main:
- Release Please scans commits
- Identifies the change as a feature in lib2 (minor version bump)
- Creates a release PR with:
- Root
CHANGELOG.mdupdated with the new feature pkg/lib2/version.goupdated (e.g., fromv1.0.0tov1.1.0)
3. Release PR is Merged
Once the release PR is reviewed and merged:
- Release Please creates a single root git tag:
v2.3.0(repository tag)
- GitHub release pages are created automatically
4. GoReleaser Builds Artifacts
A GitHub Action can be triggered on root tag creation to run GoReleaser:
- name: GoReleaser for lib2
if: startsWith(github.ref, 'refs/tags/lib2:')
run: |
cd pkg/lib2
goreleaser release --clean
This:
- Builds binaries for Linux and macOS (both amd64 and ARM64)
- Creates tar.gz archives
- Generates SHA256 checksums
- Attaches everything to the GitHub release
Development Workflow
Making Changes
Follow conventional commits for all changes:
# For new features
git commit -m "feat(lib1): [description]"
# For bug fixes
git commit -m "fix(app): [description]"
# For breaking changes
git commit -m "feat!: [description]"
Package Scopes
Always include a scope to indicate which package is affected:
feat(lib1):- Changes to lib1feat(lib2):- Changes to lib2feat(app):- Changes to appfeat:- Changes that affect all packages
Testing Releases Locally
To test Release Please locally:
npm install -g release-please
release-please release-pr \
--dry-run \
--manifest-file .release-please-manifest.json \
--config-file .release-please-config.json
To test GoReleaser builds:
cd pkg/lib2
goreleaser check # Validate configuration
goreleaser build # Build binaries
goreleaser release --snapshot --clean # Test release (no upload)
Lessons Learned
1. Version File Management
We maintain version.go files in each package:
// pkg/lib1/version.go
package lib1
const version = "1.0.0"
Release Please automatically updates these files, keeping the source of truth in code.
2. Tag Naming is Critical
Using a single root tag (e.g., v1.0.0) ensures all packages and binaries are versioned together, making releases simple and traceable.
3. Conventional Commits Must Be Enforced
We use commitlint to validate commit messages before they reach the repository:
{
"extends": ["@commitlint/config-conventional"]
}
4. Separate Configurations Give Flexibility
While more files to maintain, separate GoReleaser configurations per package allow for:
- Different binaries per package
- Package-specific build flags
- Centralized versioning and changelog for all packages
5. GitHub Actions Integration is Key
The real magic happens in GitHub Actions:
- Release Please Action runs on each push to main
- GoReleaser Action is triggered on tag creation
- Both work together seamlessly
Future Improvements
Potential enhancements we’re considering:
- Publish to Package Managers: Distribute to Homebrew, AUR, etc.
- Docker Images: Build and push Docker images for each release
- Windows Support: Extend GoReleaser to build Windows binaries
- Automated Testing: Run tests as part of the release pipeline
- Release Notes Generation: Custom release notes in addition to changelogs
Conclusion
By combining Release Please and GoReleaser, we’ve automated our release process to be:
- Automatic: No manual version management
- Traceable: Every release corresponds to specific commits
- Semantic: Version bumps follow semantic versioning rules
- Multi-Platform: Binaries for Linux and macOS automatically
- Auditable: Full changelog history in git and GitHub
The setup does require some initial configuration, but the benefits in terms of reduced manual work and improved reliability are well worth the investment. If you’re managing a Go monorepo, I highly recommend adopting these tools.
Resources
- Release Please Documentation
- Release Please Config Reference
- GoReleaser Documentation
- Conventional Commits
- Semantic Versioning
Questions or feedback? Feel free to open an issue or discussion in the repository.