Monorepos
August 10, 2026 · View on GitHub
git_ops releases any number of packages from one repository. Each package
gets its own version stream (tagged <name>-vX.Y.Z), its own changelog, and
its own release cadence, while one mix git_ops.release run handles them
all. No mix project is required, so the packages can be anything — Elixir,
node, terraform, a directory of YAML.
Configuration
Monorepo configuration lives in a git_ops.json file at the repository
root, which takes precedence over the application environment:
{
"repository_url": "https://github.com/my_org/my_repo",
"first_parent": true,
"packages": {
"my_app": {
"managed_files": [{ "path": "mix.exs", "type": "mix" }]
},
"web": {
"managed_files": [{ "path": "package.json", "type": "json" }],
"exclude_paths": ["docs"],
"patch_on_any_change": true
},
"web/shared": {
"managed_files": [{ "path": "package.json", "type": "json" }]
},
"infra": {
"managed_files": [{ "path": "version.txt", "type": "raw" }]
}
},
"linked_packages": [["web", "web/shared"]]
}
Each key in packages is a directory. A commit belongs to the deepest
package whose directory contains it — a commit touching only web/shared
releases web/shared, not web.
depends_on declares shared code a package ships without containing: a
package with "depends_on": ["libs/ui"] is released by commits to
libs/ui, with those commits' changelog entries appearing in (and their
types driving the bump of) every package that declares them.
Per-package options:
| option | default | meaning |
|---|---|---|
name | the directory basename | display name and tag component |
version_tag_prefix | <name>-v | tag prefix for this package |
changelog_file | <path>/CHANGELOG.md | created on first release if missing |
version_source | "tags" | where the current version is read from |
managed_files | [] | files updated with the new version on release |
exclude_paths | [] | package-relative paths whose commits don't count |
depends_on | [] | repo-relative paths whose commits also count — shared code the package ships |
patch_on_any_change | false | release a patch for any conventional commit, not only fixes/features/breaking changes |
pr_group | none | pull-request grouping (see below) |
managed_files entries take a type — "mix" (@version "..."), "json"
(a "version": "..." field), "raw" (the file's whole content is the
version), or "string" (, "~> ...") — or a custom "pattern" template in
which {version} is replaced.
Top-level options: repository_url, types (changelog sections, e.g.
{"chore": {"header": "Chores", "hidden": false}}), section_order
(changelog section order by type; defaults to features first, fixes next,
chores last), first_parent (count only first-parent commits, for
squash/merge workflows), linked_packages (groups that always release
together at the same version), release_strategy and pr_labels (see
below).
Releasing
By default mix git_ops.release releases every package with releasable
commits in one commit on the current branch, with one tag per released
package — the single-package flow, applied per package.
Through pull requests
"release_strategy": "pull_request" splits releasing into two idempotent
phases, designed to run in CI on every push to the main branch:
mix git_ops.releasecomputes pending releases, builds the changelog and version-file updates into a commit using git plumbing (the working tree is never touched), force-pushes agit-ops/release/<name>branch, and opens or updates its pull request. Packages sharing apr_groupshare one branch and PR; every other package gets its own.pr_labelsare applied when a PR is first created.- Merging a release PR is the release.
mix git_ops.tag_mergedthen reconciles tags: any package whose version file holds a version with no matching tag gets tagged at the commit that set it, the tag is pushed, and a GitHub release is created from the changelog entry. The commit that set the version must matchtag_merged_commit_pattern(default^chore(\(.+\))?: release), so a stray version-file edit in an ordinary commit is refused loudly instead of becoming a release.
Both tasks derive everything from repository state, so a run that dies
partway is repaired by the next one. GitHub API calls authenticate with the
GITHUB_TOKEN environment variable; pushes use the ambient git credentials.
Use a token whose pushes trigger workflows (a PAT or app token — in GitHub
Actions, pushes made with the default GITHUB_TOKEN don't) if tags are what
trigger your deploys.
Running without a host project
git_ops can't be a mix archive (archives can't have dependencies), so a
repository with no mix project runs it through Mix.install:
# scripts/release.exs
Mix.install([{:git_ops, "~> 2.10"}])
case System.argv() do
["tag_merged" | args] -> Mix.Tasks.GitOps.TagMerged.run(args)
args -> Mix.Tasks.GitOps.Release.run(args)
end
A CI job then checks out with full history (fetch-depth: 0; a
filter: blob:none partial clone works — file contents are read through
git show), configures the bot's git identity, and runs
elixir scripts/release.exs tag_merged followed by
elixir scripts/release.exs.
Trying it out
--dry-run never pushes or calls the GitHub API. With --output <dir> it
writes each would-be pull request's body and full file contents under the
directory, so you can inspect exactly what a config change does to the
release plan:
mix git_ops.release --dry-run --output /tmp/proposals
mix git_ops.tag_merged --dry-run
Bootstrapping a package
A package's version history starts from its tags, so a package that has never been released needs one baseline tag:
git tag my_app-v1.0.0 <sha>
Place it on the commit you consider already-released: commits after it feed the first release PR, so tagging deep in history changelogs everything since.