summaryrefslogtreecommitdiffstats
path: root/python/mozrelease/mozrelease
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozrelease/mozrelease')
-rw-r--r--python/mozrelease/mozrelease/__init__.py0
-rw-r--r--python/mozrelease/mozrelease/attribute_builds.py214
-rw-r--r--python/mozrelease/mozrelease/balrog.py72
-rw-r--r--python/mozrelease/mozrelease/buglist_creator.py261
-rw-r--r--python/mozrelease/mozrelease/chunking.py27
-rw-r--r--python/mozrelease/mozrelease/l10n.py17
-rw-r--r--python/mozrelease/mozrelease/mach_commands.py141
-rw-r--r--python/mozrelease/mozrelease/partner_repack.py895
-rw-r--r--python/mozrelease/mozrelease/paths.py85
-rw-r--r--python/mozrelease/mozrelease/platforms.py54
-rw-r--r--python/mozrelease/mozrelease/scriptworker_canary.py107
-rw-r--r--python/mozrelease/mozrelease/update_verify.py275
-rw-r--r--python/mozrelease/mozrelease/util.py26
-rw-r--r--python/mozrelease/mozrelease/versions.py114
14 files changed, 2288 insertions, 0 deletions
diff --git a/python/mozrelease/mozrelease/__init__.py b/python/mozrelease/mozrelease/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/python/mozrelease/mozrelease/__init__.py
diff --git a/python/mozrelease/mozrelease/attribute_builds.py b/python/mozrelease/mozrelease/attribute_builds.py
new file mode 100644
index 0000000000..094c70e1bf
--- /dev/null
+++ b/python/mozrelease/mozrelease/attribute_builds.py
@@ -0,0 +1,214 @@
+#! /usr/bin/env python
+# 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/.
+
+import argparse
+import json
+import logging
+import mmap
+import os
+import shutil
+import struct
+import sys
+import tempfile
+import urllib.parse
+from pathlib import Path
+
+logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
+log = logging.getLogger()
+
+
+def write_attribution_data(filepath, data):
+ """Insert data into a prepared certificate in a signed PE file.
+
+ Returns False if the file isn't a valid PE file, or if the necessary
+ certificate was not found.
+
+ This function assumes that somewhere in the given file's certificate table
+ there exists a 1024-byte space which begins with the tag "__MOZCUSTOM__:".
+ The given data will be inserted into the file following this tag.
+
+ We don't bother updating the optional header checksum.
+ Windows doesn't check it for executables, only drivers and certain DLL's.
+ """
+ with open(filepath, "r+b") as file:
+ mapped = mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_WRITE)
+
+ # Get the location of the PE header and the optional header
+ pe_header_offset = struct.unpack("<I", mapped[0x3C:0x40])[0]
+ optional_header_offset = pe_header_offset + 24
+
+ # Look up the magic number in the optional header,
+ # so we know if we have a 32 or 64-bit executable.
+ # We need to know that so that we can find the data directories.
+ pe_magic_number = struct.unpack(
+ "<H", mapped[optional_header_offset : optional_header_offset + 2]
+ )[0]
+ if pe_magic_number == 0x10B:
+ # 32-bit
+ cert_dir_entry_offset = optional_header_offset + 128
+ elif pe_magic_number == 0x20B:
+ # 64-bit. Certain header fields are wider.
+ cert_dir_entry_offset = optional_header_offset + 144
+ else:
+ # Not any known PE format
+ mapped.close()
+ return False
+
+ # The certificate table offset and length give us the valid range
+ # to search through for where we should put our data.
+ cert_table_offset = struct.unpack(
+ "<I", mapped[cert_dir_entry_offset : cert_dir_entry_offset + 4]
+ )[0]
+ cert_table_size = struct.unpack(
+ "<I", mapped[cert_dir_entry_offset + 4 : cert_dir_entry_offset + 8]
+ )[0]
+
+ if cert_table_offset == 0 or cert_table_size == 0:
+ # The file isn't signed
+ mapped.close()
+ return False
+
+ tag = b"__MOZCUSTOM__:"
+ tag_index = mapped.find(
+ tag, cert_table_offset, cert_table_offset + cert_table_size
+ )
+ if tag_index == -1:
+ mapped.close()
+ return False
+
+ # convert to quoted-url byte-string for insertion
+ data = urllib.parse.quote(data).encode("utf-8")
+ mapped[tag_index + len(tag) : tag_index + len(tag) + len(data)] = data
+
+ return True
+
+
+def validate_attribution_code(attribution):
+ log.info("Checking attribution %s" % attribution)
+ return_code = True
+
+ if len(attribution) == 0:
+ log.error("Attribution code has 0 length")
+ return False
+
+ # Set to match https://searchfox.org/mozilla-central/rev/a92ed79b0bc746159fc31af1586adbfa9e45e264/browser/components/attribution/AttributionCode.jsm#24 # noqa
+ MAX_LENGTH = 1010
+ if len(attribution) > MAX_LENGTH:
+ log.error("Attribution code longer than %s chars" % MAX_LENGTH)
+ return_code = False
+
+ # this leaves out empty values like 'foo='
+ params = urllib.parse.parse_qsl(attribution)
+ used_keys = set()
+ for key, value in params:
+ # check for invalid keys
+ if key not in (
+ "source",
+ "medium",
+ "campaign",
+ "content",
+ "experiment",
+ "variation",
+ "ua",
+ "dlsource",
+ ):
+ log.error("Invalid key %s" % key)
+ return_code = False
+
+ # avoid ambiguity from repeated keys
+ if key in used_keys:
+ log.error("Repeated key %s" % key)
+ return_code = False
+ else:
+ used_keys.add(key)
+
+ # TODO the service checks for valid source, should we do that here too ?
+
+ # We have two types of attribution with different requirements:
+ # 1) Partner attribution, which requires a few UTM parameters sets
+ # 2) Attribution of vanilla builds, which only requires `dlsource`
+ #
+ # Perhaps in an ideal world we would check what type of build we're
+ # attributing to make sure that eg: partner builds don't get `dlsource`
+ # instead of what they actually want -- but the likelyhood of that
+ # happening is vanishingly small, so it's probably not worth doing.
+ if "dlsource" not in used_keys:
+ for key in ("source", "medium", "campaign", "content"):
+ if key not in used_keys:
+ return_code = False
+
+ if return_code is False:
+ log.error(
+ "Either 'dlsource' must be provided, or all of: 'source', 'medium', 'campaign', and 'content'. Use '(not set)' if one of the latter is not needed."
+ )
+ return return_code
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Add attribution to Windows installer(s).",
+ epilog="""
+ By default, configuration from envvar ATTRIBUTION_CONFIG is used, with
+ expected format
+ [{"input": "in/abc.exe", "output": "out/def.exe", "attribution": "abcdef"},
+ {"input": "in/ghi.exe", "output": "out/jkl.exe", "attribution": "ghijkl"}]
+ for 1 or more attributions. Or the script arguments may be used for a single attribution.
+
+ The attribution code should be a string which is not url-encoded.
+
+ If command line arguments are used instead, one or more `--input` parameters may be provided.
+ Each will be written to the `--output` directory provided to a file of the same name as the
+ input filename. All inputs will be attributed with the same `--attribution` code.
+ """,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--input",
+ default=[],
+ action="append",
+ help="Source installer to attribute; may be specified multiple times",
+ )
+ parser.add_argument("--output", help="Location to write the attributed installers")
+ parser.add_argument("--attribution", help="Attribution code")
+ args = parser.parse_args()
+
+ if os.environ.get("ATTRIBUTION_CONFIG"):
+ work = json.loads(os.environ["ATTRIBUTION_CONFIG"])
+ elif args.input and args.output and args.attribution:
+ work = []
+ for i in args.input:
+ fn = os.path.basename(i)
+ work.append(
+ {
+ "input": i,
+ "output": os.path.join(args.output, fn),
+ "attribution": args.attribution,
+ }
+ )
+ else:
+ log.error("No configuration found. Set ATTRIBUTION_CONFIG or pass arguments.")
+ return 1
+
+ cached_code_checks = []
+ for job in work:
+ if job["attribution"] not in cached_code_checks:
+ status = validate_attribution_code(job["attribution"])
+ if status:
+ cached_code_checks.append(job["attribution"])
+ else:
+ log.error("Failed attribution code check")
+ return 1
+
+ with tempfile.TemporaryDirectory() as td:
+ log.info("Attributing installer %s ..." % job["input"])
+ tf = shutil.copy(job["input"], td)
+ if write_attribution_data(tf, job["attribution"]):
+ Path(job["output"]).parent.mkdir(parents=True, exist_ok=True)
+ shutil.move(tf, job["output"])
+ log.info("Wrote %s" % job["output"])
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/python/mozrelease/mozrelease/balrog.py b/python/mozrelease/mozrelease/balrog.py
new file mode 100644
index 0000000000..31418d352e
--- /dev/null
+++ b/python/mozrelease/mozrelease/balrog.py
@@ -0,0 +1,72 @@
+# -*- 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/.
+
+
+def _generate_show_url(context, entry):
+ url = entry["url"]
+ return {
+ "actions": "showURL",
+ "openURL": url.format(**context),
+ }
+
+
+def _generate_product_details(context, entry):
+ url = entry["url"]
+ return {
+ "detailsURL": url.format(**context),
+ "type": "minor",
+ }
+
+
+_FIELD_TYPES = {
+ "show-url": _generate_show_url,
+ "product-details": _generate_product_details,
+}
+
+
+def _generate_conditions(context, entry):
+ if (
+ "release-types" in entry
+ and context["release-type"] not in entry["release-types"]
+ ):
+ return None
+ if "blob-types" in entry and context["blob-type"] not in entry["blob-types"]:
+ return None
+ if "products" in entry and context["product"] not in entry["products"]:
+ return None
+
+ conditions = {}
+ if "locales" in entry:
+ conditions["locales"] = entry["locales"]
+ if "versions" in entry:
+ conditions["versions"] = [
+ version.format(**context) for version in entry["versions"]
+ ]
+ if "update-channel" in entry:
+ conditions["channels"] = [
+ entry["update-channel"] + suffix
+ for suffix in ("", "-localtest", "-cdntest")
+ ]
+ if "build-ids" in entry:
+ conditions["buildIDs"] = [
+ buildid.format(**context) for buildid in entry["build-ids"]
+ ]
+ return conditions
+
+
+def generate_update_properties(context, config):
+ result = []
+ for entry in config:
+ fields = _FIELD_TYPES[entry["type"]](context, entry)
+ conditions = _generate_conditions(context, entry.get("conditions", {}))
+
+ if conditions is not None:
+ result.append(
+ {
+ "fields": fields,
+ "for": conditions,
+ }
+ )
+ return result
diff --git a/python/mozrelease/mozrelease/buglist_creator.py b/python/mozrelease/mozrelease/buglist_creator.py
new file mode 100644
index 0000000000..8c7b8d0391
--- /dev/null
+++ b/python/mozrelease/mozrelease/buglist_creator.py
@@ -0,0 +1,261 @@
+# -*- 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/.
+
+import logging
+import os
+import re
+from operator import itemgetter
+
+import requests
+from mozilla_version.gecko import GeckoVersion
+from taskcluster import Notify, optionsFromEnvironment
+
+BUGLIST_TEMPLATE = "* [Bugs since previous changeset]({url})\n"
+BACKOUT_REGEX = re.compile(r"back(\s?)out|backed out|backing out", re.IGNORECASE)
+BACKOUT_TEMPLATE = "* [Backouts since previous changeset]({url})\n"
+BUGZILLA_BUGLIST_TEMPLATE = "https://bugzilla.mozilla.org/buglist.cgi?bug_id={bugs}"
+BUG_NUMBER_REGEX = re.compile(r"bug \d+", re.IGNORECASE)
+CHANGELOG_TO_FROM_STRING = "{product}_{version}_RELEASE"
+CHANGESET_URL_TEMPLATE = (
+ "{repo}/{logtype}" "?rev={to_version}+%25+{from_version}&revcount=1000"
+)
+FULL_CHANGESET_TEMPLATE = "* [Full Mercurial changelog]({url})\n"
+LIST_DESCRIPTION_TEMPLATE = "Comparing Mercurial tag {from_version} to {to_version}:\n"
+MAX_BUGS_IN_BUGLIST = 250
+MERCURIAL_TAGS_URL_TEMPLATE = "{repo}/json-tags"
+NO_BUGS = "" # Return this when bug list can't be created
+URL_SHORTENER_TEMPLATE = "https://bugzilla.mozilla.org/rest/bitly/shorten?url={url}"
+
+log = logging.getLogger(__name__)
+
+
+def create_bugs_url(product, current_version, current_revision, repo=None):
+ """
+ Creates list of bugs and backout bugs for release-drivers email
+
+ :param release: dict -> containing information about release, from Ship-It
+ :return: str -> description of compared releases, with Bugzilla links
+ containing all bugs in changeset
+ """
+ try:
+ # Extract the important data, ignore if beta1 release
+ if current_version.beta_number == 1:
+ # If the version is beta 1, don't make any links
+ return NO_BUGS
+
+ if repo is None:
+ repo = get_repo_by_version(current_version)
+ # Get the tag version, for display purposes
+ current_version_tag = tag_version(product, current_version)
+
+ # Get all Hg tags for this branch, determine the previous version
+ tag_url = MERCURIAL_TAGS_URL_TEMPLATE.format(repo=repo)
+ mercurial_tags_json = requests.get(tag_url).json()
+ previous_version_tag = get_previous_tag_version(
+ product, current_version, current_version_tag, mercurial_tags_json
+ )
+
+ # Get the changeset between these versions, parse for all unique bugs and backout bugs
+ resp = requests.get(
+ CHANGESET_URL_TEMPLATE.format(
+ repo=repo,
+ from_version=previous_version_tag,
+ to_version=current_revision,
+ logtype="json-log",
+ )
+ )
+ changeset_data = resp.json()
+ unique_bugs, unique_backout_bugs = get_bugs_in_changeset(changeset_data)
+
+ # Return a descriptive string with links if any relevant bugs are found
+ if unique_bugs or unique_backout_bugs:
+ description = LIST_DESCRIPTION_TEMPLATE.format(
+ from_version=previous_version_tag, to_version=current_version_tag
+ )
+
+ if unique_bugs:
+ description += BUGLIST_TEMPLATE.format(
+ url=create_buglist_url(unique_bugs)
+ )
+ if unique_backout_bugs:
+ description += BACKOUT_TEMPLATE.format(
+ url=create_buglist_url(unique_backout_bugs)
+ )
+
+ changeset_html = CHANGESET_URL_TEMPLATE.format(
+ repo=repo,
+ from_version=previous_version_tag,
+ to_version=current_revision,
+ logtype="log",
+ )
+ description += FULL_CHANGESET_TEMPLATE.format(url=changeset_html)
+
+ return description
+ else:
+ return NO_BUGS
+
+ except Exception as err:
+ log.info(err)
+ return NO_BUGS
+
+
+def get_bugs_in_changeset(changeset_data):
+ unique_bugs, unique_backout_bugs = set(), set()
+ for changeset in changeset_data["entries"]:
+ if is_excluded_change(changeset):
+ continue
+
+ changeset_desc = changeset["desc"]
+ bug_re = BUG_NUMBER_REGEX.search(changeset_desc)
+
+ if bug_re:
+ bug_number = bug_re.group().split(" ")[1]
+
+ if is_backout_bug(changeset_desc):
+ unique_backout_bugs.add(bug_number)
+ else:
+ unique_bugs.add(bug_number)
+
+ return unique_bugs, unique_backout_bugs
+
+
+def is_excluded_change(changeset):
+ excluded_change_keywords = [
+ "a=test-only",
+ "a=release",
+ ]
+ return any(keyword in changeset["desc"] for keyword in excluded_change_keywords)
+
+
+def is_backout_bug(changeset_description):
+ return bool(BACKOUT_REGEX.search(changeset_description))
+
+
+def create_buglist_url(buglist):
+ return BUGZILLA_BUGLIST_TEMPLATE.format(bugs="%2C".join(buglist))
+
+
+def tag_version(product, version):
+ underscore_version = str(version).replace(".", "_")
+ return CHANGELOG_TO_FROM_STRING.format(
+ product=product.upper(), version=underscore_version
+ )
+
+
+def parse_tag_version(tag):
+ dot_version = ".".join(tag.split("_")[1:-1])
+ return GeckoVersion.parse(dot_version)
+
+
+def get_previous_tag_version(
+ product,
+ current_version,
+ current_version_tag,
+ mercurial_tags_json,
+):
+ """
+ Gets the previous hg version tag for the product and branch, given the current version tag
+ """
+
+ def _invalid_tag_filter(tag):
+ """Filters by product and removes incorrect major version + base, end releases"""
+ prod_major_version_re = r"^{product}_{major_version}".format(
+ product=product.upper(), major_version=current_version.major_number
+ )
+
+ return (
+ "BASE" not in tag
+ and "END" not in tag
+ and "RELEASE" in tag
+ and re.match(prod_major_version_re, tag)
+ )
+
+ # Get rid of irrelevant tags, sort by date and extract the tag string
+ tags = {
+ (parse_tag_version(item["tag"]), item["tag"])
+ for item in mercurial_tags_json["tags"]
+ if _invalid_tag_filter(item["tag"])
+ }
+ # Add the current version to the list
+ tags.add((current_version, current_version_tag))
+ tags = sorted(tags, key=lambda tag: tag[0])
+
+ # Find where the current version is and go back one to get the previous version
+ next_version_index = list(map(itemgetter(0), tags)).index(current_version) - 1
+
+ return tags[next_version_index][1]
+
+
+def get_repo_by_version(version):
+ """
+ Get the repo a given version is found on.
+ """
+ if version.is_beta:
+ return "https://hg.mozilla.org/releases/mozilla-beta"
+ elif version.is_release:
+ return "https://hg.mozilla.org/releases/mozilla-release"
+ elif version.is_esr:
+ return "https://hg.mozilla.org/releases/mozilla-esr{}".format(
+ version.major_number
+ )
+ else:
+ raise Exception(
+ "Unsupported version type {}: {}".format(version.version_type.name, version)
+ )
+
+
+def email_release_drivers(
+ addresses,
+ product,
+ version,
+ build_number,
+ repo,
+ revision,
+ task_group_id,
+):
+ # Send an email to the mailing after the build
+ email_buglist_string = create_bugs_url(product, version, revision, repo=repo)
+
+ content = """\
+A new build has been started:
+
+Commit: [{revision}]({repo}/rev/{revision})
+Task group: [{task_group_id}]({root_url}/tasks/groups/{task_group_id})
+
+{email_buglist_string}
+""".format(
+ repo=repo,
+ revision=revision,
+ root_url=os.environ["TASKCLUSTER_ROOT_URL"],
+ task_group_id=task_group_id,
+ email_buglist_string=email_buglist_string,
+ )
+
+ # On r-d, we prefix the subject of the email in order to simplify filtering
+ subject_prefix = ""
+ if product in {"fennec"}:
+ subject_prefix = "[mobile] "
+ if product in {"firefox", "devedition"}:
+ subject_prefix = "[desktop] "
+
+ subject = "{} Build of {} {} build {}".format(
+ subject_prefix, product, version, build_number
+ )
+
+ # use proxy if configured, otherwise local credentials from env vars
+ if "TASKCLUSTER_PROXY_URL" in os.environ:
+ notify_options = {"rootUrl": os.environ["TASKCLUSTER_PROXY_URL"]}
+ else:
+ notify_options = optionsFromEnvironment()
+
+ notify = Notify(notify_options)
+ for address in addresses:
+ notify.email(
+ {
+ "address": address,
+ "subject": subject,
+ "content": content,
+ }
+ )
diff --git a/python/mozrelease/mozrelease/chunking.py b/python/mozrelease/mozrelease/chunking.py
new file mode 100644
index 0000000000..8c45f74354
--- /dev/null
+++ b/python/mozrelease/mozrelease/chunking.py
@@ -0,0 +1,27 @@
+# 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 copy import copy
+
+
+class ChunkingError(Exception):
+ pass
+
+
+def getChunk(things, chunks, thisChunk):
+ if thisChunk > chunks:
+ raise ChunkingError(
+ "thisChunk (%d) is greater than total chunks (%d)" % (thisChunk, chunks)
+ )
+ possibleThings = copy(things)
+ nThings = len(possibleThings)
+ for c in range(1, chunks + 1):
+ n = nThings // chunks
+ # If our things aren't evenly divisible by the number of chunks
+ # we need to append one more onto some of them
+ if c <= (nThings % chunks):
+ n += 1
+ if c == thisChunk:
+ return possibleThings[0:n]
+ del possibleThings[0:n]
diff --git a/python/mozrelease/mozrelease/l10n.py b/python/mozrelease/mozrelease/l10n.py
new file mode 100644
index 0000000000..1e2a15878d
--- /dev/null
+++ b/python/mozrelease/mozrelease/l10n.py
@@ -0,0 +1,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/.
+
+
+def getPlatformLocales(shipped_locales, platform):
+ platform_locales = []
+ for line in shipped_locales.splitlines():
+ locale = line.strip().split()[0]
+ # ja-JP-mac locale is a MacOS only locale
+ if locale == "ja-JP-mac" and not platform.startswith("mac"):
+ continue
+ # Skip the "ja" locale on MacOS
+ if locale == "ja" and platform.startswith("mac"):
+ continue
+ platform_locales.append(locale)
+ return platform_locales
diff --git a/python/mozrelease/mozrelease/mach_commands.py b/python/mozrelease/mozrelease/mach_commands.py
new file mode 100644
index 0000000000..e7c8da59fe
--- /dev/null
+++ b/python/mozrelease/mozrelease/mach_commands.py
@@ -0,0 +1,141 @@
+# -*- 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/.
+
+
+import logging
+import sys
+
+from mach.decorators import Command, CommandArgument, SubCommand
+from mozilla_version.gecko import GeckoVersion
+
+
+@Command(
+ "release",
+ category="release",
+ description="Task that are part of the release process.",
+)
+def release(command_context):
+ """
+ The release subcommands all relate to the release process.
+ """
+
+
+@SubCommand(
+ "release",
+ "buglist",
+ description="Generate list of bugs since the last release.",
+)
+@CommandArgument(
+ "--version",
+ required=True,
+ type=GeckoVersion.parse,
+ help="The version being built.",
+)
+@CommandArgument("--product", required=True, help="The product being built.")
+@CommandArgument("--repo", help="The repo being built.")
+@CommandArgument("--revision", required=True, help="The revision being built.")
+def buglist(command_context, version, product, revision, repo):
+ setup_logging(command_context)
+ from mozrelease.buglist_creator import create_bugs_url
+
+ print(
+ create_bugs_url(
+ product=product,
+ current_version=version,
+ current_revision=revision,
+ repo=repo,
+ )
+ )
+
+
+@SubCommand(
+ "release",
+ "send-buglist-email",
+ description="Send an email with the bugs since the last release.",
+)
+@CommandArgument(
+ "--address",
+ required=True,
+ action="append",
+ dest="addresses",
+ help="The email address to send the bug list to "
+ "(may be specified more than once.",
+)
+@CommandArgument(
+ "--version",
+ type=GeckoVersion.parse,
+ required=True,
+ help="The version being built.",
+)
+@CommandArgument("--product", required=True, help="The product being built.")
+@CommandArgument("--repo", required=True, help="The repo being built.")
+@CommandArgument("--revision", required=True, help="The revision being built.")
+@CommandArgument("--build-number", required=True, help="The build number")
+@CommandArgument("--task-group-id", help="The task group of the build.")
+def buglist_email(command_context, **options):
+ setup_logging(command_context)
+ from mozrelease.buglist_creator import email_release_drivers
+
+ email_release_drivers(**options)
+
+
+@SubCommand(
+ "release",
+ "push-scriptworker-canary",
+ description="Push tasks to try, to test new scriptworker deployments.",
+)
+@CommandArgument(
+ "--address",
+ required=True,
+ action="append",
+ dest="addresses",
+ help="The email address to send notifications to "
+ "(may be specified more than once).",
+)
+@CommandArgument(
+ "--scriptworker",
+ required=True,
+ action="append",
+ dest="scriptworkers",
+ help="Scriptworker to run canary for (may be specified more than once).",
+)
+@CommandArgument(
+ "--ssh-key-secret",
+ required=False,
+ help="Taskcluster secret with ssh-key to use for hg.mozilla.org",
+)
+def push_scriptworker_canary(command_context, scriptworkers, addresses, ssh_key_secret):
+ setup_logging(command_context)
+ from mozrelease.scriptworker_canary import push_canary
+
+ push_canary(
+ scriptworkers=scriptworkers,
+ addresses=addresses,
+ ssh_key_secret=ssh_key_secret,
+ )
+
+
+def setup_logging(command_context, quiet=False, verbose=True):
+ """
+ Set up Python logging for all loggers, sending results to stderr (so
+ that command output can be redirected easily) and adding the typical
+ mach timestamp.
+ """
+ # remove the old terminal handler
+ old = command_context.log_manager.replace_terminal_handler(None)
+
+ # re-add it, with level and fh set appropriately
+ if not quiet:
+ level = logging.DEBUG if verbose else logging.INFO
+ command_context.log_manager.add_terminal_logging(
+ fh=sys.stderr,
+ level=level,
+ write_interval=old.formatter.write_interval,
+ write_times=old.formatter.write_times,
+ )
+
+ # all of the taskgraph logging is unstructured logging
+ command_context.log_manager.enable_unstructured()
diff --git a/python/mozrelease/mozrelease/partner_repack.py b/python/mozrelease/mozrelease/partner_repack.py
new file mode 100644
index 0000000000..1d64f43cca
--- /dev/null
+++ b/python/mozrelease/mozrelease/partner_repack.py
@@ -0,0 +1,895 @@
+#!/usr/bin/env python
+# Documentation: https://firefox-source-docs.mozilla.org/taskcluster/partner-repacks.html
+
+import json
+import logging
+import os
+import re
+import stat
+import sys
+import tarfile
+import urllib.parse
+import urllib.request
+import zipfile
+from optparse import OptionParser
+from pathlib import Path
+from shutil import copy, copytree, move, rmtree, which
+from subprocess import Popen
+
+from redo import retry
+
+logging.basicConfig(
+ stream=sys.stdout,
+ level=logging.INFO,
+ format="%(asctime)-15s - %(levelname)s - %(message)s",
+)
+log = logging.getLogger(__name__)
+
+
+# Set default values.
+PARTNERS_DIR = Path("..") / ".." / "workspace" / "partners"
+# No platform in this path because script only supports repacking a single platform at once
+DEFAULT_OUTPUT_DIR = "%(partner)s/%(partner_distro)s/%(locale)s"
+TASKCLUSTER_ARTIFACTS = (
+ os.environ.get("TASKCLUSTER_ROOT_URL", "https://firefox-ci-tc.services.mozilla.com")
+ + "/api/queue/v1/task/{taskId}/artifacts"
+)
+UPSTREAM_ENUS_PATH = "public/build/{filename}"
+UPSTREAM_L10N_PATH = "public/build/{locale}/{filename}"
+
+WINDOWS_DEST_DIR = Path("firefox")
+MAC_DEST_DIR = Path("Contents/Resources")
+LINUX_DEST_DIR = Path("firefox")
+
+BOUNCER_PRODUCT_TEMPLATE = (
+ "partner-firefox-{release_type}-{partner}-{partner_distro}-latest"
+)
+
+
+class StrictFancyURLopener(urllib.request.FancyURLopener):
+ """Unlike FancyURLopener this class raises exceptions for generic HTTP
+ errors, like 404, 500. It reuses URLopener.http_error_default redefined in
+ FancyURLopener"""
+
+ def http_error_default(self, url, fp, errcode, errmsg, headers):
+ urllib.request.URLopener.http_error_default(
+ self, url, fp, errcode, errmsg, headers
+ )
+
+
+def rmdirRecursive(directory: Path):
+ """
+ This is similar to a call of shutil.rmtree(), except that it
+ should work better on Windows since it will more aggressively
+ attempt to remove files marked as "read-only".
+ """
+
+ def rmdir_including_read_only(func, path: str, exc_info):
+ """
+ Source: https://stackoverflow.com/a/4829285
+ path contains the path of the file that couldn't be removed.
+ Let's just assume that it's read-only and unlink it.
+ """
+ path = Path(path)
+
+ path.chmod(mode=stat.S_IWRITE)
+ path.unlink()
+
+ rmtree(str(directory), onerror=rmdir_including_read_only)
+
+
+def printSeparator():
+ log.info("##################################################")
+
+
+def shellCommand(cmd):
+ log.debug("Executing %s" % cmd)
+ log.debug(f"in {Path.cwd()}")
+ # Shell command output gets dumped immediately to stdout, whereas
+ # print statements get buffered unless we flush them explicitly.
+ sys.stdout.flush()
+ p = Popen(cmd, shell=True)
+ (_, ret) = os.waitpid(p.pid, 0)
+ if ret != 0:
+ ret_real = (ret & 0xFF00) >> 8
+ log.error("Error: shellCommand had non-zero exit status: %d" % ret_real)
+ log.error("Command: %s" % cmd, exc_info=True)
+ sys.exit(ret_real)
+ return True
+
+
+def isLinux(platform: str):
+ return "linux" in platform
+
+
+def isLinux32(platform: str):
+ return "linux32" in platform or "linux-i686" in platform or platform == "linux"
+
+
+def isLinux64(platform: str):
+ return "linux64" in platform or "linux-x86_64" in platform
+
+
+def isMac(platform: str):
+ return "mac" in platform
+
+
+def isWin(platform: str):
+ return "win" in platform
+
+
+def isWin32(platform: str):
+ return "win32" in platform
+
+
+def isWin64(platform: str):
+ return platform == "win64"
+
+
+def isWin64Aarch64(platform: str):
+ return platform == "win64-aarch64"
+
+
+def isValidPlatform(platform: str):
+ return (
+ isLinux64(platform)
+ or isLinux32(platform)
+ or isMac(platform)
+ or isWin64(platform)
+ or isWin64Aarch64(platform)
+ or isWin32(platform)
+ )
+
+
+def parseRepackConfig(file: Path, platform: str):
+ """Did you hear about this cool file format called yaml ? json ? Yeah, me neither"""
+ config = {}
+ config["platforms"] = []
+ for line in file.open():
+ line = line.rstrip("\n")
+ # Ignore empty lines
+ if line.strip() == "":
+ continue
+ # Ignore comments
+ if line.startswith("#"):
+ continue
+ [key, value] = line.split("=", 2)
+ value = value.strip('"')
+ # strings that don't need special handling
+ if key in ("dist_id", "replacement_setup_exe"):
+ config[key] = value
+ continue
+ # booleans that don't need special handling
+ if key in ("migrationWizardDisabled", "oem", "repack_stub_installer"):
+ if value.lower() == "true":
+ config[key] = True
+ continue
+ # special cases
+ if key == "locales":
+ config["locales"] = value.split(" ")
+ continue
+ if key.startswith("locale."):
+ config[key] = value
+ continue
+ if key == "deb_section":
+ config["deb_section"] = re.sub("/", "\/", value)
+ continue
+ if isValidPlatform(key):
+ ftp_platform = getFtpPlatform(key)
+ if ftp_platform == getFtpPlatform(platform) and value.lower() == "true":
+ config["platforms"].append(ftp_platform)
+ continue
+
+ # this only works for one locale because setup.exe is localised
+ if config.get("replacement_setup_exe") and len(config.get("locales", [])) > 1:
+ log.error(
+ "Error: replacement_setup_exe is only supported for one locale, got %s"
+ % config["locales"]
+ )
+ sys.exit(1)
+ # also only works for one platform because setup.exe is platform-specific
+
+ if config["platforms"]:
+ return config
+
+
+def getFtpPlatform(platform: str):
+ """Returns the platform in the format used in building package names.
+ Note: we rely on this code being idempotent
+ i.e. getFtpPlatform(getFtpPlatform(foo)) should work
+ """
+ if isLinux64(platform):
+ return "linux-x86_64"
+ if isLinux(platform):
+ return "linux-i686"
+ if isMac(platform):
+ return "mac"
+ if isWin64Aarch64(platform):
+ return "win64-aarch64"
+ if isWin64(platform):
+ return "win64"
+ if isWin32(platform):
+ return "win32"
+
+
+def getFileExtension(platform: str):
+ """The extension for the output file, which may be passed to the internal-signing task"""
+ if isLinux(platform):
+ return "tar.bz2"
+ elif isMac(platform):
+ return "tar.gz"
+ elif isWin(platform):
+ return "zip"
+
+
+def getFilename(platform: str):
+ """Returns the filename to be repacked for the platform"""
+ return f"target.{getFileExtension(platform)}"
+
+
+def getAllFilenames(platform: str, repack_stub_installer):
+ """Returns the full list of filenames we want to downlaod for each platform"""
+ file_names = [getFilename(platform)]
+ if isWin(platform):
+ # we want to copy forward setup.exe from upstream tasks to make it easier to repackage
+ # windows installers later
+ file_names.append("setup.exe")
+ # Same for the stub installer with setup-stub.exe, but only in win32 repack jobs
+ if isWin32(platform) and repack_stub_installer:
+ file_names.append("setup-stub.exe")
+ return tuple(file_names)
+
+
+def getTaskArtifacts(taskId):
+ try:
+ retrieveFile(
+ TASKCLUSTER_ARTIFACTS.format(taskId=taskId), Path("tc_artifacts.json")
+ )
+ tc_index = json.load(open("tc_artifacts.json"))
+ return tc_index["artifacts"]
+ except (ValueError, KeyError):
+ log.error("Failed to get task artifacts from TaskCluster")
+ raise
+
+
+def getUpstreamArtifacts(upstream_tasks, repack_stub_installer):
+ useful_artifacts = getAllFilenames(options.platform, repack_stub_installer)
+
+ artifact_ids = {}
+ for taskId in upstream_tasks:
+ for artifact in getTaskArtifacts(taskId):
+ name = artifact["name"]
+ if not name.endswith(useful_artifacts):
+ continue
+ if name in artifact_ids:
+ log.error(
+ "Duplicated artifact %s processing tasks %s & %s",
+ name,
+ taskId,
+ artifacts[name],
+ )
+ sys.exit(1)
+ else:
+ artifact_ids[name] = taskId
+ log.debug(
+ "Found artifacts: %s" % json.dumps(artifact_ids, indent=4, sort_keys=True)
+ )
+ return artifact_ids
+
+
+def getArtifactNames(platform: str, locale, repack_stub_installer):
+ file_names = getAllFilenames(platform, repack_stub_installer)
+ if locale == "en-US":
+ names = [UPSTREAM_ENUS_PATH.format(filename=f) for f in file_names]
+ else:
+ names = [
+ UPSTREAM_L10N_PATH.format(locale=locale, filename=f) for f in file_names
+ ]
+ return names
+
+
+def retrieveFile(url, file_path: Path):
+ success = True
+ url = urllib.parse.quote(url, safe=":/")
+ log.info(f"Downloading from {url}")
+ log.info(f"To: {file_path}")
+ log.info(f"CWD: {Path.cwd()}")
+ try:
+ # use URLopener, which handles errors properly
+ retry(
+ StrictFancyURLopener().retrieve,
+ kwargs=dict(url=url, filename=str(file_path)),
+ )
+ except IOError:
+ log.error("Error downloading %s" % url, exc_info=True)
+ success = False
+ try:
+ file_path.unlink()
+ except OSError:
+ log.info(f"Cannot remove {file_path}", exc_info=True)
+
+ return success
+
+
+def getBouncerProduct(partner, partner_distro):
+ if "RELEASE_TYPE" not in os.environ:
+ log.fatal("RELEASE_TYPE must be set in the environment")
+ sys.exit(1)
+ release_type = os.environ["RELEASE_TYPE"]
+ # For X.0 releases we get 'release-rc' but the alias should use 'release'
+ if release_type == "release-rc":
+ release_type = "release"
+ return BOUNCER_PRODUCT_TEMPLATE.format(
+ release_type=release_type,
+ partner=partner,
+ partner_distro=partner_distro,
+ )
+
+
+class RepackBase(object):
+ def __init__(
+ self,
+ build: str,
+ partner_dir: Path,
+ build_dir: Path,
+ final_dir: Path,
+ ftp_platform: str,
+ repack_info,
+ file_mode=0o644,
+ quiet=False,
+ source_locale=None,
+ locale=None,
+ ):
+ self.base_dir = Path.cwd()
+ self.build = build
+ self.full_build_path = build_dir / build
+ if not self.full_build_path.is_absolute():
+ self.full_build_path = self.base_dir / self.full_build_path
+ self.full_partner_path = self.base_dir / partner_dir
+ self.working_dir = final_dir / "working"
+ self.final_dir = final_dir
+ self.final_build = final_dir / Path(build).name
+ self.ftp_platform = ftp_platform
+ self.repack_info = repack_info
+ self.file_mode = file_mode
+ self.quiet = quiet
+ self.source_locale = source_locale
+ self.locale = locale
+ self.working_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+
+ def announceStart(self):
+ log.info(
+ "Repacking %s %s build %s" % (self.ftp_platform, self.locale, self.build)
+ )
+
+ def announceSuccess(self):
+ log.info(
+ "Done repacking %s %s build %s"
+ % (self.ftp_platform, self.locale, self.build)
+ )
+
+ def unpackBuild(self):
+ copy(str(self.full_build_path), ".")
+
+ def createOverrideIni(self, partner_path: Path):
+ """If this is a partner specific locale (like en-HK), set the
+ distribution.ini to use that locale, not the default locale.
+ """
+ if self.locale != self.source_locale:
+ file_path = partner_path / "distribution" / "distribution.ini"
+ with file_path.open(file_path.is_file() and "a" or "w") as open_file:
+ open_file.write("[Locale]\n")
+ open_file.write("locale=" + self.locale + "\n")
+
+ """ Some partners need to override the migration wizard. This is done
+ by adding an override.ini file to the base install dir.
+ """
+ # modify distribution.ini if 44 or later and we have migrationWizardDisabled
+ if int(options.version.split(".")[0]) >= 44:
+ file_path = partner_path / "distribution" / "distribution.ini"
+ with file_path.open() as open_file:
+ ini = open_file.read()
+
+ if ini.find("EnableProfileMigrator") >= 0:
+ return
+ else:
+ browser_dir = partner_path / "browser"
+ if not browser_dir.exists():
+ browser_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+ file_path = browser_dir / "override.ini"
+ if "migrationWizardDisabled" in self.repack_info:
+ log.info("Adding EnableProfileMigrator to %r" % (file_path,))
+ with file_path.open(file_path.is_file() and "a" or "w") as open_file:
+ open_file.write("[XRE]\n")
+ open_file.write("EnableProfileMigrator=0\n")
+
+ def copyFiles(self, platform_dir: Path):
+ log.info(f"Copying files into {platform_dir}")
+ # Check whether we've already copied files over for this partner.
+ if not platform_dir.exists():
+ platform_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+ for i in ["distribution", "extensions"]:
+ full_path = self.full_partner_path / i
+ if full_path.exists():
+ copytree(str(full_path), str(platform_dir / i))
+ self.createOverrideIni(platform_dir)
+
+ def repackBuild(self):
+ pass
+
+ def stage(self):
+ move(self.build, str(self.final_dir))
+ self.final_build.chmod(self.file_mode)
+
+ def cleanup(self):
+ self.final_build.unlink()
+
+ def doRepack(self):
+ self.announceStart()
+ os.chdir(self.working_dir)
+ self.unpackBuild()
+ self.copyFiles()
+ self.repackBuild()
+ self.stage()
+ os.chdir(self.base_dir)
+ rmdirRecursive(self.working_dir)
+ self.announceSuccess()
+
+
+class RepackLinux(RepackBase):
+ def __init__(
+ self,
+ build: str,
+ partner_dir: Path,
+ build_dir: Path,
+ final_dir: Path,
+ ftp_platform: str,
+ repack_info,
+ **kwargs,
+ ):
+ super(RepackLinux, self).__init__(
+ build,
+ partner_dir,
+ build_dir,
+ final_dir,
+ ftp_platform,
+ repack_info,
+ **kwargs,
+ )
+ self.uncompressed_build = build.replace(".bz2", "")
+
+ def unpackBuild(self):
+ super(RepackLinux, self).unpackBuild()
+ bunzip2_cmd = "bunzip2 %s" % self.build
+ shellCommand(bunzip2_cmd)
+ if not Path(self.uncompressed_build).exists():
+ log.error(f"Error: Unable to uncompress build {self.build}")
+ sys.exit(1)
+
+ def copyFiles(self):
+ super(RepackLinux, self).copyFiles(LINUX_DEST_DIR)
+
+ def repackBuild(self):
+ if options.quiet:
+ tar_flags = "rf"
+ else:
+ tar_flags = "rvf"
+ tar_cmd = "tar %s %s %s" % (tar_flags, self.uncompressed_build, LINUX_DEST_DIR)
+ shellCommand(tar_cmd)
+ bzip2_command = "bzip2 %s" % self.uncompressed_build
+ shellCommand(bzip2_command)
+
+
+class RepackMac(RepackBase):
+ def __init__(
+ self,
+ build: str,
+ partner_dir: Path,
+ build_dir: Path,
+ final_dir: Path,
+ ftp_platform: str,
+ repack_info,
+ **kwargs,
+ ):
+ super(RepackMac, self).__init__(
+ build,
+ partner_dir,
+ build_dir,
+ final_dir,
+ ftp_platform,
+ repack_info,
+ **kwargs,
+ )
+ self.uncompressed_build = build.replace(".gz", "")
+
+ def unpackBuild(self):
+ super(RepackMac, self).unpackBuild()
+ gunzip_cmd = "gunzip %s" % self.build
+ shellCommand(gunzip_cmd)
+ if not Path(self.uncompressed_build).exists():
+ log.error(f"Error: Unable to uncompress build {self.build}")
+ sys.exit(1)
+ self.appName = self.getAppName()
+
+ def getAppName(self):
+ # Cope with Firefox.app vs Firefox Nightly.app by returning the first root object/folder found
+ t = tarfile.open(self.build.rsplit(".", 1)[0])
+ for name in t.getnames():
+ root_object = name.split("/")[0]
+ if root_object.endswith(".app"):
+ log.info(f"Found app name in tarball: {root_object}")
+ return root_object
+ log.error(
+ f"Error: Unable to determine app name from tarball: {self.build} - Expected .app in root"
+ )
+ sys.exit(1)
+
+ def copyFiles(self):
+ super(RepackMac, self).copyFiles(Path(self.appName) / MAC_DEST_DIR)
+
+ def repackBuild(self):
+ if options.quiet:
+ tar_flags = "rf"
+ else:
+ tar_flags = "rvf"
+ # the final arg is quoted because it may contain a space, eg Firefox Nightly.app/....
+ tar_cmd = "tar %s %s '%s'" % (
+ tar_flags,
+ self.uncompressed_build,
+ Path(self.appName) / MAC_DEST_DIR,
+ )
+ shellCommand(tar_cmd)
+ gzip_command = "gzip %s" % self.uncompressed_build
+ shellCommand(gzip_command)
+
+
+class RepackWin(RepackBase):
+ def __init__(
+ self,
+ build: str,
+ partner_dir: Path,
+ build_dir: Path,
+ final_dir: Path,
+ ftp_platform: str,
+ repack_info,
+ **kwargs,
+ ):
+ super(RepackWin, self).__init__(
+ build,
+ partner_dir,
+ build_dir,
+ final_dir,
+ ftp_platform,
+ repack_info,
+ **kwargs,
+ )
+
+ def copyFiles(self):
+ super(RepackWin, self).copyFiles(WINDOWS_DEST_DIR)
+
+ def repackBuild(self):
+ if options.quiet:
+ zip_flags = "-rq"
+ else:
+ zip_flags = "-r"
+ zip_cmd = f"zip {zip_flags} {self.build} {WINDOWS_DEST_DIR}"
+ shellCommand(zip_cmd)
+
+ # we generate the stub installer during the win32 build, so repack it on win32 too
+ if isWin32(options.platform) and self.repack_info.get("repack_stub_installer"):
+ log.info("Creating target-stub.zip to hold custom urls")
+ dest = str(self.final_build).replace("target.zip", "target-stub.zip")
+ z = zipfile.ZipFile(dest, "w")
+ # load the partner.ini template and interpolate %LOCALE% to the actual locale
+ with (self.full_partner_path / "stub" / "partner.ini").open() as open_file:
+ partner_ini_template = open_file.readlines()
+ partner_ini = ""
+ for l in partner_ini_template:
+ l = l.replace("%LOCALE%", self.locale)
+ l = l.replace("%BOUNCER_PRODUCT%", self.repack_info["bouncer_product"])
+ partner_ini += l
+ z.writestr("partner.ini", partner_ini)
+ # we need an empty firefox directory to use the repackage code
+ d = zipfile.ZipInfo("firefox/")
+ # https://stackoverflow.com/a/6297838, zip's representation of drwxr-xr-x permissions
+ # is 040755 << 16L, bitwise OR with 0x10 for the MS-DOS directory flag
+ d.external_attr = 1106051088
+ z.writestr(d, "")
+ z.close()
+
+ def stage(self):
+ super(RepackWin, self).stage()
+ setup_dest = Path(str(self.final_build).replace("target.zip", "setup.exe"))
+ if "replacement_setup_exe" in self.repack_info:
+ log.info("Overriding setup.exe with custom copy")
+ retrieveFile(self.repack_info["replacement_setup_exe"], setup_dest)
+ else:
+ # otherwise copy forward the vanilla copy
+ log.info("Copying vanilla setup.exe forward for installer creation")
+ setup = str(self.full_build_path).replace("target.zip", "setup.exe")
+ copy(setup, str(setup_dest))
+ setup_dest.chmod(self.file_mode)
+
+ # we generate the stub installer in the win32 build, so repack it on win32 too
+ if isWin32(options.platform) and self.repack_info.get("repack_stub_installer"):
+ log.info(
+ "Copying vanilla setup-stub.exe forward for stub installer creation"
+ )
+ setup_dest = Path(
+ str(self.final_build).replace("target.zip", "setup-stub.exe")
+ )
+ setup_source = str(self.full_build_path).replace(
+ "target.zip", "setup-stub.exe"
+ )
+ copy(setup_source, str(setup_dest))
+ setup_dest.chmod(self.file_mode)
+
+
+if __name__ == "__main__":
+ error = False
+ partner_builds = {}
+ repack_build = {
+ "linux-i686": RepackLinux,
+ "linux-x86_64": RepackLinux,
+ "mac": RepackMac,
+ "win32": RepackWin,
+ "win64": RepackWin,
+ "win64-aarch64": RepackWin,
+ }
+
+ parser = OptionParser(usage="usage: %prog [options]")
+ parser.add_option(
+ "-d",
+ "--partners-dir",
+ dest="partners_dir",
+ default=str(PARTNERS_DIR),
+ help="Specify the directory where the partner config files are found",
+ )
+ parser.add_option(
+ "-p",
+ "--partner",
+ dest="partner",
+ help="Repack for a single partner, specified by name",
+ )
+ parser.add_option(
+ "-v", "--version", dest="version", help="Set the version number for repacking"
+ )
+ parser.add_option(
+ "-n",
+ "--build-number",
+ dest="build_number",
+ default=1,
+ help="Set the build number for repacking",
+ )
+ parser.add_option("--platform", dest="platform", help="Set the platform to repack")
+ parser.add_option(
+ "--include-oem",
+ action="store_true",
+ dest="include_oem",
+ default=False,
+ help="Process partners marked as OEM (these are usually one-offs)",
+ )
+ parser.add_option(
+ "-q",
+ "--quiet",
+ action="store_true",
+ dest="quiet",
+ default=False,
+ help="Suppress standard output from the packaging tools",
+ )
+ parser.add_option(
+ "--taskid",
+ action="append",
+ dest="upstream_tasks",
+ help="Specify taskIds for upstream artifacts, using 'internal sign' tasks. Multiples "
+ "expected, e.g. --taskid foo --taskid bar. Alternatively, use a space-separated list "
+ "stored in UPSTREAM_TASKIDS in the environment.",
+ )
+ parser.add_option(
+ "-l",
+ "--limit-locale",
+ action="append",
+ dest="limit_locales",
+ default=[],
+ )
+
+ (options, args) = parser.parse_args()
+
+ if not options.quiet:
+ log.setLevel(logging.DEBUG)
+ else:
+ log.setLevel(logging.WARNING)
+
+ options.partners_dir = Path(options.partners_dir.rstrip("/"))
+ if not options.partners_dir.is_dir():
+ log.error(f"Error: partners dir {options.partners_dir} is not a directory.")
+ error = True
+
+ if not options.version:
+ log.error("Error: you must specify a version number.")
+ error = True
+
+ if not options.platform:
+ log.error("No platform specified.")
+ error = True
+
+ if not isValidPlatform(options.platform):
+ log.error("Invalid platform %s." % options.platform)
+ error = True
+
+ upstream_tasks = options.upstream_tasks or os.getenv("UPSTREAM_TASKIDS")
+ if not upstream_tasks:
+ log.error(
+ "upstream tasks should be defined using --taskid args or "
+ "UPSTREAM_TASKIDS in env."
+ )
+ error = True
+
+ for tool in ("tar", "bunzip2", "bzip2", "gunzip", "gzip", "zip"):
+ if not which(tool):
+ log.error(f"Error: couldn't find the {tool} executable in PATH.")
+ error = True
+
+ if error:
+ sys.exit(1)
+
+ base_workdir = Path.cwd()
+
+ # Look up the artifacts available on our upstreams, but only if we need to
+ artifact_ids = {}
+
+ # Local directories for builds
+ script_directory = Path.cwd()
+ original_builds_dir = (
+ script_directory
+ / "original_builds"
+ / options.version
+ / f"build{options.build_number}"
+ )
+ repack_version = f"{options.version}-{options.build_number}"
+ if os.getenv("MOZ_AUTOMATION"):
+ # running in production
+ repacked_builds_dir = Path("/builds/worker/artifacts")
+ else:
+ # local development
+ repacked_builds_dir = script_directory / "artifacts"
+ original_builds_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+ repacked_builds_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+ printSeparator()
+
+ # For each partner in the partners dir
+ # Read/check the config file
+ # Download required builds (if not already on disk)
+ # Perform repacks
+
+ # walk the partner dirs, find valid repack.cfg configs, and load them
+ partner_dirs = []
+ need_stub_installers = False
+ for root, _, all_files in os.walk(options.partners_dir):
+ root = root.lstrip("/")
+ partner = root[len(str(options.partners_dir)) + 1 :].split("/")[0]
+ partner_distro = os.path.split(root)[-1]
+ if options.partner:
+ if (
+ options.partner != partner
+ and options.partner != partner_distro[: len(options.partner)]
+ ):
+ continue
+
+ for file in all_files:
+ if file == "repack.cfg":
+ log.debug(
+ "Found partner config: {} ['{}'] {}".format(
+ root, "', '".join(_), file
+ )
+ )
+ root = Path(root)
+ repack_cfg = root / file
+ repack_info = parseRepackConfig(repack_cfg, options.platform)
+ if not repack_info:
+ log.debug(
+ "no repack_info for platform %s in %s, skipping"
+ % (options.platform, repack_cfg)
+ )
+ continue
+ if repack_info.get("repack_stub_installer"):
+ need_stub_installers = True
+ repack_info["bouncer_product"] = getBouncerProduct(
+ partner, partner_distro
+ )
+ partner_dirs.append((partner, partner_distro, root, repack_info))
+
+ log.info("Retrieving artifact lists from upstream tasks")
+ artifact_ids = getUpstreamArtifacts(upstream_tasks, need_stub_installers)
+ if not artifact_ids:
+ log.fatal("No upstream artifacts were found")
+ sys.exit(1)
+
+ for partner, partner_distro, full_partner_dir, repack_info in partner_dirs:
+ log.info(
+ "Starting repack process for partner: %s/%s" % (partner, partner_distro)
+ )
+ if "oem" in repack_info and options.include_oem is False:
+ log.info(
+ "Skipping partner: %s - marked as OEM and --include-oem was not set"
+ % partner
+ )
+ continue
+
+ repack_stub_installer = repack_info.get("repack_stub_installer")
+ # where everything ends up
+ partner_repack_dir = repacked_builds_dir / DEFAULT_OUTPUT_DIR
+
+ # Figure out which base builds we need to repack.
+ for locale in repack_info["locales"]:
+ if options.limit_locales and locale not in options.limit_locales:
+ log.info("Skipping %s because it is not in limit_locales list", locale)
+ continue
+ source_locale = locale
+ # Partner has specified a different locale to
+ # use as the base for their custom locale.
+ if "locale." + locale in repack_info:
+ source_locale = repack_info["locale." + locale]
+ for platform in repack_info["platforms"]:
+ # ja-JP-mac only exists for Mac, so skip non-existent
+ # platform/locale combos.
+ if (source_locale == "ja" and isMac(platform)) or (
+ source_locale == "ja-JP-mac" and not isMac(platform)
+ ):
+ continue
+ ftp_platform = getFtpPlatform(platform)
+
+ local_filepath = original_builds_dir / ftp_platform / locale
+ local_filepath.mkdir(mode=0o755, exist_ok=True, parents=True)
+ final_dir = Path(
+ str(partner_repack_dir)
+ % dict(
+ partner=partner,
+ partner_distro=partner_distro,
+ locale=locale,
+ )
+ )
+ if final_dir.exists():
+ rmdirRecursive(final_dir)
+ final_dir.mkdir(mode=0o755, exist_ok=True, parents=True)
+
+ # for the main repacking artifact
+ file_name = getFilename(ftp_platform)
+ local_filename = local_filepath / file_name
+
+ # Check to see if this build is already on disk, i.e.
+ # has already been downloaded.
+ artifacts = getArtifactNames(platform, locale, repack_stub_installer)
+ for artifact in artifacts:
+ local_artifact = local_filepath / Path(artifact).name
+ if local_artifact.exists():
+ log.info(f"Found {local_artifact} on disk, not downloading")
+ continue
+
+ if artifact not in artifact_ids:
+ log.fatal(
+ "Can't determine what taskID to retrieve %s from", artifact
+ )
+ sys.exit(1)
+ original_build_url = "%s/%s" % (
+ TASKCLUSTER_ARTIFACTS.format(taskId=artifact_ids[artifact]),
+ artifact,
+ )
+ retrieveFile(original_build_url, local_artifact)
+
+ # Make sure we have the local file now
+ if not local_filename.exists():
+ log.info(f"Error: Unable to retrieve {file_name}\n")
+ sys.exit(1)
+
+ repackObj = repack_build[ftp_platform](
+ file_name,
+ full_partner_dir,
+ local_filepath,
+ final_dir,
+ ftp_platform,
+ repack_info,
+ locale=locale,
+ source_locale=source_locale,
+ )
+ repackObj.doRepack()
diff --git a/python/mozrelease/mozrelease/paths.py b/python/mozrelease/mozrelease/paths.py
new file mode 100644
index 0000000000..b3d48c4ac7
--- /dev/null
+++ b/python/mozrelease/mozrelease/paths.py
@@ -0,0 +1,85 @@
+# 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 six.moves.urllib.parse import urlunsplit
+
+product_ftp_map = {
+ "fennec": "mobile",
+}
+
+
+def product2ftp(product):
+ return product_ftp_map.get(product, product)
+
+
+def getCandidatesDir(product, version, buildNumber, protocol=None, server=None):
+ if protocol:
+ assert server is not None, "server is required with protocol"
+
+ product = product2ftp(product)
+ directory = "/{}/candidates/{}-candidates/build{}".format(
+ product,
+ str(version),
+ str(buildNumber),
+ )
+
+ if protocol:
+ return urlunsplit((protocol, server, directory, None, None))
+ else:
+ return directory
+
+
+def getReleasesDir(product, version=None, protocol=None, server=None):
+ if protocol:
+ assert server is not None, "server is required with protocol"
+
+ directory = "/{}/releases".format(product)
+ if version:
+ directory = "{}/{}".format(directory, version)
+
+ if protocol:
+ return urlunsplit((protocol, server, directory, None, None))
+ else:
+ return directory
+
+
+def getReleaseInstallerPath(productName, brandName, version, platform, locale="en-US"):
+ if productName not in ("fennec",):
+ if platform.startswith("linux"):
+ return "/".join(
+ [
+ p.strip("/")
+ for p in [
+ platform,
+ locale,
+ "%s-%s.tar.bz2" % (productName, version),
+ ]
+ ]
+ )
+ elif "mac" in platform:
+ return "/".join(
+ [
+ p.strip("/")
+ for p in [platform, locale, "%s %s.dmg" % (brandName, version)]
+ ]
+ )
+ elif platform.startswith("win"):
+ return "/".join(
+ [
+ p.strip("/")
+ for p in [
+ platform,
+ locale,
+ "%s Setup %s.exe" % (brandName, version),
+ ]
+ ]
+ )
+ else:
+ raise "Unsupported platform"
+ else:
+ if platform.startswith("android"):
+ filename = "%s-%s.%s.android-arm.apk" % (productName, version, locale)
+ return "/".join([p.strip("/") for p in [platform, locale, filename]])
+ else:
+ raise "Unsupported platform"
diff --git a/python/mozrelease/mozrelease/platforms.py b/python/mozrelease/mozrelease/platforms.py
new file mode 100644
index 0000000000..2970725a73
--- /dev/null
+++ b/python/mozrelease/mozrelease/platforms.py
@@ -0,0 +1,54 @@
+# 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/.
+
+update_platform_map = {
+ "android": ["Android_arm-eabi-gcc3"],
+ "android-arm": ["Android_arm-eabi-gcc3"],
+ "android-x86": ["Android_x86-gcc3"],
+ "android-x86_64": ["Android_x86-64-gcc3"],
+ "android-aarch64": ["Android_aarch64-gcc3"],
+ "linux-i686": ["Linux_x86-gcc3"],
+ "linux-x86_64": ["Linux_x86_64-gcc3"],
+ "mac": [
+ "Darwin_x86_64-gcc3-u-i386-x86_64",
+ "Darwin_x86-gcc3-u-i386-x86_64",
+ "Darwin_aarch64-gcc3",
+ "Darwin_x86-gcc3",
+ "Darwin_x86_64-gcc3",
+ ],
+ "win32": ["WINNT_x86-msvc", "WINNT_x86-msvc-x86", "WINNT_x86-msvc-x64"],
+ "win64": ["WINNT_x86_64-msvc", "WINNT_x86_64-msvc-x64"],
+ "win64-aarch64": ["WINNT_aarch64-msvc-aarch64"],
+}
+
+# ftp -> shipped locales map
+sl_platform_map = {
+ "linux-i686": "linux",
+ "linux-x86_64": "linux",
+ "mac": "osx",
+ "win32": "win32",
+ "win64": "win64",
+}
+
+# ftp -> info file platform map
+info_file_platform_map = {
+ "linux-i686": "linux",
+ "linux-x86_64": "linux64",
+ "mac": "macosx64",
+ "win32": "win32",
+ "win64": "win64",
+ "win64-aarch64": "win64_aarch64",
+}
+
+
+def ftp2updatePlatforms(platform):
+ return update_platform_map[platform]
+
+
+def ftp2shippedLocales(platform):
+ return sl_platform_map.get(platform, platform)
+
+
+def ftp2infoFile(platform):
+ return info_file_platform_map.get(platform, platform)
diff --git a/python/mozrelease/mozrelease/scriptworker_canary.py b/python/mozrelease/mozrelease/scriptworker_canary.py
new file mode 100644
index 0000000000..dabdc6868d
--- /dev/null
+++ b/python/mozrelease/mozrelease/scriptworker_canary.py
@@ -0,0 +1,107 @@
+# -*- 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/.
+
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+
+import taskcluster
+from appdirs import user_config_dir
+from gecko_taskgraph import GECKO
+from mach.base import FailedCommandError
+
+logger = logging.getLogger(__name__)
+
+
+TASK_TYPES = {
+ "signing": ["linux-signing", "linux-signing-partial"],
+ "beetmover": ["beetmover-candidates"],
+ "bouncer": ["bouncer-submit"],
+ "balrog": ["balrog-submit"],
+ "tree": ["tree"],
+}
+
+
+def get_secret(secret):
+ # use proxy if configured, otherwise use local credentials from env vars
+ if "TASKCLUSTER_PROXY_URL" in os.environ:
+ secrets_options = {"rootUrl": os.environ["TASKCLUSTER_PROXY_URL"]}
+ else:
+ secrets_options = taskcluster.optionsFromEnvironment()
+ secrets = taskcluster.Secrets(secrets_options)
+ return secrets.get(secret)["secret"]
+
+
+@contextmanager
+def configure_ssh(ssh_key_secret):
+ if ssh_key_secret is None:
+ yield
+
+ # If we get here, we are running in automation.
+ # We use a user hgrc, so that we also get the system-wide hgrc settings.
+ hgrc = Path(user_config_dir("hg")) / "hgrc"
+ if hgrc.exists():
+ raise FailedCommandError(f"Not overwriting `{hgrc}`; cannot configure ssh.")
+
+ try:
+ ssh_key_dir = Path(tempfile.mkdtemp())
+
+ ssh_key = get_secret(ssh_key_secret)
+ ssh_key_file = ssh_key_dir / "id_rsa"
+ ssh_key_file.write_text(ssh_key["ssh_privkey"])
+ ssh_key_file.chmod(0o600)
+
+ hgrc_content = (
+ "[ui]\n"
+ "username = trybld\n"
+ "ssh = ssh -i {path} -l {user}\n".format(
+ path=ssh_key_file, user=ssh_key["user"]
+ )
+ )
+ hgrc.write_text(hgrc_content)
+
+ yield
+ finally:
+ shutil.rmtree(str(ssh_key_dir))
+ hgrc.unlink()
+
+
+def push_canary(scriptworkers, addresses, ssh_key_secret):
+ if ssh_key_secret and os.environ.get("MOZ_AUTOMATION", "0") != "1":
+ # We make assumptions about the layout of the docker image
+ # for creating the hgrc that we use for the key.
+ raise FailedCommandError("Cannot use ssh-key-secret outside of automation.")
+
+ # Collect the set of `mach try scriptworker` task sets to run.
+ tasks = []
+ for scriptworker in scriptworkers:
+ worker_tasks = TASK_TYPES.get(scriptworker)
+ if worker_tasks:
+ logger.info("Running tasks for {}: {}".format(scriptworker, worker_tasks))
+ tasks.extend(worker_tasks)
+ else:
+ logger.info("No tasks for {}.".format(scriptworker))
+
+ mach = Path(GECKO) / "mach"
+ base_command = [str(mach), "try", "scriptworker"]
+ for address in addresses:
+ base_command.extend(
+ [
+ "--route",
+ "notify.email.{}.on-failed".format(address),
+ "--route",
+ "notify.email.{}.on-exception".format(address),
+ ]
+ )
+
+ with configure_ssh(ssh_key_secret):
+ env = os.environ.copy()
+ for task in tasks:
+ subprocess.check_call(base_command + [task], env=env)
diff --git a/python/mozrelease/mozrelease/update_verify.py b/python/mozrelease/mozrelease/update_verify.py
new file mode 100644
index 0000000000..49fe21db15
--- /dev/null
+++ b/python/mozrelease/mozrelease/update_verify.py
@@ -0,0 +1,275 @@
+# -*- 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/.
+
+import os
+import re
+
+from six import string_types
+
+from .chunking import getChunk
+
+
+class UpdateVerifyError(Exception):
+ pass
+
+
+class UpdateVerifyConfig(object):
+ comment_regex = re.compile("^#")
+ key_write_order = (
+ "release",
+ "product",
+ "platform",
+ "build_id",
+ "locales",
+ "channel",
+ "patch_types",
+ "from",
+ "aus_server",
+ "ftp_server_from",
+ "ftp_server_to",
+ "to",
+ "mar_channel_IDs",
+ "override_certs",
+ "to_build_id",
+ "to_display_version",
+ "to_app_version",
+ "updater_package",
+ )
+ global_keys = (
+ "product",
+ "channel",
+ "aus_server",
+ "to",
+ "to_build_id",
+ "to_display_version",
+ "to_app_version",
+ "override_certs",
+ )
+ release_keys = (
+ "release",
+ "build_id",
+ "locales",
+ "patch_types",
+ "from",
+ "ftp_server_from",
+ "ftp_server_to",
+ "mar_channel_IDs",
+ "platform",
+ "updater_package",
+ )
+ first_only_keys = (
+ "from",
+ "aus_server",
+ "to",
+ "to_build_id",
+ "to_display_version",
+ "to_app_version",
+ "override_certs",
+ )
+ compare_attrs = global_keys + ("releases",)
+
+ def __init__(
+ self,
+ product=None,
+ channel=None,
+ aus_server=None,
+ to=None,
+ to_build_id=None,
+ to_display_version=None,
+ to_app_version=None,
+ override_certs=None,
+ ):
+ self.product = product
+ self.channel = channel
+ self.aus_server = aus_server
+ self.to = to
+ self.to_build_id = to_build_id
+ self.to_display_version = to_display_version
+ self.to_app_version = to_app_version
+ self.override_certs = override_certs
+ self.releases = []
+
+ def __eq__(self, other):
+ self_list = [getattr(self, attr) for attr in self.compare_attrs]
+ other_list = [getattr(other, attr) for attr in self.compare_attrs]
+ return self_list == other_list
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def _parseLine(self, line):
+ entry = {}
+ items = re.findall(r"\w+=[\"'][^\"']*[\"']", line)
+ for i in items:
+ m = re.search(r"(?P<key>\w+)=[\"'](?P<value>.+)[\"']", i).groupdict()
+ if m["key"] not in self.global_keys and m["key"] not in self.release_keys:
+ raise UpdateVerifyError(
+ "Unknown key '%s' found on line:\n%s" % (m["key"], line)
+ )
+ if m["key"] in entry:
+ raise UpdateVerifyError(
+ "Multiple values found for key '%s' on line:\n%s" % (m["key"], line)
+ )
+ entry[m["key"]] = m["value"]
+ if not entry:
+ raise UpdateVerifyError("No parseable data in line '%s'" % line)
+ return entry
+
+ def _addEntry(self, entry, first):
+ releaseKeys = {}
+ for k, v in entry.items():
+ if k in self.global_keys:
+ setattr(self, k, entry[k])
+ elif k in self.release_keys:
+ # "from" is reserved in Python
+ if k == "from":
+ releaseKeys["from_path"] = v
+ else:
+ releaseKeys[k] = v
+ self.addRelease(**releaseKeys)
+
+ def read(self, config):
+ f = open(config)
+ # Only the first non-comment line of an update verify config should
+ # have a "from" and"ausServer". Ignore any subsequent lines with them.
+ first = True
+ for line in f.readlines():
+ # Skip comment lines
+ if self.comment_regex.search(line):
+ continue
+ self._addEntry(self._parseLine(line), first)
+ first = False
+
+ def write(self, fh):
+ first = True
+ for releaseInfo in self.releases:
+ for key in self.key_write_order:
+ if key in self.global_keys and (
+ first or key not in self.first_only_keys
+ ):
+ value = getattr(self, key)
+ elif key in self.release_keys:
+ value = releaseInfo[key]
+ else:
+ value = None
+ if value is not None:
+ fh.write(key.encode("utf-8"))
+ fh.write(b"=")
+ if isinstance(value, (list, tuple)):
+ fh.write(('"%s" ' % " ".join(value)).encode("utf-8"))
+ else:
+ fh.write(('"%s" ' % value).encode("utf-8"))
+ # Rewind one character to avoid having a trailing space
+ fh.seek(-1, os.SEEK_CUR)
+ fh.write(b"\n")
+ first = False
+
+ def addRelease(
+ self,
+ release=None,
+ build_id=None,
+ locales=[],
+ patch_types=["complete"],
+ from_path=None,
+ ftp_server_from=None,
+ ftp_server_to=None,
+ mar_channel_IDs=None,
+ platform=None,
+ updater_package=None,
+ ):
+ """Locales and patch_types can be passed as either a string or a list.
+ If a string is passed, they will be converted to a list for internal
+ storage"""
+ if self.getRelease(build_id, from_path):
+ raise UpdateVerifyError(
+ "Couldn't add release identified by build_id '%s' and from_path '%s': "
+ "already exists in config" % (build_id, from_path)
+ )
+ if isinstance(locales, string_types):
+ locales = sorted(list(locales.split()))
+ if isinstance(patch_types, string_types):
+ patch_types = list(patch_types.split())
+ self.releases.append(
+ {
+ "release": release,
+ "build_id": build_id,
+ "locales": locales,
+ "patch_types": patch_types,
+ "from": from_path,
+ "ftp_server_from": ftp_server_from,
+ "ftp_server_to": ftp_server_to,
+ "mar_channel_IDs": mar_channel_IDs,
+ "platform": platform,
+ "updater_package": updater_package,
+ }
+ )
+
+ def addLocaleToRelease(self, build_id, locale, from_path=None):
+ r = self.getRelease(build_id, from_path)
+ if not r:
+ raise UpdateVerifyError(
+ "Couldn't add '%s' to release identified by build_id '%s' and from_path '%s': "
+ "'%s' doesn't exist in this config."
+ % (locale, build_id, from_path, build_id)
+ )
+ r["locales"].append(locale)
+ r["locales"] = sorted(r["locales"])
+
+ def getRelease(self, build_id, from_path):
+ for r in self.releases:
+ if r["build_id"] == build_id and r["from"] == from_path:
+ return r
+ return {}
+
+ def getFullReleaseTests(self):
+ return [r for r in self.releases if r["from"] is not None]
+
+ def getQuickReleaseTests(self):
+ return [r for r in self.releases if r["from"] is None]
+
+ def getChunk(self, chunks, thisChunk):
+ fullTests = []
+ quickTests = []
+ for test in self.getFullReleaseTests():
+ for locale in test["locales"]:
+ fullTests.append([test["build_id"], locale, test["from"]])
+ for test in self.getQuickReleaseTests():
+ for locale in test["locales"]:
+ quickTests.append([test["build_id"], locale, test["from"]])
+ allTests = getChunk(fullTests, chunks, thisChunk)
+ allTests.extend(getChunk(quickTests, chunks, thisChunk))
+
+ newConfig = UpdateVerifyConfig(
+ self.product,
+ self.channel,
+ self.aus_server,
+ self.to,
+ self.to_build_id,
+ self.to_display_version,
+ self.to_app_version,
+ self.override_certs,
+ )
+ for t in allTests:
+ build_id, locale, from_path = t
+ if from_path == "None":
+ from_path = None
+ r = self.getRelease(build_id, from_path)
+ try:
+ newConfig.addRelease(
+ r["release"],
+ build_id,
+ locales=[],
+ ftp_server_from=r["ftp_server_from"],
+ ftp_server_to=r["ftp_server_to"],
+ patch_types=r["patch_types"],
+ from_path=from_path,
+ mar_channel_IDs=r["mar_channel_IDs"],
+ platform=r["platform"],
+ updater_package=r["updater_package"],
+ )
+ except UpdateVerifyError:
+ pass
+ newConfig.addLocaleToRelease(build_id, locale, from_path)
+ return newConfig
diff --git a/python/mozrelease/mozrelease/util.py b/python/mozrelease/mozrelease/util.py
new file mode 100644
index 0000000000..3858c40514
--- /dev/null
+++ b/python/mozrelease/mozrelease/util.py
@@ -0,0 +1,26 @@
+# -*- 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 yaml.loader import SafeLoader
+
+
+class UnicodeLoader(SafeLoader):
+ def construct_yaml_str(self, node):
+ return self.construct_scalar(node)
+
+
+UnicodeLoader.add_constructor("tag:yaml.org,2002:str", UnicodeLoader.construct_yaml_str)
+
+
+def load(stream):
+ """
+ Parse the first YAML document in a stream
+ and produce the corresponding Python object.
+ """
+ loader = UnicodeLoader(stream)
+ try:
+ return loader.get_single_data()
+ finally:
+ loader.dispose()
diff --git a/python/mozrelease/mozrelease/versions.py b/python/mozrelease/mozrelease/versions.py
new file mode 100644
index 0000000000..e3e47d4e4a
--- /dev/null
+++ b/python/mozrelease/mozrelease/versions.py
@@ -0,0 +1,114 @@
+# 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/.
+
+import re
+from distutils.version import StrictVersion
+
+from looseversion import LooseVersion
+
+
+class MozillaVersionCompareMixin:
+ def __cmp__(self, other):
+ # We expect this function to never be called.
+ raise AssertionError()
+
+ def _cmp(self, other):
+ has_esr = set()
+ if isinstance(other, LooseModernMozillaVersion) and str(other).endswith("esr"):
+ # If other version ends with esr, coerce through MozillaVersion ending up with
+ # a StrictVersion if possible
+ has_esr.add("other")
+ other = MozillaVersion(str(other)[:-3]) # strip ESR from end of string
+ if isinstance(self, LooseModernMozillaVersion) and str(self).endswith("esr"):
+ # If our version ends with esr, coerce through MozillaVersion ending up with
+ # a StrictVersion if possible
+ has_esr.add("self")
+ self = MozillaVersion(str(self)[:-3]) # strip ESR from end of string
+ if isinstance(other, LooseModernMozillaVersion) or isinstance(
+ self, LooseModernMozillaVersion
+ ):
+ # If we're still LooseVersion for self or other, run LooseVersion compare
+ # Being sure to pass through Loose Version type first
+ val = LooseVersion._cmp(
+ LooseModernMozillaVersion(str(self)),
+ LooseModernMozillaVersion(str(other)),
+ )
+ else:
+ # No versions are loose, therefore we can use StrictVersion
+ val = StrictVersion._cmp(self, other)
+ if has_esr.isdisjoint(set(["other", "self"])) or has_esr.issuperset(
+ set(["other", "self"])
+ ):
+ # If both had esr string or neither, then _cmp() was accurate
+ return val
+ elif val != 0:
+ # cmp is accurate here even if esr is present in only 1 compare, since
+ # versions are not equal
+ return val
+ elif "other" in has_esr:
+ return -1 # esr is not greater than non esr
+ return 1 # non esr is greater than esr
+
+
+class ModernMozillaVersion(MozillaVersionCompareMixin, StrictVersion):
+ """A version class that is slightly less restrictive than StrictVersion.
+ Instead of just allowing "a" or "b" as prerelease tags, it allows any
+ alpha. This allows us to support the once-shipped "3.6.3plugin1" and
+ similar versions."""
+
+ version_re = re.compile(
+ r"""^(\d+) \. (\d+) (\. (\d+))?
+ ([a-zA-Z]+(\d+))?$""",
+ re.VERBOSE,
+ )
+
+
+class AncientMozillaVersion(MozillaVersionCompareMixin, StrictVersion):
+ """A version class that is slightly less restrictive than StrictVersion.
+ Instead of just allowing "a" or "b" as prerelease tags, it allows any
+ alpha. This allows us to support the once-shipped "3.6.3plugin1" and
+ similar versions.
+ It also supports versions w.x.y.z by transmuting to w.x.z, which
+ is useful for versions like 1.5.0.x and 2.0.0.y"""
+
+ version_re = re.compile(
+ r"""^(\d+) \. (\d+) \. \d (\. (\d+))
+ ([a-zA-Z]+(\d+))?$""",
+ re.VERBOSE,
+ )
+
+
+class LooseModernMozillaVersion(MozillaVersionCompareMixin, LooseVersion):
+ """A version class that is more restrictive than LooseVersion.
+ This class reduces the valid strings to "esr", "a", "b" and "rc" in order
+ to support esr. StrictVersion requires a trailing number after all strings."""
+
+ component_re = re.compile(r"(\d+ | a | b | rc | esr | \.)", re.VERBOSE)
+
+ def __repr__(self):
+ return "LooseModernMozillaVersion ('%s')" % str(self)
+
+
+def MozillaVersion(version):
+ try:
+ return ModernMozillaVersion(version)
+ except ValueError:
+ pass
+ try:
+ if version.count(".") == 3:
+ return AncientMozillaVersion(version)
+ except ValueError:
+ pass
+ try:
+ return LooseModernMozillaVersion(version)
+ except ValueError:
+ pass
+ raise ValueError("Version number %s is invalid." % version)
+
+
+def getPrettyVersion(version):
+ version = re.sub(r"a([0-9]+)$", r" Alpha \1", version)
+ version = re.sub(r"b([0-9]+)$", r" Beta \1", version)
+ version = re.sub(r"rc([0-9]+)$", r" RC \1", version)
+ return version