Monorepos

View Source

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:

optiondefaultmeaning
namethe directory basenamedisplay name and tag component
version_tag_prefix<name>-vtag prefix for this package
changelog_file<path>/CHANGELOG.mdcreated 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_changefalserelease a patch for any conventional commit, not only fixes/features/breaking changes
pr_groupnonepull-request grouping (see below)
solo_prfalsewith pr_group: also keep a standalone release PR for this package (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, pr_labels and close_stale_pull_requests (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:

  1. mix git_ops.release computes pending releases, builds the changelog and version-file updates into a commit using git plumbing (the working tree is never touched), force-pushes a git-ops/release/<name> branch, and opens or updates its pull request. Packages sharing a pr_group share one branch and PR; every other package gets its own. Because a pr_group becomes part of the branch name, it may only contain letters, digits, +, ., _, /, and -. A grouped package with "solo_pr": true additionally keeps its own standalone PR: merging the group PR releases the whole group including it, while merging the standalone PR releases only that package. Each PR's body links to the other and says which packages the alternative releases; whichever merges first, the next run updates or closes the remaining one. (solo_pr without a pr_group changes nothing — such a package already has its own PR.) pr_labels are applied when a PR is first created. With "close_stale_pull_requests": true, a package with no releasable changes also has its open release PR closed, which retires a proposal whose release merged while an earlier run was still in flight.
  2. Merging a release PR is the release. mix git_ops.tag_merged then 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 match tag_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.