add semver-next
command
Add a sub-command for computing the next release version number by applying Semantic Versioning rules to the release notes added to a project since the last published release. Add configuration options to control which notes sections trigger updates to each level of the version number. Change-Id: I96be0c81a3947aaa0bf9080b500cf1bc77abe655 Signed-off-by: Doug Hellmann <doug@doughellmann.com>
This commit is contained in:
parent
05d52d38b4
commit
789ecb12d2
@ -176,6 +176,14 @@ mistakes. The command exits with an error code if there are any
|
|||||||
mistakes, so it can be used in a build pipeline to force some
|
mistakes, so it can be used in a build pipeline to force some
|
||||||
correctness.
|
correctness.
|
||||||
|
|
||||||
|
Computing Next Release Version
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Run ``reno -q semver-next`` to compute the next SemVer_ version number
|
||||||
|
based on the types of release notes found since the last release.
|
||||||
|
|
||||||
|
.. _SemVer: https://semver.org
|
||||||
|
|
||||||
.. _configuration:
|
.. _configuration:
|
||||||
|
|
||||||
Configuring Reno
|
Configuring Reno
|
||||||
|
8
releasenotes/notes/semver-next-63c68cf10ec91f09.yaml
Normal file
8
releasenotes/notes/semver-next-63c68cf10ec91f09.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Add the ``semver-next`` command to calculate the next release
|
||||||
|
version based on the available release notes. Three new
|
||||||
|
configuration options (``semver_major``, ``semver_minor``, and
|
||||||
|
``semver_patch``) define the sections that should cause different
|
||||||
|
types of version increments. See :doc:`/user/usage` for details.
|
@ -188,6 +188,22 @@ _OPTIONS = [
|
|||||||
name that will be passed to the encoding kwarg for open(), so any
|
name that will be passed to the encoding kwarg for open(), so any
|
||||||
codec or alias from stdlib's codec module is valid.
|
codec or alias from stdlib's codec module is valid.
|
||||||
""")),
|
""")),
|
||||||
|
|
||||||
|
Opt('semver_major', ['upgrade'],
|
||||||
|
textwrap.dedent("""\
|
||||||
|
The sections that indicate release notes triggering major version
|
||||||
|
updates for the next release, from X.Y.Z to X+1.0.0.
|
||||||
|
""")),
|
||||||
|
Opt('semver_minor', ['features'],
|
||||||
|
textwrap.dedent("""\
|
||||||
|
The sections that indicate release notes triggering minor version
|
||||||
|
updates for the next release, from X.Y.Z to X.Y+1.0.
|
||||||
|
""")),
|
||||||
|
Opt('semver_patch', ['fixes'],
|
||||||
|
textwrap.dedent("""\
|
||||||
|
The sections that indicate release notes triggering patch version
|
||||||
|
updates for the next release, from X.Y.Z to X.Y.Z+1.
|
||||||
|
""")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
18
reno/main.py
18
reno/main.py
@ -21,6 +21,7 @@ from reno import defaults
|
|||||||
from reno import linter
|
from reno import linter
|
||||||
from reno import lister
|
from reno import lister
|
||||||
from reno import report
|
from reno import report
|
||||||
|
from reno import semver
|
||||||
|
|
||||||
_query_args = [
|
_query_args = [
|
||||||
(('--version',),
|
(('--version',),
|
||||||
@ -193,6 +194,23 @@ def main(argv=sys.argv[1:]):
|
|||||||
)
|
)
|
||||||
do_linter.set_defaults(func=linter.lint_cmd)
|
do_linter.set_defaults(func=linter.lint_cmd)
|
||||||
|
|
||||||
|
do_semver = subparsers.add_parser(
|
||||||
|
'semver-next',
|
||||||
|
help='calculate next release version based on semver rules',
|
||||||
|
)
|
||||||
|
do_semver.add_argument(
|
||||||
|
'reporoot',
|
||||||
|
default='.',
|
||||||
|
nargs='?',
|
||||||
|
help='root of the git repository',
|
||||||
|
)
|
||||||
|
do_semver.add_argument(
|
||||||
|
'--branch',
|
||||||
|
default=config.Config.get_default('branch'),
|
||||||
|
help='the branch to scan, defaults to the current',
|
||||||
|
)
|
||||||
|
do_semver.set_defaults(func=semver.semver_next_cmd)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
# no arguments, print help messaging, then exit with error(1)
|
# no arguments, print help messaging, then exit with error(1)
|
||||||
if not args.command:
|
if not args.command:
|
||||||
|
101
reno/semver.py
Normal file
101
reno/semver.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
from reno import loader
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_next_version(conf):
|
||||||
|
"Compute the next semantic version based on the available release notes."
|
||||||
|
LOG.debug('starting semver-next')
|
||||||
|
ldr = loader.Loader(conf, ignore_cache=True)
|
||||||
|
LOG.debug('known versions: %s', ldr.versions)
|
||||||
|
|
||||||
|
# We want to include any notes in the local working directory or
|
||||||
|
# in any commits that came after the last tag. We should never end
|
||||||
|
# up with more than 2 entries in to_include.
|
||||||
|
to_include = []
|
||||||
|
for to_consider in ldr.versions:
|
||||||
|
if to_consider == '*working-copy*':
|
||||||
|
to_include.append(to_consider)
|
||||||
|
continue
|
||||||
|
# This check relies on PEP 440 versioning
|
||||||
|
parsed = version.Version(to_consider)
|
||||||
|
if parsed.post:
|
||||||
|
to_include.append(to_consider)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
# If we found no commits then we're sitting on a real tag and
|
||||||
|
# there is nothing to do to update the version.
|
||||||
|
if not to_include:
|
||||||
|
LOG.debug('found no staged notes and no post-release commits')
|
||||||
|
return ldr.versions[0]
|
||||||
|
|
||||||
|
LOG.debug('including notes from %s', to_include)
|
||||||
|
|
||||||
|
candidate_bases = to_include[:]
|
||||||
|
if candidate_bases[0] == '*working-copy*':
|
||||||
|
candidate_bases = candidate_bases[1:]
|
||||||
|
|
||||||
|
if not candidate_bases:
|
||||||
|
# We have a real tag and some locally modified files. Use the
|
||||||
|
# real tag as the basis of the next version.
|
||||||
|
base_version = version.Version(ldr.versions[1])
|
||||||
|
else:
|
||||||
|
base_version = version.Version(candidate_bases[0])
|
||||||
|
|
||||||
|
LOG.debug('base version %s', base_version)
|
||||||
|
|
||||||
|
inc_minor = False
|
||||||
|
inc_patch = False
|
||||||
|
for ver in to_include:
|
||||||
|
for filename, sha in ldr[ver]:
|
||||||
|
notes = ldr.parse_note_file(filename, sha)
|
||||||
|
for section in conf.semver_major:
|
||||||
|
if notes.get(section, []):
|
||||||
|
LOG.debug('found breaking change in %r section of %s',
|
||||||
|
section, filename)
|
||||||
|
return '{}.0.0'.format(base_version.major + 1)
|
||||||
|
for section in conf.semver_minor:
|
||||||
|
if notes.get(section, []):
|
||||||
|
LOG.debug('found feature in %r section of %s',
|
||||||
|
section, filename)
|
||||||
|
inc_minor = True
|
||||||
|
break
|
||||||
|
for section in conf.semver_patch:
|
||||||
|
if notes.get(section, []):
|
||||||
|
LOG.debug('found bugfix in %r section of %s',
|
||||||
|
section, filename)
|
||||||
|
inc_patch = True
|
||||||
|
break
|
||||||
|
|
||||||
|
major = base_version.major
|
||||||
|
minor = base_version.minor
|
||||||
|
patch = base_version.micro
|
||||||
|
if inc_patch:
|
||||||
|
patch += 1
|
||||||
|
if inc_minor:
|
||||||
|
minor += 1
|
||||||
|
patch = 0
|
||||||
|
return '{}.{}.{}'.format(major, minor, patch)
|
||||||
|
|
||||||
|
|
||||||
|
def semver_next_cmd(args, conf):
|
||||||
|
"Calculate next semantic version number"
|
||||||
|
print(compute_next_version(conf))
|
||||||
|
return 0
|
169
reno/tests/test_semver.py
Normal file
169
reno/tests/test_semver.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import fixtures
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
|
||||||
|
from reno import config
|
||||||
|
from reno import semver
|
||||||
|
from reno.tests import base
|
||||||
|
|
||||||
|
|
||||||
|
class TestSemVer(base.TestCase):
|
||||||
|
|
||||||
|
note_bodies = {
|
||||||
|
'none': textwrap.dedent("""
|
||||||
|
prelude: >
|
||||||
|
This should not cause any version update.
|
||||||
|
"""),
|
||||||
|
'major': textwrap.dedent("""
|
||||||
|
upgrade:
|
||||||
|
- This should cause a major version update.
|
||||||
|
"""),
|
||||||
|
'minor': textwrap.dedent("""
|
||||||
|
features:
|
||||||
|
- This should cause a minor version update.
|
||||||
|
"""),
|
||||||
|
'patch': textwrap.dedent("""
|
||||||
|
fixes:
|
||||||
|
- This should cause a patch version update.
|
||||||
|
"""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_note_body(self, filename, sha):
|
||||||
|
return self.note_bodies.get(filename, '')
|
||||||
|
|
||||||
|
def _get_dates(self):
|
||||||
|
return {'1.0.0': 1547874431}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestSemVer, self).setUp()
|
||||||
|
self.useFixture(
|
||||||
|
fixtures.MockPatch('reno.scanner.Scanner.get_file_at_commit',
|
||||||
|
new=self._get_note_body)
|
||||||
|
)
|
||||||
|
self.useFixture(
|
||||||
|
fixtures.MockPatch('reno.scanner.Scanner.get_version_dates',
|
||||||
|
new=self._get_dates)
|
||||||
|
)
|
||||||
|
self.c = config.Config('.')
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_same(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('1.1.1', []),
|
||||||
|
])
|
||||||
|
expected = '1.1.1'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_same_with_note(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('1.1.1', [('none', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '1.1.1'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_major_working_copy(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('major', 'shaA')]),
|
||||||
|
('1.1.1', []),
|
||||||
|
])
|
||||||
|
expected = '2.0.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_major_working_and_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('none', 'shaA')]),
|
||||||
|
('1.1.1-1', [('major', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '2.0.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_major_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('1.1.1-1', [('major', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '2.0.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_minor_working_copy(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('minor', 'shaA')]),
|
||||||
|
('1.1.1', []),
|
||||||
|
])
|
||||||
|
expected = '1.2.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_minor_working_and_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('none', 'shaA')]),
|
||||||
|
('1.1.1-1', [('minor', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '1.2.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_minor_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('1.1.1-1', [('minor', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '1.2.0'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_patch_working_copy(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('patch', 'shaA')]),
|
||||||
|
('1.1.1', []),
|
||||||
|
])
|
||||||
|
expected = '1.1.2'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_patch_working_and_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('*working-copy*', [('none', 'shaA')]),
|
||||||
|
('1.1.1-1', [('patch', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '1.1.2'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
@mock.patch('reno.scanner.Scanner.get_notes_by_version')
|
||||||
|
def test_patch_post_release(self, mock_get_notes):
|
||||||
|
mock_get_notes.return_value = collections.OrderedDict([
|
||||||
|
('1.1.1-1', [('patch', 'shaA')]),
|
||||||
|
])
|
||||||
|
expected = '1.1.2'
|
||||||
|
actual = semver.compute_next_version(self.c)
|
||||||
|
self.assertEqual(expected, actual)
|
@ -5,3 +5,4 @@
|
|||||||
pbr
|
pbr
|
||||||
PyYAML>=3.10
|
PyYAML>=3.10
|
||||||
dulwich>=0.15.0 # Apache-2.0
|
dulwich>=0.15.0 # Apache-2.0
|
||||||
|
packaging>=20.4
|
||||||
|
Loading…
x
Reference in New Issue
Block a user