#!/usr/bin/python """Release testtools on Launchpad. Steps: 1. Make sure all "Fix committed" bugs are assigned to 'next' 2. Rename 'next' to the new version 3. Release the milestone 4. Upload the tarball 5. Create a new 'next' milestone 6. Mark all "Fix committed" bugs in the milestone as "Fix released" Assumes that NEWS is in the parent directory, that the release sections are underlined with '~' and the subsections are underlined with '-'. Assumes that this file is in the 'scripts' directory a testtools tree that has already had a tarball built and uploaded with 'python setup.py sdist upload --sign'. """ from datetime import datetime, timedelta, tzinfo import logging import os import sys from launchpadlib.launchpad import Launchpad from launchpadlib import uris APP_NAME = 'testtools-lp-release' CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache') SERVICE_ROOT = uris.LPNET_SERVICE_ROOT FIX_COMMITTED = "Fix Committed" FIX_RELEASED = "Fix Released" # Launchpad file type for a tarball upload. CODE_RELEASE_TARBALL = 'Code Release Tarball' PROJECT_NAME = 'testtools' NEXT_MILESTONE_NAME = 'next' class _UTC(tzinfo): """UTC""" def utcoffset(self, dt): return timedelta(0) def tzname(self, dt): return "UTC" def dst(self, dt): return timedelta(0) UTC = _UTC() def configure_logging(): level = logging.INFO log = logging.getLogger(APP_NAME) log.setLevel(level) handler = logging.StreamHandler() handler.setLevel(level) formatter = logging.Formatter("%(levelname)s: %(message)s") handler.setFormatter(formatter) log.addHandler(handler) return log LOG = configure_logging() def get_path(relpath): """Get the absolute path for something relative to this file.""" return os.path.abspath( os.path.join( os.path.dirname(os.path.dirname(__file__)), relpath)) def assign_fix_committed_to_next(testtools, next_milestone): """Find all 'Fix Committed' and make sure they are in 'next'.""" fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED)) for task in fixed_bugs: LOG.debug("{}".format(task.title)) if task.milestone != next_milestone: task.milestone = next_milestone LOG.info("Re-assigning {}".format(task.title)) task.lp_save() def rename_milestone(next_milestone, new_name): """Rename 'next_milestone' to 'new_name'.""" LOG.info("Renaming {} to {}".format(next_milestone.name, new_name)) next_milestone.name = new_name next_milestone.lp_save() def get_release_notes_and_changelog(news_path): release_notes = [] changelog = [] state = None last_line = None def is_heading_marker(line, marker_char): return line and line == marker_char * len(line) LOG.debug("Loading NEWS from {}".format(news_path)) with open(news_path, 'r') as news: for line in news: line = line.strip() if state is None: if (is_heading_marker(line, '~') and not last_line.startswith('NEXT')): milestone_name = last_line state = 'release-notes' else: last_line = line elif state == 'title': # The line after the title is a heading marker line, so we # ignore it and change state. That which follows are the # release notes. state = 'release-notes' elif state == 'release-notes': if is_heading_marker(line, '-'): state = 'changelog' # Last line in the release notes is actually the first # line of the changelog. changelog = [release_notes.pop(), line] else: release_notes.append(line) elif state == 'changelog': if is_heading_marker(line, '~'): # Last line in changelog is actually the first line of the # next section. changelog.pop() break else: changelog.append(line) else: raise ValueError("Couldn't parse NEWS") release_notes = '\n'.join(release_notes).strip() + '\n' changelog = '\n'.join(changelog).strip() + '\n' return milestone_name, release_notes, changelog def release_milestone(milestone, release_notes, changelog): date_released = datetime.now(tz=UTC) LOG.info( "Releasing milestone: {}, date {}".format(milestone.name, date_released)) release = milestone.createProductRelease( date_released=date_released, changelog=changelog, release_notes=release_notes, ) milestone.is_active = False milestone.lp_save() return release def create_milestone(series, name): """Create a new milestone in the same series as 'release_milestone'.""" LOG.info("Creating milestone {} in series {}".format(name, series.name)) return series.newMilestone(name=name) def close_fixed_bugs(milestone): tasks = list(milestone.searchTasks()) for task in tasks: LOG.debug("Found {}".format(task.title)) if task.status == FIX_COMMITTED: LOG.info("Closing {}".format(task.title)) task.status = FIX_RELEASED else: LOG.warning( "Bug not fixed, removing from milestone: {}".format(task.title)) task.milestone = None task.lp_save() def upload_tarball(release, tarball_path): with open(tarball_path) as tarball: tarball_content = tarball.read() sig_path = tarball_path + '.asc' with open(sig_path) as sig: sig_content = sig.read() tarball_name = os.path.basename(tarball_path) LOG.info("Uploading tarball: {}".format(tarball_path)) release.add_file( file_type=CODE_RELEASE_TARBALL, file_content=tarball_content, filename=tarball_name, signature_content=sig_content, signature_filename=sig_path, content_type="application/x-gzip; charset=binary") def release_project(launchpad, project_name, next_milestone_name): testtools = launchpad.projects[project_name] next_milestone = testtools.getMilestone(name=next_milestone_name) release_name, release_notes, changelog = get_release_notes_and_changelog( get_path('NEWS')) LOG.info("Releasing {} {}".format(project_name, release_name)) # Since reversing these operations is hard, and inspecting errors from # Launchpad is also difficult, do some looking before leaping. errors = [] tarball_path = get_path('dist/{}-{}.tar.gz'.format(project_name, release_name)) if not os.path.isfile(tarball_path): errors.append("{} does not exist".format(tarball_path)) if not os.path.isfile(tarball_path + '.asc'): errors.append("{} does not exist".format(tarball_path + '.asc')) if testtools.getMilestone(name=release_name): errors.append("Milestone {} exists on {}".format(release_name, project_name)) if errors: for error in errors: LOG.error(error) return 1 assign_fix_committed_to_next(testtools, next_milestone) rename_milestone(next_milestone, release_name) release = release_milestone(next_milestone, release_notes, changelog) upload_tarball(release, tarball_path) create_milestone(next_milestone.series_target, next_milestone_name) close_fixed_bugs(next_milestone) return 0 def main(args): launchpad = Launchpad.login_with( APP_NAME, SERVICE_ROOT, CACHE_DIR, credentials_file='.lp_creds') return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME) if __name__ == '__main__': sys.exit(main(sys.argv))