Guides

Monorepos

monorepos.mdEdit on GitHub

By default chamoji treats a repo as one package. When the apps or packages in a monorepo ship on their own, add a release section to chamoji.config.json. Each app or package then becomes a unit, with its own version, tag, changelog and GitHub release. This guide shows how the pieces fit, with real runs in three small repos.

MONOREPOS SINCE 0.13.0

chamoji reads chamoji.config.json in the current folder, not at the repo root, so run chamoji from the root. From apps/web, next-version gives a single-package answer.

A release section needs chamoji 0.13.0 or newer. See Older versions of the CLI.

One package, or many

Without a release section, a repo has one version, one v* tag and one CHANGELOG.md. That is also right for a monorepo whose packages ship together. Such a repo gets the same versions, tags, changelog and release commit as with chamoji 0.12.0. The only addition is a note, once per clone, from next-version or release in a repo with two or more packages under apps/:

chamoji release --dry-run

chamoji: this looks like a monorepo with several apps; run chamoji setup to version each one
…

It never shows under --json or in CI.

With a release section, each unit releases on its own. A unit releases when:

  • it has an own commit that implies a version bump;
  • you name it with --unit, or give it a version;
  • a commit since its last tag has a Release: line that names it;
  • under release-dependents, it has a shared commit that implies a bump;
  • another unit in its fixed group releases;
  • under release-dependents, a unit it depends on releases.

Otherwise it isn’t released, and its waiting changes go in its next release. --unit limits every case but a fixed group.

chamoji setup writes the section for you, as Set up a repo shows. This guide explains what it writes, for reading it or writing it by hand. Config lists every key.

MONOREPOSshape: apps

An app monorepo

The first example is a shop with two apps, apps/web and apps/mobile, an end-to-end test app in apps/e2e, and a shared package in packages/ui. The mobile app is an Expo app with an app.config.ts. This is what chamoji setup releases --yes wrote:

chamoji.config.json

{
  "release": {
    "schema": 1,
    "shape": "apps",
    "units": {
      "mobile": {
        "path": "apps/mobile",
        "tag": "mobile/v{version}",
        "versionFiles": [
          "apps/mobile/app.config.ts"
        ]
      },
      "web": {
        "path": "apps/web",
        "tag": "web/v{version}"
      }
    },
    "exclude": [
      "apps/e2e"
    ],
    "sharedChanges": "ride-along",
    "rootFiles": "none"
  }
}

Each app gets its own tag, such as web/v1.3.0, and a CHANGELOG.md in its folder. Since the last tags, there is one commit in each app, a fix in packages/ui, two commits at the root (the config and a CI change), and a new test in apps/e2e:

chamoji release --dry-run

Would release:
mobile  1.0.0 → 1.1.0  minor  1 own, 1 shared
web  1.2.0 → 1.3.0  minor  1 own, 1 shared
files: apps/mobile/CHANGELOG.md, apps/mobile/package.json, apps/mobile/app.config.ts, apps/web/CHANGELOG.md, apps/web/package.json
apps/e2e: 1 commit not released (excluded)

## mobile/v1.1.0

## 1.1.0 - 2026-11-05

### Added

- ✨ Add the cart screen (c9949b2)

### Fixed

- 🐛 Fix the button's focus ring (a089b0b) (via `packages/ui`)

## web/v1.3.0

## 1.3.0 - 2026-11-05

### Added

- ✨ Add the search page (ad8e540)

### Fixed

- 🐛 Fix the button's focus ring (a089b0b) (via `packages/ui`)

The packages/ui fix rides along with both apps. The root commits are left out, because rootFiles is none. The next sections explain both.

MONOREPOSshape: libraries

A library monorepo

The second example is a kit of three published packages: @acme/core, and @acme/cli and @acme/ui, which both depend on core. This section was written by hand:

chamoji.config.json

{
	"release": {
		"schema": 1,
		"shape": "libraries",
		"units": {
			"core": {"path": "packages/core"},
			"cli": {"path": "packages/cli"},
			"ui": {"path": "packages/ui"}
		},
		"fixed": [["core", "cli"]]
	}
}

Each package gets a tag such as @acme/ui@1.2.1, the default for libraries, and a CHANGELOG.md in its folder.

  • A fixed group always shares one version, the largest bump of any of its units. When one of them releases, they all do.
  • Under release-dependents, the default for libraries, a package that releases also releases the packages that depend on it, by at least a patch. They get the new range and a Dependencies line.

chamoji release --dry-run

Would release:
core  1.2.0 → 1.3.0  minor  1 own, 0 shared
cli  1.2.0 → 1.3.0  fixed  0 own, 0 shared
ui  1.2.0 → 1.2.1  patch  1 own, 0 shared
files: packages/core/CHANGELOG.md, packages/core/package.json, packages/cli/CHANGELOG.md, packages/cli/package.json, packages/ui/CHANGELOG.md, packages/ui/package.json

## @acme/core@1.3.0

## 1.3.0 - 2026-11-05

### Added

- ✨ Add a retry option (cdadc1f)

## @acme/cli@1.3.0

## 1.3.0 - 2026-11-05

### Changed

- Released with core at the group's version

### Dependencies

- ⬆️ 1 dependency update: @acme/core

## @acme/ui@1.2.1

## 1.2.1 - 2026-11-05

### Fixed

- 🐛 Fix the dark theme (7a5ab4b)

### Dependencies

- ⬆️ 1 dependency update: @acme/core

The release rewrites the range in each dependent’s package.json:

git show HEAD -- packages/ui/package.json

…
 {
   "name": "@acme/ui",
-  "version": "1.2.0",
-  "dependencies": {"@acme/core": "^1.2.0"}
+  "version": "1.2.1",
+  "dependencies": {"@acme/core": "^1.3.0"}
 }

Ranges are rewritten in dependencies only. Peer ranges stay as written, and so do workspace: ranges, since pnpm rewrites those when you publish.

shape can be apps or libraries for now. single is the default behaviour, one release for the whole repo, and takes no other settings.

Which commits belong to which unit

The files a commit changes decide it. A scope such as feat(web): or an emoji never overrides them. Paths match on whole folder names, so apps/web doesn’t match apps/web-admin.

  • Name
    Own
    Description

    It changes a file under the unit’s path. A commit that changes several units is an own commit for each.

  • Name
    Shared
    Description

    It changes a file in an internal package the unit uses, one that isn’t a unit itself, and nothing under the unit’s own path. It’s marked “via packages/ui” in the notes.

  • Name
    Root-only
    Description

    It changes only files outside every package, such as the lockfile or CI. rootFiles decides what happens to it.

  • Name
    Excluded
    Description

    It changes only paths in exclude. It’s left out, and the dry run says how many.

A unit’s commits are the ones since its own last tag.

Root-only commits. rootFiles is none by default, which leaves them out. note adds one line to each unit’s notes, and all counts each one as an own commit for every unit. In the shop, the root-only commits are the CI change and the commit that added chamoji.config.json. This is the same dry run with rootFiles set each way:

chamoji release --dry-run --unit web

Would release:
web  1.2.0 → 1.3.0  minor  1 own, 1 shared
files: apps/web/CHANGELOG.md, apps/web/package.json
apps/e2e: 1 commit not released (excluded)
mobile: left out by --unit (1 change of its own, 1 shared waiting)

## web/v1.3.0

## 1.3.0 - 2026-11-05

### Added

- ✨ Add the search page (ad8e540)

### Changed

- Tooling and CI: 2 changes

### Fixed

- 🐛 Fix the button's focus ring (a089b0b) (via `packages/ui`)

Shared changes ride along

For apps, a shared commit never releases an app on its own, without a Release: line or --unit. An app releases when it has an own commit that implies a version bump, and its notes then carry every shared commit since its last release.

After both apps released, the shop got one more commit, a fix in packages/ui. On its own, it releases nothing:

chamoji release --dry-run

Nothing to release: no unit has a commit of its own that implies a version bump.
mobile: no changes of its own; 1 shared change rides along with its next release
web: no changes of its own; 1 shared change rides along with its next release

It exits 3, as next-version does when nothing would release. When a shared fix has to ship now, add a Release: line to the commit:

git log -1 --format=%B

🔒 Fix the session refresh

Release: mobile

chamoji release --dry-run

Would release:
mobile  1.1.0 → 1.1.1  patch  0 own, 1 shared
files: apps/mobile/CHANGELOG.md, apps/mobile/package.json, apps/mobile/app.config.ts
web: no changes of its own; 1 shared change rides along with its next release

## mobile/v1.1.1

## 1.1.1 - 2026-11-05

### Security

- 🔒️ Fix the session refresh (ba4115d) (via `packages/ui`)

Release: web, mobile names several units, and Release: all names every unit. chamoji release --unit mobile does the same from the command line.

Web still has the fix waiting. A docs commit to web doesn’t release it, because a docs change implies no version bump:

chamoji release --dry-run

Nothing to release: no unit has a commit of its own that implies a version bump.
web: no release needed (1 change of its own doesn't need one); 1 shared change rides along with its next release

It exits 3. A feature in web instead releases both:

chamoji release --dry-run --unit web

Would release:
web  1.3.0 → 1.4.0  minor  1 own, 1 shared
files: apps/web/CHANGELOG.md, apps/web/package.json

## web/v1.4.0

## 1.4.0 - 2026-11-05

### Added

- ✨ Sort search results by price (d1561df)

### Security

- 🔒️ Fix the session refresh (ba4115d) (via `packages/ui`)

For libraries, the default is the other way round: under "sharedChanges": "release-dependents", a shared change releases every unit that uses it. Either shape accepts either value.

Two more settings change what a release makes:

  • "githubReleases": "combined" makes one GitHub release per release commit, instead of one per unit.
  • "rootChangelog": true adds a root CHANGELOG.md that lists every release, with links to the units’ changelogs.

Tags

A unit’s tag is a template. {unit} is the unit’s name in units, {name} its package name and {version} the version. The default is {unit}/v{version} for apps and {name}@{version} for libraries.

chamoji finds a unit’s last release by reading the tags that fit its template, and the older formats you list in read. So a repo can keep the tags it already has:

release.units

"web": {"path": "apps/web", "tag": "web/v{version}", "read": ["web-v{version}"]}
  • A tag that doesn’t parse but is a real release gets a version in tags.aliases.
  • Tags that aren’t releases, such as ones that came with a template, go in tags.ignore as regular expressions. They are not globs: ^wip/ matches tags that start with wip/, and wip/* doesn’t mean that.
  • Two units can share a template without {unit} or {name}, such as v{version}, only when they are in one fixed group. They then share one tag and one GitHub release, with a section for each.

Tags that match nothing stay in git, untouched. chamoji never moves, deletes or renames a tag. Config has both keys.

Versions and version files

  • The first release. A unit with no tag of its own releases firstVersion when you set it. Otherwise it releases the version already in its manifest.
  • Date versions. "versioning": "calver" gives a unit versions such as 2026.11.0: the year, the month and a count of that unit’s releases in the month, with no zero padding so the version stays valid semver. Its manifest version is ignored, and its first release is a date version too, unless firstVersion says otherwise.
  • While at 0.x. "zeroMajor": "minor" makes a breaking change raise the minor version instead of the major.

Each unit’s package.json is bumped, with its formatting kept. List any other file that carries the version in versionFiles, by its path from the repo root. chamoji edits Expo’s app.json and app.config.ts or .js, an Info.plist, an android/app/build.gradle, and any other text file where it finds the current version. In the shop, the release bumped mobile’s app.config.ts to version: '1.1.0'.

If it can’t find the version in a listed file, it stops before changing anything, even in a dry run:

chamoji release --dry-run --unit mobile

chamoji: can't set the version in apps/mobile/app.config.ts: it has no top-level version: "…" property to set

That was an app.config.ts with version: pkg.version. It exits 2. The rules for Expo and native files:

  • Expo’s app.config can be an object, an arrow function, defineConfig(…) or export default config, and its version must be a plain string such as "1.2.0". Any other shape stops the release instead of guessing. That includes version: pkg.version, a call with more than one argument, a template string with ${…}, or a second version key.
  • An Info.plist that uses $(MARKETING_VERSION) isn’t supported yet, since chamoji doesn’t bump the marketing version in the Xcode project. Bump it in Xcode yourself. If you list that Info.plist in versionFiles, the release stops and tells you to remove it.
  • Build numbers (CFBundleVersion, versionCode, Expo’s ios.buildNumber and android.versionCode) are left alone unless the unit sets "buildNumber": "increment", which raises them by one. That does nothing when the app uses EAS remote versioning, with appVersionSource set to remote.

Template history

If the repo began as a clone of a template you started from, and keeps pulling from it, chamoji can keep the template’s history out of your notes. The third example is a storefront that began as a clone of a made-up Acme Starter template, at its tag 4.0.0. The template’s remote is upstream, and the storefront then made two commits of its own. Setup found the template:

chamoji setup releases --yes --explain

Found 1 app (web) and 1 internal package. web gets its own version. 1 tag looks like it came from Acme Starter and will be ignored. Shared changes ride along with each app's own releases.

release.schema = 1 (the section's format)
release.shape = "apps" (detected in the repo)
release.units.web.path = "apps/web" (detected in the repo)
release.units.web.tag = "web/v{version}" (detected in the repo)
release.exclude = [] (detected in the repo)
release.sharedChanges = "ride-along" (recommended for apps)
release.rootFiles = "none" (recommended for apps)
release.zeroMajor = "minor" (recommended for apps)
release.upstream.remote = "upstream" (detected in the repo)
release.upstream.name = "Acme Starter" (recommended for apps)
release.upstream.since = "9f865558fe4b1874e321759d3561580b8bfdad1b" (detected in the repo)
release.tags.ignore = ["^\\d+\\.\\d+\\.\\d+$"] (detected in the repo)

Wrote chamoji.config.json. Review it and commit it; git restore chamoji.config.json undoes this.
  • remote is the template’s remote, which must be fetched.
  • name comes from the owner in the remote’s URL. It names the template in your notes.
  • since is your first commit that isn’t the template’s, as a hash or ref.
  • tags.ignore keeps the template’s version tags from reading as your releases.

The template’s history is every commit that one of these reaches:

  • the upstream remote’s fetched branches;
  • a tag in tags.ignore whose name reads as a version, such as 4.0.2 or v4.0.0, with or without a remote. Other ignored tags, such as wip/…, are your own and don’t count;
  • upstream.authors, a list of emails: a commit on HEAD by one of them, compared ignoring case, counts as the template’s.

The last two cover template work that came in through a branch the template has since deleted. upstream.authors needs chamoji 0.14.0. Setup lists authors only when some of their commits are ones the remote no longer reaches.

Those commits are left out of every unit’s notes and never release anything. Each merge that brings them in becomes one line, naming the newest template tag it brings, or the merged commit’s short hash when it brings none. Here the template shipped 4.0.2, and the storefront merged it:

chamoji release --dry-run

Would release:
web  0.1.0 → 0.2.0  minor  2 own, 0 shared
files: apps/web/CHANGELOG.md, apps/web/package.json

## web/v0.2.0

## 0.2.0 - 2026-11-05

### Added

- ✨ Add the checkout page (104cecc)

### Changed

- ⬆️ Updated the Acme Starter base to 4.0.2 (62d42cf)

The merge counts as one own commit for the units it changed. The template’s login fix and password reset page don’t appear.

In CI, the checkout usually has only origin. Run git remote add upstream <url> && git fetch upstream before chamoji. Without it, chamoji warns, and falls back on the ignored version tags. This is a fresh clone with only origin:

chamoji release --dry-run

Would release:
web  0.1.0 → 0.2.0  minor  2 own, 0 shared
files: apps/web/CHANGELOG.md, apps/web/package.json
warning: upstream remote "upstream" has no fetched branches here, so template history is recognised only by ignored version tags; run git remote add upstream <url> && git fetch upstream before chamoji
…

Commands

In a repo with release units

chamoji release --dry-run              # every unit that would release, with versions, tags and notes
chamoji release --unit web             # only web
chamoji release 2.0.0 --unit mobile    # a version you choose, for one unit
chamoji next-version --json            # what each unit would release, and what is waiting
chamoji changelog --unit web           # web's releases
chamoji changelog --for-tag web/v1.4.0 # the notes as that release committed them

A bare chamoji changelog needs --unit or --for-tag here:

Per unit

$ chamoji changelog
chamoji: this repo releases units (mobile, web); pass --unit <name> or --for-tag <tag>

release, next-version and changelog have every flag.

Using the Action

  • Release mode: set the unit input to release a single unit.
  • On a pushed tag, the Action finds the unit the tag belongs to, and writes that tag’s notes to its GitHub release.
  • On a pull request, the preview comment lists only the units the PR touches, with the releases merging it would make.

In a repo with release units, the preview runs the CLI with its working directory at the PR head, and never with your secrets. So the cli input must be unset, node with a built CLI file from the workspace, such as node packages/cli/dist/cli.js, or the chamoji command by its full path. A bare chamoji doesn’t work in the 0.14.0 Action. Anything else, such as npx, a shell wrapper or tsx, is refused, and so are option words. GitHub Action has the rest.

Older versions of the CLI

A release section needs chamoji 0.13.0 or newer:

  • 0.12.1 refuses the section with “this repo’s release settings need chamoji 0.13.0 or newer”.
  • 0.12.0 knows nothing about it. It would ignore the section and cut one release for the whole repo.
  • The Action refuses to run an older CLI when the section exists.

Was this page helpful?