Skip to content

Monorepos

Dan Riddell edited this page Sep 30, 2026 · 1 revision

Monorepos

A Go module nested in a repository releases independently, from tags named <dir>/vX.Y.Z, where <dir> is its directory relative to the git top level.

repo/
  go.mod                  ← tagged v1.0.0
  services/
    api/
      go.mod              ← tagged services/api/v1.2.0
      letsgo.mod
    worker/
      go.mod              ← tagged services/worker/v0.4.1

There is nothing to configure. Run any letsgo command inside a nested module and it scopes itself to that module — the working directory is the whole interface.

cd services/api
letsgo plan             # sees services/api/v1.2.0, not v1.0.0
letsgo release

The prefix is derived, never configured

The tag prefix comes from the module directory and nothing else. No directive sets it, and there is no flag for it, because a prefix that could disagree with the directory is a way to publish a release under a name that does not belong to it.

letsgo tag proposes <prefix>vX.Y.Z. plan accepts only tags of that shape for a scoped module, and says which directory to cd into when it finds a prefixed tag belonging to somewhere else.

What becomes scoped

  • The previous tag is chosen only from tags carrying the same prefix. A root release whose history contains web/v9.0.0 still compares against v1.0.0.
  • The changelog includes only commits touching <dir>, excluding nested module directories inside it.
  • The API gate compares <dir> at the previous tag with <dir> at HEAD.
  • verify with no tag picks the highest release carrying the module's prefix.
  • yank adds the stripped version's retract to <dir>/go.mod.
  • install.sh downloads from releases/download/<tag>/, with the prefix URL-escaped consistently in the formula, the cask and the script.

The manifest and the release

The manifest gains tag_prefix, which may be empty. tag keeps the prefix and version is stripped of it:

{ "tag": "services/api/v1.2.0", "version": "1.2.0", "tag_prefix": "services/api/" }

The release is titled <dir> vX.Y.Z — services/api v1.2.0 — so a releases page carrying several modules reads as a list rather than a collision. At the root the title is just the tag.

The latest badge

GitHub has one "latest" per repository, and a module release quietly taking it breaks every consumer who was following the root.

release latest=auto     # the default
release latest=true
release latest=false

Under auto, only a root-scope release may be marked latest.

Builds ignore go.work

Scoped builds run with GOWORK=off. A go.work file exists to point the toolchain at your uncommitted local edits, which is exactly what a release must not build. With it off, a module in a workspace produces the same digests it would produce from a clean clone.

For the same reason, plan fails when go.mod has a replace pointing at a local path, and cites the line. A release that depends on a directory outside the module is a release nobody else can reproduce.

Self-update

selfupdate.Options.TagPrefix keeps an updater inside its own module's releases — without it, a binary from services/api would happily offer to update itself to the root project's newest tag.

The module directive is a different thing

module web in letsgo.mod builds ./web under a root tag: one project, one version, one tag. That is the right tool when a directory was split out for dependency hygiene but is still versioned with the repository.

It is not a scoped release, and v1.2.0 is not a version of the module .../web, so go install .../web/cmd/x@v1.2.0 does not resolve and the proxy has nothing to warm. letsgo raises a plan Warn saying so and skips the proxy warm rather than failing on it every time.

If the nested module wants its own version, give it its own tag prefix — that is what scoped releases are.

In CI

A tag names one module, so every other matrix entry finds no release of its own at that commit. Guard on the prefix:

on:
  push:
    tags: ["*/v*"]

jobs:
  release:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        module: [services/api, services/worker]
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: actions/setup-go@v7
        with: { go-version-file: '${{ matrix.module }}/go.mod' }
      - uses: danielriddell21/letsgo-action@v1
        id: release
        if: startsWith(github.ref_name, matrix.module)
        with:
          working-directory: ${{ matrix.module }}
      - run: echo "released ${{ steps.release.outputs.tag }}"
        if: steps.release.outputs.tag != ''

The action's tag output carries the prefix, so a downstream step knows which module it just published.

Working out which modules changed, and building the matrix from that, is left to CI. letsgo releases the module you point it at; deciding which ones to point it at is a repository's own question and not a property of any one release.

At the root, nothing changes

With an empty scope every command behaves exactly as it does in a single-module repository — same tags, same manifest fields, same output.

Clone this wiki locally