Repository: Qiskit/qiskit-bot

Issue:
Add support for publishing pre-releases
Looking to future releases it would be good to support doing release candidate releases prior to publishing a final release. To support this qiskit-bot will need to be improved to recognize a prerelease tag (vs a real release flag). On a pre-release tag qiskit-bot should create the stable branch, and the changelog, but not update the metapackage. Then on subsequent release candidates from the stable branch we should only generate the changelog. Then on the full release we will treat it like a stable point release but for the changelog generation we will want the from the previous full release to the new one (not just the incremental from the last release candidate) and to update the metapackage. We also probably should add a config flag to say whether the project supports release candidates or not because it does influence stable branch creation.

Discussion:

Patch:
diff --git a/qiskit_bot/release_process.py b/qiskit_bot/release_process.py
index 97a9bb7..f6d5d39 100644
--- a/qiskit_bot/release_process.py
+++ b/qiskit_bot/release_process.py
@@ -21,6 +21,7 @@ import shutil
 import subprocess
 
 import fasteners
+from packaging.version import parse
 import github
 
 from qiskit_bot import config
@@ -234,33 +235,31 @@ def _generate_changelog(repo, log_string, categories, show_missing=False):
     return changelog
 
 
-def create_github_release(repo, log_string, version_number, categories):
+def create_github_release(repo, log_string, version_number, categories,
+                          prerelease=False):
     changelog = _generate_changelog(repo, log_string, categories)
     release_name = repo.name + ' ' + version_number
-    repo.gh_repo.create_git_release(version_number, release_name, changelog)
+    repo.gh_repo.create_git_release(version_number, release_name, changelog,
+                                    prerelease=prerelease)
 
 
-def _get_log_string(version_number_pieces):
-    version_number = '.'.join(version_number_pieces)
+def _get_log_string(version_obj):
+    # If a second prerelease show log from first
+    if version_obj.is_prerelease and version_obj.pre[1] > 1:
+        old_version = (
+            f"{version_obj.base_version}{version_obj.pre[0]}"
+            f"{version_obj.pre[1] - 1}"
+        )
     # If a patch release log between 0.A.X..0.A.X-1
-    if int(version_number_pieces[2]) > 0:
-        old_version_string = '%s.%s.%s' % (
-            version_number_pieces[0],
-            version_number_pieces[1],
-            int(version_number_pieces[2]) - 1)
-        log_string = '%s...%s' % (
-            version_number,
-            old_version_string)
+    elif version_obj.micro > 0:
+        old_version = (
+            f"{version_obj.epoch}.{version_obj.minor}."
+            f"{version_obj.micro - 1}"
+        )
     # If a minor release log between 0.X.0..0.X-1.0
     else:
-        old_version_string = '%s.%s.%s' % (
-            version_number_pieces[0],
-            int(version_number_pieces[1]) - 1,
-            0)
-        log_string = '%s...%s' % (
-            version_number,
-            old_version_string)
-    return log_string
+        old_version = f"{version_obj.epoch}.{version_obj.minor - 1}.0"
+    return f"{version_obj}...{old_version}"
 
 
 def finish_release(version_number, repo, conf, meta_repo):
@@ -268,7 +267,10 @@ def finish_release(version_number, repo, conf, meta_repo):
     working_dir = conf.get('working_dir')
     lock_dir = os.path.join(working_dir, 'lock')
     repo_config = repo.repo_config
-    version_number_pieces = version_number.split('.')
+    version_obj = parse(version_number)
+    is_prerelease = version_obj.is_prerelease
+    is_postrelease = version_obj.is_postrelease
+    version_number_pieces = version_obj.base_version.split('.')
     branch_number = '.'.join(version_number_pieces[:2])
 
     with fasteners.InterProcessLock(os.path.join(lock_dir, repo.name)):
@@ -285,14 +287,15 @@ def finish_release(version_number, repo, conf, meta_repo):
     def _changelog_process():
         with fasteners.InterProcessLock(os.path.join(lock_dir, repo.name)):
             git.checkout_default_branch(repo, pull=True)
-            log_string = _get_log_string(version_number_pieces)
+            log_string = _get_log_string(version_obj)
             categories = repo.get_local_config().get(
                 'categories', config.default_changelog_categories)
             create_github_release(repo, log_string, version_number,
-                                  categories)
+                                  categories, is_prerelease)
             git.checkout_default_branch(repo, pull=True)
 
-    multiprocessing.Process(target=_changelog_process).start()
+    if not is_postrelease:
+        multiprocessing.Process(target=_changelog_process).start()
 
     def _meta_process():
         with fasteners.InterProcessLock(os.path.join(lock_dir,
@@ -301,6 +304,6 @@ def finish_release(version_number, repo, conf, meta_repo):
             git.checkout_default_branch(meta_repo, pull=True)
 
     # Only bump the metapackage for tracked/required packages optional extra
-    # versions are not pinned
-    if not repo.repo_config.get('optional_package'):
+    # versions are not pinned and if not a pre-release
+    if not repo.repo_config.get('optional_package') and not is_prerelease:
         multiprocessing.Process(target=_meta_process).start()
diff --git a/requirements.txt b/requirements.txt
index 3d1cbd5..fc59442 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,3 +8,4 @@ PyYAML>=3.10.0 # MIT
 PyGithub>=1.43
 fasteners>=0.15
 voluptuous>=0.11.0
+packaging


Test patch:
diff --git a/tests/test_release_process.py b/tests/test_release_process.py
index 04d8184..6bfcb99 100644
--- a/tests/test_release_process.py
+++ b/tests/test_release_process.py
@@ -16,6 +16,7 @@ import os
 import unittest
 
 import fixtures
+from packaging.version import parse
 
 from qiskit_bot import config
 from qiskit_bot import release_process
@@ -851,18 +852,29 @@ qiskit-terra==0.16.0
         self.generate_mock.called_once_with(meta_repo)
 
     def test_get_log_string(self):
-        version_pieces = ['0', '10', '2']
+        version_obj = parse("0.10.2")
         self.assertEqual('0.10.2...0.10.1',
-                         release_process._get_log_string(version_pieces))
-        version_pieces = ['0', '3', '0']
+                         release_process._get_log_string(version_obj))
+        version_obj = parse("0.3.0")
         self.assertEqual('0.3.0...0.2.0',
-                         release_process._get_log_string(version_pieces))
-        version_pieces = ['0', '3', '25']
+                         release_process._get_log_string(version_obj))
+        version_obj = parse("0.3.25")
         self.assertEqual('0.3.25...0.3.24',
-                         release_process._get_log_string(version_pieces))
-        version_pieces = ['0', '25', '0']
+                         release_process._get_log_string(version_obj))
+        version_obj = parse("0.25.0")
         self.assertEqual('0.25.0...0.24.0',
-                         release_process._get_log_string(version_pieces))
+                         release_process._get_log_string(version_obj))
+
+    def test_get_log_string_prerelease(self):
+        version_obj = parse("0.25.0rc1")
+        self.assertEqual('0.25.0rc1...0.24.0',
+                         release_process._get_log_string(version_obj))
+        version_obj = parse("0.25.0rc2")
+        self.assertEqual('0.25.0rc2...0.25.0rc1',
+                         release_process._get_log_string(version_obj))
+        version_obj = parse("0.25.0b1")
+        self.assertEqual('0.25.0b1...0.24.0',
+                         release_process._get_log_string(version_obj))
 
     @unittest.mock.patch.object(release_process, 'git')
     @unittest.mock.patch.object(release_process, 'create_github_release')
@@ -1683,3 +1695,98 @@ qiskit-terra==0.16.1
         meta_repo.gh_repo.create_pull.assert_called_once_with(
             'Bump Meta', base='main', head='bump_meta', body=body)
         self.generate_mock.called_once_with(meta_repo)
+
+    @unittest.mock.patch.object(release_process, 'git')
+    @unittest.mock.patch.object(release_process, 'create_github_release')
+    @unittest.mock.patch.object(release_process, 'bump_meta')
+    def test_finish_prerelease(self, bump_meta_mock, github_release_mock,
+                               git_mock):
+        meta_repo = unittest.mock.MagicMock()
+        meta_repo.repo_config = {}
+        meta_repo.name = 'qiskit'
+        repo = unittest.mock.MagicMock()
+        repo.name = 'qiskit-terra'
+        repo.repo_config = {'branch_on_release': True}
+        repo.get_local_config = lambda: {}
+        conf = {'working_dir': self.temp_dir.path}
+        with unittest.mock.patch.object(
+                release_process, 'multiprocessing'
+        ) as mp_mock:
+            mp_mock.Process = ProcessMock
+            release_process.finish_release('0.12.0rc1', repo, conf, meta_repo)
+        git_mock.create_branch.assert_called_once_with(
+            "stable/0.12", "0.12.0rc1", repo, push=True
+        )
+        github_release_mock.assert_called_once_with(
+            repo, '0.12.0rc1...0.11.0', '0.12.0rc1',
+            config.default_changelog_categories, True)
+        bump_meta_mock.assert_not_called()
+
+    @unittest.mock.patch.object(release_process, 'git')
+    @unittest.mock.patch.object(release_process, 'create_github_release')
+    @unittest.mock.patch.object(release_process, 'bump_meta')
+    def test_finish_release_with_pre_existing_branch(self, bump_meta_mock,
+                                                     github_release_mock,
+                                                     git_mock):
+        meta_repo = unittest.mock.MagicMock()
+        meta_repo.repo_config = {}
+        meta_repo.name = 'qiskit'
+        repo = unittest.mock.MagicMock()
+        repo.name = 'qiskit-terra'
+        # After a pre-release we've already created a stable branch so we
+        # should create a bump meta pr and not create a branch.
+        fake_branch = unittest.mock.MagicMock()
+        fake_branch.name = "stable/0.12"
+        repo.gh_repo.get_branches.return_value = [fake_branch]
+        repo.repo_config = {'branch_on_release': True}
+        repo.get_local_config = lambda: {}
+        conf = {'working_dir': self.temp_dir.path}
+        with unittest.mock.patch.object(
+                release_process, 'multiprocessing'
+        ) as mp_mock:
+            mp_mock.Process = ProcessMock
+            release_process.finish_release('0.12.0', repo, conf, meta_repo)
+        git_mock.create_branch.assert_not_called()
+        bump_meta_mock.called_once_with(meta_repo, repo, '0.12.0')
+        github_release_mock.assert_called_once_with(
+            repo, '0.12.0...0.11.0', '0.12.0',
+            config.default_changelog_categories, False)
+
+    @unittest.mock.patch.object(release_process, 'git')
+    @unittest.mock.patch.object(release_process, 'create_github_release')
+    @unittest.mock.patch.object(release_process, 'bump_meta')
+    def test_finish_prerelease_with_pre_existing_branch(self, bump_meta_mock,
+                                                        github_release_mock,
+                                                        git_mock):
+        meta_repo = unittest.mock.MagicMock()
+        meta_repo.repo_config = {}
+        meta_repo.name = 'qiskit'
+        repo = unittest.mock.MagicMock()
+        repo.name = 'qiskit-terra'
+        # After a pre-release we've already created a stable branch so we
+        # should create a bump meta pr and not create a branch.
+        fake_branch = unittest.mock.MagicMock()
+        fake_branch.name = "stable/0.12"
+        repo.gh_repo.get_branches.return_value = [fake_branch]
+        repo.repo_config = {'branch_on_release': True}
+        repo.get_local_config = lambda: {}
+        conf = {'working_dir': self.temp_dir.path}
+        with unittest.mock.patch.object(
+                release_process, 'multiprocessing'
+        ) as mp_mock:
+            mp_mock.Process = ProcessMock
+            release_process.finish_release('0.12.0rc2', repo, conf, meta_repo)
+        bump_meta_mock.assert_not_called()
+        github_release_mock.assert_called_once_with(
+            repo, '0.12.0rc2...0.12.0rc1', '0.12.0rc2',
+            config.default_changelog_categories, True)
+        git_mock.create_branch.assert_not_called()
+
+
+class ProcessMock:
+
+    def __init__(self, target=None):
+        self.target = target
+
+    def start(self):
+        self.target()