#!/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 = u"Fix Committed" FIX_RELEASED = u"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("%s" % (task.title,)) if task.milestone != next_milestone: task.milestone = next_milestone LOG.info("Re-assigning %s" % (task.title,)) task.lp_save() def rename_milestone(next_milestone, new_name): """Rename 'next_milestone' to 'new_name'.""" LOG.info("Renaming %s to %s" % (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 %s" % (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, '~'): 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: %s, date %s" % (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 %s in series %s" % (name, series.name)) return series.newMilestone(name=name) def close_fixed_bugs(milestone): tasks = list(milestone.searchTasks()) for task in tasks: LOG.debug("Found %s" % (task.title,)) if task.status == FIX_COMMITTED: LOG.info("Closing %s" % (task.title,)) task.status = FIX_RELEASED else: LOG.warning( "Bug not fixed, removing from milestone: %s" % (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: %s" % (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 %s %s" % (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/%s-%s.tar.gz' % (project_name, release_name,)) if not os.path.isfile(tarball_path): errors.append("%s does not exist" % (tarball_path,)) if not os.path.isfile(tarball_path + '.asc'): errors.append("%s does not exist" % (tarball_path + '.asc',)) if testtools.getMilestone(name=release_name): errors.append("Milestone %s exists on %s" % (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) return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME) if __name__ == '__main__': sys.exit(main(sys.argv))