Bug 1210539 - Add "updates" builder to release promotion task r=jlund DONTBUILD a=release
authorRail Aliiev <rail@mozilla.com>
Fri, 19 Feb 2016 12:53:03 -0800
changeset 304334 645b7a96e0573d1c099b66fb95740e6bbdc82b85
parent 304333 3fa33fff66a397be9cf7192ece73523c2bb6cbee
child 304335 39ba035c04520e74a8c459b1964b8d619dc634ce
push id9175
push userraliiev@mozilla.com
push dateThu, 03 Mar 2016 03:39:52 +0000
treeherdermozilla-aurora@0bee186afe5a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjlund, release
bugs1210539
milestone46.0a2
Bug 1210539 - Add "updates" builder to release promotion task r=jlund DONTBUILD a=release MozReview-Commit-ID: ANFJ6g4CT05
testing/mozharness/configs/releases/updates_beta.py
testing/mozharness/configs/releases/updates_date.py
testing/mozharness/configs/releases/updates_release.py
testing/mozharness/mozharness/mozilla/merge.py
testing/mozharness/mozharness/mozilla/release.py
testing/mozharness/mozharness/mozilla/repo_manupulation.py
testing/mozharness/scripts/merge_day/gecko_migration.py
testing/mozharness/scripts/release/postrelease_version_bump.py
testing/mozharness/scripts/release/updates.py
testing/mozharness/test/test_mozilla_release.py
new file mode 100644
--- /dev/null
+++ b/testing/mozharness/configs/releases/updates_beta.py
@@ -0,0 +1,34 @@
+
+config = {
+    "log_name": "updates_date",
+    "repo": {
+        "repo": "https://hg.mozilla.org/build/tools",
+        "revision": "default",
+        "dest": "tools",
+        "vcs": "hg",
+    },
+    "push_dest": "ssh://hg.mozilla.org/build/tools",
+    "shipped-locales-url": "https://hg.mozilla.org/releases/mozilla-beta/raw-file/{revision}/browser/locales/shipped-locales",
+    "ignore_no_changes": True,
+    "ssh_user": "ffxbld",
+    "ssh_key": "~/.ssh/ffxbld_rsa",
+    "archive_domain": "archive.mozilla.org",
+    "archive_prefix": "https://archive.mozilla.org",
+    "previous_archive_prefix": "https://archive.mozilla.org",
+    "download_domain": "download.mozilla.org",
+    "balrog_url": "https://aus5.mozilla.org",
+    "balrog_username": "ffxbld",
+    "update_channels": {
+        "beta": {
+            "version_regex": r"^(\d+\.\d+(b\d+)?)$",
+            "requires_mirrors": False,
+            "patcher_config": "moBeta-branch-patcher2.cfg",
+            "update_verify_channel": "beta-localtest",
+            "mar_channel_ids": [
+                "firefox-mozilla-beta", "firefox-mozilla-beta",
+            ],
+            "channel_names": ["beta", "beta-localtest", "beta-cdntest"],
+            "rules_to_update": ["firefox-beta-cdntest", "firefox-beta-localtest"],
+        },
+    },
+}
new file mode 100644
--- /dev/null
+++ b/testing/mozharness/configs/releases/updates_date.py
@@ -0,0 +1,34 @@
+
+config = {
+    "log_name": "bump_date",
+    # TODO: use real repo
+    "repo": {
+        "repo": "https://hg.mozilla.org/users/raliiev_mozilla.com/tools",
+        "revision": "default",
+        "dest": "tools",
+        "vcs": "hg",
+    },
+    # TODO: use real repo
+    "push_dest": "ssh://hg.mozilla.org/users/raliiev_mozilla.com/tools",
+    "shipped-locales-url": "https://hg.mozilla.org/projects/date/raw-file/{revision}/browser/locales/shipped-locales",
+    "ignore_no_changes": True,
+    "ssh_user": "ffxbld",
+    "ssh_key": "~/.ssh/ffxbld_rsa",
+    "archive_domain": "mozilla-releng-beet-mover-dev.s3-website-us-west-2.amazonaws.com",
+    "archive_prefix": "http://mozilla-releng-beet-mover-dev.s3-website-us-west-2.amazonaws.com",
+    "previous_archive_prefix": "https://archive.mozilla.org",
+    "download_domain": "download.mozilla.org",
+    "balrog_url": "http://ec2-54-241-39-23.us-west-1.compute.amazonaws.com",
+    "balrog_username": "stage-ffxbld",
+    "update_channels": {
+        "date": {
+            "version_regex": r"^(\d+\.\d+(b\d+)?)$",
+            "requires_mirrors": False,
+            "patcher_config": "mozDate-branch-patcher2.cfg",
+            "update_verify_channel": "date-localtest",
+            "mar_channel_ids": [],
+            "channel_names": ["date", "date-localtest", "date-cdntest"],
+            "rules_to_update": ["firefox-date-cdntest", "firefox-date-localtest"],
+        }
+    },
+}
new file mode 100644
--- /dev/null
+++ b/testing/mozharness/configs/releases/updates_release.py
@@ -0,0 +1,43 @@
+
+config = {
+    "log_name": "updates_date",
+    "repo": {
+        "repo": "https://hg.mozilla.org/build/tools",
+        "revision": "default",
+        "dest": "tools",
+        "vcs": "hg",
+    },
+    "push_dest": "ssh://hg.mozilla.org/build/tools",
+    "shipped-locales-url": "https://hg.mozilla.org/releases/mozilla-release/raw-file/{revision}/browser/locales/shipped-locales",
+    "ignore_no_changes": True,
+    "ssh_user": "ffxbld",
+    "ssh_key": "~/.ssh/ffxbld_rsa",
+    "archive_domain": "archive.mozilla.org",
+    "archive_prefix": "https://archive.mozilla.org",
+    "previous_archive_prefix": "https://archive.mozilla.org",
+    "download_domain": "download.mozilla.org",
+    "balrog_url": "https://aus5.mozilla.org",
+    "balrog_username": "ffxbld",
+    "update_channels": {
+        "beta": {
+            "version_regex": r"^(\d+\.\d+(b\d+)?)$",
+            "requires_mirrors": False,
+            "patcher_config": "moBeta-branch-patcher2.cfg",
+            "update_verify_channel": "beta-localtest",
+            "mar_channel_ids": [
+                "firefox-mozilla-beta", "firefox-mozilla-beta",
+            ],
+            "channel_names": ["beta", "beta-localtest", "beta-cdntest"],
+            "rules_to_update": ["firefox-beta-cdntest", "firefox-beta-localtest"],
+        },
+        "release": {
+            "version_regex": r"^\d+\.\d+(\.\d+)?$",
+            "requires_mirrors": True,
+            "patcher_config": "mozRelease-branch-patcher2.cfg",
+            "update_verify_channel": "release-localtest",
+            "mar_channel_ids": [],
+            "channel_names": ["release", "release-localtest", "release-cdntest"],
+            "rules_to_update": ["firefox-release-cdntest", "firefox-release-localtest"],
+        },
+    },
+}
--- a/testing/mozharness/mozharness/mozilla/release.py
+++ b/testing/mozharness/mozharness/mozilla/release.py
@@ -4,16 +4,17 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this file,
 # You can obtain one at http://mozilla.org/MPL/2.0/.
 # ***** END LICENSE BLOCK *****
 """release.py
 
 """
 
 import os
+from distutils.version import LooseVersion, StrictVersion
 
 from mozharness.base.config import parse_config_file
 
 
 # SignAndroid {{{1
 class ReleaseMixin():
     release_config = {}
 
@@ -43,8 +44,29 @@ class ReleaseMixin():
             self.release_config['release_channel'] = rc['releaseChannel']
         else:
             self.info("No release config file; using default config.")
             for key in ('version', 'buildnum',
                         'ftp_server', 'ftp_user', 'ftp_ssh_key'):
                 self.release_config[key] = c[key]
         self.info("Release config:\n%s" % self.release_config)
         return self.release_config
+
+
+def get_previous_version(version, partial_versions):
+    """ The patcher config bumper needs to know the exact previous version
+    We use LooseVersion for ESR because StrictVersion can't parse the trailing
+    'esr', but StrictVersion otherwise because it can sort X.0bN lower than X.0.
+    The current version is excluded to avoid an error if build1 is aborted
+    before running the updates builder and now we're doing build2
+    """
+    if version.endswith('esr'):
+        return str(max(LooseVersion(v) for v in partial_versions if
+                       v != version))
+    else:
+        # StrictVersion truncates trailing zero in versions with more than 1
+        # dot. Compose a structure that will be sorted by StrictVersion and
+        # return untouched version
+        composed = sorted([(v, StrictVersion(v)) for v in partial_versions if
+                           v != version], key=lambda x: x[1], reverse=True)
+        return composed[0][0]
+
+
rename from testing/mozharness/mozharness/mozilla/merge.py
rename to testing/mozharness/mozharness/mozilla/repo_manupulation.py
--- a/testing/mozharness/mozharness/mozilla/merge.py
+++ b/testing/mozharness/mozharness/mozilla/repo_manupulation.py
@@ -1,16 +1,16 @@
 import os
 
 from mozharness.base.errors import HgErrorList
 from mozharness.base.log import FATAL, INFO
 from mozharness.base.vcs.mercurial import MercurialVCS
 
 
-class GeckoMigrationMixin(object):
+class MercurialRepoManipulationMixin(object):
 
     def get_version(self, repo_root,
                     version_file="browser/config/version.txt"):
         version_path = os.path.join(repo_root, version_file)
         contents = self.read_from_file(version_path, error_level=FATAL)
         lines = [l for l in contents.splitlines() if l and
                  not l.startswith("#")]
         return lines[-1].split(".")
@@ -55,17 +55,17 @@ class GeckoMigrationMixin(object):
             we don't want to have to clobber and reclone from scratch every
             time.
 
             This is an attempt to clean up the local repos without needing a
             clobber.
             """
         dirs = self.query_abs_dirs()
         hg = self.query_exe("hg", return_type="list")
-        hg_repos = self.query_gecko_repos()
+        hg_repos = self.query_repos()
         hg_strip_error_list = [{
             'substr': r'''abort: empty revision set''', 'level': INFO,
             'explanation': "Nothing to clean up; we're good!",
         }] + HgErrorList
         for repo_config in hg_repos:
             repo_name = repo_config["dest"]
             repo_path = os.path.join(dirs['abs_work_dir'], repo_name)
             if os.path.exists(repo_path):
@@ -107,16 +107,41 @@ class GeckoMigrationMixin(object):
             self.run_command(hg + ["diff"], cwd=cwd)
             self.hg_commit(
                 cwd, user=self.config['hg_user'],
                 message=self.query_commit_message(),
                 ignore_no_changes=self.config.get("ignore_no_changes", False)
             )
         self.info("Now verify |hg out| and |hg out --patch| if you're paranoid, and --push")
 
+    def hg_tag(self, cwd, tags, user=None, message=None, revision=None,
+               force=None, halt_on_failure=True):
+        if isinstance(tags, basestring):
+            tags = [tags]
+        cmd = self.query_exe('hg', return_type='list') + ['tag']
+        if not message:
+            message = "No bug - Tagging %s" % os.path.basename(cwd)
+            if revision:
+                message = "%s %s" % (message, revision)
+            message = "%s with %s" % (message, ', '.join(tags))
+            message += " a=release DONTBUILD CLOSED TREE"
+        self.info(message)
+        cmd.extend(['-m', message])
+        if user:
+            cmd.extend(['-u', user])
+        if revision:
+            cmd.extend(['-r', revision])
+        if force:
+            cmd.append('-f')
+        cmd.extend(tags)
+        return self.run_command(
+            cmd, cwd=cwd, halt_on_failure=halt_on_failure,
+            error_list=HgErrorList
+        )
+
     def push(self):
         """
             """
         error_message = """Push failed!  If there was a push race, try rerunning
 the script (--clean-repos --pull --migrate).  The second run will be faster."""
         hg = self.query_exe("hg", return_type="list")
         for cwd in self.query_push_dirs():
             if not cwd:
--- a/testing/mozharness/scripts/merge_day/gecko_migration.py
+++ b/testing/mozharness/scripts/merge_day/gecko_migration.py
@@ -25,27 +25,28 @@ from getpass import getpass
 sys.path.insert(1, os.path.dirname(os.path.dirname(sys.path[0])))
 
 from mozharness.base.errors import HgErrorList
 from mozharness.base.python import VirtualenvMixin, virtualenv_config_options
 from mozharness.base.vcs.vcsbase import MercurialScript
 from mozharness.mozilla.selfserve import SelfServeMixin
 from mozharness.mozilla.updates.balrog import BalrogMixin
 from mozharness.mozilla.buildbot import BuildbotMixin
-from mozharness.mozilla.merge import GeckoMigrationMixin
+from mozharness.mozilla.repo_manupulation import MercurialRepoManipulationMixin
 
 VALID_MIGRATION_BEHAVIORS = (
     "beta_to_release", "aurora_to_beta", "central_to_aurora", "release_to_esr",
     "bump_second_digit",
 )
 
 
 # GeckoMigration {{{1
 class GeckoMigration(MercurialScript, BalrogMixin, VirtualenvMixin,
-                     SelfServeMixin, BuildbotMixin, GeckoMigrationMixin):
+                     SelfServeMixin, BuildbotMixin,
+                     MercurialRepoManipulationMixin):
     config_options = [
         [['--hg-user', ], {
             "action": "store",
             "dest": "hg_user",
             "type": "string",
             "default": "ffxbld <release@mozilla.com>",
             "help": "Specify what user to use to commit to hg.",
         }],
@@ -133,17 +134,17 @@ class GeckoMigration(MercurialScript, Ba
             if url:
                 dir_name = self.get_filename_from_url(url)
                 self.info("adding %s" % dir_name)
                 self.abs_dirs['abs_%s_dir' % k] = os.path.join(
                     dirs['abs_work_dir'], dir_name
                 )
         return self.abs_dirs
 
-    def query_gecko_repos(self):
+    def query_repos(self):
         """ Build a list of repos to clone.
             """
         if self.gecko_repos:
             return self.gecko_repos
         self.info("Building gecko_repos list...")
         dirs = self.query_abs_dirs()
         self.gecko_repos = []
         for k in ('from', 'to'):
@@ -189,41 +190,16 @@ class GeckoMigration(MercurialScript, Ba
         return self.query_hg_revision(dirs['abs_from_dir'])
 
     def query_to_revision(self):
         """ Shortcut to get the revision for the to repo
             """
         dirs = self.query_abs_dirs()
         return self.query_hg_revision(dirs['abs_to_dir'])
 
-    def hg_tag(self, cwd, tags, user=None, message=None, revision=None,
-               force=None, halt_on_failure=True):
-        if isinstance(tags, basestring):
-            tags = [tags]
-        message = "No bug - Tagging %s" % os.path.basename(cwd)
-        if revision:
-            message = "%s %s" % (message, revision)
-        message = "%s with %s" % (message, ', '.join(tags))
-        message += " a=release DONTBUILD CLOSED TREE"
-        self.info(message)
-        cmd = self.query_exe('hg', return_type='list') + ['tag']
-        if user:
-            cmd.extend(['-u', user])
-        if message:
-            cmd.extend(['-m', message])
-        if revision:
-            cmd.extend(['-r', revision])
-        if force:
-            cmd.append('-f')
-        cmd.extend(tags)
-        return self.run_command(
-            cmd, cwd=cwd, halt_on_failure=halt_on_failure,
-            error_list=HgErrorList
-        )
-
     def hg_merge_via_debugsetparents(self, cwd, old_head, new_head,
                                      preserve_tags=True, user=None):
         """ Merge 2 heads avoiding non-fastforward commits
             """
         hg = self.query_exe('hg', return_type='list')
         cmd = hg + ['debugsetparents', new_head, old_head]
         self.run_command(cmd, cwd=cwd, error_list=HgErrorList,
                          halt_on_failure=True)
@@ -478,17 +454,17 @@ class GeckoMigration(MercurialScript, Ba
     def pull(self):
         """ Pull tools first, then use hgtool for the gecko repos
             """
         repos = [{
             "repo": self.config["tools_repo_url"],
             "revision": self.config["tools_repo_revision"],
             "dest": "tools",
             "vcs": "hg",
-        }] + self.query_gecko_repos()
+        }] + self.query_repos()
         super(GeckoMigration, self).pull(repos=repos)
 
     def lock_update_paths(self):
         self.lock_balrog_rules(self.config["balrog_rules_to_lock"])
 
     def migrate(self):
         """ Perform the migration.
             """
@@ -496,18 +472,16 @@ class GeckoMigration(MercurialScript, Ba
         from_fx_major_version = self.get_version(dirs['abs_from_dir'])[0]
         to_fx_major_version = self.get_version(dirs['abs_to_dir'])[0]
         base_from_rev = self.query_from_revision()
         base_to_rev = self.query_to_revision()
         base_tag = self.config['base_tag'] % {'major_version': from_fx_major_version}
         end_tag = self.config['end_tag'] % {'major_version': to_fx_major_version}
         self.hg_tag(
             dirs['abs_from_dir'], base_tag, user=self.config['hg_user'],
-            message="Added %s tag for changeset %s. IGNORE BROKEN CHANGESETS DONTBUILD CLOSED TREE NO BUG a=release" %
-                    (base_tag, base_from_rev),
             revision=base_from_rev,
         )
         new_from_rev = self.query_from_revision()
         self.info("New revision %s" % new_from_rev)
         pull_revision = None
         if not self.config.get("pull_all_branches"):
             pull_revision = new_from_rev
         self.pull_from_repo(
@@ -517,18 +491,16 @@ class GeckoMigration(MercurialScript, Ba
         )
         if self.config.get("requires_head_merge") is not False:
             self.hg_merge_via_debugsetparents(
                 dirs['abs_to_dir'], old_head=base_to_rev, new_head=new_from_rev,
                 user=self.config['hg_user'],
             )
         self.hg_tag(
             dirs['abs_to_dir'], end_tag, user=self.config['hg_user'],
-            message="Added %s tag for changeset %s. IGNORE BROKEN CHANGESETS DONTBUILD CLOSED TREE NO BUG a=release" %
-                    (end_tag, base_to_rev),
             revision=base_to_rev, force=True,
         )
         # Call beta_to_release etc.
         if not hasattr(self, self.config['migration_behavior']):
             self.fatal("Don't know how to proceed with migration_behavior %s !" % self.config['migration_behavior'])
         getattr(self, self.config['migration_behavior'])(end_tag=end_tag)
         self.info("Verify the diff, and apply any manual changes, such as disabling features, and --commit-changes")
 
--- a/testing/mozharness/scripts/release/postrelease_version_bump.py
+++ b/testing/mozharness/scripts/release/postrelease_version_bump.py
@@ -11,22 +11,22 @@ A script to increase in-tree version num
 """
 
 import os
 import sys
 
 sys.path.insert(1, os.path.dirname(os.path.dirname(sys.path[0])))
 from mozharness.base.vcs.vcsbase import MercurialScript
 from mozharness.mozilla.buildbot import BuildbotMixin
-from mozharness.mozilla.merge import GeckoMigrationMixin
+from mozharness.mozilla.repo_manupulation import MercurialRepoManipulationMixin
 
 
 # PostReleaseVersionBump {{{1
 class PostReleaseVersionBump(MercurialScript, BuildbotMixin,
-                             GeckoMigrationMixin):
+                             MercurialRepoManipulationMixin):
     config_options = [
         [['--hg-user', ], {
             "action": "store",
             "dest": "hg_user",
             "type": "string",
             "default": "ffxbld <release@mozilla.com>",
             "help": "Specify what user to use to commit to hg.",
         }],
@@ -97,17 +97,17 @@ class PostReleaseVersionBump(MercurialSc
             """
         if self.abs_dirs:
             return self.abs_dirs
         self.abs_dirs = super(PostReleaseVersionBump, self).query_abs_dirs()
         self.abs_dirs["abs_gecko_dir"] = os.path.join(
                 self.abs_dirs['abs_work_dir'], self.config["repo"]["dest"])
         return self.abs_dirs
 
-    def query_gecko_repos(self):
+    def query_repos(self):
         """Build a list of repos to clone."""
         return [self.config["repo"]]
 
     def query_commit_dirs(self):
         return [self.query_abs_dirs()["abs_gecko_dir"]]
 
     def query_commit_message(self):
         return "Automatic version bump. CLOSED TREE NO BUG a=release"
@@ -120,17 +120,17 @@ class PostReleaseVersionBump(MercurialSc
         hg_ssh_opts = "ssh -l {user} -i {key}".format(
             user=self.config["ssh_user"],
             key=os.path.expanduser(self.config["ssh_key"])
         )
         return ["-e", hg_ssh_opts]
 
     def pull(self):
         super(PostReleaseVersionBump, self).pull(
-                repos=self.query_gecko_repos())
+                repos=self.query_repos())
 
     def bump_postrelease(self, *args, **kwargs):
         """Bump version"""
         dirs = self.query_abs_dirs()
         for f in self.config["version_files"]:
             curr_version = ".".join(
                 self.get_version(dirs['abs_gecko_dir'], f["file"]))
             self.replace(os.path.join(dirs['abs_gecko_dir'], f["file"]),
new file mode 100644
--- /dev/null
+++ b/testing/mozharness/scripts/release/updates.py
@@ -0,0 +1,296 @@
+#!/usr/bin/env python
+# lint_ignore=E501
+# ***** BEGIN LICENSE BLOCK *****
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+# ***** END LICENSE BLOCK *****
+""" updates.py
+
+A script to bump patcher configs, generate update verification configs, and
+publish top-level release blob information to Balrog.
+
+It clones the tools repo, modifies the existing patcher config to include
+current release build information, generates update verification configs,
+commits the changes and tags the repo using tags by Releng convention.
+After the changes are pushed to the repo, the script submits top-level release
+information to Balrog.
+"""
+
+import os
+import re
+import sys
+
+sys.path.insert(1, os.path.dirname(os.path.dirname(sys.path[0])))
+from mozharness.base.vcs.vcsbase import MercurialScript
+from mozharness.mozilla.buildbot import BuildbotMixin
+from mozharness.mozilla.repo_manupulation import MercurialRepoManipulationMixin
+from mozharness.mozilla.release import get_previous_version
+
+
+# UpdatesBumper {{{1
+class UpdatesBumper(MercurialScript, BuildbotMixin,
+                    MercurialRepoManipulationMixin):
+    config_options = [
+        [['--hg-user', ], {
+            "action": "store",
+            "dest": "hg_user",
+            "type": "string",
+            "default": "ffxbld <release@mozilla.com>",
+            "help": "Specify what user to use to commit to hg.",
+        }],
+        [['--ssh-user', ], {
+            "action": "store",
+            "dest": "ssh_user",
+            "type": "string",
+            "help": "SSH username with hg.mozilla.org permissions",
+        }],
+        [['--ssh-key', ], {
+            "action": "store",
+            "dest": "ssh_key",
+            "type": "string",
+            "help": "Path to SSH key.",
+        }],
+    ]
+
+    def __init__(self, require_config_file=True):
+        super(UpdatesBumper, self).__init__(
+            config_options=self.config_options,
+            all_actions=[
+                'clobber',
+                'pull',
+                'download-shipped-locales',
+                'bump-configs',
+                'commit-changes',
+                'tag',
+                'push',
+                'submit-to-balrog',
+            ],
+            default_actions=[
+                'clobber',
+                'pull',
+                'download-shipped-locales',
+                'bump-configs',
+                'commit-changes',
+                'tag',
+                'push',
+                'submit-to-balrog',
+            ],
+            config={
+                'buildbot_json_path': 'buildprops.json',
+                'credentials_file': 'oauth.txt',
+            },
+            require_config_file=require_config_file
+        )
+
+    def _pre_config_lock(self, rw_config):
+        super(UpdatesBumper, self)._pre_config_lock(rw_config)
+        # override properties from buildbot properties here as defined by
+        # taskcluster properties
+        self.read_buildbot_config()
+        if not self.buildbot_config:
+            self.warning("Skipping buildbot properties overrides")
+            return
+        # TODO: version and appVersion should come from repo
+        props = self.buildbot_config["properties"]
+        for prop in ['product', 'version', 'build_number', 'revision',
+                     'appVersion', 'balrog_api_root', "channels"]:
+            if props.get(prop):
+                self.info("Overriding %s with %s" % (prop, props[prop]))
+                self.config[prop] = props.get(prop)
+
+        partials = [v.strip() for v in props["partial_versions"].split(",")]
+        self.config["partial_versions"] = [v.split("build") for v in partials]
+        self.config["platforms"] = [p.strip() for p in
+                                    props["platforms"].split(",")]
+        self.config["channels"] = [c.strip() for c in
+                                   props["channels"].split(",")]
+
+    def query_abs_dirs(self):
+        if self.abs_dirs:
+            return self.abs_dirs
+        self.abs_dirs = super(UpdatesBumper, self).query_abs_dirs()
+        self.abs_dirs["abs_tools_dir"] = os.path.join(
+            self.abs_dirs['abs_work_dir'], self.config["repo"]["dest"])
+        return self.abs_dirs
+
+    def query_repos(self):
+        """Build a list of repos to clone."""
+        return [self.config["repo"]]
+
+    def query_commit_dirs(self):
+        return [self.query_abs_dirs()["abs_tools_dir"]]
+
+    def query_commit_message(self):
+        return "Automated configuration bump"
+
+    def query_push_dirs(self):
+        return self.query_commit_dirs()
+
+    def query_push_args(self, cwd):
+        # cwd is not used here
+        hg_ssh_opts = "ssh -l {user} -i {key}".format(
+            user=self.config["ssh_user"],
+            key=os.path.expanduser(self.config["ssh_key"])
+        )
+        return ["-e", hg_ssh_opts]
+
+    def query_shipped_locales_path(self):
+        dirs = self.query_abs_dirs()
+        return os.path.join(dirs["abs_work_dir"], "shipped-locales")
+
+    def query_channel_configs(self):
+        """Return a list of channel configs.
+        For RC builds it returns "release" and "beta" using
+        "enabled_if_version_matches" to match RC.
+
+        :return: list
+        """
+        return [(n, c) for n, c in self.config["update_channels"].items() if
+                n in self.config["channels"]]
+
+    def pull(self):
+        super(UpdatesBumper, self).pull(
+            repos=self.query_repos())
+
+    def download_shipped_locales(self):
+        dirs = self.query_abs_dirs()
+        self.mkdir_p(dirs["abs_work_dir"])
+        url = self.config["shipped-locales-url"].format(
+            revision=self.config["revision"])
+        if not self.download_file(url=url,
+                                  file_name=self.query_shipped_locales_path()):
+            self.fatal("Unable to fetch shipped-locales from %s" % url)
+
+    def bump_configs(self):
+        for channel, channel_config in self.query_channel_configs():
+            self.bump_patcher_config(channel_config)
+            self.bump_update_verify_configs(channel, channel_config)
+
+    def query_matching_partials(self, channel_config):
+        return [(v, b) for v, b in self.config["partial_versions"] if
+                re.match(channel_config["version_regex"], v)]
+
+    def query_patcher_config(self, channel_config):
+        dirs = self.query_abs_dirs()
+        patcher_config = os.path.join(
+            dirs["abs_tools_dir"], "release/patcher-configs",
+            channel_config["patcher_config"])
+        return patcher_config
+
+    def query_update_verify_config(self, channel, platform):
+        dirs = self.query_abs_dirs()
+        uvc = os.path.join(
+            dirs["abs_tools_dir"], "release/updates",
+            "{}-{}-{}.cfg".format(channel, self.config["product"], platform))
+        return uvc
+
+    def bump_patcher_config(self, channel_config):
+        # TODO: to make it possible to run this before we have files copied to
+        # the candidates directory, we need to add support to fetch build IDs
+        # from tasks.
+        dirs = self.query_abs_dirs()
+        env = {"PERL5LIB": os.path.join(dirs["abs_tools_dir"], "lib/perl")}
+        partial_versions = [v[0] for v in
+                            self.query_matching_partials(channel_config)]
+        script = os.path.join(
+            dirs["abs_tools_dir"], "release/patcher-config-bump.pl")
+        patcher_config = self.query_patcher_config(channel_config)
+        cmd = [self.query_exe("perl"), script]
+        cmd.extend([
+            "-p", self.config["product"],
+            "-r", self.config["product"].capitalize(),
+            "-v", self.config["version"],
+            "-a", self.config["appVersion"],
+            "-o", get_previous_version(
+                self.config["version"], partial_versions),
+            "-b", str(self.config["build_number"]),
+            "-c", patcher_config,
+            "-f", self.config["archive_domain"],
+            "-d", self.config["download_domain"],
+            "-l", self.query_shipped_locales_path(),
+        ])
+        for v in partial_versions:
+            cmd.extend(["--partial-version", v])
+        for p in self.config["platforms"]:
+            cmd.extend(["--platform", p])
+        for mar_channel_id in channel_config["mar_channel_ids"]:
+            cmd.extend(["--mar-channel-id", mar_channel_id])
+        self.run_command(cmd, halt_on_failure=True, env=env)
+
+    def bump_update_verify_configs(self, channel, channel_config):
+        dirs = self.query_abs_dirs()
+        script = os.path.join(
+            dirs["abs_tools_dir"],
+            "scripts/build-promotion/create-update-verify-config.py")
+        patcher_config = self.query_patcher_config(channel_config)
+        for platform in self.config["platforms"]:
+            cmd = [self.query_exe("python"), script]
+            output = self.query_update_verify_config(channel, platform)
+            cmd.extend([
+                "--config", patcher_config,
+                "--platform", platform,
+                "--update-verify-channel",
+                channel_config["update_verify_channel"],
+                "--output", output,
+                "--archive-prefix", self.config["archive_prefix"],
+                "--previous-archive-prefix",
+                self.config["previous_archive_prefix"],
+                "--product", self.config["product"],
+                "--balrog-url", self.config["balrog_url"],
+                "--build-number", str(self.config["build_number"]),
+            ])
+
+            self.run_command(cmd, halt_on_failure=True)
+
+    def tag(self):
+        dirs = self.query_abs_dirs()
+        tags = ["{product}_{version}_BUILD{build_number}_RUNTIME",
+                "{product}_{version}_RELEASE_RUNTIME"]
+        tags = [t.format(product=self.config["product"].upper(),
+                         version=self.config["version"].replace(".", "_"),
+                         build_number=self.config["build_number"])
+                for t in tags]
+        self.hg_tag(cwd=dirs["abs_tools_dir"], tags=tags,
+                    user=self.config["hg_user"])
+
+    def submit_to_balrog(self):
+        for _, channel_config in self.query_channel_configs():
+            self._submit_to_balrog(channel_config)
+
+    def _submit_to_balrog(self, channel_config):
+        dirs = self.query_abs_dirs()
+        auth = os.path.join(os.getcwd(), self.config['credentials_file'])
+        cmd = [
+            self.query_exe("python"),
+            os.path.join(dirs["abs_tools_dir"],
+                         "scripts/build-promotion/balrog-release-pusher.py")]
+        cmd.extend([
+            "--api-root", self.config["balrog_api_root"],
+            "--download-domain", self.config["download_domain"],
+            "--archive-domain", self.config["archive_domain"],
+            "--credentials-file", auth,
+            "--product", self.config["product"],
+            "--version", self.config["version"],
+            "--build-number", str(self.config["build_number"]),
+            "--app-version", self.config["appVersion"],
+            "--username", self.config["balrog_username"],
+            "--verbose",
+        ])
+        for c in channel_config["channel_names"]:
+            cmd.extend(["--channel", c])
+        for r in channel_config["rules_to_update"]:
+            cmd.extend(["--rule-to-update", r])
+        for p in self.config["platforms"]:
+            cmd.extend(["--platform", p])
+        for v, build_number in self.query_matching_partials(channel_config):
+            partial = "{version}build{build_number}".format(
+                version=v, build_number=build_number)
+            cmd.extend(["--partial-update", partial])
+        if channel_config["requires_mirrors"]:
+            cmd.append("--requires-mirrors")
+        self.retry(lambda: self.run_command(cmd))
+
+# __main__ {{{1
+if __name__ == '__main__':
+    UpdatesBumper().run_and_exit()
new file mode 100644
--- /dev/null
+++ b/testing/mozharness/test/test_mozilla_release.py
@@ -0,0 +1,42 @@
+import unittest
+from mozharness.mozilla.release import get_previous_version
+
+
+class TestGetPreviousVersion(unittest.TestCase):
+    def testESR(self):
+        self.assertEquals(
+            '31.5.3esr',
+            get_previous_version('31.6.0esr',
+                                 ['31.5.3esr', '31.5.2esr', '31.4.0esr']))
+
+    def testReleaseBuild1(self):
+        self.assertEquals(
+            '36.0.4',
+            get_previous_version('37.0', ['36.0.4', '36.0.1', '35.0.1']))
+
+    def testReleaseBuild2(self):
+        self.assertEquals(
+            '36.0.4',
+            get_previous_version('37.0',
+                                 ['37.0', '36.0.4', '36.0.1', '35.0.1']))
+
+    def testBetaMidCycle(self):
+        self.assertEquals(
+            '37.0b4',
+            get_previous_version('37.0b5', ['37.0b4', '37.0b3']))
+
+    def testBetaEarlyCycle(self):
+        # 37.0 is the RC build
+        self.assertEquals(
+            '38.0b1',
+            get_previous_version('38.0b2', ['38.0b1', '37.0']))
+
+    def testBetaFirstInCycle(self):
+        self.assertEquals(
+            '37.0',
+            get_previous_version('38.0b1', ['37.0', '37.0b7']))
+
+    def testTwoDots(self):
+        self.assertEquals(
+            '37.1.0',
+            get_previous_version('38.0b1', ['37.1.0', '36.0']))