← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/series-alias into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/series-alias into lp:launchpad.

Commit message:
Implement Distribution.development_series_alias.  This refers to the latest series with any active publications for archive publication, and to Distribution.currentseries for uploads and web redirects.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1198279 in Launchpad itself: "Alias for development series of distributions"
  https://bugs.launchpad.net/launchpad/+bug/1198279

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/series-alias/+merge/178103

== Summary ==

We want to be able to set an alias for the development series of Ubuntu, and have that be automatically resolved for the purpose of downloads (i.e. symlinks in the archive) and uploads.  Bug 1198279.

== Proposed fix ==

We have a new Distribution.development_series_alias DB column, which we'll use for this.  When publishing an archive, symlink this to the latest series with any active publications.  When uploading a package, automatically map the alias to Distribution.currentseries.  When traversing /<distribution>/<alias> on the web UI, redirect to /<distribution>/<currentseries>.

== Pre-implementation notes ==

This was rather more complex than initially supposed, mainly due to difficulties with PPAs, where the distribution's current series doesn't necessarily exist at all.  We want people to be able to assume that the alias exists, but don't want to have to copy packages forward in PPAs or forcibly republish all PPAs any time the series changes.  After some debate with William, we ended up with the compromise that we'll make the alias point to the latest series with any active publications, which should generally correspond to where most work is happening.

Otherwise, it's largely straightforward, although William dissuaded me from making the relevant internal APIs follow aliases in all situations.  Instead, I've added a follow_aliases keyword argument in a few places so that those callers that explicitly want alias resolution can ask for it.  Distribution.getSeries forces follow_aliases=True on the webservice.

== LOC Rationale ==

+253.  I claim credit and will try to find something to offset this later ...

== Tests ==

Touches lots of registry code.  Probably best to run the lot.  I ran registry, soyuz, archiveuploader, and archivepublisher.

== Demo and Q/A ==

We should QA at least the following paths:

 * Publish primary archive, check symlinks
 * Publish PPAs with various series/publication combinations, check symlinks
 * Upload to devel
 * Visit /ubuntu/devel and some URLs underneath it in a web browser
-- 
https://code.launchpad.net/~cjwatson/launchpad/series-alias/+merge/178103
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/series-alias into lp:launchpad.
=== modified file 'lib/lp/archivepublisher/publishing.py'
--- lib/lp/archivepublisher/publishing.py	2013-07-05 15:01:08 +0000
+++ lib/lp/archivepublisher/publishing.py	2013-08-01 15:28:56 +0000
@@ -41,7 +41,10 @@
     get_ppa_reference,
     RepositoryIndexFile,
     )
-from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.pocket import (
+    PackagePublishingPocket,
+    pocketsuffix,
+    )
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.sqlbase import sqlvalues
@@ -417,6 +420,92 @@
                     self.checkDirtySuiteBeforePublishing(distroseries, pocket)
                 self._writeSuite(distroseries, pocket)
 
+    def _allIndexFiles(self, distroseries):
+        """Return all index files on disk for a distroseries."""
+        components = self.archive.getComponentsForSeries(distroseries)
+        for pocket in self.archive.getPockets():
+            suite_name = distroseries.getSuite(pocket)
+            for component in components:
+                yield os.path.join(
+                    self._config.distsroot, suite_name, component.name,
+                    "source", "Sources")
+                for arch in distroseries.architectures:
+                    if not arch.enabled:
+                        continue
+                    arch_path = "binary-%s" % arch.architecturetag
+                    yield os.path.join(
+                        self._config.distsroot, suite_name, component.name,
+                        arch_path, "Packages")
+                    for subcomp in self.subcomponents:
+                        yield os.path.join(
+                            self._config.distsroot, suite_name, component.name,
+                            subcomp, arch_path, "Packages")
+
+    def _latestNonEmptySeries(self):
+        """Find the latest non-empty series in an archive.
+
+        Doing this properly (series with highest version and any active
+        publications) is expensive.  However, we just went to the effort of
+        publishing everything; so a quick-and-dirty approach is to look
+        through what we published on disk.
+        """
+        for distroseries in self.distro:
+            for index in self._allIndexFiles(distroseries):
+                try:
+                    if os.path.getsize(index) > 0:
+                        return distroseries
+                except OSError:
+                    pass
+
+    def createSeriesAliases(self):
+        """Ensure that any series aliases exist.
+
+        The natural implementation would be to point the alias at
+        self.distro.currentseries, but that works poorly for PPAs, where
+        it's possible that no packages have been published for the current
+        series.  We also don't want to have to go through and republish all
+        PPAs when we create a new series.  Thus, we instead do the best we
+        can by pointing the alias at the latest series with any publications
+        in the archive, which is the best approximation to a development
+        series for that PPA.
+
+        This does mean that the published alias might point to an older
+        series, then you upload something to the alias and find that the
+        alias has now moved to a newer series.  What can I say?  The
+        requirements are not entirely coherent for PPAs given that packages
+        are not automatically copied forward.
+        """
+        alias = self.distro.development_series_alias
+        if alias is not None:
+            current = self._latestNonEmptySeries()
+            if current is None:
+                return
+            for pocket in self.archive.getPockets():
+                if pocket == PackagePublishingPocket.RELEASE:
+                    alias_suite = alias
+                else:
+                    alias_suite = "%s%s" % (alias, pocketsuffix[pocket])
+                current_suite = current.getSuite(pocket)
+                current_suite_path = os.path.join(
+                    self._config.distsroot, current_suite)
+                if not os.path.isdir(current_suite_path):
+                    continue
+                alias_suite_path = os.path.join(
+                    self._config.distsroot, alias_suite)
+                if os.path.islink(alias_suite_path):
+                    if os.readlink(alias_suite_path) == current_suite:
+                        continue
+                elif os.path.isdir(alias_suite_path):
+                    # Perhaps somebody did something misguided ...
+                    self.log.warning(
+                        "Alias suite path %s is a directory!" % alias_suite)
+                    continue
+                try:
+                    os.unlink(alias_suite_path)
+                except OSError:
+                    pass
+                os.symlink(current_suite, alias_suite_path)
+
     def _writeComponentIndexes(self, distroseries, pocket, component):
         """Write Index files for single distroseries + pocket + component.
 

=== modified file 'lib/lp/archivepublisher/tests/test_publisher.py'
--- lib/lp/archivepublisher/tests/test_publisher.py	2013-06-18 07:17:20 +0000
+++ lib/lp/archivepublisher/tests/test_publisher.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for publisher class."""
@@ -1173,6 +1173,47 @@
             self.assertReleaseContentsMatch(
                 release, 'main/i18n/Index', i18n_index_file.read())
 
+    def testCreateSeriesAliasesNoAlias(self):
+        """createSeriesAliases has nothing to do by default."""
+        publisher = Publisher(
+            self.logger, self.config, self.disk_pool,
+            self.ubuntutest.main_archive)
+        publisher.createSeriesAliases()
+        self.assertEqual([], os.listdir(self.config.distsroot))
+
+    def _assertPublishesSeriesAlias(self, publisher, expected):
+        publisher.A_publish(False)
+        publisher.C_writeIndexes(False)
+        publisher.createSeriesAliases()
+        self.assertTrue(os.path.exists(os.path.join(
+            self.config.distsroot, expected)))
+        for pocket, suffix in pocketsuffix.items():
+            path = os.path.join(self.config.distsroot, "devel%s" % suffix)
+            expected_path = os.path.join(
+                self.config.distsroot, expected + suffix)
+            # A symlink for the RELEASE pocket exists.  Symlinks for other
+            # pockets only exist if the respective targets exist.
+            if not suffix or os.path.exists(expected_path):
+                self.assertTrue(os.path.islink(path))
+                self.assertEqual(expected + suffix, os.readlink(path))
+            else:
+                self.assertFalse(os.path.islink(path))
+
+    def testCreateSeriesAliasesChangesAlias(self):
+        """createSeriesAliases tracks the latest published series."""
+        publisher = Publisher(
+            self.logger, self.config, self.disk_pool,
+            self.ubuntutest.main_archive)
+        self.ubuntutest.development_series_alias = "devel"
+        # Oddly, hoary-test has a higher version than breezy-autotest.
+        self.getPubSource(distroseries=self.ubuntutest["breezy-autotest"])
+        self._assertPublishesSeriesAlias(publisher, "breezy-autotest")
+        hoary_pub = self.getPubSource(
+            distroseries=self.ubuntutest["hoary-test"])
+        self._assertPublishesSeriesAlias(publisher, "hoary-test")
+        hoary_pub.requestDeletion(self.ubuntutest.owner)
+        self._assertPublishesSeriesAlias(publisher, "breezy-autotest")
+
     def testHtaccessForPrivatePPA(self):
         # A htaccess file is created for new private PPA's.
 

=== modified file 'lib/lp/archiveuploader/tests/test_uploadpolicy.py'
--- lib/lp/archiveuploader/tests/test_uploadpolicy.py	2012-10-25 09:02:11 +0000
+++ lib/lp/archiveuploader/tests/test_uploadpolicy.py	2013-08-01 15:28:56 +0000
@@ -1,6 +1,6 @@
 #!/usr/bin/python
 #
-# Copyright 2010-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from zope.component import getUtility
@@ -21,6 +21,7 @@
 from lp.services.database.sqlbase import flush_database_updates
 from lp.testing import (
     celebrity_logged_in,
+    person_logged_in,
     TestCase,
     TestCaseWithFactory,
     )
@@ -194,6 +195,20 @@
             NotFoundError, policy.setDistroSeriesAndPocket,
             'nonexistent_security')
 
+    def test_setDistroSeriesAndPocket_honours_aliases(self):
+        # setDistroSeriesAndPocket honours uploads to the development series
+        # alias, if set.
+        policy = AbstractUploadPolicy()
+        policy.distro = self.factory.makeDistribution()
+        series = self.factory.makeDistroSeries(
+            distribution=policy.distro, status=SeriesStatus.DEVELOPMENT)
+        self.assertRaises(
+            NotFoundError, policy.setDistroSeriesAndPocket, "devel")
+        with person_logged_in(policy.distro.owner):
+            policy.distro.development_series_alias = "devel"
+        policy.setDistroSeriesAndPocket("devel")
+        self.assertEqual(series, policy.distroseries)
+
     def test_redirect_release_uploads_primary(self):
         # With the insecure policy, the
         # Distribution.redirect_release_uploads flag causes uploads to the

=== modified file 'lib/lp/archiveuploader/uploadpolicy.py'
--- lib/lp/archiveuploader/uploadpolicy.py	2012-10-25 09:02:11 +0000
+++ lib/lp/archiveuploader/uploadpolicy.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Policy management for the upload handler."""
@@ -145,8 +145,8 @@
             return
 
         self.distroseriesname = dr_name
-        (self.distroseries,
-         self.pocket) = self.distro.getDistroSeriesAndPocket(dr_name)
+        self.distroseries, self.pocket = self.distro.getDistroSeriesAndPocket(
+            dr_name, follow_aliases=True)
 
         if self.archive is None:
             self.archive = self.distroseries.main_archive

=== modified file 'lib/lp/archiveuploader/uploadprocessor.py'
--- lib/lp/archiveuploader/uploadprocessor.py	2013-06-26 06:02:00 +0000
+++ lib/lp/archiveuploader/uploadprocessor.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Code for 'processing' 'uploads'. Also see nascentupload.py.
@@ -743,7 +743,7 @@
 
     suite_name = parts[1]
     try:
-        distribution.getDistroSeriesAndPocket(suite_name)
+        distribution.getDistroSeriesAndPocket(suite_name, follow_aliases=True)
     except NotFoundError:
         raise exc_type("Could not find suite '%s'." % suite_name)
 

=== modified file 'lib/lp/registry/browser/distribution.py'
--- lib/lp/registry/browser/distribution.py	2013-04-10 08:35:47 +0000
+++ lib/lp/registry/browser/distribution.py	2013-08-01 15:28:56 +0000
@@ -174,6 +174,13 @@
     def traverse_archive(self, name):
         return self.context.getArchive(name)
 
+    def traverse(self, name):
+        try:
+            return super(DistributionNavigation, self).traverse(name)
+        except NotFoundError:
+            resolved = self.context.resolveSeriesAlias(name)
+            return self.redirectSubTree(canonical_url(resolved), status=303)
+
 
 class DistributionSetNavigation(Navigation):
 

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2013-04-03 03:09:04 +0000
+++ lib/lp/registry/configure.zcml	2013-08-01 15:28:56 +0000
@@ -1713,6 +1713,7 @@
                 bug_reported_acknowledgement
                 bug_reporting_guidelines
                 description
+                development_series_alias
                 displayname
                 driver
                 enable_bug_expiration

=== modified file 'lib/lp/registry/doc/distroseries.txt'
--- lib/lp/registry/doc/distroseries.txt	2013-05-01 21:23:16 +0000
+++ lib/lp/registry/doc/distroseries.txt	2013-08-01 15:28:56 +0000
@@ -56,6 +56,17 @@
     >>> print distroseriesset.queryByVersion(ubuntu, "5.05")
     None
 
+queryByName works on series aliases too if follow_aliases is True.
+
+    >>> ignored = login_person(ubuntu.owner.activemembers[0])
+    >>> ubuntu.development_series_alias = "devel"
+    >>> login(ANONYMOUS)
+    >>> print distroseriesset.queryByName(ubuntu, "devel")
+    None
+    >>> print distroseriesset.queryByName(
+    ...     ubuntu, "devel", follow_aliases=True).name
+    hoary
+
 We verify that a distroseries does in fact fully provide IDistroSeries:
 
     >>> verifyObject(IDistroSeries, warty)

=== modified file 'lib/lp/registry/interfaces/distribution.py'
--- lib/lp/registry/interfaces/distribution.py	2012-12-18 02:24:43 +0000
+++ lib/lp/registry/interfaces/distribution.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces including and related to IDistribution."""
@@ -76,6 +76,7 @@
     )
 from lp.registry.interfaces.announcement import IMakesAnnouncements
 from lp.registry.interfaces.distributionmirror import IDistributionMirror
+from lp.registry.interfaces.distroseries import DistroSeriesNameField
 from lp.registry.interfaces.karma import IKarmaContext
 from lp.registry.interfaces.milestone import (
     ICanGetMilestonesDirectly,
@@ -362,6 +363,13 @@
         description=_("Redirect release pocket uploads to proposed pocket"),
         readonly=False, required=True))
 
+    development_series_alias = exported(DistroSeriesNameField(
+        title=_("Alias for development series"),
+        description=_(
+            "If set, an alias for the current development series in this "
+            "distribution."),
+        constraint=name_validator, readonly=False, required=False))
+
     def getArchiveIDList(archive=None):
         """Return a list of archive IDs suitable for sqlvalues() or quote().
 
@@ -396,12 +404,20 @@
     def getDevelopmentSeries():
         """Return the DistroSeries which are marked as in development."""
 
+    def resolveSeriesAlias(name):
+        """Resolve a series alias.
+
+        :param name: The name to resolve.
+        :raises NoSuchDistroSeries: If there is no match.
+        """
+
     @operation_parameters(
         name_or_version=TextLine(title=_("Name or version"), required=True))
     # Really IDistroSeries, see _schema_circular_imports.py.
     @operation_returns_entry(Interface)
+    @call_with(follow_aliases=True)
     @export_read_operation()
-    def getSeries(name_or_version):
+    def getSeries(name_or_version, follow_aliases=False):
         """Return the series with the name or version given.
 
         :param name_or_version: The `IDistroSeries.name` or
@@ -494,7 +510,7 @@
             and the value is a `IDistributionSourcePackageRelease`.
         """
 
-    def getDistroSeriesAndPocket(distroseriesname):
+    def getDistroSeriesAndPocket(distroseriesname, follow_aliases=False):
         """Return a (distroseries,pocket) tuple which is the given textual
         distroseriesname in this distribution."""
 

=== modified file 'lib/lp/registry/interfaces/distroseries.py'
--- lib/lp/registry/interfaces/distroseries.py	2013-05-01 18:13:17 +0000
+++ lib/lp/registry/interfaces/distroseries.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces including and related to IDistroSeries."""
@@ -7,6 +7,7 @@
 
 __all__ = [
     'DerivationError',
+    'DistroSeriesNameField',
     'IDistroSeries',
     'IDistroSeriesEditRestricted',
     'IDistroSeriesPublic',
@@ -997,11 +998,12 @@
         """Return a set of distroseriess that can be translated in
         rosetta."""
 
-    def queryByName(distribution, name):
+    def queryByName(distribution, name, follow_aliases=False):
         """Query a DistroSeries by name.
 
         :distribution: An IDistribution.
         :name: A string.
+        :follow_aliases: If True, follow series aliases.
 
         Returns the matching DistroSeries, or None if not found.
         """

=== modified file 'lib/lp/registry/model/distribution.py'
--- lib/lp/registry/model/distribution.py	2013-07-11 06:12:20 +0000
+++ lib/lp/registry/model/distribution.py	2013-08-01 15:28:56 +0000
@@ -248,6 +248,7 @@
     active = True
     package_derivatives_email = StringCol(notNull=False, default=None)
     redirect_release_uploads = BoolCol(notNull=True, default=False)
+    development_series_alias = StringCol(notNull=False, default=None)
 
     def __repr__(self):
         displayname = self.displayname.encode('ASCII', 'backslashreplace')
@@ -823,15 +824,25 @@
         return getUtility(
             IArchiveSet).getByDistroAndName(self, name)
 
-    def getSeries(self, name_or_version):
+    def resolveSeriesAlias(self, name):
+        """See `IDistribution`."""
+        if self.development_series_alias == name:
+            currentseries = self.currentseries
+            if currentseries is not None:
+                return currentseries
+        raise NoSuchDistroSeries(name)
+
+    def getSeries(self, name_or_version, follow_aliases=False):
         """See `IDistribution`."""
         distroseries = Store.of(self).find(DistroSeries,
                Or(DistroSeries.name == name_or_version,
                DistroSeries.version == name_or_version),
             DistroSeries.distribution == self).one()
-        if not distroseries:
-            raise NoSuchDistroSeries(name_or_version)
-        return distroseries
+        if distroseries:
+            return distroseries
+        if follow_aliases:
+            return self.resolveSeriesAlias(name_or_version)
+        raise NoSuchDistroSeries(name_or_version)
 
     def getDevelopmentSeries(self):
         """See `IDistribution`."""
@@ -954,7 +965,8 @@
             search_text=search_text, owner=owner, sort=sort,
             distribution=self).getResults()
 
-    def getDistroSeriesAndPocket(self, distroseries_name):
+    def getDistroSeriesAndPocket(self, distroseries_name,
+                                 follow_aliases=False):
         """See `IDistribution`."""
         # Get the list of suffixes.
         suffixes = [suffix for suffix, ignored in suffixpocket.items()]
@@ -963,13 +975,18 @@
 
         for suffix in suffixes:
             if distroseries_name.endswith(suffix):
+                left_size = len(distroseries_name) - len(suffix)
+                left = distroseries_name[:left_size]
                 try:
-                    left_size = len(distroseries_name) - len(suffix)
-                    return (self[distroseries_name[:left_size]],
-                            suffixpocket[suffix])
+                    return self[left], suffixpocket[suffix]
                 except KeyError:
+                    if follow_aliases:
+                        try:
+                            resolved = self.resolveSeriesAlias(left)
+                            return resolved, suffixpocket[suffix]
+                        except NoSuchDistroSeries:
+                            pass
                     # Swallow KeyError to continue round the loop.
-                    pass
 
         raise NotFoundError(distroseries_name)
 

=== modified file 'lib/lp/registry/model/distroseries.py'
--- lib/lp/registry/model/distroseries.py	2013-07-11 06:12:20 +0000
+++ lib/lp/registry/model/distroseries.py	2013-08-01 15:28:56 +0000
@@ -55,6 +55,7 @@
 from lp.bugs.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin,
     )
+from lp.registry.errors import NoSuchDistroSeries
 from lp.registry.interfaces.distroseries import (
     DerivationError,
     IDistroSeries,
@@ -1488,9 +1489,17 @@
             DistroSeries.hide_all_translations == False,
             DistroSeries.id == POTemplate.distroseriesID).config(distinct=True)
 
-    def queryByName(self, distribution, name):
+    def queryByName(self, distribution, name, follow_aliases=False):
         """See `IDistroSeriesSet`."""
-        return DistroSeries.selectOneBy(distribution=distribution, name=name)
+        series = DistroSeries.selectOneBy(distribution=distribution, name=name)
+        if series is not None:
+            return series
+        if follow_aliases:
+            try:
+                return distribution.resolveSeriesAlias(name)
+            except NoSuchDistroSeries:
+                pass
+        return None
 
     def queryByVersion(self, distribution, version):
         """See `IDistroSeriesSet`."""

=== modified file 'lib/lp/registry/stories/distribution/xx-distribution-overview.txt'
--- lib/lp/registry/stories/distribution/xx-distribution-overview.txt	2012-07-30 12:11:31 +0000
+++ lib/lp/registry/stories/distribution/xx-distribution-overview.txt	2013-08-01 15:28:56 +0000
@@ -1,4 +1,6 @@
-= Distributions =
+=============
+Distributions
+=============
 
 Launchpad can be used by both upstream projects and distributions - the
 system understands the intrinsic differences between those kinds of
@@ -6,16 +8,18 @@
 system.
 
 
-== Distribution listings ==
+Distribution listings
+=====================
 
-There is a listing of all distributions at /distributions/:
+There is a listing of all distributions at /distros/:
 
     >>> anon_browser.open('http://launchpad.dev/distros/')
     >>> print anon_browser.title
     Distributions registered in Launchpad
 
 
-== Distribution home pages ==
+Distribution home pages
+=======================
 
 Each distribution has a home page in the system.  The page will show a
 consolidated view of active series, milestones and derivatives of that
@@ -79,23 +83,23 @@
 The 5 latest derivatives are displayed on the home page
 along with a link to list all of them.
 
-   >>> anon_browser.open('http://launchpad.dev/ubuntu')
-   >>> print extract_text(find_tag_by_id(anon_browser.contents,
-   ... 'derivatives'))
-   Latest derivatives
-   9.9.9
-   &#8220;Hoary Mock&#8221; series
-   (from Warty)
-   8.06
-   &#8220;Krunch&#8221; series
-   (from Hoary)
-   6.6.6
-   &#8220;Breezy Badger Autotest&#8221; series
-   (from Warty)
-   2005
-   &#8220;Guada2005&#8221; series
-   (from Hoary)
-   All derivatives
+    >>> anon_browser.open('http://launchpad.dev/ubuntu')
+    >>> print extract_text(
+    ...     find_tag_by_id(anon_browser.contents, 'derivatives'))
+    Latest derivatives
+    9.9.9
+    &#8220;Hoary Mock&#8221; series
+    (from Warty)
+    8.06
+    &#8220;Krunch&#8221; series
+    (from Hoary)
+    6.6.6
+    &#8220;Breezy Badger Autotest&#8221; series
+    (from Warty)
+    2005
+    &#8220;Guada2005&#8221; series
+    (from Hoary)
+    All derivatives
 
 
 The "All derivatives" link takes you to the derivatives page.
@@ -106,16 +110,39 @@
 If there are no derivatives, the link to the derivatives page is
 not there.
 
-   >>> anon_browser.open('http://launchpad.dev/ubuntutest')
-   >>> print extract_text(find_tag_by_id(anon_browser.contents,
-   ... 'derivatives'))
-   Latest derivatives
-   No derivatives.
-
-
- == Registration information ==
-
-The distroseries pages presents the registeration information.
+    >>> anon_browser.open('http://launchpad.dev/ubuntutest')
+    >>> print extract_text(
+    ...     find_tag_by_id(anon_browser.contents, 'derivatives'))
+    Latest derivatives
+    No derivatives.
+
+
+If there is a development series alias, it becomes a redirect.
+
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> from lp.testing import celebrity_logged_in
+    >>> from zope.component import getUtility
+
+    >>> anon_browser.open("http://launchpad.dev/ubuntu/devel";)
+    Traceback (most recent call last):
+    ...
+    NotFound: Object: <Distribution ...>, name: u'devel'
+
+    >>> with celebrity_logged_in("admin"):
+    ...     ubuntu = getUtility(IDistributionSet).getByName(u"ubuntu")
+    ...     ubuntu.development_series_alias = "devel"
+    >>> anon_browser.open("http://launchpad.dev/ubuntu/devel";)
+    >>> print anon_browser.url
+    http://launchpad.dev/ubuntu/hoary
+    >>> anon_browser.open("http://launchpad.dev/ubuntu/devel/+builds";)
+    >>> print anon_browser.url
+    http://launchpad.dev/ubuntu/hoary/+builds
+
+
+Registration information
+========================
+
+The distroseries pages presents the registration information.
 
     >>> anon_browser.open('http://launchpad.dev/ubuntu')
 
@@ -129,7 +156,8 @@
     http://launchpad.dev/~ubuntu-team
 
 
-== Redirection for webservice URLs ==
+Redirection for webservice URLs
+===============================
 
 The webservice exposes a URL for the archive associated with the distribution.
 Displaying the page for that URL is nonsensical (it looks like the PPA

=== modified file 'lib/lp/registry/stories/webservice/xx-distribution.txt'
--- lib/lp/registry/stories/webservice/xx-distribution.txt	2012-10-25 11:47:43 +0000
+++ lib/lp/registry/stories/webservice/xx-distribution.txt	2013-08-01 15:28:56 +0000
@@ -32,6 +32,7 @@
     date_created: u'2006-10-16T18:31:43.415195+00:00'
     derivatives_collection_link: u'http://.../ubuntu/derivatives'
     description: u'Ubuntu is a new approach...'
+    development_series_alias: None
     display_name: u'Ubuntu'
     domain_name: u'ubuntulinux.org'
     driver_link: None
@@ -165,7 +166,8 @@
 "getCountryMirror" returns the country DNS mirror for a given country;
 returning None if there isn't one.
 
-    >>> # Prepare stuff.
+Prepare stuff.
+
     >>> from zope.component import getUtility
     >>> from lp.testing.pages import webservice_for_person
     >>> from lp.services.webapp.interfaces import OAuthPermission
@@ -187,7 +189,8 @@
     ...     permission=OAuthPermission.WRITE_PUBLIC)
     >>> logout()
 
-    >>> # Mark new mirror as official and a country mirror.
+Mark new mirror as official and a country mirror.
+
     >>> patch = {
     ...     u'status': 'Official',
     ...     u'country_dns_mirror': True

=== modified file 'lib/lp/registry/tests/test_distribution.py'
--- lib/lp/registry/tests/test_distribution.py	2012-09-18 19:41:02 +0000
+++ lib/lp/registry/tests/test_distribution.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Distribution."""
@@ -404,8 +404,20 @@
             name="dappere", version="42.6")
         self.assertEquals(series, distro.getSeries("42.6"))
 
-
-class SeriesTests(TestCaseWithFactory):
+    def test_development_series_alias(self):
+        distro = self.factory.makeDistribution()
+        with person_logged_in(distro.owner):
+            distro.development_series_alias = "devel"
+        self.assertRaises(
+            NoSuchDistroSeries, distro.getSeries, "devel", follow_aliases=True)
+        series = self.factory.makeDistroSeries(
+            distribution=distro, status=SeriesStatus.DEVELOPMENT)
+        self.assertRaises(NoSuchDistroSeries, distro.getSeries, "devel")
+        self.assertEqual(
+            series, distro.getSeries("devel", follow_aliases=True))
+
+
+class DerivativesTests(TestCaseWithFactory):
     """Test IDistribution.derivatives.
     """
 
@@ -416,10 +428,8 @@
         distro2 = self.factory.makeDistribution()
         previous_series = self.factory.makeDistroSeries(distribution=distro1)
         series = self.factory.makeDistroSeries(
-            distribution=distro2,
-            previous_series=previous_series)
-        self.assertContentEqual(
-            [series], distro1.derivatives)
+            distribution=distro2, previous_series=previous_series)
+        self.assertContentEqual([series], distro1.derivatives)
 
 
 class DistroSnapshotTestCase(TestCaseWithFactory):

=== modified file 'lib/lp/soyuz/model/archive.py'
--- lib/lp/soyuz/model/archive.py	2013-07-22 09:38:22 +0000
+++ lib/lp/soyuz/model/archive.py	2013-08-01 15:28:56 +0000
@@ -628,9 +628,11 @@
             SourcePackagePublishingHistory, *clauses).order_by(
                 SourcePackageName.name, Desc(SourcePackageRelease.version),
                 Desc(SourcePackagePublishingHistory.id))
+
         def eager_load(rows):
             load_related(
                 SourcePackageRelease, rows, ['sourcepackagereleaseID'])
+
         return DecoratedResultSet(sources, pre_iter_hook=eager_load)
 
     @property
@@ -1749,7 +1751,7 @@
             distribution = self.distribution
         if to_series is not None:
             result = getUtility(IDistroSeriesSet).queryByName(
-                distribution, to_series)
+                distribution, to_series, follow_aliases=True)
             if result is None:
                 raise NoSuchDistroSeries(to_series)
             series = result

=== modified file 'lib/lp/soyuz/scripts/publishdistro.py'
--- lib/lp/soyuz/scripts/publishdistro.py	2013-05-01 18:39:38 +0000
+++ lib/lp/soyuz/scripts/publishdistro.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Publisher script class."""
@@ -305,6 +305,8 @@
         publisher.D_writeReleaseFiles(careful_indexing)
         # The caller will commit this last step.
 
+        publisher.createSeriesAliases()
+
     def main(self):
         """See `LaunchpadScript`."""
         self.validateOptions()

=== modified file 'lib/lp/soyuz/scripts/tests/test_publishdistro.py'
--- lib/lp/soyuz/scripts/tests/test_publishdistro.py	2013-05-02 00:14:02 +0000
+++ lib/lp/soyuz/scripts/tests/test_publishdistro.py	2013-08-01 15:28:56 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Functional tests for publish-distro.py script."""
@@ -30,7 +30,6 @@
 from lp.soyuz.enums import (
     ArchivePurpose,
     ArchiveStatus,
-    BinaryPackageFormat,
     PackagePublishingStatus,
     )
 from lp.soyuz.interfaces.archive import IArchiveSet
@@ -373,6 +372,7 @@
         self.C_doFTPArchive = FakeMethod()
         self.C_writeIndexes = FakeMethod()
         self.D_writeReleaseFiles = FakeMethod()
+        self.createSeriesAliases = FakeMethod()
 
 
 class TestPublishDistroMethods(TestCaseWithFactory):


Follow ups