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
, '~'):
112 milestone_name
= last_line
113 state
= 'release-notes'
116 elif state
== 'title':
117 # The line after the title is a heading marker line, so we
118 # ignore it and change state. That which follows are the
120 state
= 'release-notes'
121 elif state
== 'release-notes':
122 if is_heading_marker(line
, '-'):
124 # Last line in the release notes is actually the first
125 # line of the changelog.
126 changelog
= [release_notes
.pop(), line
]
128 release_notes
.append(line
)
129 elif state
== 'changelog':
130 if is_heading_marker(line
, '~'):
131 # Last line in changelog is actually the first line of the
136 changelog
.append(line
)
138 raise ValueError("Couldn't parse NEWS")
140 release_notes
= '\n'.join(release_notes
).strip() + '\n'
141 changelog
= '\n'.join(changelog
).strip() + '\n'
142 return milestone_name
, release_notes
, changelog
145 def release_milestone(milestone
, release_notes
, changelog
):
146 date_released
= datetime
.now(tz
=UTC
)
148 "Releasing milestone: %s, date %s" % (milestone
.name
, date_released
))
149 release
= milestone
.createProductRelease(
150 date_released
=date_released
,
152 release_notes
=release_notes
,
154 milestone
.is_active
= False
159 def create_milestone(series
, name
):
160 """Create a new milestone in the same series as 'release_milestone'."""
161 LOG
.info("Creating milestone %s in series %s" % (name
, series
.name
))
162 return series
.newMilestone(name
=name
)
165 def close_fixed_bugs(milestone
):
166 tasks
= list(milestone
.searchTasks())
168 LOG
.debug("Found %s" % (task
.title
,))
169 if task
.status
== FIX_COMMITTED
:
170 LOG
.info("Closing %s" % (task
.title
,))
171 task
.status
= FIX_RELEASED
174 "Bug not fixed, removing from milestone: %s" % (task
.title
,))
175 task
.milestone
= None
179 def upload_tarball(release
, tarball_path
):
180 with
open(tarball_path
) as tarball
:
181 tarball_content
= tarball
.read()
182 sig_path
= tarball_path
+ '.asc'
183 with
open(sig_path
) as sig
:
184 sig_content
= sig
.read()
185 tarball_name
= os
.path
.basename(tarball_path
)
186 LOG
.info("Uploading tarball: %s" % (tarball_path
,))
188 file_type
=CODE_RELEASE_TARBALL
,
189 file_content
=tarball_content
, filename
=tarball_name
,
190 signature_content
=sig_content
,
191 signature_filename
=sig_path
,
192 content_type
="application/x-gzip; charset=binary")
195 def release_project(launchpad
, project_name
, next_milestone_name
):
196 testtools
= launchpad
.projects
[project_name
]
197 next_milestone
= testtools
.getMilestone(name
=next_milestone_name
)
198 release_name
, release_notes
, changelog
= get_release_notes_and_changelog(
200 LOG
.info("Releasing %s %s" % (project_name
, release_name
))
201 # Since reversing these operations is hard, and inspecting errors from
202 # Launchpad is also difficult, do some looking before leaping.
204 tarball_path
= get_path('dist/%s-%s.tar.gz' % (project_name
, release_name
,))
205 if not os
.path
.isfile(tarball_path
):
206 errors
.append("%s does not exist" % (tarball_path
,))
207 if not os
.path
.isfile(tarball_path
+ '.asc'):
208 errors
.append("%s does not exist" % (tarball_path
+ '.asc',))
209 if testtools
.getMilestone(name
=release_name
):
210 errors
.append("Milestone %s exists on %s" % (release_name
, project_name
))
215 assign_fix_committed_to_next(testtools
, next_milestone
)
216 rename_milestone(next_milestone
, release_name
)
217 release
= release_milestone(next_milestone
, release_notes
, changelog
)
218 upload_tarball(release
, tarball_path
)
219 create_milestone(next_milestone
.series_target
, next_milestone_name
)
220 close_fixed_bugs(next_milestone
)
225 launchpad
= Launchpad
.login_with(APP_NAME
, SERVICE_ROOT
, CACHE_DIR
)
226 return release_project(launchpad
, PROJECT_NAME
, NEXT_MILESTONE_NAME
)
229 if __name__
== '__main__':
230 sys
.exit(main(sys
.argv
))