Look for and process sem-ver pseudo headers in git

At the moment careful changelog review is needed by humans to
determine the next version number. Semantic versioning means that all
we need to know to get a reasonable default next version (and a lower
bound on the next version) is a record of bugfix/deprecation/feature
work and api-breaking commits. In this patch we start scanning for
such headers from the git history to remove this burden from
developers/project release managers. Higher versions can of course
be used either via pre-versioning or by tagging the desired version.

implements: blueprint pbr-semver
sem-ver: feature
Change-Id: Id5e8cd723d5186d1bd8c01599eae8933e6f7ea6d
This commit is contained in:
Robert Collins
2014-07-21 14:31:16 +12:00
parent c7e00a36de
commit c1c99a7c31
3 changed files with 104 additions and 4 deletions

View File

@@ -36,6 +36,16 @@ If a given revision is tagged, that's the version.
If it's not, then we take the last tagged version number and increment it to If it's not, then we take the last tagged version number and increment it to
get a minimum target version. get a minimum target version.
We then walk git history back to the last release. Within each commit we look
for a sem-ver: pseudo header, and if found parse it looking for keywords.
Unknown symbols are not an error (so that folk can't wedge pbr or break their
tree), but we will emit an info level warning message. Known symbols:
``feature``, ``api-break``, ``deprecation``, ``bugfix``. A missing
sem-ver line is equivalent to ``sem-ver: bugfix``. The ``bugfix`` symbol causes
a patch level increment to the version. The ``feature`` and ``deprecation``
symbols cause a minor version increment. The ``api-break`` symbol causes a
major version increment.
If postversioning is in use, we use the resulting version number as the target If postversioning is in use, we use the resulting version number as the target
version. version.

View File

@@ -776,6 +776,43 @@ def have_sphinx():
return _have_sphinx return _have_sphinx
def _get_increment_kwargs(git_dir, tag):
"""Calculate the sort of semver increment needed from git history.
Every commit from HEAD to tag is consider for sem-ver metadata lines.
See the pbr docs for their syntax.
:return: a dict of kwargs for passing into SemanticVersion.increment.
"""
result = {}
if tag:
version_spec = tag + "..HEAD"
else:
version_spec = "HEAD"
changelog = _run_git_command(['log', version_spec], git_dir)
header_len = len(' sem-ver:')
commands = [line[header_len:].strip() for line in changelog.split('\n')
if line.startswith(' sem-ver:')]
symbols = set()
for command in commands:
symbols.update([symbol.strip() for symbol in command.split(',')])
def _handle_symbol(symbol, symbols, impact):
if symbol in symbols:
result[impact] = True
symbols.discard(symbol)
_handle_symbol('bugfix', symbols, 'patch')
_handle_symbol('feature', symbols, 'minor')
_handle_symbol('deprecation', symbols, 'minor')
_handle_symbol('api-break', symbols, 'major')
for symbol in symbols:
log.info('[pbr] Unknown sem-ver symbol %r' % symbol)
# We don't want patch in the kwargs since it is not a keyword argument -
# its the default minimum increment.
result.pop('patch', None)
return result
def _get_revno_and_last_tag(git_dir): def _get_revno_and_last_tag(git_dir):
"""Return the commit data about the most recent tag. """Return the commit data about the most recent tag.
@@ -813,7 +850,7 @@ def _get_version_from_git_target(git_dir, target_version):
['log', '-n1', '--pretty=format:%h'], git_dir) ['log', '-n1', '--pretty=format:%h'], git_dir)
tag, distance = _get_revno_and_last_tag(git_dir) tag, distance = _get_revno_and_last_tag(git_dir)
last_semver = version.SemanticVersion.from_pip_string(tag or '0') last_semver = version.SemanticVersion.from_pip_string(tag or '0')
new_version = last_semver.increment() new_version = last_semver.increment(**_get_increment_kwargs(git_dir, tag))
if target_version is not None and new_version > target_version: if target_version is not None and new_version > target_version:
raise ValueError( raise ValueError(
"git history requires a target version of %(new)s, but target " "git history requires a target version of %(new)s, but target "

View File

@@ -73,12 +73,15 @@ class TestRepo(fixtures.Fixture):
'example@example.com'], self._basedir) 'example@example.com'], self._basedir)
base._run_cmd(['git', 'add', '.'], self._basedir) base._run_cmd(['git', 'add', '.'], self._basedir)
def commit(self): def commit(self, message_content='test commit'):
files = len(os.listdir(self._basedir)) files = len(os.listdir(self._basedir))
path = self._basedir + '/%d' % files path = self._basedir + '/%d' % files
open(path, 'wt').close() open(path, 'wt').close()
base._run_cmd(['git', 'add', path], self._basedir) base._run_cmd(['git', 'add', path], self._basedir)
base._run_cmd(['git', 'commit', '-m', 'test commit'], self._basedir) base._run_cmd(['git', 'commit', '-m', message_content], self._basedir)
def uncommit(self):
base._run_cmd(['git', 'reset', '--hard', 'HEAD^'], self._basedir)
def tag(self, version): def tag(self, version):
base._run_cmd( base._run_cmd(
@@ -237,6 +240,20 @@ class TestVersions(base.BaseTestCase):
version = packaging._get_version_from_git() version = packaging._get_version_from_git()
self.assertThat(version, matchers.StartsWith('1.2.4.dev1.g')) self.assertThat(version, matchers.StartsWith('1.2.4.dev1.g'))
def test_untagged_version_minor_bump(self):
self.repo.commit()
self.repo.tag('1.2.3')
self.repo.commit('sem-ver: deprecation')
version = packaging._get_version_from_git()
self.assertThat(version, matchers.StartsWith('1.3.0.dev1.g'))
def test_untagged_version_major_bump(self):
self.repo.commit()
self.repo.tag('1.2.3')
self.repo.commit('sem-ver: api-break')
version = packaging._get_version_from_git()
self.assertThat(version, matchers.StartsWith('2.0.0.dev1.g'))
def test_untagged_version_has_dev_version_preversion(self): def test_untagged_version_has_dev_version_preversion(self):
self.repo.commit() self.repo.commit()
self.repo.tag('1.2.3') self.repo.tag('1.2.3')
@@ -244,7 +261,7 @@ class TestVersions(base.BaseTestCase):
version = packaging._get_version_from_git('1.2.5') version = packaging._get_version_from_git('1.2.5')
self.assertThat(version, matchers.StartsWith('1.2.5.dev1.g')) self.assertThat(version, matchers.StartsWith('1.2.5.dev1.g'))
def test_preversion_too_low(self): def test_preversion_too_low_simple(self):
# That is, the target version is either already released or not high # That is, the target version is either already released or not high
# enough for the semver requirements given api breaks etc. # enough for the semver requirements given api breaks etc.
self.repo.commit() self.repo.commit()
@@ -256,6 +273,42 @@ class TestVersions(base.BaseTestCase):
ValueError, packaging._get_version_from_git, '1.2.3') ValueError, packaging._get_version_from_git, '1.2.3')
self.assertThat(err.args[0], matchers.StartsWith('git history')) self.assertThat(err.args[0], matchers.StartsWith('git history'))
def test_preversion_too_low_semver_headers(self):
# That is, the target version is either already released or not high
# enough for the semver requirements given api breaks etc.
self.repo.commit()
self.repo.tag('1.2.3')
self.repo.commit('sem-ver: feature')
# Note that we can't target 1.2.4, the feature header means we need
# to be working on 1.3.0 or above.
err = self.assertRaises(
ValueError, packaging._get_version_from_git, '1.2.4')
self.assertThat(err.args[0], matchers.StartsWith('git history'))
def test_get_kwargs_corner_cases(self):
# No tags:
git_dir = self.repo._basedir + '/.git'
get_kwargs = lambda tag: packaging._get_increment_kwargs(git_dir, tag)
def _check_combinations(tag):
self.repo.commit()
self.assertEqual(dict(), get_kwargs(tag))
self.repo.commit('sem-ver: bugfix')
self.assertEqual(dict(), get_kwargs(tag))
self.repo.commit('sem-ver: feature')
self.assertEqual(dict(minor=True), get_kwargs(tag))
self.repo.uncommit()
self.repo.commit('sem-ver: deprecation')
self.assertEqual(dict(minor=True), get_kwargs(tag))
self.repo.uncommit()
self.repo.commit('sem-ver: api-break')
self.assertEqual(dict(major=True), get_kwargs(tag))
self.repo.commit('sem-ver: deprecation')
self.assertEqual(dict(major=True, minor=True), get_kwargs(tag))
_check_combinations('')
self.repo.tag('1.2.3')
_check_combinations('1.2.3')
def load_tests(loader, in_tests, pattern): def load_tests(loader, in_tests, pattern):
return testscenarios.load_tests_apply_scenarios(loader, in_tests, pattern) return testscenarios.load_tests_apply_scenarios(loader, in_tests, pattern)