A release of mine had to be deleted, re-tagged and rebuilt because one file still carried the previous version. The missed edit was not the interesting failure. The interesting one is that nothing in the repository knew how many places were supposed to be edited, so "done" was a feeling.
"How many places does this number live?" is answerable in about a minute. Here is how I answered it for my own project, with the exact numbers, and the three ways it fails that I could reproduce on a copy of the tree.
First count: the number itself
Take the version string and ask git which tracked files contain it.
import subprocess
VERSION = "0.3.25"
files = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.split()
hits = [f for f in files
if VERSION in open(f, encoding="utf-8", errors="ignore").read()]
print(len(files), "tracked files,", len(hits), "mention the version")
for f in hits:
print(" ", f)
My project answers 716 tracked files, 8 of them mentioning 0.3.25, in nine occurrences. All eight are genuine declarations:
| file | declarations | kind |
|---|---|---|
emrg/__init__.py |
1 | the authoritative one |
pyproject.toml |
1 | packaging metadata |
uv.lock |
1 | the project's own entry in the lockfile |
emrg/gui/package.json |
1 | the GUI app |
emrg/gui/package-lock.json |
2 | the GUI app again, root and packages[""] |
packaging/build-runtime.sh |
1 | a fallback echo when git is absent |
packaging/make-installer.sh |
1 | same |
packaging/make-run-installer.sh |
1 | same |
That is the comfortable result, and it is worth having: the set of files that hardcode the number is exactly the set that has to change. It also has a corollary you may not want. If a test or a document hardcodes the version, it lands in this list too, and you have to decide whether that is a claim you are willing to maintain. In my tree nothing outside those eight mentions the number — the tests read it instead of restating it — and that is a property I now check rather than assume.
Second count: the field name
The first count is a property of searching for a value you already know. A bump tool does not know it. It is asked to write a version it has not seen yet, so it cannot search for the value; it has to find the declarations themselves, and that means searching for the field name.
The field name is much wider than the declaration. One file in my project is a JavaScript lockfile. Counting "version" fields in it:
334 fields named "version"
2 of them are the application's own
332 belong to dependencies
A regex that rewrites "version": "<something>" in that file rewrites a third of the dependency tree. This is not a hypothetical worry: the project's notes record a one-line version bump that became 556 lines of diff, because a tool rewrote every registry URL in a lockfile on the way past.
So the anchor has to identify the declaration and nothing else. In a lockfile the only nearby thing that is unambiguously yours is the package's own name:
re.compile(r'"name": "emrg-gui",\n\s*"version": "\d+\.\d+\.\d+"')
and the number of matches is asserted, not assumed — exactly 2, because the lockfile legitimately carries the app version twice. You can see where this is going.
The three arms I could reproduce
All three ran against a copy of the eight files, never the real tree.
Arm 1 — a drift that is named, not summarised. I left one source behind and asked the check to report:
checking all 8 files against 0.3.25 (emrg/__init__.py) ...
FAIL: 1 drift(s) found:
- pyproject.toml: 0.3.24 != 0.3.25
rc 1, and the sentence names the file and both values. A summary like "1 source disagrees" would have sent me reading eight files to find which one.
Arm 2 — an anchor that stops matching. I removed a single space from the lockfile's name field, the kind of churn a different formatter produces:
FAIL: 1 drift(s) found:
- emrg/gui/package-lock.json: expected 2 version declaration(s)
matching the anchor, found 0
rc 1. This is the arm worth copying. The dangerous version of this tool is the one that finds zero matches and reports "0 occurrences replaced — success": it writes nothing, exits 0, and the release then builds a version the repository never declared. An anchor that can silently match nothing is not an anchor, it is a hope.
Arm 3 — refusing without damage. With the drift from arm 1 still in place, I ran a real bump. It exited rc 2 before writing anything:
error: pyproject.toml: contains ['0.3.24'] but emrg/__init__.py says 0.3.25
- sources are already inconsistent
and I hashed all eight files before and after: zero files changed. That ordering is a design decision — validate every source, then write — and it is the one I would copy first. The alternative is worse than failing loudly: a late failure after earlier files were already rewritten leaves the tree half-bumped, and "the tool refused, so nothing happened" turns into manual cleanup.
Then the clean run, for contrast:
updated 0.3.26 (from 0.3.25) - 8 files
...
OK: all 8 version sources agree on 0.3.26
The tree you checked is part of the claim
The tool prints a tree: line before its verdict. That line exists because of a false green I know about: a reviewer standing in a checkout one version behind ran the check and read OK: all 8 version sources agree on 0.3.4 — about the tree they were standing in, not the release they were reviewing. The check was correct. The question was different from the one it answered, and nothing in the output said so.
Two rules follow. A guard should name its subject in its own output. And the reviewer's version of the command has to be able to point at a tree that is not the current directory — otherwise the honest reading is "eight sources agree somewhere I am not looking".
The tag is another place the version lives
One more place, and it is easy to miss because it is not a file: the tag you push. If the pipeline takes the release's name from the tag and the artifact's version from the tree, a tag cut on the wrong commit publishes a release whose two halves disagree — and every machine that installs it sees a version mismatch forever. So the last check before tagging is not "did I bump", it is "does this tag name the version this tree declares".
Doing it in your own repository
- Write the site list down as data. A table in a README rots silently. A list inside the bump script fails when it is wrong, which is when you want it to.
- Assert a count per file, not "at least one match". Files that legitimately hold the declaration twice are exactly where a bare matcher would do damage, and exactly where a count earns its keep.
- Break the anchor on purpose, once, and keep the reading. If it answers "0 replaced, success", you have a tool you cannot trust; the fix is a missing-anchor error, not a more careful afternoon.
- Validate all, then write. The two-pass form costs a dozen lines and turns a partial write into a refusal.
- Keep the tool and the test in separate jobs. The test catches drift after an edit happened; the tool prevents an edit from being partial. Neither one substitutes for the other, and a project usually has the test and forgets the tool.
The one-line version
A version number is a join across files, and joins fail at the edges. So count the join — the files, the declarations, and the fields that merely look like them — and make a missing member a loud failure instead of a quiet one.
The numbers above come from scripts/bump-version.py in the project this post is published from, argszero/emrg. The three arms are re-runnable: copy the eight files anywhere, point the check at them, and break an anchor by one space.
Comments
Join the discussionNo comments yet. Be the first to comment.