1#!/usr/bin/python 2 3"""Release testtools on Launchpad. 4 5Steps: 6 1. Make sure all "Fix committed" bugs are assigned to 'next' 7 2. Rename 'next' to the new version 8 3. Release the milestone 9 4. Upload the tarball 10 5. Create a new 'next' milestone 11 6. Mark all "Fix committed" bugs in the milestone as "Fix released" 12 13Assumes that NEWS is in the parent directory, that the release sections are 14underlined with '~' and the subsections are underlined with '-'. 15 16Assumes that this file is in the 'scripts' directory a testtools tree that has 17already had a tarball built and uploaded with 'python setup.py sdist upload 18--sign'. 19""" 20 21from datetime import datetime, timedelta, tzinfo 22import logging 23import os 24import sys 25 26from launchpadlib.launchpad import Launchpad 27from launchpadlib import uris 28 29 30APP_NAME = 'testtools-lp-release' 31CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache') 32SERVICE_ROOT = uris.LPNET_SERVICE_ROOT 33 34FIX_COMMITTED = "Fix Committed" 35FIX_RELEASED = "Fix Released" 36 37# Launchpad file type for a tarball upload. 38CODE_RELEASE_TARBALL = 'Code Release Tarball' 39 40PROJECT_NAME = 'testtools' 41NEXT_MILESTONE_NAME = 'next' 42 43 44class _UTC(tzinfo): 45 """UTC""" 46 47 def utcoffset(self, dt): 48 return timedelta(0) 49 50 def tzname(self, dt): 51 return "UTC" 52 53 def dst(self, dt): 54 return timedelta(0) 55 56UTC = _UTC() 57 58 59def configure_logging(): 60 level = logging.INFO 61 log = logging.getLogger(APP_NAME) 62 log.setLevel(level) 63 handler = logging.StreamHandler() 64 handler.setLevel(level) 65 formatter = logging.Formatter("%(levelname)s: %(message)s") 66 handler.setFormatter(formatter) 67 log.addHandler(handler) 68 return log 69LOG = configure_logging() 70 71 72def get_path(relpath): 73 """Get the absolute path for something relative to this file.""" 74 return os.path.abspath( 75 os.path.join( 76 os.path.dirname(os.path.dirname(__file__)), relpath)) 77 78 79def assign_fix_committed_to_next(testtools, next_milestone): 80 """Find all 'Fix Committed' and make sure they are in 'next'.""" 81 fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED)) 82 for task in fixed_bugs: 83 LOG.debug("{}".format(task.title)) 84 if task.milestone != next_milestone: 85 task.milestone = next_milestone 86 LOG.info("Re-assigning {}".format(task.title)) 87 task.lp_save() 88 89 90def rename_milestone(next_milestone, new_name): 91 """Rename 'next_milestone' to 'new_name'.""" 92 LOG.info("Renaming {} to {}".format(next_milestone.name, new_name)) 93 next_milestone.name = new_name 94 next_milestone.lp_save() 95 96 97def get_release_notes_and_changelog(news_path): 98 release_notes = [] 99 changelog = [] 100 state = None 101 last_line = None 102 103 def is_heading_marker(line, marker_char): 104 return line and line == marker_char * len(line) 105 106 LOG.debug("Loading NEWS from {}".format(news_path)) 107 with open(news_path, 'r') as news: 108 for line in news: 109 line = line.strip() 110 if state is None: 111 if (is_heading_marker(line, '~') and 112 not last_line.startswith('NEXT')): 113 milestone_name = last_line 114 state = 'release-notes' 115 else: 116 last_line = line 117 elif state == 'title': 118 # The line after the title is a heading marker line, so we 119 # ignore it and change state. That which follows are the 120 # release notes. 121 state = 'release-notes' 122 elif state == 'release-notes': 123 if is_heading_marker(line, '-'): 124 state = 'changelog' 125 # Last line in the release notes is actually the first 126 # line of the changelog. 127 changelog = [release_notes.pop(), line] 128 else: 129 release_notes.append(line) 130 elif state == 'changelog': 131 if is_heading_marker(line, '~'): 132 # Last line in changelog is actually the first line of the 133 # next section. 134 changelog.pop() 135 break 136 else: 137 changelog.append(line) 138 else: 139 raise ValueError("Couldn't parse NEWS") 140 141 release_notes = '\n'.join(release_notes).strip() + '\n' 142 changelog = '\n'.join(changelog).strip() + '\n' 143 return milestone_name, release_notes, changelog 144 145 146def release_milestone(milestone, release_notes, changelog): 147 date_released = datetime.now(tz=UTC) 148 LOG.info( 149 "Releasing milestone: {}, date {}".format(milestone.name, date_released)) 150 release = milestone.createProductRelease( 151 date_released=date_released, 152 changelog=changelog, 153 release_notes=release_notes, 154 ) 155 milestone.is_active = False 156 milestone.lp_save() 157 return release 158 159 160def create_milestone(series, name): 161 """Create a new milestone in the same series as 'release_milestone'.""" 162 LOG.info("Creating milestone {} in series {}".format(name, series.name)) 163 return series.newMilestone(name=name) 164 165 166def close_fixed_bugs(milestone): 167 tasks = list(milestone.searchTasks()) 168 for task in tasks: 169 LOG.debug("Found {}".format(task.title)) 170 if task.status == FIX_COMMITTED: 171 LOG.info("Closing {}".format(task.title)) 172 task.status = FIX_RELEASED 173 else: 174 LOG.warning( 175 "Bug not fixed, removing from milestone: {}".format(task.title)) 176 task.milestone = None 177 task.lp_save() 178 179 180def upload_tarball(release, tarball_path): 181 with open(tarball_path) as tarball: 182 tarball_content = tarball.read() 183 sig_path = tarball_path + '.asc' 184 with open(sig_path) as sig: 185 sig_content = sig.read() 186 tarball_name = os.path.basename(tarball_path) 187 LOG.info("Uploading tarball: {}".format(tarball_path)) 188 release.add_file( 189 file_type=CODE_RELEASE_TARBALL, 190 file_content=tarball_content, filename=tarball_name, 191 signature_content=sig_content, 192 signature_filename=sig_path, 193 content_type="application/x-gzip; charset=binary") 194 195 196def release_project(launchpad, project_name, next_milestone_name): 197 testtools = launchpad.projects[project_name] 198 next_milestone = testtools.getMilestone(name=next_milestone_name) 199 release_name, release_notes, changelog = get_release_notes_and_changelog( 200 get_path('NEWS')) 201 LOG.info("Releasing {} {}".format(project_name, release_name)) 202 # Since reversing these operations is hard, and inspecting errors from 203 # Launchpad is also difficult, do some looking before leaping. 204 errors = [] 205 tarball_path = get_path('dist/{}-{}.tar.gz'.format(project_name, release_name)) 206 if not os.path.isfile(tarball_path): 207 errors.append("{} does not exist".format(tarball_path)) 208 if not os.path.isfile(tarball_path + '.asc'): 209 errors.append("{} does not exist".format(tarball_path + '.asc')) 210 if testtools.getMilestone(name=release_name): 211 errors.append("Milestone {} exists on {}".format(release_name, project_name)) 212 if errors: 213 for error in errors: 214 LOG.error(error) 215 return 1 216 assign_fix_committed_to_next(testtools, next_milestone) 217 rename_milestone(next_milestone, release_name) 218 release = release_milestone(next_milestone, release_notes, changelog) 219 upload_tarball(release, tarball_path) 220 create_milestone(next_milestone.series_target, next_milestone_name) 221 close_fixed_bugs(next_milestone) 222 return 0 223 224 225def main(args): 226 launchpad = Launchpad.login_with( 227 APP_NAME, SERVICE_ROOT, CACHE_DIR, credentials_file='.lp_creds') 228 return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME) 229 230 231if __name__ == '__main__': 232 sys.exit(main(sys.argv)) 233