Repository: learningequality/kolibri

Issue:
make __init__.py's version a 3-tuple
### Observed behavior

Currently, `__init__.py` looks like this:

```python
#: This may not be the exact version as it's subject to modification with
#: get_version() - use ``kolibri.__version__`` for the exact version string.
VERSION = (0, 8, 0, 'alpha', 0)
```

The fact that the fourth value (`'alpha'`/`'beta'`/`'final'`) needs to be updated makes creating releases and managing release branches very frustrating:

* we need to do a whole PR+review+merge to set it to `'final'`, create a release tag, then do _another_ PR+review+merge to set it back to either `'alpha'` or `'beta'`
* this creates repeated merge conflicts when syncing old releases back to `develop`, or even worse across multiple release branches
* there doesn't seem to be any difference between `'beta'` and `'alpha'`
* the fifth value (`0`) is completely ignored
* need to create an `alpha0` tag on `develop` after cutting a new beta or we get the confusing error `You have added a final tag without bumping kolibri.VERISON`

### Expected behavior

Since the alpha/beta/final status is reflected in git tags and not in source code, we shouldn't need to update these values in source. Instead, we can simply do:

```python
#: This string does not reflect alpha/beta/final status and is modified by get_version()
#: Use ``kolibri.__version__`` for the exact version string.
VERSION = (0, 8, 0)
```

Then the final/beta/alpha status can be driven by git tags and the process for releasing will be greatly simplified

Final releases should be embedded in the code. Everything else can come from the git tag. Anything interim can be versioned with a git hash, because finding parents is unreliable.

If a tag is made (alpha, beta, rc, or final) that does not match the 3-tuple in `__init__.py`, then the code should throw an error, to prevent tags being made on the wrong branch or code.

### User-facing consequences

No user-facing consequences, but this would improve developer experience for creating new releases and managing release branches.

### Steps to reproduce

Go through the process of creating a patch release and then syncing those changes back to `develop`





Discussion:
cc @rtibbles @benjaoming @aronasorman 
I'm very much :-1: on re-opening anything that has to do with versioning. We spent so much time on this already, I think we just need to stick to a routine and move along.
Current problem in `develop` was because the 0.8 release branch had a tag `0.9.0-alpha0`, I deleted that tag and bumped `kolibri.VERSION`. It now works.

Exception was legit.

```
Obtaining file:///home/travis/build/learningequality/kolibri
    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/home/travis/build/learningequality/kolibri/setup.py", line 16, in <module>
        import kolibri
      File "kolibri/__init__.py", line 13, in <module>
        __version__ = str(get_version(VERSION))
      File "kolibri/utils/lru_cache.py", line 137, in wrapper
        result = user_function(*args, **kwds)
      File "kolibri/utils/version.py", line 385, in get_version
        return get_prerelease_version(version)
      File "kolibri/utils/version.py", line 297, in get_prerelease_version
        git_version,
    AssertionError: Version detected from git describe --tags, but it's inconsistent with kolibri.__version__.__version__ is: (0, 8, 0, u'beta', 0), tag says: (0, 9, 0, 'alpha', 0).
```
> I'm very much 👎 on re-opening anything that has to do with versioning. We spent so much time on this already, I think we just need to stick to a routine and move along.

no urgency on this. just flagging that it's a frustrating process currently
[kolibri/utils/version.py](https://github.com/learningequality/kolibri/commits/develop/kolibri/utils/version.py) has over 30 commits, together with a rather lengthy set of tests. I think it's worth giving it a long-lived chance, i.e. let the routines settle in.

The other day, I was chatting about some annoying stuff in Django, and I think we found out that it was like "opening up a can of worms... and bugs" :smile: 

Let's reap this!
We've tried reaping this for 3 months now, and it continues to be an incredibly frustrating process with multiple merge conflicts, builds that are broken for no good reason, and continual busy work to keep version tuples and git tags aligned.

Final releases should be embedded in the code. Everything else can come from the git tag. Anything interim can be versioned with a git hash, because finding parents is unreliable.
> Final releases should be embedded in the code. Everything else can come from the git tag. Anything interim can be versioned with a git hash, because finding parents is unreliable.

This sounds correct to me

Hi @rtibbles @indirectlylit !

It sounds like it's time to find out where the versioning process can be relaxed.

> incredibly frustrating

I'm sorry that this is causing issues. I would like to point out the nature of CI and builds: They break, and for various reasons. Waiting for something to fail for a "meta" reason and then rebuilding is not nice of course, but the version string is really important, more than just as some meta reasoning, because it generates one of the most important pieces of knowledge upon which all of subsequent support inquiries and remote debugging rely on. Better have builds break before releasing to the wider community than risking ambiguity in versions.

Especially also worth noting that every bit of extra time for assertions on our side makes holistic sense, because fellow community members may be spending a large amount of time downloading due lack or bandwidth or cannot access subsequent upgrades.

But because of striving for a "press of a button" release mechanism, we carefully put assertions of where we should be vary of human errors.

The frustration may also indicate a warning of pressure being high before releases, so in that regards there are also other factors at play.

> builds that are broken for no good reason

That isn't true, they break because of cautiousness :) The assertions are there with good reasons, they implement our release policy (and guard against human or unexpected errors). If they need to be adjusted, then let's do that.

Since we are many maintainers, often working under pressure, I'd advocate staying no the cautious.

> Final releases should be embedded in the code. Everything else can come from the git tag.

We also need the `kolibri/VERSION` file. The complexity remains in the order or version resolution:

1) If final release, don't do stuff like create `git` subprocesses, just assume the final version
2) If pre-release step 1:
    a. Derive from `git --describe` (if in a git repo and that repo is a *kolibri* repo!) and recall all the issues with branches etc, so verify the output before using it...
    b. Derive from `kolibri/VERSION` if that file exists (and is inside a reasonable threshold of `kolibri.__version__` because we are/were cautious of distributing the wrong file, resolving the wrong path or whatever)
    c. Fall back to whatever is in `kolibri/`
3) If pre-release step 2: Suffix the pre-release version with `.dev+git...`

This version is ultimately a correct final version: `(1, 2, 3, 'post', 1)`, should resolve to `1.2.3.post1`. In case something messes up after a release (for instance a corrupt file) and it cannot be re-called and has to be re-built with minor adjustments, this notation is used. We never used it, so maybe we want to rule out that possibility.

In relation to the proposal of removing the last version component and *only* relying on `git --describe`  / `kolibri/VERSION`, I would say: **_Yes, that can be done_**, provided that we do not want to use `1.2.3.post1` for final releases.

I can make a test case + fix for #3678 and then move onto work to ignore the latter version component, although I don't know if this is originally motivated by that very same issue..

*If* you feel tempted to simplify this problem out of frustrations, please have a recreational visit to `utils/version.py` :smile: 

Resolving patch:
diff --git a/kolibri/__init__.py b/kolibri/__init__.py
--- a/kolibri/__init__.py
+++ b/kolibri/__init__.py
@@ -15,13 +15,12 @@
 
 #: This may not be the exact version as it's subject to modification with
 #: get_version() - use ``kolibri.__version__`` for the exact version string.
-VERSION = (0, 15, 8, "final", 0)
+VERSION = (0, 15, 8)
 
 __author__ = "Learning Equality"
 __email__ = "info@learningequality.org"
 __version__ = str(get_version(VERSION))
 
-
 #: A list of all available plugins defined within the Kolibri repo
 #: Define it here to avoid introspection malarkey, and to allow for
 #: import in setup.py for creating a list of plugin entry points.
diff --git a/kolibri/utils/version.py b/kolibri/utils/version.py
--- a/kolibri/utils/version.py
+++ b/kolibri/utils/version.py
@@ -13,73 +13,64 @@
    to decide the version of Kolibri as a string. This is especially something
    that PyPi and setuptools use.
 
- * ``kolibri.VERSION`` is a tuple containing version information, it's set in
-   ``kolibri/__init__.py`` is automatically suffixed in pre-releases by a
-   number of rules defined below. For a final release (not a pre-release),
-   it will be used exactly as it appears.
+ * ``kolibri.VERSION`` is a tuple containing major, minor, and patch version information,
+   it's set in ``kolibri/__init__.py``
 
  * ``kolibri/VERSION`` is a file containing the exact version of Kolibri for a
-   distributed environment (pre-releases only!)
+   distributed environment - when it exists, as long as its major, minor, and patch
+   versions are compatible with ``kolibri.VERSION`` then it is used as the version.
+   If these versions do not match, an AssertionError will be thrown.
 
  * ``git describe --tags`` is a command run to fetch tag information from a git
    checkout with the Kolibri code. The information is used to validate the
-   major components of ``kolibri.VERSION`` and to suffix the final version of
-   prereleases. This information is stored permanently in ``kolibri/VERSION``
-   before shipping a pre-release by calling ``make writeversion`` during
-   ``make dist`` etc.
-
-
-Confused? Here's a table:
-
-
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| Release type | ``kolibri.VERSION`` | ``kolibri/VERSION`` | Git data                  | Examples                            |
-+==============+=====================+=====================+===========================+=====================================+
-| Final        | Canonical, only     | N/A                 | N/A                       | 0.1.0, 0.2.2,                       |
-|              | information used    |                     |                           | 0.2.post1                           |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| dev release  | (1, 2, 3, 'alpha',  | Fallback            | timestamp of latest       | 1.2.3.dev0+git.123.f1234567         |
-| (alpha0)     | 0), 0th alpha = a   |                     | commit + hash             |                                     |
-|              | dev release! Never  |                     |                           |                                     |
-|              | used as a canonical |                     |                           |                                     |
-|              |                     |                     |                           |                                     |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| alpha1+      | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-|              | 1)                  |                     |                           | 1.2.3a1,                            |
-|              |                     |                     |                           | 4 changes                           |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3a1.dev0+git.4.f1234567         |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| beta1+       | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-|              | 1)                  |                     |                           | 1.2.3b1,                            |
-|              |                     |                     |                           | 5 changes                           |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3b1.dev0+git.5.f1234567         |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| rc1+         | (1, 2, 3, 'alpha',  | Fallback            | ``git describe --tags``   | Clean head:                         |
-| (release     | 1)                  |                     |                           | 1.2.3rc1,                           |
-| candidate)   |                     |                     |                           | Changes                             |
-|              |                     |                     |                           | since tag:                          |
-|              |                     |                     |                           | 1.2.3rc1.dev0+git.f1234567          |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-| beta0, rc0,  | DO NOT USE          | Fallback            | timestamp of latest       | 1.2.3b0.dev0+git.123.f1234567       |
-| post0, x.y.0 |                     |                     | commit + hash             |                                     |
-|              |                     |                     |                           |                                     |
-+--------------+---------------------+---------------------+---------------------------+-------------------------------------+
-
-
-**Fallback**: ``kolibri/VERSION`` is auto-generated with ``make writeversion``
-during the build process. The file is read as a fallback when there's no git
-data available in a pre-release (which is the case in an installed
-environment).
+   major components of ``kolibri.VERSION`` and to add a suffix (if needed).
+   This information is stored permanently in ``kolibri/VERSION`` before shipping
+   any built asset by calling ``make writeversion`` during ``make dist`` etc.
+
+
+This table shows examples of kolibri.VERSION and git data used to generate a specific version:
+
+
++--------------+---------------------+---------------------------+-------------------------------------+
+| Release type | ``kolibri.VERSION`` | Git data                  | Examples                            |
++==============+=====================+===========================+=====================================+
+| Final        | (1, 2, 3)           | Final tag: e.g. v1.2.3    | 1.2.3                               |
++--------------+---------------------+---------------------------+-------------------------------------+
+| dev release  | (1, 2, 3)           | timestamp of latest       | 1.2.3.dev0+git.123.f1234567         |
+| (alpha0)     |                     | commit + hash             |                                     |
++--------------+---------------------+---------------------------+-------------------------------------+
+| alpha1+      | (1, 2, 3)           | Alpha tag: e.g. v1.2.3a1  | Clean head:                         |
+|              |                     |                           | 1.2.3a1,                            |
+|              |                     |                           | 4 changes                           |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3a1.dev0+git.4.f1234567         |
++--------------+---------------------+---------------------------+-------------------------------------+
+| beta1+       | (1, 2, 3)           | Beta tag: e.g. v1.2.3b1   | Clean head:                         |
+|              |                     |                           | 1.2.3b1,                            |
+|              |                     |                           | 5 changes                           |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3b1.dev0+git.5.f1234567         |
++--------------+---------------------+---------------------------+-------------------------------------+
+| rc1+         | (1, 2, 3)           | RC tag: e.g. v1.2.3rc1    | Clean head:                         |
+| (release     |                     |                           | 1.2.3rc1,                           |
+| candidate)   |                     |                           | Changes                             |
+|              |                     |                           | since tag:                          |
+|              |                     |                           | 1.2.3rc1.dev0+git.f1234567          |
++--------------+---------------------+---------------------------+-------------------------------------+
+
+
+**Built assets**: ``kolibri/VERSION`` is auto-generated with ``make writeversion``
+during the build process. The file is read in preference to git
+data in order to prioritize swift version resolution in an installed
+environment.
 
 
 Release order example 1.2.3 release:
 
- * ``VERSION = (1, 2, 3, 'alpha', 0)`` throughout the development phase, this
+ * ``VERSION = (1, 2, 3)`` throughout the development phase, this
    results in a lot of ``1.2.3.dev0+git1234abcd`` with no need for
    git tags.
- * ``VERSION = (1, 2, 3, 'alpha', 1)`` for the first alpha release.
+ * ``VERSION = (1, 2, 3)`` for the first alpha release, a git tag v1.2.3a0 is made.
 
 .. warning::
     Do not import anything from the rest of Kolibri in this module, it's
@@ -117,41 +108,15 @@
 BUILD_VERSION = "build"
 
 
-def get_major_version(version=None):
+def get_major_version(version):
     """
     :returns: String w/ first digit part of version tuple x.y.z
     """
-    version = get_complete_version(version)
+
     major = ".".join(str(x) for x in version[:3])
     return major
 
 
-def get_complete_version(version=None):
-    """
-    :returns: A tuple of the version. If version argument is non-empty, then
-              checks for correctness of the tuple provided.
-    """
-    if version is None:
-        from kolibri import VERSION as version
-    else:
-        if len(version) != 5:
-            raise AssertionError
-        if version[3] not in ORDERED_VERSIONS:
-            raise AssertionError
-
-    return version
-
-
-def get_docs_version(version=None):
-    """
-    :returns: Version string for use in Sphinx docs
-    """
-    version = get_complete_version(version)
-    if version[3] != "final":
-        return "dev"
-    return "%d.%d" % version[:2]
-
-
 def get_git_changeset():
     """
     Returns a numeric identifier of the latest git changeset.
@@ -262,14 +227,12 @@ def get_version_from_git(get_git_describe_string):
     suffix = ".dev0+git" + suffix if suffix else ""
 
     return (
-        get_complete_version(
-            (
-                int(major),
-                int(minor),
-                int(patch),
-                m.group("release") or "final",
-                int(m.group("release_number") or 0),
-            )
+        (
+            int(major),
+            int(minor),
+            int(patch),
+            m.group("release") or "final",
+            int(m.group("release_number") or 0),
         ),
         suffix,
     )
@@ -294,31 +257,22 @@ def get_prerelease_version(version):
     \\*, \\*, \\*, "alpha", 0: Maps to latest commit timestamp
     \\*, \\*, \\*, "alpha", >0: Uses latest git tag, asserting that there is such.
     """
-
     mapping = {"alpha": "a", "beta": "b", "rc": "rc"}
-
     major = get_major_version(version)
-    major_and_release = major + mapping[version[3]] + str(version[4])
 
     # Calculate suffix...
     tag_describe = get_git_describe(version)
 
     # If the detected git describe data is not valid, then either respect
-    # that we are in alpha-0 mode or raise an error
+
     if tag_describe:
 
         git_version, suffix = get_version_from_git(tag_describe)
-
-        if not git_version[:3] == version[:3]:
-            # If it's the 0th alpha, load suffix info from git changeset
-            if version[4] == 0 and version[3] == "alpha":
-                # Throw away the description from git
-                suffix = get_git_changeset()
-                # Replace 'alpha' with .dev
-                return major + ".dev0" + suffix
-
-            # If the tag was not of a final version, we will fail.
-            elif not git_version[4] == "final" and git_version[:3] > version[:3]:
+        # We will check if the git_tag and version strings are the same length,
+        # and compare it the first three characters of each string to see if they matches.
+        # if not, we then raise an AssertionError.
+        if not suffix:
+            if not git_version[:3] == version[:3]:
                 raise AssertionError(
                     (
                         "Version detected from git describe --tags, but it's "
@@ -326,108 +280,80 @@ def get_prerelease_version(version):
                         "__version__ is: {}, tag says: {}."
                     ).format(str(version), git_version)
                 )
-
-        if git_version[3] == "final" and version[3] != "final":
+        # checks if the version number in git_version is greater than the version number in version.
+        # If it is, the code raises an AssertionError
+        if git_version[:3] > version[:3]:
             raise AssertionError(
-                "You have added a final tag without bumping kolibri.VERSION, "
-                + "OR you need to make a new alpha0 tag. Current tag: {}".format(
-                    git_version
-                )
-                + "\n\n"
-                "Often, this is because of missing tag information, try "
-                "running:\n"
-                "\n"
-                "   git fetch <upstream>"
+                (
+                    "Version detected from git describe --tags, but it's "
+                    "inconsistent with kolibri.__version__."
+                    "__version__ is: {}, tag says: {}."
+                ).format(str(version), git_version)
+            )
+        # checks if the tag in git_version is the same to the final version number in version.
+        # If it is, we return the major version number.
+        # And If the tag was of a final version, we will use it.
+
+        if git_version[:3] == version[:3]:
+            if git_version[3] == "final":
+                if not suffix:
+                    return major
+                else:
+                    # If there's a suffix, we're post the final tag for the release
+                    # so set it to an alpha to give a more meaningful version number
+                    # although it is slighly incorrect as we have already released.
+                    git_version = (
+                        git_version[0],
+                        git_version[1],
+                        git_version[2],
+                        "alpha",
+                        git_version[4],
+                    )
+
+            return (
+                get_major_version(git_version)
+                + mapping[git_version[3]]
+                + str(git_version[4])
+                + suffix
             )
 
-        return (
-            get_major_version(git_version)
-            + mapping[git_version[3]]
-            + str(git_version[4])
-            + suffix
-        )
+    return major + ".dev0" + (get_git_changeset() or "")
+
 
+def get_version_from_file(version):
     # No git data, will look for a VERSION file
     version_file = get_version_file()
 
     # Check that the version file is consistent
     if version_file:
-
         # Because \n may have been appended
         version_file = version_file.strip()
-
-        # If there is a '.dev', we can remove it, otherwise we check it
-        # for consistency and fail if inconsistent
-        version_file_base = parse_version(version_file).base_version
-
-        # If a final release is specified in the VERSION file, then it
-        # has to be a final release in the VERSION tuple as well.
-        # A final release specified in a VERSION file (pep 440) is
-        # something that doesn't end like a1, b1, post1, and rc1
-        pep440_is_final = re.compile(r"^\d+(\.\d)+(\.post\d+)?$")
-        version_file_is_final = pep440_is_final.match(version_file)
-
-        if version_file_is_final and version_file != major_and_release:
+        version_major_minor_patch = parse_version(version_file).base_version
+        split_version = version_major_minor_patch.split(".")
+        major = int(split_version[0])
+        minor = int(split_version[1])
+        patch = int(split_version[2])
+        # If the major, minor, and patch of the parsed version number
+        # We will raise an error
+        if (major, minor, patch) != version[:3]:
             raise AssertionError(
                 (
-                    "kolibri/VERSION file specified as final release but "
-                    "kolibri.__version__. is not a final release."
-                    "__version__ is: {}, file says: {}."
+                    "Version detected from VERSION file, but it's "
+                    "inconsistent with kolibri.__version__."
+                    "__version__ is: {}, VERSION file says: {}."
                 ).format(str(version), version_file)
             )
 
-        if not major_and_release.startswith(version_file_base):
-            raise AssertionError(
-                (
-                    "kolibri/VERSION file inconsistent with "
-                    "kolibri.__version__.\n"
-                    "__version__ is: {}, file says: {}\n\n{} should start "
-                    "with {}"
-                ).format(
-                    str(version), version_file, major_and_release, version_file_base
-                )
-            )
         return version_file
 
-    # Finally, if there was no git data or VERSION file, map the alpha-0 to
-    # .dev
-    if version[4] == 0 and version[3] == "alpha":
-        mapping["alpha"] = ".dev"
-        major_and_release = major + mapping[version[3]] + str(version[4])
-
-    # In all circumstances, return the initial findings
-    return major_and_release
-
 
 @lru_cache()
-def get_version(version=None):
-    """
-    Returns a PEP 440-compliant version number from VERSION.
-    Derives additional information from git repository if code is contained
-    in such.
-
-    This is important to read from PEP-404 (which this function is compliant
-    with):
+def get_version(version):
+    version_str = get_version_from_file(version)
+    if version_str:
+        return version_str
 
-    Within a numeric release ( 1.0.0 , 2.7.3 ), the following suffixes are
-    permitted and MUST be ordered as shown:
-
-    .devN, aN, bN, rcN, <no suffix>, .postN
-    """
-    version = get_complete_version(version)
-
-    # Prerelease versions are special, we parse git data and look for special
-    # VERSION files in package data to fetch auto-generated data.
-    if version[3] != "final":
-        return get_prerelease_version(version)
-
-    major = get_major_version(version)
-
-    sub = ""
-    if version[4] > 0:
-        sub = ".post{}".format(version[4])
-
-    return str(major + sub)
+    return get_prerelease_version(version)
 
 
 def get_version_and_operator_from_range(version_range):
@@ -472,6 +398,7 @@ def normalize_version_to_semver(version):
     dev = dev_match.group(2)
 
     # extract the numeric semver component and the stuff that comes after
+
     numeric, after = re.match(
         r"(^\d+\.\d+[0-9]*\.?[0-9]*)([a-z0-9.+]*)", version
     ).groups()


Test patch:
diff --git a/kolibri/utils/tests/test_version.py b/kolibri/utils/tests/test_version.py
--- a/kolibri/utils/tests/test_version.py
+++ b/kolibri/utils/tests/test_version.py
@@ -18,10 +18,6 @@
 get_version = version.get_version.__wrapped__  # @UndefinedVariable
 
 
-def dont_call_me_maybe(msg):
-    raise AssertionError(msg)
-
-
 class TestKolibriVersion(unittest.TestCase):
     def test_version(self):
         """
@@ -32,46 +28,61 @@ def test_version(self):
 
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
-    def test_alpha_0_version(self, file_mock, describe_mock):
+    @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
+    def test_no_tag_no_file_version_no_git_changeset(
+        self, get_git_changeset_mock, file_mock, describe_mock
+    ):
         """
         Test that when doing something with a 0th alpha doesn't provoke any
-        hickups with ``git describe --tag``.
+        hiccups with ``git describe --tag``.
+        If the version file returns nothing, and get_git_describe returns nothing,
+        and get_git_changeset returns nothing,
+        then get_prerelease_version should return x.y.z.dev0
+        as we are not on a final release, but we can't determine any more information
+        beyond that, so we can only say this is a dev0 release and nothing more.
         """
-        v = get_version((0, 1, 0, "alpha", 0))
-        self.assertIn("0.1.0.dev0", v)
+        v = get_version((0, 1, 0))
+        self.assertEqual("0.1.0.dev0", v)
 
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    @mock.patch(
+        "kolibri.utils.version.get_git_changeset", return_value="+git.1234567890"
+    )
+    def test_no_tag_no_file_version_git_changeset(
+        self, get_git_changeset_mock, file_mock, describe_mock
+    ):
+        """
+        Test that when doing something with a 0th alpha doesn't provoke any
+        hiccups with ``git describe --tag``.
+        If the version file returns nothing, and get_git_describe returns nothing,
+        then get_prerelease_version should return x.y.z.dev0 and the output of
+        get_git_changeset to give an incrementing version number in absence of any
+        other relevant tag information.
+        """
+        v = get_version((0, 1, 0))
+        self.assertEqual("0.1.0.dev0+git.1234567890", v)
+
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.1.0-alpha1")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     def test_alpha_1_version(self, file_mock, describe_mock):
         """
         Test some normal alpha version, but don't assert that the
         ``git describe --tag`` is consistent (it will change in future test
         runs)
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
-    def test_alpha_1_version_no_git(self, describe_mock):
-        """
-        Not running from git and no VERSION file.
-        """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        version.get_version_file = lambda: None
-        try:
-            v = get_version((0, 1, 0, "alpha", 1))
-            self.assertIn("0.1.0a1", v)
-        finally:
-            version.get_version_file = get_version_file
-
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a1")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
     def test_alpha_1_version_file(self, describe_mock, file_mock):
         """
         Test that a simple 0.1a1 works when loaded from a VERSION file
+        If the version file returns a version that matches the
+        major, minor, patch of the version tuple, return that version string
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a1\n")
@@ -82,7 +93,7 @@ def test_version_file_linebreaks(self, describe_mock, file_mock):
 
         See: https://github.com/learningequality/kolibri/issues/2464
         """
-        v = get_version((0, 1, 0, "alpha", 1))
+        v = get_version((0, 1, 0))
         self.assertIn("0.1.0a1", v)
 
     @mock.patch(
@@ -94,44 +105,24 @@ def test_version_file_local_git_version(self, describe_mock, file_mock):
         """
         Test that a version file with git describe output is correctly parsed
         """
-        v = get_version((0, 7, 1, "beta", 1))
+        v = get_version((0, 7, 1))
         self.assertIn("0.7.1b1.dev0+git.2.gfd48a7a", v)
 
+    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a1\n")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
     @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
     def test_alpha_0_inconsistent_version_file(
-        self, get_git_changeset_mock, describe_mock
+        self, get_git_changeset_mock, describe_mock, version_file_mock
     ):
         """
-        Test that inconsistent file data also just fails
+        Test that inconsistent version file data also just fails
+        If the version file returns a version that doesn't match the
+        major, minor, patch of the version tuple, throw an assertion error
         """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        inconsistent_versions = ("0.2.0a1", "0.1.1a1", "0.1.0")
+        inconsistent_versions = ("0.2.0a1", "0.1.1a1")
         for v in inconsistent_versions:
-            version.get_version_file = lambda: v
-            try:
-                self.assertRaises(AssertionError, get_version, (0, 1, 0, "alpha", 0))
-            finally:
-                version.get_version_file = get_version_file
-
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
-    @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
-    def test_alpha_1_inconsistent_version_file(
-        self, get_git_changeset_mock, describe_mock
-    ):
-        """
-        Test that inconsistent file data also just fails
-        """
-        # Simple mocking
-        get_version_file = version.get_version_file
-        inconsistent_versions = ("0.2.0a1", "0.1.1a1", "0.1.0")
-        for v in inconsistent_versions:
-            version.get_version_file = lambda: v
-            try:
-                self.assertRaises(AssertionError, get_version, (0, 1, 0, "alpha", 1))
-            finally:
-                version.get_version_file = get_version_file
+            version_file_mock.return_value = v
+            self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0b1")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
@@ -143,69 +134,45 @@ def test_alpha_0_consistent_version_file(
         Test that a VERSION file can overwrite an alpha-0 (dev) state.
         Because a prerelease can be made with a version file.
         """
-        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0b1"
+        assert get_version((0, 1, 0)) == "0.1.0b1"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
         return_value="v0.1.0-alpha1-123-abcdfe12",
     )
-    def test_alpha_0_consistent_git(self, describe_mock, file_mock):
+    def test_alpha_1_consistent_git(self, describe_mock, file_mock):
         """
         Tests that git describe data for an alpha-1 tag generates an a1 version
         string.
         """
-        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0a1.dev0+git.123.abcdfe12"
+        assert get_version((0, 1, 0)) == "0.1.0a1.dev0+git.123.abcdfe12"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
-        return_value="v0.1.0-alpha1-123-abcdfe12",
+        return_value="v0.1.0-alpha1",
     )
-    def test_alpha_1_consistent_git(self, describe_mock, file_mock):
+    def test_alpha_1_consistent_git_tag(self, describe_mock, file_mock):
         """
         Tests that git describe data for an alpha-1 tag generates an a1 version
         string.
+        If the version file returns nothing, and get_git_describe returns a version
+        tag like v0.1.0-alpha1 and it matches the major, minor, patch of the version tuple,
+        return the version (without the v) (because that means we are on a tagged commit)
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a1.dev0+git.123.abcdfe12"
-
-    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0b2")
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
-    @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
-    def test_beta_1_consistent_version_file(
-        self, get_git_changeset_mock, describe_mock, file_mock
-    ):
-        """
-        Test that a VERSION file can overwrite an beta-1 state in case the
-        version was bumped in ``kolibri.VERSION``.
-        """
-        assert get_version((0, 1, 0, "beta", 1)) == "0.1.0b2"
-
-    @mock.patch(
-        "kolibri.utils.version.get_version_file",
-        return_value="0.7.1b1.dev0+git.12.g2a8fe31",
-    )
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
-    @mock.patch("kolibri.utils.version.get_git_changeset", return_value=None)
-    def test_beta_1_consistent_dev_release_version_file(
-        self, get_git_changeset_mock, describe_mock, file_mock
-    ):
-        """
-        Test that a VERSION file can overwrite an beta-1 state in case the
-        version was bumped in ``kolibri.VERSION``.
-        """
-        assert get_version((0, 7, 1, "alpha", 0)) == "0.7.1b1.dev0+git.12.g2a8fe31"
+        assert get_version((0, 1, 0)) == "0.1.0a1"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0b1")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.0.1")
     @mock.patch("kolibri.utils.version.get_git_changeset", return_value="+git123")
-    def test_version_file_ignored(
+    def test_version_file_overrides(
         self, get_git_changeset_mock, describe_mock, file_mock
     ):
         """
-        Test that the VERSION file is NOT used where git data is available
+        Test that the VERSION file is used when git data is available
         """
-        assert get_version((0, 1, 0, "alpha", 0)) == "0.1.0.dev0+git123"
+        assert get_version((0, 1, 0)) == "0.1.0b1"
 
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0")
     @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
@@ -215,37 +182,43 @@ def test_version_file_final(self, get_git_changeset_mock, describe_mock, file_mo
         Test that a VERSION specifying a final version will work when the
         kolibri.VERSION tuple is consistent.
         """
-        assert get_version((0, 1, 0, "final", 0)) == "0.1.0"
+        assert get_version((0, 1, 0)) == "0.1.0"
 
-    def test_alpha_1_inconsistent_git(self):
+    @mock.patch(
+        "kolibri.utils.version.get_version_file",
+        return_value=None,
+    )
+    @mock.patch("kolibri.utils.version.get_git_describe")
+    def test_alpha_1_inconsistent_git_tag(self, describe_mock, file_mock):
         """
         Test that we fail when git returns inconsistent data
+        Only when the returned tag is a greater major, minor, patch
+        version than what we have encoded in the version tuple.
+        This should ensure that we notice if we apply a tag on the wrong
+        repository branch.
         """
-        # Simple mocking
-        git_describe = version.get_git_describe
-        try:
-            version.get_git_describe = lambda *x: "v0.2.0-beta1"
-            self.assertRaises(AssertionError, get_version, (0, 1, 0, "alpha", 1))
-            version.get_git_describe = lambda *x: "v0.2.0-beta2"
-            self.assertRaises(AssertionError, get_version, (0, 1, 0, "beta", 0))
-            version.get_git_describe = lambda *x: "v0.1.0"
-            self.assertRaises(AssertionError, get_version, (0, 1, 0, "alpha", 0))
-        finally:
-            version.get_git_describe = git_describe
+        describe_mock.return_value = "v0.2.0-beta1"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
+        describe_mock.return_value = "v0.2.0"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     @mock.patch(
         "kolibri.utils.version.get_git_describe",
-        return_value="v0.1.0-beta1-123-abcdfe12",
     )
-    def test_alpha_1_beta_1_consistent_git(self, describe_mock):
+    def test_alpha_1_inconsistent_git(self, describe_mock, file_mock):
         """
-        Test that a beta1 git tag can override kolibri.__version__ reading
-        alpha0.
+        Tests that git describe data for an alpha-1 tag generates an a1 version
+        string.
+        Only when the returned version is a greater major, minor, patch
+        version than what we have encoded in the version tuple.
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0b1.dev0+git.123.abcdfe12"
+        describe_mock.return_value = "v0.2.0-alpha1-123-abcdfe12"
+        self.assertRaises(AssertionError, get_version, (0, 1, 0))
 
     @mock.patch("subprocess.Popen")
-    def test_git_describe_parser(self, popen_mock):
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_git_describe_parser(self, file_mock, popen_mock):
         """
         Test that we get the git describe data when it's there
         """
@@ -253,19 +226,23 @@ def test_git_describe_parser(self, popen_mock):
         attrs = {"communicate.return_value": ("v0.1.0-beta1-123-abcdfe12", "")}
         process_mock.configure_mock(**attrs)
         popen_mock.return_value = process_mock
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0b1.dev0+git.123.abcdfe12"
+        assert get_version((0, 1, 0)) == "0.1.0b1.dev0+git.123.abcdfe12"
 
     @mock.patch("subprocess.Popen")
     @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
     def test_git_random_tag(self, file_mock, popen_mock):
         """
         Test that we don't fail if some random tag appears
+        Always fallback to .dev0 to give some indication that
+        this is not a final release, even if we can discern nothing else.
+        Noting that the subprocess.Popen mock also causes get_git_changeset
+        to return nothing meaningful either, so we don't even get that additional information.
         """
         process_mock = mock.Mock()
         attrs = {"communicate.return_value": ("foobar", "")}
         process_mock.configure_mock(**attrs)
         popen_mock.return_value = process_mock
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a1"
+        assert get_version((0, 1, 0)) == "0.1.0.dev0"
 
     @mock.patch("subprocess.Popen", side_effect=EnvironmentError())
     @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.0a2")
@@ -273,45 +250,39 @@ def test_prerelease_no_git(self, file_mock, popen_mock):
         """
         Test that we don't fail and that the version file is used
         """
-        assert get_version((0, 1, 0, "alpha", 1)) == "0.1.0a2"
+        assert get_version((0, 1, 0)) == "0.1.0a2"
 
-    @mock.patch(
-        "kolibri.utils.version.get_complete_version",
-        side_effect=lambda x: x if x else (0, 2, 0, "alpha", 2),
-    )
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.2.0-beta1")
-    def test_beta_1_git(self, describe_mock, complete_mock):
-        """
-        Test that we use git tag data when our version is alpha
-        """
-        self.assertEqual(get_version(), "0.2.0b1")
-
-    @mock.patch("kolibri.utils.version.get_git_describe", return_value=None)
-    def test_final(self, describe_mock):
+    @mock.patch("kolibri.utils.version.get_git_describe")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.1.1")
+    def test_final_patch(self, file_mock, describe_mock):
         """
         Test that the major version is set as expected on a final release
         """
-        v = get_version((0, 1, 0, "final", 0))
-        self.assertEqual(v, "0.1.0")
+        v = get_version((0, 1, 1))
+        self.assertEqual(v, "0.1.1")
         assert describe_mock.call_count == 0
 
-    @mock.patch("kolibri.utils.version.get_git_describe")
-    def test_final_patch(self, describe_mock):
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.1.1")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_final_tag(self, file_mock, describe_mock):
         """
-        Test that the major version is set as expected on a final release
+        Test that the major version is set as expected on a final release tag
         """
-        v = get_version((0, 1, 1, "final", 0))
+        v = get_version((0, 1, 1))
         self.assertEqual(v, "0.1.1")
-        assert describe_mock.call_count == 0
+        assert describe_mock.call_count == 1
 
-    @mock.patch("kolibri.utils.version.get_git_describe")
-    def test_final_post(self, describe_mock):
+    @mock.patch(
+        "kolibri.utils.version.get_git_describe", return_value="v0.1.1-6-gdef09150"
+    )
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_after_final_tag(self, file_mock, describe_mock):
         """
-        Test that the major version is set as expected on a final release
+        Test that the version is set as the next patch alpha when we are beyond the final release tag
         """
-        v = get_version((0, 1, 1, "final", 1))
-        self.assertEqual(v, "0.1.1.post1")
-        assert describe_mock.call_count == 0
+        v = get_version((0, 1, 1))
+        self.assertEqual(v, "0.1.1a0.dev0+git.6.gdef09150")
+        assert describe_mock.call_count == 1
 
     def test_version_compat(self):
         """
@@ -470,3 +441,20 @@ def test_normalize_version_to_semver_beta(self):
             ),
             "0.16-b.1.c",
         )
+
+    @mock.patch("kolibri.utils.version.get_git_describe", return_value="v0.15.8")
+    @mock.patch("kolibri.utils.version.get_version_file", return_value=None)
+    def test_get_version(self, file_mock, describe_mock):
+        self.assertEqual(
+            get_version((0, 15, 8)),
+            "0.15.8",
+        )
+        assert describe_mock.call_count == 1
+
+    @mock.patch("kolibri.utils.version.get_version_file", return_value="0.15.8")
+    def test_get_version_from_file(self, describe_mock):
+        self.assertEqual(
+            get_version((0, 15, 8)),
+            "0.15.8",
+        )
+        assert describe_mock.call_count == 1