diff --git a/dist/release.py b/dist/release.py new file mode 100755 index 0000000..282f1aa --- /dev/null +++ b/dist/release.py @@ -0,0 +1,228 @@ +#!/usr/bin/python + +import re +import sys +import os +from multiprocessing import Process +from utils import * + +try: + from argparse import ArgumentParser +except: + prettyprint(''' + Welcome to the Mod-Lang-Scala Release Script. + This release script requires that you use at least Python 2.7.0. + It appears that you do not have the collections.Counter available, + which are available by default in Python 2.7.0. + ''', Levels.FATAL) + sys.exit(1) + +modules = [] +uploader = None +git = None + + +def help_and_exit(): + prettyprint(''' + Welcome to the Mod-Lang-Scala Release Script. + +%s Usage:%s + + $ bin/release.py + +%s E.g.,%s + + $ bin/release.py 0.1.0 %s<-- this will tag off master.%s + + ''' % ( + Colors.yellow(), Colors.end_color(), Colors.yellow(), Colors.end_color(), + Colors.green(), Colors.end_color()), + Levels.INFO) + sys.exit(0) + + +def validate_version(version): + version_pattern = get_version_pattern() + if version_pattern.match(version): + return version.strip().upper() + else: + prettyprint("Invalid version '" + version + "'!\n", Levels.FATAL) + help_and_exit() + + +def switch_to_tag_release(branch): + if git.remote_branch_exists(): + git.switch_to_branch() + git.create_tag_branch() + else: + prettyprint( + "Branch %s cannot be found on upstream repository. Aborting!" + % branch, Levels.FATAL) + sys.exit(100) + + +def update_version(base_dir, version): + os.chdir(base_dir) + gradle_props = './gradle.properties' + + pieces = re.compile('[\.\-]').split(version) + + # 1. Update mod-lang-scala version in root gradle properties file + f_in = open(gradle_props) + f_out = open(gradle_props + '.tmp', 'w') + re_version = re.compile('\s*version=') + try: + for l in f_in: + if re_version.match(l): + prettyprint("Update %s to version %s" + % (gradle_props, version), Levels.DEBUG) + f_out.write('version=%s\n' % version) + else: + f_out.write(l) + finally: + f_in.close() + f_out.close() + + # Rename back gradle properties file + os.rename(gradle_props + ".tmp", gradle_props) + + # Now make sure this goes back into the repository. + git.commit(gradle_props, + "'Release Script: update mod-lang-scala version %s'" % version) + + # And return the next version - currently unused + return pieces[0] + '.' + str(int(pieces[1]) + 1) + '.' + '0-SNAPSHOT' + + +def do_task(target, args, async_processes): + if settings.multi_threaded: + async_processes.append(Process(target=target, args=args)) + else: + target(*args) + +### This is the starting place for this script. +def release(): + global settings + global uploader + global git + assert_python_minimum_version(2, 5) + + parser = ArgumentParser() + parser.add_argument('-d', '--dry-run', action='store_true', dest='dry_run', + help="release dry run", default=False) + parser.add_argument('-v', '--verbose', action='store_true', dest='verbose', + help="verbose logging", default=True) + parser.add_argument('-n', '--non-interactive', action='store_true', + dest='non_interactive', + help="non interactive script", default=False) + parser.add_argument('-x', '--next-version', action='store', dest='next_version', + help="next sbt plugin version") + + # TODO Add branch... + (settings, extras) = parser.parse_known_args() + if len(extras) == 0: + prettyprint("No release version given", Levels.FATAL) + sys.exit(1) + + version = extras[0] + interactive = not settings.non_interactive + + base_dir = os.getcwd() + branch = "master" + + escalante_version = settings.escalante_version + if escalante_version is None: + prettyprint("No mod-lang-scala version given", Levels.FATAL) + sys.exit(1) + +# next_version = settings.next_version +# if next_version is None: +# proceed = input_with_default( +# 'No next SBT plugin version given! Are you sure you want to proceed?', 'N') +# if not proceed.upper().startswith('Y'): +# prettyprint("... User Abort!", Levels.WARNING) +# sys.exit(1) + + prettyprint( + "Releasing mod-lang-sca;a version %s from branch '%s'" + % (version, branch), Levels.INFO) + + if interactive: + sure = input_with_default("Are you sure you want to continue?", "N") + if not sure.upper().startswith("Y"): + prettyprint("... User Abort!", Levels.WARNING) + sys.exit(1) + + prettyprint("OK, releasing! Please stand by ...", Levels.INFO) + + ## Set up network interactive tools + if settings.dry_run: + # Use stubs + prettyprint( + "*** This is a DRY RUN. No changes will be committed. Used to test this release script only. ***" + , Levels.DEBUG) + prettyprint("Your settings are %s" % settings, Levels.DEBUG) + uploader = DryRunUploader() + else: + prettyprint("*** LIVE Run ***", Levels.DEBUG) + prettyprint("Your settings are %s" % settings, Levels.DEBUG) + uploader = Uploader(settings) + + git = Git(branch, version, settings) + if interactive and not git.is_upstream_clone(): + proceed = input_with_default( + 'This is not a clone of an %supstream%s mod-lang-scala repository! Are you sure you want to proceed?' % ( + Colors.UNDERLINE, Colors.END), 'N') + if not proceed.upper().startswith('Y'): + prettyprint("... User Abort!", Levels.WARNING) + sys.exit(1) + + ## Release order: + # Step 1: Tag in Git + prettyprint("Step 1: Tagging %s in git as %s" % (branch, version), Levels.INFO) + switch_to_tag_release(branch) + prettyprint("Step 1: Complete", Levels.INFO) + + # Step 2: Update version in tagged files + prettyprint("Step 2: Updating version number", Levels.INFO) + update_version(base_dir, version) + prettyprint("Step 2: Complete", Levels.INFO) + + # Step 3: Build and test in SBT + prettyprint("Step 3: Build and publish", Levels.INFO) + build_publish(settings) + prettyprint("Step 3: Complete", Levels.INFO) + + async_processes = [] + + ## Wait for processes to finish + for p in async_processes: + p.start() + + for p in async_processes: + p.join() + + ## Tag the release + git.tag_for_release() + + if not settings.dry_run: + git.push_tags_to_origin() + git.cleanup() + git.push_master_to_origin() + else: + prettyprint( + "In dry-run mode. Not pushing tag to remote origin and not removing temp release branch %s." % git.working_branch + , Levels.DEBUG) + +# # Update master with next version +# next_version = settings.next_version +# if next_version is not None: +# # Update to next version +# prettyprint("Step 4: Updating version number for next release", Levels.INFO) +# update_version(base_dir, next_version) +# git.push_master_to_origin() +# prettyprint("Step 4: Complete", Levels.INFO) + + +if __name__ == "__main__": + release() diff --git a/dist/utils.py b/dist/utils.py new file mode 100755 index 0000000..e00d5a2 --- /dev/null +++ b/dist/utils.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8; -*- +# +# Copyright 2012 Red Hat, Inc. and/or its affiliates. +# +# Licensed under the Eclipse Public License version 1.0, available at +# http://www.eclipse.org/legal/epl-v10.html + +import os +import fnmatch +import re +import subprocess +import sys +import readline +import shutil +import random +#settings_file = '%s/.escalante' % os.getenv('HOME') + +### Known config keys +maven_pom_xml_namespace = "http://maven.apache.org/POM/4.0.0" +#default_settings = {'dry_run': False, 'multi_threaded': False, 'verbose': False, 'use_colors': True} +#boolean_keys = ['dry_run', 'multi_threaded', 'verbose'] + +class Colors(object): + MAGENTA = '\033[95m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + CYAN = '\033[96m' + END = '\033[0m' + UNDERLINE = '\033[4m' + + @staticmethod + def magenta(): + if use_colors(): + return Colors.MAGENTA + else: + return "" + + @staticmethod + def green(): + if use_colors(): + return Colors.GREEN + else: + return "" + + @staticmethod + def yellow(): + if use_colors(): + return Colors.YELLOW + else: + return "" + + @staticmethod + def red(): + if use_colors(): + return Colors.RED + else: + return "" + + @staticmethod + def cyan(): + if use_colors(): + return Colors.CYAN + else: + return "" + + @staticmethod + def end_color(): + if use_colors(): + return Colors.END + else: + return "" + + +class Levels(Colors): + C_DEBUG = Colors.CYAN + C_INFO = Colors.GREEN + C_WARNING = Colors.YELLOW + C_FATAL = Colors.RED + C_ENDC = Colors.END + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + FATAL = "FATAL" + + @staticmethod + def get_color(level): + if use_colors(): + return getattr(Levels, "C_" + level) + else: + return "" + + +def use_colors(): + return True + +# return ('use_colors' in settings and settings['use_colors']) or ('use_colors' not in settings) + +def prettyprint(message, level): + start_color = Levels.get_color(level) + end_color = Levels.end_color() + + print "[%s%s%s] %s" % (start_color, level, end_color, message) + + +def to_bool(x): + if type(x) == bool: + return x + if type(x) == str: + return {'true': True, 'false': False}.get(x.strip().lower()) + + +def input_with_default(msg, default): + i = raw_input( + "%s %s[%s]%s: " % (msg, Colors.magenta(), default, Colors.end_color())) + if i.strip() == "": + i = default + return i + + +def get_search_path(executable): + """Retrieves a search path based on where the current executable is located. Returns a string to be prepended to add""" + in_bin_dir = re.compile('^.*/?bin/.*.py') + if in_bin_dir.search(executable): + return "./" + else: + return "../" + + +def strip_leading_dots(filename): + return filename.strip('/. ') + + +def to_set(list): + """Crappy implementation of creating a Set from a List. To cope with older Python versions""" + temp_dict = {} + for entry in list: + temp_dict[entry] = "dummy" + return temp_dict.keys() + + +class GlobDirectoryWalker: + """A forward iterator that traverses a directory tree""" + + def __init__(self, directory, pattern="*"): + self.stack = [directory] + self.pattern = pattern + self.files = [] + self.index = 0 + + def __getitem__(self, index): + while True: + try: + file = self.files[self.index] + self.index = self.index + 1 + except IndexError: + # pop next directory from stack + self.directory = self.stack.pop() + self.files = os.listdir(self.directory) + self.index = 0 + else: + # got a filename + fullname = os.path.join(self.directory, file) + if os.path.isdir(fullname) and not os.path.islink(fullname): + self.stack.append(fullname) + if fnmatch.fnmatch(file, self.pattern): + return fullname + + +class Git(object): + '''Encapsulates git functionality necessary for releasing Infinispan''' + cmd = 'git' + # Helper functions to clean up branch lists + @staticmethod + def clean(e): return e.strip().replace(' ', '').replace('*', '') + + @staticmethod + def non_empty(e): return e != None and e.strip() != '' + + @staticmethod + def current(e): return e != None and e.strip().replace(' ', '').startswith( + '*') + + def __init__(self, branch, tag_name, settings): + self.settings = settings + if not self.is_git_directory(): + raise Exception( + 'Attempting to run git outside of a repository. Current directory is %s' % os.path.abspath( + os.path.curdir)) + + self.branch = branch + self.tag = tag_name + self.verbose = False + if settings.verbose: + self.verbose = True + rand = '%x'.upper() % (random.random() * 100000) + self.working_branch = '__temp_%s' % rand + self.original_branch = self.current_branch() + + def run_git(self, opts): + call = [self.cmd] + if type(opts) == list: + for o in opts: + call.append(o) + elif type(opts) == str: + for o in opts.split(' '): + if o != '': + call.append(o) + else: + raise Error("Cannot handle argument of type %s" % type(opts)) + if self.settings.verbose: + prettyprint('Executing %s' % call, Levels.DEBUG) + # Non-interactive shells have issues with piping, so do normal check calls + if self.settings.non_interactive: + return subprocess.check_call(call) + else: + return subprocess.Popen( + call, stdout=subprocess.PIPE).communicate()[0].split('\n') + + def is_git_directory(self): + if self.settings.non_interactive: + return True + else: + return self.run_git('branch')[0] != '' + + def is_upstream_clone(self): + if self.settings.non_interactive: + return True + else: + r = self.run_git('remote show -n origin') + cleaned = map(self.clean, r) + + def push(e): return e.startswith('PushURL:') + + def remove_noise(e): return e.replace('PushURL:', '') + + push_urls = map(remove_noise, filter(push, cleaned)) + return len(push_urls) == 1 and \ + push_urls[0] == 'git@github.com:escalante/sbt-escalante.git' + + def clean_branches(self, raw_branch_list): + return map(self.clean, filter(self.non_empty, raw_branch_list)) + + def remote_branch_exists(self): + """Tests whether the branch exists on the remote origin""" + if self.settings.non_interactive: + return True + else: + branches = self.clean_branches(self.run_git("branch -r")) + + def replace_origin(b): return b.replace('origin/', '') + + return self.branch in map(replace_origin, branches) + + def switch_to_branch(self): + """Switches the local repository to the specified branch. Creates it if it doesn't already exist.""" + if self.settings.non_interactive: + # If non-interactive, the repo is clean, checkout directly, unless master + # TODO: Uncomment this and make it conditional when non-master + # self.run_git("branch %s origin/%s" % (self.branch, self.branch)) + self.run_git("checkout %s" % self.branch) + else: + local_branches = self.clean_branches(self.run_git("branch")) + if self.branch not in local_branches: + self.run_git("branch %s origin/%s" % (self.branch, self.branch)) + self.run_git("checkout %s" % self.branch) + + def create_tag_branch(self): + '''Creates and switches to a temp tagging branch, based off the release branch.''' + self.run_git("checkout -b %s %s" % (self.working_branch, self.branch)) + + def commit(self, files, message): + '''Commits the set of files to the current branch with a generated commit message.''' + for f in files: + self.run_git("add %s" % f) + + self.run_git(["commit", "-m", message]) + + def tag_for_release(self): + """Tags the current branch for release using the tag name.""" + self.run_git(["tag", "-a", "-m", "'Release Script: tag %s'" % self.tag, + self.tag]) + + def push_tags_to_origin(self): + '''Pushes the updated tags to origin''' + self.run_git("push origin --tags") + + def push_master_to_origin(self): + '''Pushes the updated tags to origin''' + self.run_git("push origin master") + + def current_branch(self): + """Returns the current branch you are on""" + if (self.settings.non_interactive): + return 'master' + else: + return map(self.clean, filter(self.current, self.run_git('branch')))[0] + + def cleanup(self): + '''Cleans up any temporary branches created''' + self.run_git("checkout %s" % self.original_branch) + self.run_git("branch -D %s" % self.working_branch) + + +class DryRun(object): + location_root = "%s/%s" % (os.getenv("HOME"), "escalante-release-dry-run") + + def find_version(self, url): + return os.path.split(url)[1] + + def copy(self, src, dst): + prettyprint( + " DryRun: Executing %s" % ['rsync', '-rv', '--protocol=28', src, + dst], Levels.DEBUG) + try: + os.makedirs(dst) + except: + pass + subprocess.check_call(['rsync', '-rv', '--protocol=28', src, dst]) + + +class Uploader(object): + def __init__(self, settings): + if settings.verbose: + self.scp_cmd = ['scp', '-rv'] + self.rsync_cmd = ['rsync', '-rv', '--protocol=28'] + else: + self.scp_cmd = ['scp', '-r'] + self.rsync_cmd = ['rsync', '-r', '--protocol=28'] + + def upload_scp(self, fr, to, flags=[]): + self.upload(fr, to, flags, list(self.scp_cmd)) + + def upload_rsync(self, fr, to, flags=[]): + self.upload(fr, to, flags, list(self.rsync_cmd)) + + def upload(self, fr, to, flags, cmd): + for e in flags: + cmd.append(e) + cmd.append(fr) + cmd.append(to) + subprocess.check_call(cmd) + + +class DryRunUploader(DryRun): + def upload_scp(self, fr, to, flags=[]): + self.upload(fr, to, "scp") + + def upload_rsync(self, fr, to, flags=[]): + self.upload(fr, to.replace(':', '____').replace('@', "__"), "rsync") + + def upload(self, fr, to, type): + self.copy(fr, "%s/%s/%s" % (self.location_root, type, to)) + + +def build_publish(settings): + """Builds the distribution in the current working dir""" + sbt_commands = [['clean', "install"]] + if not settings.dry_run: + sbt_commands.append(['uploadArchives']) + + for c in sbt_commands: + c.insert(0, './gradlew') + prettyprint("Execute Gradle command %s" % c, Levels.DEBUG) + subprocess.check_call(c) + + +def get_version_pattern(): + return re.compile("^([0-9]\.[0-9])\.[0-9]", re.IGNORECASE) + + +def get_version_major_minor(full_version): + pattern = get_version_pattern() + matcher = pattern.match(full_version) + return matcher.group(1) + + +def assert_python_minimum_version(major, minor): + e = re.compile('([0-9])\.([0-9])\.([0-9]).*') + m = e.match(sys.version) + major_ok = int(m.group(1)) == major + minor_ok = int(m.group(2)) >= minor + if not (minor_ok and major_ok): + prettyprint("This script requires Python >= %s.%s.0. You have %s" % ( + major, minor, sys.version), Levels.FATAL) + sys.exit(3) +