--- 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 is in Python. Again, this is just because that's what the author uses. * The project 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. * It's OK for Bumper to make several commits and a tag in the git repository. # Using Bumper In the examples below, we'll use Bumper on a fairly typical Python 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. 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. 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 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, 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 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, 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