visit
For qiBuild, I had three files to patch because the code version number was hard-coded in several places (
setup.py
, __init__.py
, and docs/conf.py
).On the other hand, for Choregraphe the version number was hard-coded in the top
CMakeLists.txt
file only, but there was quite a bit of code to “forward” the version number from the top CMake
file down to the version.hpp
and main.cpp
files.Both solutions had their pros and cons but I could not decide which one was best. Since I was pretty sure I was not the first one to have encountered this issue, I started to look around for better solutions..bumpversion.cfg
) containing the current version and the aforementioned list of files.# in .bumpversion.cfg
[bumpversion]
current_version = 1.0.1
commit = True
tag = True
[bumpversion:file:setup.py]
[bumpversion:file:qiBuild/__init__.py]
[bumpversion:file:docs/conf.py]
bumpversion patch
A commit was automatically made along with a matching tag:$ git show
commit ec50897893ce4ecfb (HEAD -> master, tag: v1.0.2)
Author: Dimitri Merejkowsky <[email protected]>
Date: Wed May 20 16:58:27 2020 +0200
Bump version: 1.0.1 → 1.0.2
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
[bumpversion]
-current_version = 1.0.1
+current_version = 1.0.2
commit = True
tag = True
diff --git a/qiBuild/__init__.py b/qiBuild/__init__.py
--- a/qiBuild/__init__.py
+++ b/qiBuild/__init__.py
-__version__ = “1.0.1”
+__version__ = “1.0.2”
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
setup(
name="qiBuild",
- version="1.0.1",
+ version="1.0.2",
)
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
project = "qiBuild"
- version="1.0.1",
+ version="1.0.2",
Great! Now I just had to run
python setup.py sdist upload
and the 1.0.2 release was published on pypi.org.For the NAOqi project, it worked very well too.I could delete a bunch of CMake and preprocessor code and replace it with just one line of C++ code:// in naoqi/version.hpp
static std::string const NAOQI_VERSION = "2.3.0";
This time the
.bumpversion.cfg
file looked like this:[bumpversion]
current_version = 2.3.0
files = include/naoqi/version.hpp
Indeed, bumpversion is smart enough to bump various “parts” of the version number, namely the major, minor, and patch components used in the semver spec.
Here are some examples, assuming that the current version is 1.2.3:bumpversion patch : 1.2.3 -> 1.2.4
bumpversion minor : 1.2.3 -> 1.3.0
bumpversion major : 1.2.3 -> 2.0.0
We were using semver for qiBuild and NAOqi too at the beginning — but sometimes semver is not enough.
Let’s continue our story. When qiBuild got more usage and the software team grew, publishing qiBuild releases started becoming… scary.New features were added, refactorings were made and qiBuild had become an essential tool for all the developers in the software team (100 of them) — I was getting nervous about what would happen if I shipped a buggy release.
So, with the help of members of my team, I decided to start making release candidates. That way, a few brave colleagues could help me catch bugs before everyone upgraded to the latest stable version.Since Python developers had already come up with a version scheme that allowed for release candidates, I started using that. See for details. Basically, I could add a
rcX
suffix after the patch part.And… it turned out that doing so was far from trivial. Why?Well, bumpversion assumes you are using semver and, if you don’t, you need to specify a custom regex:parse = (?P<major>\d+)
(?P<minor>\d+)
(\.(?P<patch>\d+))? .
((?P<release>rc)(?P<rel_num>\d+))?
serialize =
{major}.{minor}
{major}.{minor}.{patch}
{major}.{minor}{release}{rel_num}
[bumpversion:part:release]
values =
a
b
rc
As you might expect, Tanker also had hard-coded version numbers and chunks of code whose only role was “forwarding” the version number from one file to another.
“This is exactly the same problem I had last time!”, I thought. So, I took a look at bumpversion again — but even after all this time the bug I opened was still not fixed.That’s when I realized there were two big problems with bumpversion which would be pretty hard to fix without rewriting a lot of code.First, it’s very hard to reliably identify “parts” of version numbers. Semantics can vary from one version scheme to the next. Even , but guessing how to bump from a release candidate to a stable one is near impossible.Secondly, there are some hidden defaults at play which make understanding what’s going on under the hood pretty hard.In other words, bumpversion was “too clever by half”.So, what to do? Well, rewrite from scratch of course! (It turned out to be a good idea after all — otherwise, I would not brag about it here :P)Back to our example — to go from 2.1.3 to 2.1.4 you run
tbump 2.1.4
instead of bumpversion patch
.Those differences come with a price.First, since there is no hard-coded default it’s harder to use tbump out of the box.However, this one was easy to fix : I added an
init
command to generate a tbump.toml
file automatically. Instead of having to read the docs, users can read the generated file and get started quickly.Secondly, since one has to specify the new version instead of a segment one wants to bump it’s easy to make mistakes, like going from 1.0.3 to 1.0.5 instead of 1.0.4.That’s where it gets interesting.You see, I was pretty annoyed by some aspects of the bumpversion UX, especially when trying to tweak the configuration file.Just watch:$ bumpversion patch --dry-run
<nothing>
$ bumpversion patch --verbose --dry-run
current_version=1.0.2
commit=True
tag=True
new_version=1.0.3
Now look at what
tbump --dry-run
does:$ tbump --dry-run 1.0.3
:: Bumping from 1.0.2 to 1.0.3
=> Would patch these files
- setup.py:3 version="1.0.2",
+ setup.py:3 version="1.0.3",
- foo.py:1 __version__ = "1.0.2"
+ foo.py:1 __version__ = "1.0.3"
- tbump.toml:2 current = "1.0.2"
+ tbump.toml:2 current = "1.0.3"
=> Would run these git commands
$ git add --update
$ git commit --message Bump to 1.0.3
$ git tag --annotate --message v1.0.3 v1.0.3
$ git push origin master
$ git push origin v1.0.3
The output is similar without the
--dry-run
option, except changes are actually applied and git commands are run.And then I realized that every time I was bumping something, I would be following the same pattern:tbump $NEW_VERSION --dry-run
tbump $NEW_VERSION
# (simplified)
def main():
bump(dry_run=True)
answer = input("Looking good (y/n)?")
if answer != "y":
sys.exit("Canceled by user")
else:
bump(dry_run=False)
So right off, we knew we would have at least three packages:
@tanker/core
, @tanker/client-browser
, and @tanker/client-node
.It did not make sense to have different version numbers for those three packages, so here’s what we ended up with:// In core/package.json
{
"name": "@tanker/core",
"version": "1.2.0",
// ...
"dependencies": {
"libsodium-wrappers": "^0.5.1",
// ...
}
}
// In client-browser/package.json
{
"name": "@tanker/client-browser",
"version": "1.2.0",
// ...
"dependencies": {
"@tanker/core": "1.2.0",
// ...
}
}
[[file]]
src = "packages/core/package.json"
search = '"version": "{current_version}"'
[[file]]
src = "packages/client-node/package.json"
search = '"version": "{current_version}"'
[[file]]
src = "packages/client-node/package.json"
search = '"@tanker/crypto": "{current_version}"'
Note the
search
line: we did not want to blindly replace all occurrences of the new version in packages.json — if a line declares a third-party dependency that happens to have the same version number as the current one, it should not get patched!Speaking of dependencies, we also needed to patch the line that specifies the version of
@tanker/core
used by @tanker/client-browser
and @tanker/client-node
:[[file]]
src = "packages/client-browser/package.json"
search = '"@tanker/core": "{current_version}"'
[[file]]
src = "packages/client-node/package.json"
search = '"@tanker/core": "{current_version}"'
That’s a total of four blocks of configuration. Then we extracted a
@tanker/crypto
package from @tanker/core
, and added two new blocks of configuration:[[file]]
# Bump version of @tanker/crypto
src = "packages/crypto/package.json"
search = '"@tanker/core": "{current_version}"'
[[file]]
# Bump version for @tanker/core too — it depends on @tanker/crypto!
src = "packages/core/package.json"
search = '"@tanker/crypto": "{current_version}"'
Unbeknownst to us, that was the start of a slippery slope: every time we added a new package in the workspace, we’d have to add two blocks of configuration for this package, and one for every package that depended on it.
This is a famous anti-pattern, and before you can say “I see a polynomial complexity!”, we ended up with : a 200 hundred lines configuration file!@tanker/.*
instead of specifying the whole name of the dependency (@tanker/core
, @tanker/crypto
, and so on)packages/*/packages.json
as a glob pattern.This pull request was of, course, quickly reviewed and merged by yours truly, and all the nasty blocks in the
tbump.toml
file were replaced with just two:[[file]]
src = "packages/**/package.json"
search = '"version": "{current_version}"'
[[file]]
src = "packages/**/package.json"
search = '"@tanker/[^"]+": "{current_version}"'
Hooks can run before the files are patched and the commit is made, or after the new commit and new tag have been pushed and are defined in the
tbump.toml
file.You can find examples of this in :[[before_commit]]
name = "Check Changelog"
cmd = "grep -q {new_version} Changelog.rst"
[[after_push]]
name = "Publish to pypi"
cmd = "tools/publish.sh"
tbump is now the deployment tool for Tanker.
We often use “before commit” hooks to perform various checks (like verifying that the version to be published is mentioned in the changelog), or to make sure that generated files are up-to-date (like
yarn.lock
for instance).We also use “after push” hooks as “executable documentation” to specify how to publish new releases (like using
poetry publish
in case of a Python package).So, what did we learn?Previously published at