Repository: readthedocs/readthedocs.org

Issue:
"stable" appearing to track future release branches
## Details

* Project URL: http://channels.readthedocs.io
* Build URL (if applicable): http://channels.readthedocs.io/en/stable/
* Read the Docs username (if applicable): andrewgodwin

## Expected Result

I expect the "stable" build version to only follow tags, not new branches, as described in http://docs.readthedocs.io/en/latest/versions.html

## Actual Result

The "stable" build says it is following `origin/2.0`, which looks like a reference to a remote Git branch rather than the project's most recent tag, `1.1.8`, which means the "stable" docs have ended up being the in-development version docs.

The "wipe" command does not appear to fix this, and I would like to avoid deleting and remaking the project just to see if it's a bad tag being stored somewhere.


Discussion:
```sh
> git ls-remote --tags git@github.com:django/channels.git
```

Shows that the most recent tag is indeed 1.1.8. Looks like a bug to me. 
@andrewgodwin I just took a couple of screenshots of our JupyterHub configuration settings in RTD: 

This first screenshot shows the far right settings menu with Advanced Settings selected.

<img width="596" alt="screenshot 2017-11-15 14 19 45" src="https://user-images.githubusercontent.com/2680980/32863539-ba50db46-ca10-11e7-8471-cc227bb5c3c6.png">

This screenshot shows the Versions page when selected from the far right settings menu (**This is different than the top level versions menu**). Double check that the versions desired match what you wish to build and see what SHA the stable version when enabled is picking up.

<img width="604" alt="screenshot 2017-11-15 14 20 21" src="https://user-images.githubusercontent.com/2680980/32863540-ba6af8d2-ca10-11e7-801d-d993233110df.png">

If these settings are not correct, wipe will have no effect.

Unfortunately, I can't see your settings. Let me know if you have further questions. Traveling the rest of the day but will be around tomorrow/Friday.
There's not even a SHA in my stable build, there is `origin/2` again - here's screenshots of the same pages from the Channels project:

![snip1](https://user-images.githubusercontent.com/36182/32870888-cc31da0a-ca32-11e7-8b6e-4de5265a3d34.PNG)
![snip2](https://user-images.githubusercontent.com/36182/32870890-cfa7005c-ca32-11e7-891a-1602e8c8fbf4.PNG)

I'll have to confirm this tomorrow, but my initial thoughts here are that we are in fact determining that the `2.0` branch is to take precedence over `1.x` series. If the `2.0` branch were to be named something along the lines of `2.0b1`, this might not surface. This isn't a good solution for your case, @andrewgodwin.

Agreed that tags should probably take precedence over a branch name. I'm not fully certain of the best UX here though -- making versioning work for everyone is hard. Perhaps we need a way to turn off the handy wavy magic we do here to select a new version.

I'll poke this tomorrow when I'm fresher.
Right, I wasn't clear if `stable` was just tags or branches - I thought it was just tags, since we use branches for all sorts of things and haven't had this before (though I guess they didn't look like a version number).

I don't mind too much what it is as long as it's well defined, but given I have a lot of links on the internet pointing to `/stable/`, some sort of alias/redirect-to-latest feature would be nice if it's determined this is working as intended, because right now people think my docs are broken.
@agjohnson @andrewgodwin FWIW, one difference that we do with JupyterHub and friends is use a configuration file for RTD (`readthedocs.yml`) in the base of our project repo.

- conda (environment.yml): https://github.com/jupyterhub/jupyterhub/blob/master/readthedocs.yml
- pip (requirements.txt): https://github.com/jupyter/nbgrader/blob/master/readthedocs.yaml

In setting up a test build on RTD (https://readthedocs.org/projects/test-channels/) using my fork as the source (https://github.com/willingc/channels) to see if the above configuration would change Andrew's build experience for stable, I discovered that trying to rebuild stable from the drop down selection on the main page or the build page results in a page with an API deprecation warning:

Endpoint is deprecated
<img width="1045" alt="screenshot 2017-11-16 10 10 20" src="https://user-images.githubusercontent.com/2680980/32908485-92a0dc10-cab8-11e7-80a9-a961e4555deb.png">
UI
<img width="1045" alt="screenshot 2017-11-16 10 14 16" src="https://user-images.githubusercontent.com/2680980/32908486-92be9fac-cab8-11e7-886f-7d52ff37574f.png">

I think this is likely part of the problem. I, too, with the test build did not have a SHA for the stable version in the Versions page above.
>  I discovered that trying to rebuild stable from the drop down selection on the main page or the build page results in a page with an API deprecation warning

Ups... Sorry. That's a bug that's already fixed in this PR at https://github.com/rtfd/readthedocs.org/pull/3260

Seems it's not deployed yet.

It _shouldn't_ be related with andrew's issue regarding the `stable` version.
@andrewgodwin if this goes on for longer without resolution lets just get a temporary redirect in at the web server level or something.

I'll see if one of us can't get to this tomorrow, i ended up swamped with work today.
Cool, thanks for your help on this!
I did a quick test on my local instance and it was pretty easy to reproduce andrew's report. I created a git repo with `master` and `1.0` branches and one tag `0.9`. RTD mark `1.0` as `stable`.

The code that decide the `stable` version is: https://github.com/rtfd/readthedocs.org/blob/7cbda6a9b318dc182cd304332a3045a1c53a4aef/readthedocs/projects/version_handling.py#L174-L189

where `version_list` is `Project.versions.all()` (this query includes branches and tags) and then in the `determine_stable_version` branch or tag doesn't matter, they are treated as the same.

I wrote an horrible hack that make tags take preference over branches and I tested with the `channels` repo and `1.1.8` was what I got as `stable`, but I think we need to define in a clear way how we want this to work. I mean, how is the logic to decide what should be the sable verision.

Once we have that defined and clear, I could try to write the chunk of code and test needed for this. It will probably involve touching other parts of the codebase also.

What are you thoughts on the logic to decide the stable?
Nice work @humitos. I'm cool with your logic. As a user, I would love to be able to provide a SHA commit for what I would like to have as stable and just have the default stable come in to play if I don't provide the SHA.
Ah, I did wonder if it was something like that. For reference, the "versions" doc at http://docs.readthedocs.io/en/latest/versions.html describes stable as `We also create a stable version, if your project has any tagged releases`, so I had thought it was already preferring tags.
> As a user, I would love to be able to provide a SHA commit for what I would like to have as stable and just have the default stable come in to play if I don't provide the SHA.

That could be a good feature. But, suposing that the `stable` version would be working properly, I think it shouldn't be necessary and it could make more confusion maybe. Anyway, I will create a new issue to discuss about this feature and not mix with this report.

That's why I wanted to more feedback on how the stable version should be determined to think in some cases that "take preference of tags over branches" won't work for a project/user.
> We also create a stable version, if your project has any tagged releases

Well, this sentence is very confusing for me because of that comma. My English knowledge doesn't help here.

I suppose that it has the same meaning than "If your project has any tagged releases, we also create a stable version". So, I understand that the `stable` version is only created when your project doesn't have any tagged release, that's not the case of channels and the `stable` is created anyway, right?

If I'm right, we should re-write that chunk of the documentation also.
Channels does indeed have tagged releases, quite a few of them - the most recent being `1.1.8`

Resolving patch:
diff --git a/readthedocs/projects/version_handling.py b/readthedocs/projects/version_handling.py
--- a/readthedocs/projects/version_handling.py
+++ b/readthedocs/projects/version_handling.py
@@ -1,24 +1,37 @@
-"""Project version handling"""
-from __future__ import absolute_import
+# -*- coding: utf-8 -*-
+"""Project version handling."""
+from __future__ import (
+    absolute_import, division, print_function, unicode_literals)
 
 import unicodedata
+from builtins import object, range
 from collections import defaultdict
 
-from builtins import (object, range)
-from packaging.version import Version
-from packaging.version import InvalidVersion
 import six
+from packaging.version import InvalidVersion, Version
 
-from readthedocs.builds.constants import LATEST_VERBOSE_NAME
-from readthedocs.builds.constants import STABLE_VERBOSE_NAME
+from readthedocs.builds.constants import (
+    LATEST_VERBOSE_NAME, STABLE_VERBOSE_NAME, TAG)
 
 
 def get_major(version):
+    """
+    Return the major version.
+
+    :param version: version to get the major
+    :type version: packaging.version.Version
+    """
     # pylint: disable=protected-access
     return version._version.release[0]
 
 
 def get_minor(version):
+    """
+    Return the minor version.
+
+    :param version: version to get the minor
+    :type version: packaging.version.Version
+    """
     # pylint: disable=protected-access
     try:
         return version._version.release[1]
@@ -28,7 +41,7 @@ def get_minor(version):
 
 class VersionManager(object):
 
-    """Prune list of versions based on version windows"""
+    """Prune list of versions based on version windows."""
 
     def __init__(self):
         self._state = defaultdict(lambda: defaultdict(list))
@@ -72,13 +85,13 @@ def get_version_list(self):
                 versions.extend(version_list)
         versions = sorted(versions)
         return [
-            version.public
-            for version in versions
-            if not version.is_prerelease]
+            version.public for version in versions if not version.is_prerelease
+        ]
 
 
 def version_windows(versions, major=1, minor=1, point=1):
-    """Return list of versions that have been pruned to version windows
+    """
+    Return list of versions that have been pruned to version windows.
 
     Uses :py:class:`VersionManager` to prune the list of versions
 
@@ -111,6 +124,18 @@ def version_windows(versions, major=1, minor=1, point=1):
 
 
 def parse_version_failsafe(version_string):
+    """
+    Parse a version in string form and return Version object.
+
+    If there is an error parsing the string, ``None`` is returned.
+
+    :param version_string: version as string object (e.g. '3.10.1')
+    :type version_string: str or unicode
+
+    :returns: version object created from a string object
+
+    :rtype: packaging.version.Version
+    """
     if not isinstance(version_string, six.text_type):
         uni_version = version_string.decode('utf-8')
     else:
@@ -126,11 +151,19 @@ def parse_version_failsafe(version_string):
 
 
 def comparable_version(version_string):
-    """This can be used as ``key`` argument to ``sorted``.
+    """
+    Can be used as ``key`` argument to ``sorted``.
 
     The ``LATEST`` version shall always beat other versions in comparison.
     ``STABLE`` should be listed second. If we cannot figure out the version
     number then we sort it to the bottom of the list.
+
+    :param version_string: version as string object (e.g. '3.10.1' or 'latest')
+    :type version_string: str or unicode
+
+    :returns: a comparable version object (e.g. 'latest' -> Version('99999.0'))
+
+    :rtype: packaging.version.Version
     """
     comparable = parse_version_failsafe(version_string)
     if not comparable:
@@ -144,12 +177,16 @@ def comparable_version(version_string):
 
 
 def sort_versions(version_list):
-    """Takes a list of ``Version`` models and return a sorted list,
+    """
+    Take a list of Version models and return a sorted list.
 
-    The returned value is a list of two-tuples. The first is the actual
-    ``Version`` model instance, the second is an instance of
-    ``packaging.version.Version``. They are ordered in descending order (latest
-    version first).
+    :param version_list: list of Version models
+    :type version_list: list(readthedocs.builds.models.Version)
+
+    :returns: sorted list in descending order (latest version first) of versions
+
+    :rtype: list(tupe(readthedocs.builds.models.Version,
+            packaging.version.Version))
     """
     versions = []
     for version_obj in version_list:
@@ -158,13 +195,20 @@ def sort_versions(version_list):
         if comparable_version:
             versions.append((version_obj, comparable_version))
 
-    return list(sorted(
-        versions,
-        key=lambda version_info: version_info[1],
-        reverse=True))
+    return list(
+        sorted(
+            versions,
+            key=lambda version_info: version_info[1],
+            reverse=True,
+        ))
 
 
 def highest_version(version_list):
+    """
+    Return the highest version for a given ``version_list``.
+
+    :rtype: tupe(readthedocs.builds.models.Version, packaging.version.Version)
+    """
     versions = sort_versions(version_list)
     if versions:
         return versions[0]
@@ -172,18 +216,29 @@ def highest_version(version_list):
 
 
 def determine_stable_version(version_list):
-    """Determine a stable version for version list
+    """
+    Determine a stable version for version list.
+
+    :param version_list: list of versions
+    :type version_list: list(readthedocs.builds.models.Version)
 
-    Takes a list of ``Version`` model instances and returns the version
-    instance which can be considered the most recent stable one. It will return
-    ``None`` if there is no stable version in the list.
+    :returns: version considered the most recent stable one or ``None`` if there
+              is no stable version in the list
+
+    :rtype: readthedocs.builds.models.Version
     """
     versions = sort_versions(version_list)
-    versions = [
-        (version_obj, comparable)
-        for version_obj, comparable in versions
-        if not comparable.is_prerelease]
+    versions = [(version_obj, comparable)
+                for version_obj, comparable in versions
+                if not comparable.is_prerelease]
+
     if versions:
+        # We take preference for tags over branches. If we don't find any tag,
+        # we just return the first branch found.
+        for version_obj, comparable in versions:
+            if version_obj.type == TAG:
+                return version_obj
+
         version_obj, comparable = versions[0]
         return version_obj
     return None
diff --git a/readthedocs/restapi/utils.py b/readthedocs/restapi/utils.py
--- a/readthedocs/restapi/utils.py
+++ b/readthedocs/restapi/utils.py
@@ -13,12 +13,9 @@
 
 def sync_versions(project, versions, type):  # pylint: disable=redefined-builtin
     """Update the database with the current versions from the repository."""
-    # Bookkeeping for keeping tag/branch identifies correct
-    verbose_names = [v['verbose_name'] for v in versions]
-    project.versions.filter(verbose_name__in=verbose_names).update(type=type)
-
     old_versions = {}
-    old_version_values = project.versions.values('identifier', 'verbose_name')
+    old_version_values = project.versions.filter(type=type).values(
+        'identifier', 'verbose_name')
     for version in old_version_values:
         old_versions[version['verbose_name']] = version['identifier']
 
diff --git a/readthedocs/restapi/views/footer_views.py b/readthedocs/restapi/views/footer_views.py
--- a/readthedocs/restapi/views/footer_views.py
+++ b/readthedocs/restapi/views/footer_views.py
@@ -1,35 +1,41 @@
+# -*- coding: utf-8 -*-
 """Endpoint to generate footer HTML."""
 
-from __future__ import absolute_import
+from __future__ import (
+    absolute_import, division, print_function, unicode_literals)
 
-from django.shortcuts import get_object_or_404
-from django.template import RequestContext, loader as template_loader
+import six
 from django.conf import settings
-
-
+from django.shortcuts import get_object_or_404
+from django.template import loader as template_loader
 from rest_framework import decorators, permissions
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework_jsonp.renderers import JSONPRenderer
 
-from readthedocs.builds.constants import LATEST
-from readthedocs.builds.constants import TAG
+from readthedocs.builds.constants import LATEST, TAG
 from readthedocs.builds.models import Version
 from readthedocs.projects.models import Project
-from readthedocs.projects.version_handling import highest_version
-from readthedocs.projects.version_handling import parse_version_failsafe
+from readthedocs.projects.version_handling import (
+    highest_version, parse_version_failsafe)
 from readthedocs.restapi.signals import footer_response
-import six
 
 
 def get_version_compare_data(project, base_version=None):
-    """Retrieve metadata about the highest version available for this project.
+    """
+    Retrieve metadata about the highest version available for this project.
 
     :param base_version: We assert whether or not the base_version is also the
                          highest version in the resulting "is_highest" value.
     """
+    versions_qs = project.versions.public().filter(active=True)
+
+    # Take preferences over tags only if the project has at least one tag
+    if versions_qs.filter(type=TAG).exists():
+        versions_qs = versions_qs.filter(type=TAG)
+
     highest_version_obj, highest_version_comparable = highest_version(
-        project.versions.public().filter(active=True))
+        versions_qs)
     ret_val = {
         'project': six.text_type(highest_version_obj),
         'version': six.text_type(highest_version_comparable),
@@ -69,32 +75,30 @@ def footer_html(request):
     subproject = request.GET.get('subproject', False)
     source_suffix = request.GET.get('source_suffix', '.rst')
 
-    new_theme = (theme == "sphinx_rtd_theme")
-    using_theme = (theme == "default")
+    new_theme = (theme == 'sphinx_rtd_theme')
+    using_theme = (theme == 'default')
     project = get_object_or_404(Project, slug=project_slug)
     version = get_object_or_404(
-        Version.objects.public(request.user, project=project, only_active=False),
+        Version.objects.public(
+            request.user, project=project, only_active=False),
         slug=version_slug)
     main_project = project.main_language_project or project
 
-    if page_slug and page_slug != "index":
-        if (
-                main_project.documentation_type == "sphinx_htmldir" or
-                main_project.documentation_type == "mkdocs"):
-            path = page_slug + "/"
-        elif main_project.documentation_type == "sphinx_singlehtml":
-            path = "index.html#document-" + page_slug
+    if page_slug and page_slug != 'index':
+        if (main_project.documentation_type == 'sphinx_htmldir' or
+                main_project.documentation_type == 'mkdocs'):
+            path = page_slug + '/'
+        elif main_project.documentation_type == 'sphinx_singlehtml':
+            path = 'index.html#document-' + page_slug
         else:
-            path = page_slug + ".html"
+            path = page_slug + '.html'
     else:
-        path = ""
+        path = ''
 
     if version.type == TAG and version.project.has_pdf(version.slug):
         print_url = (
-            'https://keminglabs.com/print-the-docs/quote?project={project}&version={version}'
-            .format(
-                project=project.slug,
-                version=version.slug))
+            'https://keminglabs.com/print-the-docs/quote?project={project}&version={version}'  # noqa
+            .format(project=project.slug, version=version.slug))
     else:
         print_url = None
 
@@ -115,14 +119,28 @@ def footer_html(request):
         'settings': settings,
         'subproject': subproject,
         'print_url': print_url,
-        'github_edit_url': version.get_github_url(docroot, page_slug, source_suffix, 'edit'),
-        'github_view_url': version.get_github_url(docroot, page_slug, source_suffix, 'view'),
-        'bitbucket_url': version.get_bitbucket_url(docroot, page_slug, source_suffix),
+        'github_edit_url': version.get_github_url(
+            docroot,
+            page_slug,
+            source_suffix,
+            'edit',
+        ),
+        'github_view_url': version.get_github_url(
+            docroot,
+            page_slug,
+            source_suffix,
+            'view',
+        ),
+        'bitbucket_url': version.get_bitbucket_url(
+            docroot,
+            page_slug,
+            source_suffix,
+        ),
         'theme': theme,
     }
 
-    html = template_loader.get_template('restapi/footer.html').render(context,
-                                                                      request)
+    html = template_loader.get_template('restapi/footer.html').render(
+        context, request)
     resp_data = {
         'html': html,
         'version_active': version.active,
@@ -130,8 +148,9 @@ def footer_html(request):
         'version_supported': version.supported,
     }
 
-    # Allow folks to hook onto the footer response for various information collection,
-    # or to modify the resp_data.
-    footer_response.send(sender=None, request=request, context=context, resp_data=resp_data)
+    # Allow folks to hook onto the footer response for various information
+    # collection, or to modify the resp_data.
+    footer_response.send(
+        sender=None, request=request, context=context, resp_data=resp_data)
 
     return Response(resp_data)


Test patch:
diff --git a/readthedocs/rtd_tests/tests/test_footer.py b/readthedocs/rtd_tests/tests/test_footer.py
--- a/readthedocs/rtd_tests/tests/test_footer.py
+++ b/readthedocs/rtd_tests/tests/test_footer.py
@@ -1,12 +1,18 @@
-from __future__ import absolute_import
-import mock
+# -*- coding: utf-8 -*-
+from __future__ import (
+    absolute_import, division, print_function, unicode_literals)
 
+import mock
+from django.test import TestCase
 from rest_framework.test import APIRequestFactory, APITestCase
 
+from readthedocs.builds.constants import BRANCH, LATEST, TAG
+from readthedocs.builds.models import Version
 from readthedocs.core.middleware import FooterNoSessionMiddleware
-from readthedocs.rtd_tests.mocks.paths import fake_paths_by_regex
 from readthedocs.projects.models import Project
-from readthedocs.restapi.views.footer_views import footer_html
+from readthedocs.restapi.views.footer_views import (
+    footer_html, get_version_compare_data)
+from readthedocs.rtd_tests.mocks.paths import fake_paths_by_regex
 
 
 class Testmaker(APITestCase):
@@ -40,10 +46,10 @@ def test_footer(self):
         self.assertEqual(r.status_code, 200)
 
     def test_footer_uses_version_compare(self):
-        version_compare = 'readthedocs.restapi.views.footer_views.get_version_compare_data'
+        version_compare = 'readthedocs.restapi.views.footer_views.get_version_compare_data'  # noqa
         with mock.patch(version_compare) as get_version_compare_data:
             get_version_compare_data.return_value = {
-                'MOCKED': True
+                'MOCKED': True,
             }
             r = self.render()
             self.assertEqual(r.status_code, 200)
@@ -85,3 +91,117 @@ def test_no_session_logged_out(self):
         home_request = self.factory.get('/')
         mid.process_request(home_request)
         self.assertEqual(home_request.session.TEST_COOKIE_NAME, 'testcookie')
+
+
+class TestVersionCompareFooter(TestCase):
+    fixtures = ['test_data']
+
+    def setUp(self):
+        self.pip = Project.objects.get(slug='pip')
+
+    def test_highest_version_from_stable(self):
+        base_version = self.pip.get_stable_version()
+        valid_data = {
+            'project': 'Version 0.8.1 of Pip (19)',
+            'url': '/dashboard/pip/version/0.8.1/',
+            'slug': ('0.8.1',),
+            'version': '0.8.1',
+            'is_highest': True,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+    def test_highest_version_from_lower(self):
+        base_version = self.pip.versions.get(slug='0.8')
+        valid_data = {
+            'project': 'Version 0.8.1 of Pip (19)',
+            'url': '/dashboard/pip/version/0.8.1/',
+            'slug': ('0.8.1',),
+            'version': '0.8.1',
+            'is_highest': False,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+    def test_highest_version_from_latest(self):
+        Version.objects.create_latest(project=self.pip)
+        base_version = self.pip.versions.get(slug=LATEST)
+        valid_data = {
+            'project': 'Version 0.8.1 of Pip (19)',
+            'url': '/dashboard/pip/version/0.8.1/',
+            'slug': ('0.8.1',),
+            'version': '0.8.1',
+            'is_highest': True,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+    def test_highest_version_over_branches(self):
+        Version.objects.create(
+            project=self.pip,
+            verbose_name='2.0.0',
+            identifier='2.0.0',
+            type=BRANCH,
+            active=True,
+        )
+
+        version = Version.objects.create(
+            project=self.pip,
+            verbose_name='1.0.0',
+            identifier='1.0.0',
+            type=TAG,
+            active=True,
+        )
+
+        base_version = self.pip.versions.get(slug='0.8.1')
+        valid_data = {
+            'project': 'Version 1.0.0 of Pip ({})'.format(version.pk),
+            'url': '/dashboard/pip/version/1.0.0/',
+            'slug': ('1.0.0',),
+            'version': '1.0.0',
+            'is_highest': False,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+    def test_highest_version_without_tags(self):
+        self.pip.versions.filter(type=TAG).update(type=BRANCH)
+
+        base_version = self.pip.versions.get(slug='0.8.1')
+        valid_data = {
+            'project': 'Version 0.8.1 of Pip (19)',
+            'url': '/dashboard/pip/version/0.8.1/',
+            'slug': ('0.8.1',),
+            'version': '0.8.1',
+            'is_highest': True,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+        base_version = self.pip.versions.get(slug='0.8')
+        valid_data = {
+            'project': 'Version 0.8.1 of Pip (19)',
+            'url': '/dashboard/pip/version/0.8.1/',
+            'slug': ('0.8.1',),
+            'version': '0.8.1',
+            'is_highest': False,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
+
+        version = Version.objects.create(
+            project=self.pip,
+            verbose_name='2.0.0',
+            identifier='2.0.0',
+            type=BRANCH,
+            active=True,
+        )
+        valid_data = {
+            'project': 'Version 2.0.0 of Pip ({})'.format(version.pk),
+            'url': '/dashboard/pip/version/2.0.0/',
+            'slug': ('2.0.0',),
+            'version': '2.0.0',
+            'is_highest': False,
+        }
+        returned_data = get_version_compare_data(self.pip, base_version)
+        self.assertDictEqual(valid_data, returned_data)
diff --git a/readthedocs/rtd_tests/tests/test_sync_versions.py b/readthedocs/rtd_tests/tests/test_sync_versions.py
--- a/readthedocs/rtd_tests/tests/test_sync_versions.py
+++ b/readthedocs/rtd_tests/tests/test_sync_versions.py
@@ -1,26 +1,38 @@
 # -*- coding: utf-8 -*-
 
-from __future__ import absolute_import
+from __future__ import (
+    absolute_import, division, print_function, unicode_literals)
+
 import json
 
 from django.test import TestCase
 
+from readthedocs.builds.constants import BRANCH, STABLE, TAG
 from readthedocs.builds.models import Version
-from readthedocs.builds.constants import STABLE
 from readthedocs.projects.models import Project
 
 
 class TestSyncVersions(TestCase):
-    fixtures = ["eric", "test_data"]
+    fixtures = ['eric', 'test_data']
 
     def setUp(self):
         self.client.login(username='eric', password='test')
         self.pip = Project.objects.get(slug='pip')
-        Version.objects.create(project=self.pip, identifier='origin/master',
-                               verbose_name='master', active=True,
-                               machine=True)
-        Version.objects.create(project=self.pip, identifier='to_delete',
-                               verbose_name='to_delete', active=False)
+        Version.objects.create(
+            project=self.pip,
+            identifier='origin/master',
+            verbose_name='master',
+            active=True,
+            machine=True,
+            type=BRANCH,
+        )
+        Version.objects.create(
+            project=self.pip,
+            identifier='to_delete',
+            verbose_name='to_delete',
+            active=False,
+            type=TAG,
+        )
 
     def test_proper_url_no_slash(self):
         version_post_data = {
@@ -33,10 +45,11 @@ def test_proper_url_no_slash(self):
                     'identifier': 'origin/to_add',
                     'verbose_name': 'to_add',
                 },
-            ]}
+            ],
+        }
 
         r = self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -46,8 +59,12 @@ def test_proper_url_no_slash(self):
 
     def test_new_tag_update_active(self):
 
-        Version.objects.create(project=self.pip, identifier='0.8.3',
-                               verbose_name='0.8.3', active=True)
+        Version.objects.create(
+            project=self.pip,
+            identifier='0.8.3',
+            verbose_name='0.8.3',
+            active=True,
+        )
 
         version_post_data = {
             'branches': [
@@ -69,22 +86,31 @@ def test_new_tag_update_active(self):
                     'identifier': '0.8.3',
                     'verbose_name': '0.8.3',
                 },
-
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
         version_9 = Version.objects.get(slug='0.9')
         self.assertTrue(version_9.active)
 
+        # Version 0.9 becomes the stable version
+        self.assertEqual(
+            version_9.identifier,
+            self.pip.get_stable_version().identifier,
+        )
+
     def test_new_tag_update_inactive(self):
 
-        Version.objects.create(project=self.pip, identifier='0.8.3',
-                               verbose_name='0.8.3', active=False)
+        Version.objects.create(
+            project=self.pip,
+            identifier='0.8.3',
+            verbose_name='0.8.3',
+            active=False,
+        )
 
         version_post_data = {
             'branches': [
@@ -106,21 +132,29 @@ def test_new_tag_update_inactive(self):
                     'identifier': '0.8.3',
                     'verbose_name': '0.8.3',
                 },
-
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
+        # Version 0.9 becomes the stable version and active
         version_9 = Version.objects.get(slug='0.9')
-        self.assertTrue(version_9.active is False)
+        self.assertEqual(
+            version_9.identifier,
+            self.pip.get_stable_version().identifier,
+        )
+        self.assertTrue(version_9.active)
+
+        # Version 0.8.3 is still inactive
+        version_8 = Version.objects.get(slug='0.8.3')
+        self.assertFalse(version_8.active)
 
 
 class TestStableVersion(TestCase):
-    fixtures = ["eric", "test_data"]
+    fixtures = ['eric', 'test_data']
 
     def setUp(self):
         self.client.login(username='eric', password='test')
@@ -147,17 +181,16 @@ def test_stable_versions(self):
                     'identifier': '0.8',
                     'verbose_name': '0.8',
                 },
-
-            ]
+            ],
         }
 
         self.assertRaises(
             Version.DoesNotExist,
             Version.objects.get,
-            slug=STABLE
+            slug=STABLE,
         )
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -174,11 +207,11 @@ def test_pre_release_are_not_stable(self):
                 {'identifier': '0.9b1', 'verbose_name': '0.9b1'},
                 {'identifier': '0.8', 'verbose_name': '0.8'},
                 {'identifier': '0.8rc2', 'verbose_name': '0.8rc2'},
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -192,11 +225,11 @@ def test_post_releases_are_stable(self):
             'tags': [
                 {'identifier': '1.0', 'verbose_name': '1.0'},
                 {'identifier': '1.0.post1', 'verbose_name': '1.0.post1'},
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -210,12 +243,15 @@ def test_invalid_version_numbers_are_not_stable(self):
         version_post_data = {
             'branches': [],
             'tags': [
-                {'identifier': 'this.is.invalid', 'verbose_name': 'this.is.invalid'},
-            ]
+                {
+                    'identifier': 'this.is.invalid',
+                    'verbose_name': 'this.is.invalid'
+                },
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -224,13 +260,19 @@ def test_invalid_version_numbers_are_not_stable(self):
         version_post_data = {
             'branches': [],
             'tags': [
-                {'identifier': '1.0', 'verbose_name': '1.0'},
-                {'identifier': 'this.is.invalid', 'verbose_name': 'this.is.invalid'},
-            ]
+                {
+                    'identifier': '1.0',
+                    'verbose_name': '1.0',
+                },
+                {
+                    'identifier': 'this.is.invalid',
+                    'verbose_name': 'this.is.invalid'
+                },
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -255,11 +297,11 @@ def test_update_stable_version(self):
                     'identifier': '0.8',
                     'verbose_name': '0.8',
                 },
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -278,7 +320,7 @@ def test_update_stable_version(self):
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -293,11 +335,11 @@ def test_update_stable_version(self):
                     'identifier': '0.7',
                     'verbose_name': '0.7',
                 },
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -319,11 +361,11 @@ def test_update_inactive_stable_version(self):
                     'identifier': '0.9',
                     'verbose_name': '0.9',
                 },
-            ]
+            ],
         }
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -339,7 +381,7 @@ def test_update_inactive_stable_version(self):
         })
 
         self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
@@ -348,16 +390,80 @@ def test_update_inactive_stable_version(self):
         self.assertFalse(version_stable.active)
         self.assertEqual(version_stable.identifier, '1.0.0')
 
+    def test_stable_version_tags_over_branches(self):
+        version_post_data = {
+            'branches': [
+                # 2.0 development
+                {'identifier': 'origin/2.0', 'verbose_name': '2.0'},
+                {'identifier': 'origin/0.9.1rc1', 'verbose_name': '0.9.1rc1'},
+            ],
+            'tags': [
+                {'identifier': '1.0rc1', 'verbose_name': '1.0rc1'},
+                {'identifier': '0.9', 'verbose_name': '0.9'},
+            ],
+        }
+
+        self.client.post(
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
+            data=json.dumps(version_post_data),
+            content_type='application/json',
+        )
+
+        # If there is a branch with a higher version, tags takes preferences
+        # over the branches to select the stable version
+        version_stable = Version.objects.get(slug=STABLE)
+        self.assertTrue(version_stable.active)
+        self.assertEqual(version_stable.identifier, '0.9')
+
+        version_post_data['tags'].append({
+            'identifier': '1.0',
+            'verbose_name': '1.0',
+        })
+
+        self.client.post(
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
+            data=json.dumps(version_post_data),
+            content_type='application/json',
+        )
+
+        version_stable = Version.objects.get(slug=STABLE)
+        self.assertTrue(version_stable.active)
+        self.assertEqual(version_stable.identifier, '1.0')
+
+    def test_stable_version_same_id_tag_branch(self):
+        version_post_data = {
+            'branches': [
+                # old 1.0 development branch
+                {'identifier': 'origin/1.0', 'verbose_name': '1.0'},
+            ],
+            'tags': [
+                # tagged 1.0 final version
+                {'identifier': '1.0', 'verbose_name': '1.0'},
+                {'identifier': '0.9', 'verbose_name': '0.9'},
+            ],
+        }
+
+        self.client.post(
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
+            data=json.dumps(version_post_data),
+            content_type='application/json',
+        )
+
+        version_stable = Version.objects.get(slug=STABLE)
+        self.assertTrue(version_stable.active)
+        self.assertEqual(version_stable.identifier, '1.0')
+        self.assertEqual(version_stable.type, 'tag')
+
     def test_unicode(self):
         version_post_data = {
             'branches': [],
             'tags': [
                 {'identifier': 'foo-£', 'verbose_name': 'foo-£'},
-            ]
+            ],
         }
 
         resp = self.client.post(
-            '/api/v2/project/%s/sync_versions/' % self.pip.pk,
+            '/api/v2/project/{}/sync_versions/'.format(self.pip.pk),
             data=json.dumps(version_post_data),
             content_type='application/json',
         )
diff --git a/requirements/testing.txt b/requirements/testing.txt
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -5,3 +5,6 @@ pytest-django==3.1.2
 pytest-xdist==1.20.1
 apipkg==1.4
 execnet==1.5.0
+
+# local debugging tools
+pdbpp