Versions guard
A GitHub Action that fails a pull request when a project changed without moving its version far enough for what changed.
Why it exists
Where a repository installs straight off main, there is no build and no publish step to hang a check on. An edit is just a commit, and the merge is the release. That leaves the diff as the only place to catch a project that shipped under its old version, which no client will fetch.
How far a version has to move is not a judgement about the size of a diff. It follows from what the changes say they are, by Conventional Commits and Semantic Versioning.
Adding it
A pull request already says what it is against, and the repository already says what it holds, so including the action is the whole of switching the check on:
name: Validate
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
# The comparison needs the fork point, so the whole history.
fetch-depth: 0
- uses: releasetools/actions/versions-guard@v0
What it asks
| the changes say | the version has to reach |
|---|---|
fix, perf, security | the next patch |
feat, deprecate | the next minor |
anything with ! or a BREAKING CHANGE: footer | the next major, or the next minor under 0.y.z |
only docs, chore, refactor, test, build, ci, style | nothing |
The largest in the range wins, so one feat among forty fixes asks for a minor. Reaching further than asked is not a failure: a minor already claimed by an earlier unreleased change absorbs every patch that follows it.
Version not bumped
packages/api is at 1.2.4, and a feat (read Cargo.toml) asks for at least
1.3.0 after 1.2.3. Somebody has 1.2.3 installed, and a client compares
versions to decide whether an update exists.
Where no change in the range carries a type there is nothing to read, and the older rule applies instead: anything that changed beyond the files a release writes has to move the version at all. A repository adopts typed commits when it is ready, and the check keeps working meanwhile.
What it compares against
The newest exact-version tag reachable from the commit, which is what git describe answers, not the newest tag by date. A 1.2.3 backported yesterday onto a release branch is not an ancestor of the commit on main, so it is another line's release and never the baseline there. On the release branch it is. A floating major such as v0 never counts, because it moves to whatever released last.
Where no such tag is reachable, the baseline is what the manifest said at the fork point. A repository that tags nothing, or that tags a published tree outside its own history, is judged that way throughout.
Declaring your projects
Every releasetools tool reads .releasetools.yaml at the repository root, and this is where the guards are configured. A repository that keeps no such file gets a warning on the pull request and no check: a guessed project would be reported on as though somebody had asked for it.
A repository is rarely one kind of thing. Groups say which files govern which directories, so a Rust crate and a TypeScript package can sit side by side:
# How this repository releases, read by every releasetools tool.
#
# Conventions: https://github.com/releasetools/conventions
# Tools: https://github.com/releasetools
projects:
- path:
- packages/api
- packages/web
manifest: package.json
changelog: CHANGELOG.md
- path: crates/engine
manifest: Cargo.toml
- path: charts/api
manifest: Chart.yaml
changelog: CHANGELOG.md
path takes one directory or a list of them, and every project is named. ./ is the repository itself. A pattern such as packages/* is refused: a declaration that resolves against the filesystem means what a run checks depends on what the tree happens to hold when it starts, and a directory created today arrives as a failed check rather than as a decision somebody made. A path that is not a directory is an error, so an entry left behind by a rename is heard about rather than skipped.
manifest and changelog are names inside each project's own directory, so manifest: package.json under packages/api reads packages/api/package.json, and .claude-plugin/plugin.json reads one a directory down. A directory two groups both name belongs to the first.
manifest is required, and nothing is guessed from what a directory happens to contain. Name every file that carries the version: a project keeping it in two places has to keep them in step, and the guard is what checks that.
projects:
- path: plugins/mutex
manifest:
- .claude-plugin/plugin.json
- .codex-plugin/plugin.json
changelog: CHANGELOG.md
A version kept in two places
manifest takes a list as well, and every file it names has to agree:
projects:
- path: services/api
manifest:
- package.json
- VERSION
Version not bumped
services/api declares 2.1.0 in services/api/package.json and 2.0.0 in
services/api/VERSION. Whichever a client reads is the one that decides
whether it updates, so they have to say the same thing.
A named file that is not there is not read, so one list can cover a group whose members differ. A version is read from a .json file's top-level version, a .toml file's package, project, tool.poetry or workspace.package table, a .yaml file's top-level version:, a .properties file's version= line, or from a file of any other name holding the version and nothing else.
What counts as a change
A release writes two things into the project it releases: the new version, and the changelog entry. Neither counts as the project changing, whatever else you say, or the check would ask for a release whose only content is the sentence announcing it.
ignore-files adds to that, and defaults to CHANGELOG.md, README.md and LICENSE. Setting it replaces those three rather than adding to them, so carry the ones you still want:
ignore-files:
- CHANGELOG.md
- README.md
- LICENSE
- docs/**
- "*.test.ts"
Each pattern matches the end of a path, on segment boundaries, so one entry covers every depth: README.md matches docs/README.md too, docs/README.md only matches one inside a docs, *.md any Markdown file, and docs/** a whole subtree. Case is ignored unless case-sensitive: true says otherwise.
This decides which projects are in scope at all. A pull request that touches only ignored files has changed no project, so nothing is asked of it.
Conventions you have not adopted
conventions:
except:
- bump-from-type
A convention named there is one no releasetools tool checks. Excepting bump-from-type drops the table above, and the older rule applies: a material change has to move the version at all, by however much.
Letting one pull request through
Label it skip-versions-guard. The action posts a notice saying it skipped, and passes, so a check somebody turned off is visible on the pull request that turned it off. The label is one fixed name rather than an input, because a reviewer should not have to read a repository's workflow to learn what its escape hatch is called. The changelog guard has its own label, so one can be skipped without the other.
What it reports
One line per project whose version moved far enough, then a verdict:
packages/api 0.2.0 -> 0.2.1
packages/web is new, at 0.1.0
Every changed project moved its version far enough
Failures arrive as annotations on the pull request, titled Version not bumped.
Inputs
| input | default | |
|---|---|---|
base | the pull request's base | the ref to compare against, resolved to where the branch forked from it. Read from the event on a pull request, and has to be set anywhere else |
Everything else is declared in .releasetools.yaml. A path, a manifest or a changelog that is absolute, or that climbs out of the repository with .., fails the run rather than being resolved. So does a file inside the repository that turns out to be a link to somewhere outside it: the working tree on a pull request is written by whoever opened it, and this reads versions out of files and prints them.
The full reference, including how a version is read out of each kind of manifest, is in the action's guide.