Skip to content

Release please - Automated Changelog & Semver

Posted on:November 20, 2025 at 06:00 PM

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:

  1. Universal Versioning: All packages and binaries share a single version tag for each release
  2. Single Changelog Management: Maintain a single root CHANGELOG for the entire repository
  3. Binary Distribution: Build and distribute CLI tools for multiple platforms
  4. Semantic Versioning: Automatically determine version bumps based on commit messages
  5. Release Tagging: Create a single root git tag for each release

Solution Architecture

We adopted a two-tool approach:

Part 1: Release Please - Semantic Release Management

What is Release Please?

Release Please is a Google-backed tool that automates the release process by:

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:

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:

  1. Commits are pushed to the main branch with conventional commit messages
  2. Release Please runs (via GitHub Actions)
  3. Release PR is created with:
  1. Developer reviews the release PR
  2. PR is merged to main
  3. Release Please creates:

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:

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:

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:

Benefits of Separate Configurations

By maintaining a .goreleaser.yml in each package directory, we get:

  1. Package-Specific Builds: Each package can have custom build configurations
  2. Clear Ownership: Developers can see exactly what gets built for their package
  3. Scalability: Easy to add new packages with their own configurations
  4. 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:

  1. Release Please scans commits
  2. Identifies the change as a feature in lib2 (minor version bump)
  3. Creates a release PR with:

3. Release PR is Merged

Once the release PR is reviewed and merged:

  1. Release Please creates a single root git tag:
  1. 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:

  1. Builds binaries for Linux and macOS (both amd64 and ARM64)
  2. Creates tar.gz archives
  3. Generates SHA256 checksums
  4. 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:

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:

5. GitHub Actions Integration is Key

The real magic happens in GitHub Actions:

  1. Release Please Action runs on each push to main
  2. GoReleaser Action is triggered on tag creation
  3. Both work together seamlessly

Future Improvements

Potential enhancements we’re considering:

  1. Publish to Package Managers: Distribute to Homebrew, AUR, etc.
  2. Docker Images: Build and push Docker images for each release
  3. Windows Support: Extend GoReleaser to build Windows binaries
  4. Automated Testing: Run tests as part of the release pipeline
  5. 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:

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


Questions or feedback? Feel free to open an issue or discussion in the repository.