--- title: Bump version numbers when releasing author: Lars Wirzenius version: git version ... # Introduction Bumper is a small utility for updating version numbers when making a release of a software project. It updates the version number in the various locations in which it is stored in the source tree, creates a git tag for indicating the release, then updates the source tree again with a version number that indicates an unreleased version. # Assumptions about the source tree Bumper makes several assumptions about the source tree, to keep things simpler. Here's a list: * The source tree is stored in git. No other version control systems are supported, sorry, but that's just because git is the only thing the author uses now. * The project may use any programming languages. * In projects using Python, the source tree contains exactly one file called `version.py`, and that file only sets the variables `__version__` and `__version_info__`. Bumper will overwrite that file so it sets the desired values, and commits those changes to git. * `setup.py` is assumed to get the version from `__version__` from the file Bumper writes, in some suitable way. Likewise the rest of the project. * In projects not using Python, the version number is stored in the file `version.txt` or `version.yaml`, and the build system is expected to get it from there. If the project version is 1.2, then `version.txt` would contain "1.2\n" and `version.yaml` would contain "version: 1.2\nversion_info: [1, 2]\n". If both files exist, the YAML file has precedence. * Bumper will update all of `version.py`, `version.txt`, and `version.yaml` if they exist. * Bumper will update NEWS and debian/changelog as well, if they exist to make sure the version number is correct. It will update them in one commit to have the release number, and in a subsequent commit with a non-release number. * Bumper will make several commits and a release tag in the git repository. The release tag is signed and annotated and of the form `foo-x.y` for project foo, release x.y. # Using Bumper In the examples below, we'll use Bumper on a project called `foo`, and we'll make a release 3.2 of it. SCENARIO release a project We will use a PGP test key for signing tags. This is in the `dot-gnupg` directory in the source tree. GIVEN a PGP key with id AA8CD13C The Foo project consists of a main program, which uses a little Python package where all the real code is, and where the version number is also stored. It also has the `version.txt` and `version.yaml` files. GIVEN Python project foo, version controlled by git AND a file foolib/version.py in foo containing ... "__version__ = '1.0'\n__version_info__ = (1, 0)\n" AND a file version.txt in foo containing "1.0\n" AND a file version.yaml in foo containing ... "version = 1.0\nversion_info = [1, 0]\n" AND project foo has Debian packaging We run Bumper to update version numbers and tag a new release. We first try with a version that's older than the current one. WHEN user attempts to run "bumper 0.1" in the foo directory THEN bumper exits with code 1 We now run Bumper properly, and it does its various things. WHEN user runs "bumper 3.2" in the foo directory Bumper creates a git tag for the release, and updates files in the tag to have the right version number. THEN git repository foo has tag foo-3.2, signed with AA8CD13C AND in foo, tag foo-3.2, foolib/version.py contains ... "__version__ = "3.2"\n__version_info__ = (3, 2)\n" AND in foo, tag foo-3.2, version.txt contains "3.2\n" AND in foo, tag foo-3.2, version.yaml contains ... "version: '3.2'\nversion_info: [3, 2]\n" AND in foo, tag foo-3.2, NEWS matches ... "^Version 3.2, released \\d\\d\\d\\d-\\d\\d-\\d\\d$" AND in foo, tag foo-3.2, has Debian version 3.2-1 Further, Bumper updates the files in master to have a version number with `+git` appended, so that any non-release builds (builds not from the tag) won't report a version number that looks like a release. AND file foolib/version.py in foo contains ... "__version__ = "3.2+git"\n__version_info__ = (3, 2, '+git')\n" AND file debian/changelog in foo matches ... "foo \\(3\\.2\\+git-1\\) UNRELEASED;" AND file version.txt in foo contains "3.2+git\n" AND file version.yaml in foo contains ... "version: 3.2+git\nversion_info: [3, 2, +git]\n" AND file NEWS in foo matches ... "Version 3\\.2\\+git, not yet released" We can also make a second release. WHEN user runs "bumper 3.4" in the foo directory THEN git repository foo has tag foo-3.4, signed with AA8CD13C AND in foo, tag foo-3.4, foolib/version.py contains ... "__version__ = "3.4"\n__version_info__ = (3, 4)\n" AND in foo, tag foo-3.4, NEWS matches ... "^Version 3.4, released \\d\\d\\d\\d-\\d\\d-\\d\\d$" AND in foo, tag foo-3.4, version.txt contains "3.4\n" AND in foo, tag foo-3.4, version.yaml contains ... "version: '3.4'\nversion_info: [3, 4]\n" AND in foo, tag foo-3.4, has Debian version 3.4-1 AND file foolib/version.py in foo contains ... "__version__ = "3.4+git"\n__version_info__ = (3, 4, '+git')\n" AND file debian/changelog in foo matches ... "foo \\(3\\.4\\+git-1\\) UNRELEASED;" AND file NEWS in foo matches ... "Version 3\\.4\\+git, not yet released" # Appendix: Scenario step implementations This chapter provides executable implementations of the various scenario steps, making this manual an automated [yarn][] test suite for Bumper. [yarn]: http://liw.fi/cmdtest/ IMPLEMENTS GIVEN a PGP key with id (\S+) import os, shutil, cliapp, yarnstep keyid = yarnstep.get_next_match() src = yarnstep.srcdir('dot-gnupg') dst = yarnstep.datadir('HOME/.gnupg') shutil.copytree(src, dst) cliapp.runcmd( ['gpg', '--list-keys', keyid], cwd=os.environ['HOME']) # Configure git to use this key for signing. cliapp.runcmd(['git', 'config', '--global', 'user.signingkey', keyid]) IMPLEMENTS GIVEN Python project (\S+), version controlled by git import os, cliapp, yarnstep project = yarnstep.get_next_match() dirname = yarnstep.datadir(project) yarnstep.write_file(os.path.join(dirname, 'foolib', '__init__.py'), ''' from .version import __version__, __version_info__ ''') yarnstep.write_file(os.path.join(dirname, 'setup.py'), ''' from distutils.core import setup import foolib setup(name='{project}', version=foolib.__version__) '''.format(project=project)) yarnstep.write_file(os.path.join(dirname, 'NEWS'), ''' NEWS for {project} ================== Version 0.0, not yet released ----------------------------- '''.format(project=project)) cliapp.runcmd(['git', 'init', dirname]) cliapp.runcmd(['git', 'add', '.'], cwd=dirname) cliapp.runcmd(['git', 'commit', '-mInitial'], cwd=dirname) IMPLEMENTS GIVEN a file (\S+) in (\S+) containing "(.*)" import os, cliapp, yarnstep filename = yarnstep.get_next_match() dirname = yarnstep.get_next_match_as_datadir_path() data = yarnstep.get_next_match() yarnstep.write_file( os.path.join(dirname, filename), yarnstep.unescape_backslashes(data)) cliapp.runcmd(['git', 'add', filename], cwd=dirname) cliapp.runcmd( ['git', 'commit', '-m', 'Add {}'.format(filename)], cwd=dirname) IMPLEMENTS GIVEN project (\S+) has Debian packaging import os, cliapp, yarnstep project = yarnstep.get_next_match() dirname = yarnstep.datadir(project) os.mkdir(os.path.join(dirname, 'debian')) cliapp.runcmd( ['dch', '--create', '-v', '1.0-1', '--package', project, ''], cwd=dirname) cliapp.runcmd(['git', 'add', 'debian'], cwd=dirname) cliapp.runcmd(['git', 'commit', '-m', 'Add debian packaging'], cwd=dirname) IMPLEMENTS WHEN user attempts to run "bumper (\S+)" in the (\S+) directory import cliapp, yarnstep version = yarnstep.get_next_match() dirname = yarnstep.get_next_match_as_datadir_path() bin = yarnstep.srcdir('bumper') returncode, out, err = cliapp.runcmd_unchecked([bin, version], cwd=dirname) yarnstep.write_file(yarnstep.datadir('bumper.exit'), str(returncode)) IMPLEMENTS THEN bumper exits with code (\d+) import yarnstep expected = yarnstep.get_next_match() actual = yarnstep.cat(yarnstep.datadir('bumper.exit')) print 'expected:', repr(expected) print 'actual:', repr(actual) assert expected == actual IMPLEMENTS WHEN user runs "bumper (\S+)" in the (\S+) directory import cliapp, yarnstep version = yarnstep.get_next_match() dirname = yarnstep.get_next_match_as_datadir_path() bin = yarnstep.srcdir('bumper') cliapp.runcmd([bin, version], cwd=dirname) IMPLEMENTS THEN file (\S+) in (\S+) contains "(.*)" import os, yarnstep filename = yarnstep.get_next_match() dirname = yarnstep.get_next_match_as_datadir_path() wanted_data_escaped = yarnstep.get_next_match() wanted_data = yarnstep.unescape_backslashes(wanted_data_escaped) actual_data = yarnstep.cat(os.path.join(dirname, filename)) print 'wanted_data:', repr(wanted_data) print 'actual_data:', repr(actual_data) assert wanted_data == actual_data IMPLEMENTS THEN file (\S+) in (\S+) matches "(.*)" import re, os, yarnstep filename = yarnstep.get_next_match() dirname = yarnstep.get_next_match_as_datadir_path() wanted_data_escaped = yarnstep.get_next_match() wanted_data = yarnstep.unescape_backslashes(wanted_data_escaped) actual_data = yarnstep.cat(os.path.join(dirname, filename)) print 'wanted_data:', repr(wanted_data) print 'actual_data:', repr(actual_data) assert re.search(wanted_data, actual_data) IMPLEMENTS THEN in (\S+), tag (\S+), (\S+) contains "(.*)" import cliapp, yarnstep dirname = yarnstep.get_next_match_as_datadir_path() tag = yarnstep.get_next_match() filename = yarnstep.get_next_match() wanted_data_escaped = yarnstep.get_next_match() wanted_data = yarnstep.unescape_backslashes(wanted_data_escaped) actual_data = cliapp.runcmd( ['git', 'cat-file', 'blob', '{}:{}'.format(tag, filename)], cwd=dirname) print 'wanted_data:', repr(wanted_data) print 'actual_data:', repr(actual_data) assert wanted_data == actual_data IMPLEMENTS THEN in (\S+), tag (\S+), (\S+) matches "(.*)" import re, cliapp, yarnstep dirname = yarnstep.get_next_match_as_datadir_path() tag = yarnstep.get_next_match() filename = yarnstep.get_next_match() wanted_data_escaped = yarnstep.get_next_match() wanted_data = yarnstep.unescape_backslashes(wanted_data_escaped) actual_data = cliapp.runcmd( ['git', 'cat-file', 'blob', '{}:{}'.format(tag, filename)], cwd=dirname) print 'wanted_data:', repr(wanted_data) print 'actual_data:', repr(actual_data) assert re.search(wanted_data, actual_data, flags=re.M) IMPLEMENTS THEN in (\S+), tag (\S+), has Debian version (\S+) import os, cliapp, yarnstep dirname = yarnstep.get_next_match_as_datadir_path() tag = yarnstep.get_next_match() version = yarnstep.get_next_match() text = cliapp.runcmd( ['git', 'cat-file', 'blob', '{}:debian/changelog'.format(tag)], cwd=dirname) line1, _ = text.split('\n', 1) print 'line1:', repr(line1) assert '({})'.format(version) in line1 assert line1.split()[2] != 'UNRELEASED;' IMPLEMENTS THEN git repository (\S+) has tag (\S+), signed with (\S+) import subprocess, cliapp, yarnstep dirname = yarnstep.get_next_match_as_datadir_path() tagname = yarnstep.get_next_match() keyid = yarnstep.get_next_match() output = cliapp.runcmd( ['git', 'verify-tag', tagname], cwd=dirname, stderr=subprocess.STDOUT) assert keyid in output