3 """Release testtools on Launchpad.
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
10 5. Create a new 'next' milestone
11 6. Mark all "Fix committed" bugs in the milestone as "Fix released"
13 Assumes that NEWS is in the parent directory, that the release sections are
14 underlined with '~' and the subsections are underlined with '-'.
16 Assumes that this file is in the 'scripts' directory a testtools tree that has
17 already had a tarball built and uploaded with 'python setup.py sdist upload
21 from datetime import datetime, timedelta, tzinfo
26 from launchpadlib.launchpad import Launchpad
27 from launchpadlib import uris
30 APP_NAME = 'testtools-lp-release'
31 CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache')
32 SERVICE_ROOT = uris.LPNET_SERVICE_ROOT
34 FIX_COMMITTED = u"Fix Committed"
35 FIX_RELEASED = u"Fix Released"
37 # Launchpad file type for a tarball upload.
38 CODE_RELEASE_TARBALL = 'Code Release Tarball'
40 PROJECT_NAME = 'testtools'
41 NEXT_MILESTONE_NAME = 'next'
47 def utcoffset(self, dt):
59 def configure_logging():
61 log = logging.getLogger(APP_NAME)
63 handler = logging.StreamHandler()
64 handler.setLevel(level)
65 formatter = logging.Formatter("%(levelname)s: %(message)s")
66 handler.setFormatter(formatter)
67 log.addHandler(handler)
69 LOG = configure_logging()
72 def get_path(relpath):
73 """Get the absolute path for something relative to this file."""
74 return os.path.abspath(
76 os.path.dirname(os.path.dirname(__file__)), relpath))
79 def 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("%s" % (task.title,))
84 if task.milestone != next_milestone:
85 task.milestone = next_milestone
86 LOG.info("Re-assigning %s" % (task.title,))
90 def rename_milestone(next_milestone, new_name):
91 """Rename 'next_milestone' to 'new_name'."""
92 LOG.info("Renaming %s to %s" % (next_milestone.name, new_name))
93 next_milestone.name = new_name
94 next_milestone.lp_save()
97 def get_release_notes_and_changelog(news_path):
103 def is_heading_marker(line, marker_char):
104 return line and line == marker_char * len(line)
106 LOG.debug("Loading NEWS from %s" % (news_path,))
107 with open(news_path, 'r') as news:
111 if (is_heading_marker(line, '~') and
112 not last_line.startwith('NEXT')):
113 milestone_name = last_line
114 state = 'release-notes'
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
121 state = 'release-notes'
122 elif state == 'release-notes':
123 if is_heading_marker(line, '-'):
125 # Last line in the release notes is actually the first
126 # line of the changelog.
127 changelog = [release_notes.pop(), line]
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
137 changelog.append(line)
139 raise ValueError("Couldn't parse NEWS")
141 release_notes = '\n'.join(release_notes).strip() + '\n'
142 changelog = '\n'.join(changelog).strip() + '\n'
143 return milestone_name, release_notes, changelog
146 def release_milestone(milestone, release_notes, changelog):
147 date_released = datetime.now(tz=UTC)
149 "Releasing milestone: %s, date %s" % (milestone.name, date_released))
150 release = milestone.createProductRelease(
151 date_released=date_released,
153 release_notes=release_notes,
155 milestone.is_active = False
160 def create_milestone(series, name):
161 """Create a new milestone in the same series as 'release_milestone'."""
162 LOG.info("Creating milestone %s in series %s" % (name, series.name))
163 return series.newMilestone(name=name)
166 def close_fixed_bugs(milestone):
167 tasks = list(milestone.searchTasks())
169 LOG.debug("Found %s" % (task.title,))
170 if task.status == FIX_COMMITTED:
171 LOG.info("Closing %s" % (task.title,))
172 task.status = FIX_RELEASED
175 "Bug not fixed, removing from milestone: %s" % (task.title,))
176 task.milestone = None
180 def 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: %s" % (tarball_path,))
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")
196 def 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(
201 LOG.info("Releasing %s %s" % (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.
205 tarball_path = get_path('dist/%s-%s.tar.gz' % (project_name, release_name,))
206 if not os.path.isfile(tarball_path):
207 errors.append("%s does not exist" % (tarball_path,))
208 if not os.path.isfile(tarball_path + '.asc'):
209 errors.append("%s does not exist" % (tarball_path + '.asc',))
210 if testtools.getMilestone(name=release_name):
211 errors.append("Milestone %s exists on %s" % (release_name, project_name))
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)
226 launchpad = Launchpad.login_with(APP_NAME, SERVICE_ROOT, CACHE_DIR)
227 return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
230 if __name__ == '__main__':
231 sys.exit(main(sys.argv))