Bug 1500897 - geckoview: only spin beetmover tasks on GECKOVIEW_XX_RELBRANCH r=dustin,tomprince
☠☠ backed out by 8ce35618b310 ☠ ☠
authorJohan Lorenzo <jlorenzo@mozilla.com>
Fri, 26 Oct 2018 16:36:49 +0000
changeset 443343 deb0260b33c1b57a0a98c409d2f839a74596985a
parent 443342 408f7a1a2d0f3e601ea64aa8c0dcf9382ae7a237
child 443344 4a85f19e21f4a4a437d4c10c1784fdcbe8788686
push id109362
push userrgurzau@mozilla.com
push dateMon, 29 Oct 2018 22:12:05 +0000
treeherdermozilla-inbound@1c7d0042fc4a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdustin, tomprince
bugs1500897
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1500897 - geckoview: only spin beetmover tasks on GECKOVIEW_XX_RELBRANCH r=dustin,tomprince geckoview: only spin beetmover tasks on GECKOVIEW_XX_RELBRANCH Differential Revision: https://phabricator.services.mozilla.com/D9551
taskcluster/ci/beetmover-geckoview/kind.yml
taskcluster/docs/attributes.rst
taskcluster/docs/parameters.rst
taskcluster/taskgraph/actions/release_promotion.py
taskcluster/taskgraph/actions/util.py
taskcluster/taskgraph/cron/__init__.py
taskcluster/taskgraph/cron/util.py
taskcluster/taskgraph/decision.py
taskcluster/taskgraph/parameters.py
taskcluster/taskgraph/target_tasks.py
taskcluster/taskgraph/test/test_decision.py
taskcluster/taskgraph/transforms/beetmover_geckoview.py
taskcluster/taskgraph/transforms/task.py
taskcluster/taskgraph/util/attributes.py
taskcluster/taskgraph/util/hg.py
--- a/taskcluster/ci/beetmover-geckoview/kind.yml
+++ b/taskcluster/ci/beetmover-geckoview/kind.yml
@@ -25,15 +25,30 @@ not-for-build-platforms:
    - linux64-devedition-nightly/opt
    - macosx64-devedition-nightly/opt
    - win32-devedition-nightly/opt
    - win64-devedition-nightly/opt
    - linux64-asan-reporter-nightly/opt
    - win64-asan-reporter-nightly/opt
 
 job-template:
-   # Beetmoving geckoview makes it available to the official maven repo. So we want beetmover to
-   # act only when the release is greenlit.
-   shipping-phase: ship
+   run-on-projects: ['mozilla-central', 'mozilla-release']
+   run-on-hg-branches:
+      by-project:
+         mozilla-release:
+            - '^GECKOVIEW_\d+_RELBRANCH$'
+         default:
+            - '.*'
+   shipping-phase:
+      by-project:
+         # Beetmoving geckoview makes it available to the official maven repo.
+         # So we want beetmover to act only when the release is greenlit. That
+         # is to say:
+         # - right after nightly builds on mozilla-central
+         # - when Fennec beta was greenlit by QA on mozilla-beta (hence the ship phase)
+         # - at every patch uplifted on the GECKOVIEW_XX_RELBRANC on mozilla-release
+         # Reminder: There is no Android/geckoview build on ESR.
+         mozilla-release: build
+         default: ship
    bucket-scope:
       by-release-level:
          production: 'project:releng:beetmover:bucket:maven-production'
          staging: 'project:releng:beetmover:bucket:maven-staging'
--- a/taskcluster/docs/attributes.rst
+++ b/taskcluster/docs/attributes.rst
@@ -32,16 +32,28 @@ either project names or the aliases
  * `all` -- everywhere (the default)
 
 For try, this attribute applies only if ``-p all`` is specified.  All jobs can
 be specified by name regardless of ``run_on_projects``.
 
 If ``run_on_projects`` is set to an empty list, then the task will not run
 anywhere, unless its build platform is specified explicitly in try syntax.
 
+run_on_hg_branches
+==================
+
+On a given project, the mercurial branch where this task should be in the target
+task set.  This is how requirements like "only run this RELBRANCH" get implemented.
+These are either the regular expression of a branch (e.g.: "GECKOVIEW_\d+_RELBRANCH")
+or the following alias:
+
+ * `all` -- everywhere (the default)
+
+Like ``run_on_projects``, the same behavior applies if it is set to an empty list.
+
 task_duplicates
 ===============
 
 This is used to indicate that we want multiple copies of the task created.
 This feature is used to track down intermittent job failures.
 
 If this value is set to N, the task-creation machinery will create a total of N
 copies of the task.  Only the first copy will be included in the taskgraph
--- a/taskcluster/docs/parameters.rst
+++ b/taskcluster/docs/parameters.rst
@@ -49,16 +49,19 @@ Push Information
 
 ``pushlog_id``
    The ID from the ``hg.mozilla.org`` pushlog
 
 ``pushdate``
    The timestamp of the push to the repository that triggered this decision
    task.  Expressed as an integer seconds since the UNIX epoch.
 
+``hg_branch``
+  The mercurial branch where the revision lives in.
+
 ``build_date``
    The timestamp of the build date. Defaults to ``pushdate`` and falls back to present time of
    taskgraph invocation. Expressed as an integer seconds since the UNIX epoch.
 
 ``moz_build_date``
    A formatted timestamp of ``build_date``. Expressed as a string with the following
    format: %Y%m%d%H%M%S
 
--- a/taskcluster/taskgraph/actions/release_promotion.py
+++ b/taskcluster/taskgraph/actions/release_promotion.py
@@ -6,18 +6,18 @@
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import json
 import os
 
 from .registry import register_callback_action
 
-from .util import (find_decision_task, find_existing_tasks_from_previous_kinds,
-                   find_hg_revision_pushlog_id)
+from .util import find_decision_task, find_existing_tasks_from_previous_kinds
+from taskgraph.util.hg import find_hg_revision_pushlog_id
 from taskgraph.util.taskcluster import get_artifact
 from taskgraph.util.partials import populate_release_history
 from taskgraph.util.partners import (
     EMEFREE_BRANCHES,
     PARTNER_BRANCHES,
     fix_partner_config,
     get_partner_config_by_url,
     get_partner_url_config,
--- a/taskcluster/taskgraph/actions/util.py
+++ b/taskcluster/taskgraph/actions/util.py
@@ -3,17 +3,16 @@
 # 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/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import copy
 import logging
-import requests
 import os
 import re
 
 from requests.exceptions import HTTPError
 
 from taskgraph import create
 from taskgraph.decision import read_artifact, write_artifact
 from taskgraph.taskgraph import TaskGraph
@@ -23,46 +22,26 @@ from taskgraph.util.taskcluster import (
     find_task_id,
     get_artifact,
     list_tasks,
     parse_time,
 )
 
 logger = logging.getLogger(__name__)
 
-PUSHLOG_TMPL = '{}/json-pushes?version=2&changeset={}&tipsonly=1&full=1'
-
 
 def find_decision_task(parameters, graph_config):
     """Given the parameters for this action, find the taskId of the decision
     task"""
     return find_task_id('{}.v2.{}.pushlog-id.{}.decision'.format(
         graph_config['trust-domain'],
         parameters['project'],
         parameters['pushlog_id']))
 
 
-def find_hg_revision_pushlog_id(parameters, graph_config, revision):
-    """Given the parameters for this action and a revision, find the
-    pushlog_id of the revision."""
-
-    repo_param = '{}head_repository'.format(graph_config['project-repo-param-prefix'])
-    pushlog_url = PUSHLOG_TMPL.format(parameters[repo_param], revision)
-    r = requests.get(pushlog_url)
-    r.raise_for_status()
-    pushes = r.json()['pushes'].keys()
-    if len(pushes) != 1:
-        raise RuntimeError(
-            "Unable to find a single pushlog_id for {} revision {}: {}".format(
-                parameters['head_repository'], revision, pushes
-            )
-        )
-    return pushes[0]
-
-
 def find_existing_tasks_from_previous_kinds(full_task_graph, previous_graph_ids,
                                             rebuild_kinds):
     """Given a list of previous decision/action taskIds and kinds to ignore
     from the previous graphs, return a dictionary of labels-to-taskids to use
     as ``existing_tasks`` in the optimization step."""
     existing_tasks = {}
     for previous_graph_id in previous_graph_ids:
         label_to_taskid = get_artifact(previous_graph_id, "public/label-to-taskid.json")
--- a/taskcluster/taskgraph/cron/__init__.py
+++ b/taskcluster/taskgraph/cron/__init__.py
@@ -10,23 +10,21 @@ from __future__ import absolute_import, 
 import datetime
 import json
 import logging
 import os
 import traceback
 import yaml
 
 from . import decision, schema
-from .util import (
-    match_utc,
-    calculate_head_rev
-)
+from .util import match_utc
 from ..create import create_task
 from .. import GECKO
 from taskgraph.util.attributes import match_run_on_projects
+from taskgraph.util.hg import calculate_head_rev
 from taskgraph.util.schema import resolve_keyed_by
 from taskgraph.util.taskcluster import get_session
 
 # Functions to handle each `job.type` in `.cron.yml`.  These are called with
 # the contents of the `job` property from `.cron.yml` and should return a
 # sequence of (taskId, task) tuples which will subsequently be fed to
 # createTask.
 JOB_TYPES = {
--- a/taskcluster/taskgraph/cron/util.py
+++ b/taskcluster/taskgraph/cron/util.py
@@ -2,18 +2,16 @@
 
 # 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/.
 
 
 from __future__ import absolute_import, print_function, unicode_literals
 
-import subprocess
-
 
 def match_utc(params, sched):
     """Return True if params['time'] matches the given schedule.
 
     If minute is not specified, then every multiple of fifteen minutes will match.
     Times not an even multiple of fifteen minutes will result in an exception
     (since they would never run).
     If hour is not specified, any hour will match. Similar for day and weekday.
@@ -33,15 +31,8 @@ def match_utc(params, sched):
     if isinstance(sched.get('weekday'), str) or isinstance(sched.get('weekday'), unicode):
         if sched.get('weekday', str()).lower() != params['time'].strftime('%A').lower():
             return False
     elif sched.get('weekday') is not None:
         # don't accept other values.
         return False
 
     return True
-
-
-def calculate_head_rev(root):
-    # we assume that run-task has correctly checked out the revision indicated by
-    # GECKO_HEAD_REF, so all that remains is to see what the current revision is.
-    # Mercurial refers to that as `.`.
-    return subprocess.check_output(['hg', 'log', '-r', '.', '-T', '{node}'], cwd=root)
--- a/taskcluster/taskgraph/decision.py
+++ b/taskcluster/taskgraph/decision.py
@@ -7,27 +7,29 @@ from __future__ import absolute_import, 
 
 import os
 import json
 import logging
 
 import time
 import yaml
 
+from . import GECKO
+from .actions import render_actions_json
+from .create import create_tasks
 from .generator import TaskGraphGenerator
-from .create import create_tasks
 from .parameters import Parameters, get_version, get_app_version
 from .taskgraph import TaskGraph
 from .try_option_syntax import parse_message
-from .actions import render_actions_json
-from .util.partials import populate_release_history
-from .util.yaml import load_yaml
+from .util.schema import validate_schema, Schema
+from taskgraph.util.hg import get_hg_revision_branch
+from taskgraph.util.partials import populate_release_history
+from taskgraph.util.yaml import load_yaml
+from voluptuous import Required, Optional
 
-from .util.schema import validate_schema, Schema
-from voluptuous import Required, Optional
 
 logger = logging.getLogger(__name__)
 
 ARTIFACTS_DIR = 'artifacts'
 
 # For each project, this gives a set of parameters specific to the project.
 # See `taskcluster/docs/parameters.rst` for information on parameters.
 PER_PROJECT_PARAMETERS = {
@@ -187,16 +189,17 @@ def taskgraph_decision(options, paramete
 
 def get_decision_parameters(config, options):
     """
     Load parameters from the command-line options for 'taskgraph decision'.
     This also applies per-project parameters, based on the given project.
 
     """
     product_dir = config['product-dir']
+    root = options.get('root') or GECKO
 
     parameters = {n: options[n] for n in [
         'base_repository',
         'head_repository',
         'head_rev',
         'head_ref',
         'message',
         'project',
@@ -221,16 +224,17 @@ def get_decision_parameters(config, opti
     parameters['filters'] = [
         'target_tasks_method',
     ]
     parameters['existing_tasks'] = {}
     parameters['do_not_optimize'] = []
     parameters['build_number'] = 1
     parameters['version'] = get_version(product_dir)
     parameters['app_version'] = get_app_version(product_dir)
+    parameters['hg_branch'] = get_hg_revision_branch(root, revision=parameters['head_rev'])
     parameters['next_version'] = None
     parameters['release_type'] = ''
     parameters['release_eta'] = ''
     parameters['release_enable_partners'] = False
     parameters['release_partners'] = []
     parameters['release_partner_config'] = {}
     parameters['release_partner_build_number'] = 1
     parameters['release_enable_emefree'] = False
--- a/taskcluster/taskgraph/parameters.py
+++ b/taskcluster/taskgraph/parameters.py
@@ -54,16 +54,17 @@ PARAMETERS = {
     'build_date': lambda: int(time.time()),
     'build_number': 1,
     'do_not_optimize': [],
     'existing_tasks': {},
     'filters': ['target_tasks_method'],
     'head_ref': get_head_ref,
     'head_repository': 'https://hg.mozilla.org/mozilla-central',
     'head_rev': get_head_ref,
+    'hg_branch': 'default',
     'level': '3',
     'message': '',
     'moz_build_date': lambda: datetime.now().strftime("%Y%m%d%H%M%S"),
     'next_version': None,
     'optimize_target_tasks': True,
     'owner': 'nobody@mozilla.com',
     'project': 'mozilla-central',
     'pushdate': lambda: int(time.time()),
--- a/taskcluster/taskgraph/target_tasks.py
+++ b/taskcluster/taskgraph/target_tasks.py
@@ -2,17 +2,17 @@
 
 # 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/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 from taskgraph import try_option_syntax
-from taskgraph.util.attributes import match_run_on_projects
+from taskgraph.util.attributes import match_run_on_projects, match_run_on_hg_branches
 
 _target_task_methods = {}
 
 
 def _target_task(name):
     def wrap(func):
         _target_task_methods[name] = func
         return func
@@ -36,16 +36,23 @@ def filter_out_cron(task, parameters):
 
 
 def filter_for_project(task, parameters):
     """Filter tasks by project.  Optionally enable nightlies."""
     run_on_projects = set(task.attributes.get('run_on_projects', []))
     return match_run_on_projects(parameters['project'], run_on_projects)
 
 
+def filter_for_hg_branch(task, parameters):
+    """Filter tasks by hg branch.
+    If `run_on_hg_branch` is not defined, then task runs on all branches"""
+    run_on_hg_branches = set(task.attributes.get('run_on_hg_branches', ['all']))
+    return match_run_on_hg_branches(parameters['hg_branch'], run_on_hg_branches)
+
+
 def filter_on_platforms(task, platforms):
     """Filter tasks on the given platform"""
     platform = task.attributes.get('build_platform')
     return (platform in platforms)
 
 
 def filter_release_tasks(task, parameters):
     platform = task.attributes.get('build_platform')
@@ -76,17 +83,17 @@ def filter_release_tasks(task, parameter
         return False
 
     return True
 
 
 def standard_filter(task, parameters):
     return all(
         filter_func(task, parameters) for filter_func in
-        (filter_out_cron, filter_for_project)
+        (filter_out_cron, filter_for_project, filter_for_hg_branch)
     )
 
 
 def _try_task_config(full_task_graph, parameters, graph_config):
     requested_tasks = parameters['try_task_config']['tasks']
     return list(set(requested_tasks) & full_task_graph.graph.nodes)
 
 
--- a/taskcluster/taskgraph/test/test_decision.py
+++ b/taskcluster/taskgraph/test/test_decision.py
@@ -6,18 +6,19 @@ from __future__ import absolute_import, 
 
 import os
 import json
 import yaml
 import shutil
 import unittest
 import tempfile
 
+from mock import patch
+from mozunit import main, MockedOpen
 from taskgraph import decision
-from mozunit import main, MockedOpen
 
 
 FAKE_GRAPH_CONFIG = {'product-dir': 'browser'}
 
 
 class TestDecision(unittest.TestCase):
 
     def test_write_artifact_json(self):
@@ -60,43 +61,49 @@ class TestGetDecisionParameters(unittest
             'message': '',
             'project': 'mozilla-central',
             'pushlog_id': 143,
             'pushdate': 1503691511,
             'owner': 'nobody@mozilla.com',
             'level': 3,
         }
 
-    def test_simple_options(self):
+    @patch('taskgraph.decision.get_hg_revision_branch')
+    def test_simple_options(self, mock_get_hg_revision_branch):
+        mock_get_hg_revision_branch.return_value = 'default'
         with MockedOpen({self.ttc_file: None}):
             params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options)
         self.assertEqual(params['pushlog_id'], 143)
         self.assertEqual(params['build_date'], 1503691511)
+        self.assertEqual(params['hg_branch'], 'default')
         self.assertEqual(params['moz_build_date'], '20170825200511')
         self.assertEqual(params['try_mode'], None)
         self.assertEqual(params['try_options'], None)
         self.assertEqual(params['try_task_config'], None)
 
-    def test_no_email_owner(self):
+    @patch('taskgraph.decision.get_hg_revision_branch')
+    def test_no_email_owner(self, _):
         self.options['owner'] = 'ffxbld'
         with MockedOpen({self.ttc_file: None}):
             params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options)
         self.assertEqual(params['owner'], 'ffxbld@noreply.mozilla.org')
 
-    def test_try_options(self):
+    @patch('taskgraph.decision.get_hg_revision_branch')
+    def test_try_options(self, _):
         self.options['message'] = 'try: -b do -t all'
         self.options['project'] = 'try'
         with MockedOpen({self.ttc_file: None}):
             params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options)
         self.assertEqual(params['try_mode'], 'try_option_syntax')
         self.assertEqual(params['try_options']['build_types'], 'do')
         self.assertEqual(params['try_options']['unittests'], 'all')
         self.assertEqual(params['try_task_config'], None)
 
-    def test_try_task_config(self):
+    @patch('taskgraph.decision.get_hg_revision_branch')
+    def test_try_task_config(self, _):
         ttc = {'tasks': ['a', 'b'], 'templates': {}}
         self.options['project'] = 'try'
         with MockedOpen({self.ttc_file: json.dumps(ttc)}):
             params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options)
             self.assertEqual(params['try_mode'], 'try_task_config')
             self.assertEqual(params['try_options'], None)
             self.assertEqual(params['try_task_config'], ttc)
 
--- a/taskcluster/taskgraph/transforms/beetmover_geckoview.py
+++ b/taskcluster/taskgraph/transforms/beetmover_geckoview.py
@@ -37,33 +37,54 @@ task_description_schema = {str(k): v for
 
 transforms = TransformSequence()
 
 beetmover_description_schema = schema.extend({
     Required('depname', default='build'): basestring,
     Optional('label'): basestring,
     Optional('treeherder'): task_description_schema['treeherder'],
 
+    Required('run-on-projects'): task_description_schema['run-on-projects'],
+    Required('run-on-hg-branches'): task_description_schema['run-on-hg-branches'],
+
     Optional('bucket-scope'): optionally_keyed_by('release-level', basestring),
-    Optional('shipping-phase'): task_description_schema['shipping-phase'],
+    Optional('shipping-phase'): optionally_keyed_by(
+        'project', task_description_schema['shipping-phase']
+    ),
     Optional('shipping-product'): task_description_schema['shipping-product'],
 })
 
 
 @transforms.add
 def validate(config, jobs):
     for job in jobs:
         label = job.get('primary-dependency', object).__dict__.get('label', '?no-label?')
         validate_schema(
             beetmover_description_schema, job,
             "In beetmover-geckoview ({!r} kind) task for {!r}:".format(config.kind, label))
         yield job
 
 
 @transforms.add
+def resolve_keys(config, jobs):
+    for job in jobs:
+        resolve_keyed_by(
+            job, 'run-on-hg-branches', item_name=job['label'], project=config.params['project']
+        )
+        resolve_keyed_by(
+            job, 'shipping-phase', item_name=job['label'], project=config.params['project']
+        )
+        resolve_keyed_by(
+            job, 'bucket-scope', item_name=job['label'],
+            **{'release-level': config.params.release_level()}
+        )
+        yield job
+
+
+@transforms.add
 def make_task_description(config, jobs):
     for job in jobs:
         dep_job = job['primary-dependency']
         attributes = dep_job.attributes
 
         treeherder = job.get('treeherder', {})
         treeherder.setdefault('symbol', 'BM-gv')
         dep_th_platform = dep_job.task.get('extra', {}).get(
@@ -84,29 +105,26 @@ def make_task_description(config, jobs):
         dependent_kind = str(dep_job.kind)
         dependencies = {dependent_kind: dep_job.label}
 
         attributes = copy_attributes_from_dependent_job(dep_job)
 
         if job.get('locale'):
             attributes['locale'] = job['locale']
 
-        resolve_keyed_by(
-            job, 'bucket-scope', item_name=job['label'],
-            **{'release-level': config.params.release_level()}
-        )
+        attributes['run_on_hg_branches'] = job['run-on-hg-branches']
 
         task = {
             'label': label,
             'description': description,
             'worker-type': get_worker_type_for_scope(config, job['bucket-scope']),
             'scopes': [job['bucket-scope'], 'project:releng:beetmover:action:push-to-maven'],
             'dependencies': dependencies,
             'attributes': attributes,
-            'run-on-projects': ['mozilla-central'],
+            'run-on-projects': job['run-on-projects'],
             'treeherder': treeherder,
             'shipping-phase': job['shipping-phase'],
         }
 
         yield task
 
 
 def generate_upstream_artifacts(build_task_ref):
--- a/taskcluster/taskgraph/transforms/task.py
+++ b/taskcluster/taskgraph/transforms/task.py
@@ -152,16 +152,19 @@ task_description_schema = Schema({
         'channel': optionally_keyed_by('project', basestring),
     },
 
     # The `run_on_projects` attribute, defaulting to "all".  This dictates the
     # projects on which this task should be included in the target task set.
     # See the attributes documentation for details.
     Optional('run-on-projects'): optionally_keyed_by('build-platform', [basestring]),
 
+    # Like `run_on_projects`, `run-on-hg-branches` defaults to "all".
+    Optional('run-on-hg-branches'): optionally_keyed_by('project', [basestring]),
+
     # The `shipping_phase` attribute, defaulting to None. This specifies the
     # release promotion phase that this task belongs to.
     Required('shipping-phase'): Any(
         None,
         'build',
         'promote',
         'push',
         'ship',
--- a/taskcluster/taskgraph/util/attributes.py
+++ b/taskcluster/taskgraph/util/attributes.py
@@ -100,16 +100,29 @@ def match_run_on_projects(project, run_o
             return True
     if 'trunk' in run_on_projects:
         if project in TRUNK_PROJECTS:
             return True
 
     return project in run_on_projects
 
 
+def match_run_on_hg_branches(hg_branch, run_on_hg_branches):
+    """Determine whether the given project is included in the `run-on-hg-branches`
+    parameter. Allows 'all'."""
+    if 'all' in run_on_hg_branches:
+        return True
+
+    for expected_hg_branch_pattern in run_on_hg_branches:
+        if re.match(expected_hg_branch_pattern, hg_branch):
+            return True
+
+    return False
+
+
 def copy_attributes_from_dependent_job(dep_job):
     attributes = {
         'build_platform': dep_job.attributes.get('build_platform'),
         'build_type': dep_job.attributes.get('build_type'),
     }
 
     attributes.update({
         attr: dep_job.attributes[attr]
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/util/hg.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+from __future__ import absolute_import
+
+import requests
+import subprocess
+
+PUSHLOG_TMPL = '{}/json-pushes?version=2&changeset={}&tipsonly=1&full=1'
+
+
+def find_hg_revision_pushlog_id(parameters, graph_config, revision):
+    """Given the parameters for this action and a revision, find the
+    pushlog_id of the revision."""
+    repo_param = '{}head_repository'.format(graph_config['project-repo-param-prefix'])
+    pushlog_url = PUSHLOG_TMPL.format(parameters[repo_param], revision)
+    r = requests.get(pushlog_url)
+    r.raise_for_status()
+    pushes = r.json()['pushes'].keys()
+    if len(pushes) != 1:
+        raise RuntimeError(
+            "Unable to find a single pushlog_id for {} revision {}: {}".format(
+                parameters['head_repository'], revision, pushes
+            )
+        )
+    return pushes[0]
+
+
+def get_hg_revision_branch(root, revision):
+    """Given the parameters for a revision, find the hg_branch (aka
+    relbranch) of the revision."""
+    return subprocess.check_output(['hg', 'identify', '--branch', '--rev', revision], cwd=root)
+
+
+def calculate_head_rev(root):
+    # we assume that run-task has correctly checked out the revision indicated by
+    # GECKO_HEAD_REF, so all that remains is to see what the current revision is.
+    # Mercurial refers to that as `.`.
+    return subprocess.check_output(['hg', 'log', '-r', '.', '-T', '{node}'], cwd=root)