Start development series 0.7-post
[0release.git] / release.py
blob9864b57f5892381d8087dcd6cf951a25e64cb601
1 # Copyright (C) 2007, Thomas Leonard
2 # See the README file for details, or visit http://0install.net.
4 import os, sys, subprocess, shutil, tarfile, tempfile
5 from zeroinstall import SafeException
6 from zeroinstall.injector import reader, model
7 from logging import info
9 import support
10 from scm import get_scm
12 XMLNS_RELEASE = 'http://zero-install.sourceforge.net/2007/namespaces/0release'
14 valid_phases = ['commit-release', 'generate-archive']
16 TMP_BRANCH_NAME = '0release-tmp'
18 def run_unit_tests(impl):
19 self_test = impl.metadata.get('self-test', None)
20 if self_test is None:
21 print "SKIPPING unit tests for %s (no 'self-test' attribute set)" % impl
22 return
23 self_test = os.path.join(impl.id, self_test)
24 print "Running self-test:", self_test
25 exitstatus = subprocess.call([self_test], cwd = os.path.dirname(self_test))
26 if exitstatus:
27 raise SafeException("Self-test failed with exit status %d" % exitstatus)
29 def do_release(local_iface, options):
30 status = support.Status()
31 local_impl = support.get_singleton_impl(local_iface)
33 local_impl_dir = local_impl.id
34 assert local_impl_dir.startswith('/')
35 local_impl_dir = os.path.realpath(local_impl_dir)
36 assert os.path.isdir(local_impl_dir)
37 assert local_iface.uri.startswith(local_impl_dir + '/')
38 local_iface_rel_path = local_iface.uri[len(local_impl_dir) + 1:]
39 assert not local_iface_rel_path.startswith('/')
40 assert os.path.isfile(os.path.join(local_impl_dir, local_iface_rel_path))
42 phase_actions = {}
43 for phase in valid_phases:
44 phase_actions[phase] = [] # List of <release:action> elements
46 release_management = local_iface.get_metadata(XMLNS_RELEASE, 'management')
47 if len(release_management) == 1:
48 info("Found <release:management> element.")
49 release_management = release_management[0]
50 for x in release_management.childNodes:
51 if x.uri == XMLNS_RELEASE and x.name == 'action':
52 phase = x.getAttribute('phase')
53 if phase not in valid_phases:
54 raise SafeException("Invalid action phase '%s' in local feed %s. Valid actions are:\n%s" % (phase, local_iface.uri, '\n'.join(valid_phases)))
55 phase_actions[phase].append(x.content)
56 else:
57 warn("Unknown <release:management> element: %s", x)
58 elif len(release_management) > 1:
59 raise SafeException("Multiple <release:management> sections in %s!" % local_iface)
60 else:
61 info("No <release:management> element found in local feed.")
63 scm = get_scm(local_iface, options)
65 def run_hooks(phase, cwd, env):
66 info("Running hooks for phase '%s'" % phase)
67 full_env = os.environ.copy()
68 full_env.update(env)
69 for x in phase_actions[phase]:
70 print "[%s]: %s" % (phase, x)
71 support.check_call(x, shell = True, cwd = cwd, env = full_env)
73 def set_to_release():
74 print "Snapshot version is " + local_impl.get_version()
75 suggested = support.suggest_release_version(local_impl.get_version())
76 release_version = raw_input("Version number for new release [%s]: " % suggested)
77 if not release_version:
78 release_version = suggested
80 scm.ensure_no_tag(release_version)
82 status.head_before_release = scm.get_head_revision()
83 status.save()
85 working_copy = local_impl.id
86 run_hooks('commit-release', cwd = working_copy, env = {'RELEASE_VERSION': release_version})
88 print "Releasing version", release_version
89 support.publish(local_iface.uri, set_released = 'today', set_version = release_version)
91 status.old_snapshot_version = local_impl.get_version()
92 status.release_version = release_version
93 status.head_at_release = scm.commit('Release %s' % release_version, branch = TMP_BRANCH_NAME, parent = 'HEAD')
94 status.save()
96 def set_to_snapshot(snapshot_version):
97 assert snapshot_version.endswith('-post')
98 support.publish(local_iface.uri, set_released = '', set_version = snapshot_version)
99 scm.commit('Start development series %s' % snapshot_version, branch = TMP_BRANCH_NAME, parent = TMP_BRANCH_NAME)
100 status.new_snapshot_version = scm.get_head_revision()
101 status.save()
103 def ensure_ready_to_release():
104 if not options.master_feed_file:
105 raise SafeException("Master feed file not set! Check your configuration")
107 scm.ensure_committed()
108 scm.ensure_versioned(local_iface_rel_path)
109 info("No uncommitted changes. Good.")
110 # Not needed for GIT. For SCMs where tagging is expensive (e.g. svn) this might be useful.
111 #run_unit_tests(local_impl)
113 scm.grep('\(^\\|[^=]\)\<\\(TODO\\|XXX\\|FIXME\\)\>')
115 def create_feed(local_iface_stream, archive_file, archive_name):
116 tmp = tempfile.NamedTemporaryFile(prefix = '0release-')
117 shutil.copyfileobj(local_iface_stream, tmp)
118 tmp.flush()
120 support.publish(tmp.name,
121 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file),
122 archive_file = archive_file,
123 archive_extract = archive_name)
124 return tmp
126 def unpack_tarball(archive_file, archive_name):
127 tar = tarfile.open(archive_file, 'r:bz2')
128 members = [m for m in tar.getmembers() if m.name != 'pax_global_header']
129 tar.extractall('.', members = members)
131 def get_previous_release(this_version):
132 """Return the highest numbered verison in the master feed before this_version.
133 @return: version, or None if there wasn't one"""
134 parsed_release_version = model.parse_version(this_version)
136 if os.path.exists(options.master_feed_file):
137 master = model.Interface(os.path.realpath(options.master_feed_file))
138 reader.update(master, master.uri, local = True)
139 versions = [impl.version for impl in master.implementations.values() if impl.version < parsed_release_version]
140 if versions:
141 return model.format_version(max(versions))
142 return None
144 def export_changelog(previous_release):
145 changelog = file('changelog-%s' % status.release_version, 'w')
146 try:
147 try:
148 scm.export_changelog(previous_release, status.head_before_release, changelog)
149 except SafeException, ex:
150 print "WARNING: Failed to generate changelog: " + str(ex)
151 else:
152 print "Wrote changelog from %s to here as %s" % (previous_release or 'start', changelog.name)
153 finally:
154 changelog.close()
156 def fail_candidate(archive_file):
157 support.backup_if_exists(archive_file)
158 scm.delete_branch(TMP_BRANCH_NAME)
159 os.unlink(support.release_status_file)
160 print "Restored to state before starting release. Make your fixes and try again..."
162 def accept_and_publish(archive_file, archive_name, local_iface_rel_path):
163 assert options.master_feed_file
165 if not options.archive_dir_public_url:
166 raise SafeException("Archive directory public URL is not set! Edit configuration and try again.")
168 if status.tagged:
169 print "Already tagged in SCM. Not re-tagging."
170 else:
171 scm.ensure_committed()
172 head = scm.get_head_revision()
173 if head != status.head_before_release:
174 raise SafeException("Changes committed since we started!\n" +
175 "HEAD was " + status.head_before_release + "\n"
176 "HEAD now " + head)
178 scm.tag(status.release_version, status.head_at_release)
179 scm.reset_hard(TMP_BRANCH_NAME)
180 scm.delete_branch(TMP_BRANCH_NAME)
182 status.tagged = 'true'
183 status.save()
185 if status.updated_master_feed:
186 print "Already added to master feed. Not changing."
187 else:
188 if os.path.exists(options.master_feed_file):
189 # Check we haven't already released this version
190 master = model.Interface(os.path.realpath(options.master_feed_file))
191 reader.update(master, master.uri, local = True)
192 existing_releases = [impl for impl in master.implementations.values() if impl.get_version() == status.release_version]
193 if len(existing_releases):
194 raise SafeException("Master feed %s already contains an implementation with version number %s!" % (options.master_feed_file, status.release_version))
196 tar = tarfile.open(archive_file, 'r:bz2')
197 stream = tar.extractfile(tar.getmember(archive_name + '/' + local_iface_rel_path))
198 remote_dl_iface = create_feed(stream, archive_file, archive_name)
199 stream.close()
201 support.publish(options.master_feed_file, local = remote_dl_iface.name, xmlsign = True, key = options.key)
202 remote_dl_iface.close()
204 status.updated_master_feed = 'true'
205 status.save()
207 def is_uploaded(url, size):
208 if url.startswith('http://TESTING/releases'):
209 return True
211 try:
212 actual_size = int(support.get_size(url))
213 except Exception, ex:
214 print "Can't get size of '%s': %s" % (url, ex)
215 return False
216 else:
217 if actual_size == size:
218 return True
219 print "WARNING: %s exists, but size is %d, not %d!" % (url, actual_size, size)
220 return False
222 # Copy files...
223 print
224 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file)
225 archive_size = os.path.getsize(archive_file)
226 if status.uploaded_archive and is_uploaded(archive_url, archive_size):
227 print "Archive already uploaded. Not uploading again."
228 else:
229 while True:
230 print "Upload %s as %s" % (archive_file, archive_url)
231 cmd = options.archive_upload_command.strip()
232 if cmd:
233 support.show_and_run(cmd, [archive_file])
234 else:
235 print "No upload command is set => please upload the archive manually now"
236 raw_input('Press Return once archive is uploaded.')
237 print
238 if is_uploaded(archive_url, archive_size):
239 print "OK, archive uploaded successfully"
240 status.uploaded_archive = 'true'
241 status.save()
242 break
243 print "** Archive still not uploaded! Try again..."
244 if cmd:
245 raw_input('Press Return to retry upload command.')
247 assert len(local_iface.feed_for) == 1
248 feed_base = os.path.dirname(local_iface.feed_for.keys()[0])
249 feed_files = [options.master_feed_file]
250 print "Upload %s into %s" % (', '.join(feed_files), feed_base)
251 cmd = options.master_feed_upload_command.strip()
252 if cmd:
253 support.show_and_run(cmd, feed_files)
254 else:
255 print "NOTE: No feed upload command set => you'll have to upload them yourself!"
257 print "Push changes to public SCM repository..."
258 public_repos = options.public_scm_repository
259 if public_repos:
260 scm.push_head_and_release(status.release_version)
261 else:
262 print "NOTE: No public repository set => you'll have to push the tag and trunk yourself."
264 os.unlink(support.release_status_file)
266 if status.head_before_release:
267 head = scm.get_head_revision()
268 if status.release_version:
269 print "RESUMING release of %s %s" % (local_iface.get_name(), status.release_version)
270 elif head == status.head_before_release:
271 print "Restarting release of %s (HEAD revision has not changed)" % local_iface.get_name()
272 else:
273 raise SafeException("Something went wrong with the last run:\n" +
274 "HEAD revision for last run was " + status.head_before_release + "\n" +
275 "HEAD revision now is " + head + "\n" +
276 "You should revert your working copy to the previous head and try again.\n" +
277 "If you're sure you want to release from the current head, delete '" + support.release_status_file + "'")
278 else:
279 print "Releasing", local_iface.get_name()
281 ensure_ready_to_release()
283 if status.release_version:
284 need_set_snapshot = False
285 if status.tagged:
286 print "Already tagged. Resuming the publishing process..."
287 elif status.new_snapshot_version:
288 head = scm.get_head_revision()
289 if head != status.head_before_release:
290 raise SafeException("There are more commits since we started!\n"
291 "HEAD was " + status.head_before_release + "\n"
292 "HEAD now " + head + "\n"
293 "To include them, delete '" + support.release_status_file + "' and try again.\n"
294 "To leave them out, put them on a new branch and reset HEAD to the release version.")
295 else:
296 raise SafeException("Something went wrong previously when setting the new snapshot version.\n" +
297 "Suggest you reset to the original HEAD of\n%s and delete '%s'." % (status.head_before_release, support.release_status_file))
298 else:
299 set_to_release()
300 assert status.release_version
301 need_set_snapshot = True
303 archive_name = support.make_archive_name(local_iface.get_name(), status.release_version)
304 archive_file = archive_name + '.tar.bz2'
306 if status.created_archive and os.path.isfile(archive_file):
307 print "Archive already created"
308 else:
309 support.backup_if_exists(archive_file)
310 scm.export(archive_name, archive_file, status.head_at_release)
312 if phase_actions['generate-archive']:
313 try:
314 unpack_tarball(archive_file, archive_name)
315 run_hooks('generate-archive', cwd = archive_name, env = {'RELEASE_VERSION': status.release_version})
316 info("Regenerating archive (may have been modified by generate-archive hooks...")
317 support.check_call(['tar', 'cjf', archive_file, archive_name])
318 except SafeException:
319 fail_candidate(archive_file)
320 raise
322 status.created_archive = 'true'
323 status.save()
325 if need_set_snapshot:
326 set_to_snapshot(status.release_version + '-post')
327 # Revert back to the original revision, so that any fixes the user makes
328 # will get applied before the tag
329 scm.reset_hard(scm.get_current_branch())
331 #backup_if_exists(archive_name)
332 unpack_tarball(archive_file, archive_name)
333 if local_impl.main:
334 main = os.path.join(archive_name, local_impl.main)
335 if not os.path.exists(main):
336 raise SafeException("Main executable '%s' not found after unpacking archive!" % main)
338 extracted_iface_path = os.path.abspath(os.path.join(archive_name, local_iface_rel_path))
339 assert os.path.isfile(extracted_iface_path), "Local feed not in archive! Is it under version control?"
340 extracted_iface = model.Interface(extracted_iface_path)
341 reader.update(extracted_iface, extracted_iface_path, local = True)
342 extracted_impl = support.get_singleton_impl(extracted_iface)
344 try:
345 run_unit_tests(extracted_impl)
346 except SafeException:
347 print "(leaving extracted directory for examination)"
348 fail_candidate(archive_file)
349 raise
350 # Unpack it again in case the unit-tests changed anything
351 shutil.rmtree(archive_name)
352 unpack_tarball(archive_file, archive_name)
354 previous_release = get_previous_release(status.release_version)
355 export_changelog(previous_release)
357 print "\nCandidate release archive:", archive_file
358 print "(extracted to %s for inspection)" % os.path.abspath(archive_name)
360 print "\nPlease check candidate and select an action:"
361 print "P) Publish candidate (accept)"
362 print "F) Fail candidate (untag)"
363 if previous_release:
364 print "D) Diff against release archive for %s" % previous_release
365 maybe_diff = ['Diff']
366 else:
367 maybe_diff = []
368 print "(you can also hit CTRL-C and resume this script when done)"
370 while True:
371 choice = support.get_choice(['Publish', 'Fail'] + maybe_diff)
372 if choice == 'Diff':
373 previous_archive_name = support.make_archive_name(local_iface.get_name(), previous_release)
374 previous_archive_file = previous_archive_name + '.tar.bz2'
375 if os.path.isfile(previous_archive_file):
376 unpack_tarball(previous_archive_file, previous_archive_name)
377 try:
378 support.show_diff(previous_archive_name, archive_name)
379 finally:
380 shutil.rmtree(previous_archive_name)
381 else:
382 # TODO: download it?
383 print "Sorry, archive file %s not found! Can't show diff." % previous_archive_file
384 else:
385 break
387 info("Deleting extracted archive %s", archive_name)
388 shutil.rmtree(archive_name)
390 if choice == 'Publish':
391 accept_and_publish(archive_file, archive_name, local_iface_rel_path)
392 else:
393 assert choice == 'Fail'
394 fail_candidate(archive_file)