Using Changesets in a polyglot monorepo

Hacker News Top Tools

Summary

A blog post explaining how to use the Changesets tool for versioning and changelog management in a polyglot monorepo spanning multiple programming languages, leveraging its GitHub Action for custom version and publish scripts.

No content available
Original Article
View Cached Full Text

Cached at: 04/21/26, 07:07 AM

# Using Changesets in a polyglot monorepo Source: [https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/) One of the nice things about working in a smaller business is you can enjoy using things that don’t need to scale to extreme sizes\. One example in the software world is[monorepos](https://en.wikipedia.org/wiki/Monorepo)\. While monorepos*can*scale well \(see Google, Facebook, and others\), doing so requires special tooling and infrastructure\. With plain`git`,[you can only go so far](https://wellarchitected.github.com/library/architecture/recommendations/scaling-git-repositories/repository-architecture-strategy/)\. While you can use it, it has meaningful advantages, like being able to make atomic changes that affect many parts of the system in a single commit, which eliminates whole classes of compatibility and integration issues\. You can always split a monorepo later \(see[`git\-filter\-repo`](https://github.com/newren/git-filter-repo)\)\. So, suppose you’re a small\-to\-medium team using a monorepo\. Let’s go further and say that this monorepo stores all your company’s code, meaning it spans many different programming languages—it’s a polyglot monorepo\. What tool can you use to manage versioning in a consistent way? I argue that[`changesets`](https://github.com/changesets/changesets)is a solid choice, even if it’s primarily focused on the JavaScript/TypeScript ecosystem\. ## Background[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#background](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#background) For any versioning tool, you are typically looking for how to: - define what content appears in the changelog/release notes - influence the version numbers of the packages - automate the commits doing the actual metadata bumps and tagging - automate the builds that happen in response `changesets`assumes per\-package[semantic versioning](https://semver.org/)\(i\.e\., all packages have their own version\)\. In addition, each package has its own`CHANGELOG\.md`\. The`changesets`team also has a GitHub Action,[`changesets/action`](https://github.com/changesets/action)which importantly allows specifying custom scripts for the`version`and`publish`commands\. That customization is what gives`changesets`support for polyglot repositories\. In`changesets`, engineers commit “changeset” files to the repository that define what content ends up in changelogs, and what packages versions are bumped \(i\.e\., major, minor, patch\)\. See the[`changesets`documentation](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)for more details\. ## Implementing an automated release process on GitHub[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#implementing-an-automated-release-process-on-github](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#implementing-an-automated-release-process-on-github) I’m a fan of[`just`](https://just.systems/)\. I also really like[`uv`scripts](https://docs.astral.sh/uv/guides/scripts/)\. The example below uses both\. I’m also going to assume you are in a enterprise setting where all your monorepo is private, not open\-source\. ### Repository setup[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#repository-setup](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#repository-setup) My recommended organization \(at least at time of writing\) is something like the following\. ``` . ├── .changeset │ ├── config.json │ └── README.md ├── contrib │ └── utils ├── docker │ └── Dockerfile ├── docs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── ... │ └── pnpm-workspace.yaml ├── Justfile ├── package-lock.json ├── package.json ├── packages │ ├── python-one │ │ ├── ... │ │ └── package.json │ ├── rust-one │ │ ├── ... │ │ └── package.json │ └── rust-two │ │ ├── ... │ │ └── package.json ├── pnpm-workspace.yaml └── third-party ``` Put all packages in a`packages/`directory, no matter what language they are\. I also enjoy having[documentation as code](https://www.writethedocs.org/guide/docs-as-code/), so let’s say you have a`docs/`directory, too, and that your docs is written in a javascript\-based frontend \(like[Starlight](https://starlight.astro.build/)\), for the purposes of highlighting a nuance later\. ### Changeset configuration[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#changeset-configuration](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#changeset-configuration) With this setup, you can configure`changesets`with a proxy`pnpm`workspace at the root with all your packages\. ``` # pnpm-workspace.yaml packages: - "packages/**" ``` And, declare your`changesets`dependencies: ``` // package.json { "name": "example-monorepo", "private": true, "devDependencies": { "@changesets/changelog-git": "^0.2.0", "@changesets/cli": "^2.29.0" } } ``` You should now also update your`\.gitignore`: ``` node_modules/ ``` Because`changesets`is built for JavaScript, we also need “proxy”`package\.json`files for all of our packages;`changesets`uses these to perform version bumps\. These can be as simple as: ``` // packages/python-one/package.json { "name": "python-one", "version": "0.1.0", "private": true } ``` With this setup, note how we are intentionally trying to*exclude*our internal`docs/`as a pnpm workspace member—we only want to version packages\. To do so, declare the`docs/`directory its*own*`pnpm`workspace, otherwise it will try and combine the`docs/`dependencies into the root`package\-lock\.json`\. This can be as simple as: ``` # docs/pnpm-workspace.pyml packages: [] ``` Next, we can add our changeset configuration: ``` // .changeset/config.json { "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json", "changelog": "@changesets/changelog-git", "commit": false, "fixed": [], "linked": [], "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [], "privatePackages": { "version": true, "tag": true }, "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true } } ``` ### Automating releases with GitHub[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#automating-releases-with-github](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#automating-releases-with-github) #### The glue to create polyglot versioning PRs[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#the-glue-to-create-polyglot-versioning-prs](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#the-glue-to-create-polyglot-versioning-prs) Next, we want to automate our releases\. That is, generating the changelog PRs, bumping package metadata, pushing tags, and triggering builds on those tags\. Let’s start with our GitHub Workflow definition, and unpack the scripts it calls\. ``` name: Release on: push: branches: - main concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write pull-requests: write jobs: release: name: Release runs-on: ubuntu-latest outputs: published: ${{ steps.changesets.outputs.published }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: cache: npm - uses: astral-sh/setup-uv@v7 - uses: taiki-e/install-action@just - run: npm install - name: Create Release Pull Request or Tag id: changesets uses: changesets/action@v1 with: version: just version publish: npx @changesets/cli publish # I like conventional commits commit: "chore(release): version packages" title: "chore(release): version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} docker: needs: [release] if: needs.release.outputs.published == 'true' uses: ./.github/workflows/docker.yml secrets: inherit ``` You might be working why we run a workflow explicitly, rather than using something like`on\.push\.tags`as a trigger\. It turns out GitHub has two fatal flaws with that intuitive approach \(at time of writing\)\. First, if you push \>3 tags at once, workflows[will not trigger](https://github.com/changesets/changesets/issues/1545)\. Unfortunately, this is a relatively common scenario in a monorepo\. Second, GitHub’s triggering of`on\.push\.tags`is[highly unreliable](https://github.com/orgs/community/discussions/27028)\. This unreliability is still present, even if you use a PAT[as they instruct](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow)\. So, instead consider an explicit`workflow\_call`for the purpose as I’ve done here\. Setting`version: just version`is the key to polyglot support\. ``` # Version packages based on changesets [doc('Consume changesets: bump versions, update changelogs, sync native version files.')] [group('release')] version: npx @changesets/cli version uv run --script contrib/utils/sync-versions.py ``` The meat of the glue for polyglot support then, is how you implement`sync\-versions\.py`\. The key bit here is we rely on`changesets`to bump the versions in`package\.json`for us when we call`npx @changesets/cli version`, but then it is up to us to propagate that version to the respective language’s metadata appropriately\. Here is an example that uses pretty naive parsing\. You can write something similar \(or better\!\) for the languages you use\. ``` #!/usr/bin/env -S uv run --script # # /// script # requires-python = ">=3.12" # dependencies = [] # /// # # Sync versions from package.json files (updated by changesets) to native # package manifests (Cargo.toml, pyproject.toml, etc.). import json import re import subprocess from enum import Enum, auto from pathlib import Path PACKAGES_DIR = Path(__file__).resolve().parent.parent.parent / "packages" class SyncResult(Enum): NOT_FOUND = auto() UP_TO_DATE = auto() UPDATED = auto() def read_package_json(pkg_dir: Path) -> dict | None: """Read and parse a package.json file.""" pkg_json = pkg_dir / "package.json" if not pkg_json.exists(): return None return json.loads(pkg_json.read_text()) def update_cargo_toml(pkg_dir: Path, version: str) -> SyncResult: """Update version in [package] section of Cargo.toml.""" cargo_toml = pkg_dir / "Cargo.toml" if not cargo_toml.exists(): return SyncResult.NOT_FOUND lines = cargo_toml.read_text().splitlines(keepends=True) in_package_section = False for i, line in enumerate(lines): stripped = line.strip() # Track which TOML section we're in if stripped.startswith("["): in_package_section = stripped == "[package]" continue if in_package_section and stripped.startswith("version"): new_line = re.sub( r'^(\s*version\s*=\s*")([^"]+)(")', rf"\g<1>{version}\3", line, ) if new_line != line: lines[i] = new_line cargo_toml.write_text("".join(lines)) rel = cargo_toml.relative_to(PACKAGES_DIR.parent) print(f" Updated {rel}") return SyncResult.UPDATED return SyncResult.UP_TO_DATE return SyncResult.UP_TO_DATE def update_pyproject_toml(pkg_dir: Path, version: str) -> SyncResult: """Update version in [project] section of pyproject.toml.""" pyproject = pkg_dir / "pyproject.toml" if not pyproject.exists(): return SyncResult.NOT_FOUND lines = pyproject.read_text().splitlines(keepends=True) in_project_section = False for i, line in enumerate(lines): stripped = line.strip() # Track which TOML section we're in if stripped.startswith("["): in_project_section = stripped == "[project]" continue if in_project_section and stripped.startswith("version"): new_line = re.sub( r'^(\s*version\s*=\s*")([^"]+)(")', rf"\g<1>{version}\3", line, ) if new_line != line: lines[i] = new_line pyproject.write_text("".join(lines)) rel = pyproject.relative_to(PACKAGES_DIR.parent) print(f" Updated {rel}") return SyncResult.UPDATED return SyncResult.UP_TO_DATE return SyncResult.UP_TO_DATE def refresh_lockfiles() -> None: """Refresh all lockfiles under the repo to match updated versions.""" repo_root = PACKAGES_DIR.parent print("Refreshing lockfiles...") # Cargo.lock — root workspace + any standalone crate lockfiles cargo_locks = sorted( set(repo_root.glob("Cargo.lock")) | set(PACKAGES_DIR.rglob("Cargo.lock")) ) for cargo_lock in cargo_locks: lock_dir = cargo_lock.parent rel = lock_dir.relative_to(repo_root) or Path(".") print(f" cargo update --workspace in {rel}") subprocess.run(["cargo", "update", "--workspace"], cwd=lock_dir, check=True) # uv.lock — Python packages for uv_lock in sorted(PACKAGES_DIR.rglob("uv.lock")): lock_dir = uv_lock.parent print(f" uv lock in {lock_dir.relative_to(repo_root)}") subprocess.run(["uv", "lock"], cwd=lock_dir, check=True) def main() -> None: print("Syncing versions from package.json to native manifests...") print() updated = 0 for pkg_json in sorted(PACKAGES_DIR.rglob("package.json")): pkg_dir = pkg_json.parent pkg_data = read_package_json(pkg_dir) if pkg_data is None: continue version = pkg_data.get("version") if version is None: continue name = pkg_data.get("name", pkg_dir.name) print(f"{name} @ {version}") results = [ update_cargo_toml(pkg_dir, version), update_pyproject_toml(pkg_dir, version), ] if any(r == SyncResult.UPDATED for r in results): updated += 1 elif all(r == SyncResult.NOT_FOUND for r in results): print(" (no native manifest found)") else: print(" (already up to date)") print() print(f"Synced {updated} package(s).") print() refresh_lockfiles() print() print("Done.") if __name__ == "__main__": main() ``` #### Reacting to package tags[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#reacting-to-package-tags](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#reacting-to-package-tags) In the standard`changesets`flow, you will now have a pull request on GitHub with the appropriate`CHANGELOG\.md`updates, as well as the metadata updates for all the relevant packages\. Once that is merged, the very same action will run, realize all the`\.changeset`files are consumed, and push tags\. With our example configuration,`changesets`will only push tags, not publish packages, because we set ``` "privatePackages": { "version": true, "tag": true } ``` in our`\.changeset/config\.json`and have all the packages set to`private: true`\. Typically, you’ll then want to react to these pushed tags\. For example, to build new docker images\. For that, rather than using an`on\.push\.tags`trigger as you would reasonably assume, you probably want a`workflow\_call`\. See the tip earlier in the post for why\. ``` on: workflow_call: {} workflow_dispatch: inputs: dry_run: description: 'Build images without pushing to GHCR' required: false type: boolean default: false no_cache: description: 'Force a build without using the cache' required: false type: boolean default: false ``` ## Summary[https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#summary](https://luke.hsiao.dev/blog/changesets-polyglot-monorepo/#summary) `changesets`can manage per\-package semantic versioning and changelogs in polyglot monorepos today, even without direct native support for multiple languages\. The trick is to treat JavaScript package manifests as the canonical source of version bumps, and then syncing those bumps via your own scripts to the language\-native manifests\. A few gotchas exist \(like explicitly making independent`pnpm\-workspace\.yaml`files for subdirectories you want to be independent, or using a separate personal access token to push tags\), but none are blockers to being able to benefit from`changesets`’s convenient workflow\. I used to suggest versioning monorepos with a single global version using a tool like[`semantic\-release`](https://github.com/semantic-release/semantic-release)\. Since trying`changesets`, I’m sold on the benefits of letting people write commit messages for future internal engineers while also adding a separate changelog note for end users\. These are often two distinct audiences, and relying on a single conventional commit to serve both is often suboptimal\.

Similar Articles

GitHub Actions for a Gleam monorepo

Lobsters Hottest

A developer shares their GitHub Actions setup for testing a Gleam monorepo with separate BEAM and JavaScript runtimes, using matrix strategies and strict formatting checks.

Patching and forking in package managers

Lobsters Hottest

This article explores strategies for patching and forking dependencies in language-specific package managers when upstream maintainers fail to address vulnerabilities. It contrasts the robust patching capabilities of system package managers with the limitations of language registries, detailing workarounds like git overrides and forks across various ecosystems.

@itsclelia: I have one big problem with agentic engineering: I want agents to operate autonomously, but I also want granular, rever…

X AI KOLs Timeline

I have one big problem with agentic engineering: I want agents to operate autonomously, but I also want granular, reversible control over every change they make. I could solve this by committing every intermediate step to Git, but that would completely pollute my repo history. So I built 𝗮𝗴𝗴𝗶𝘁: a Git-like CLI for local and remote (S3-backed) agent artifact storage, written in Rust . With aggit, my agents can stash intermediate work, create branches safely, restore previous states, and back

Highlights from Git 2.54

Lobsters Hottest

Git 2.54 ships with a new experimental `git history` command that lets users reword or split commits without touching the working tree, plus 137 contributors’ worth of other improvements.

Emacs after Magit

Lobsters Hottest

The author recounts their experience moving away from the Magit Git interface for Emacs and adopting alternatives like VC-mode and custom Git scripts, highlighting the adjustments and lessons learned.