Use full path when checking that feed is under version control
[0release.git] / release.py
blobb2fde995321bf6d6c9580a687ac4e1b4cecd9caf
1 # Copyright (C) 2009, Thomas Leonard
2 # See the README file for details, or visit http://0install.net.
4 import os, sys, subprocess, shutil, tempfile
5 from zeroinstall import SafeException
6 from zeroinstall.injector import reader, model
7 from logging import info, warn
9 import support, compile
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(local_feed, 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_dir = os.path.dirname(os.path.join(impl.id, self_test))
24 print "Running self-test:", self_test
25 exitstatus = subprocess.call(['0launch', '--main', self_test, local_feed], cwd = self_test_dir)
26 if exitstatus:
27 raise SafeException("Self-test failed with exit status %d" % exitstatus)
29 def do_release(local_iface, options):
30 assert options.master_feed_file
31 options.master_feed_file = os.path.abspath(options.master_feed_file)
33 status = support.Status()
34 local_impl = support.get_singleton_impl(local_iface)
36 local_impl_dir = local_impl.id
37 assert local_impl_dir.startswith('/')
38 local_impl_dir = os.path.realpath(local_impl_dir)
39 assert os.path.isdir(local_impl_dir)
40 assert local_iface.uri.startswith(local_impl_dir + '/')
41 local_iface_rel_path = local_iface.uri[len(local_impl_dir) + 1:]
42 assert not local_iface_rel_path.startswith('/')
43 assert os.path.isfile(os.path.join(local_impl_dir, local_iface_rel_path))
45 phase_actions = {}
46 for phase in valid_phases:
47 phase_actions[phase] = [] # List of <release:action> elements
49 add_toplevel_dir = None
50 release_management = local_iface.get_metadata(XMLNS_RELEASE, 'management')
51 if len(release_management) == 1:
52 info("Found <release:management> element.")
53 release_management = release_management[0]
54 for x in release_management.childNodes:
55 if x.uri == XMLNS_RELEASE and x.name == 'action':
56 phase = x.getAttribute('phase')
57 if phase not in valid_phases:
58 raise SafeException("Invalid action phase '%s' in local feed %s. Valid actions are:\n%s" % (phase, local_iface.uri, '\n'.join(valid_phases)))
59 phase_actions[phase].append(x.content)
60 elif x.uri == XMLNS_RELEASE and x.name == 'add-toplevel-directory':
61 add_toplevel_dir = local_iface.get_name()
62 else:
63 warn("Unknown <release:management> element: %s", x)
64 elif len(release_management) > 1:
65 raise SafeException("Multiple <release:management> sections in %s!" % local_iface)
66 else:
67 info("No <release:management> element found in local feed.")
69 scm = get_scm(local_iface, options)
71 def run_hooks(phase, cwd, env):
72 info("Running hooks for phase '%s'" % phase)
73 full_env = os.environ.copy()
74 full_env.update(env)
75 for x in phase_actions[phase]:
76 print "[%s]: %s" % (phase, x)
77 support.check_call(x, shell = True, cwd = cwd, env = full_env)
79 def set_to_release():
80 print "Snapshot version is " + local_impl.get_version()
81 suggested = support.suggest_release_version(local_impl.get_version())
82 release_version = raw_input("Version number for new release [%s]: " % suggested)
83 if not release_version:
84 release_version = suggested
86 scm.ensure_no_tag(release_version)
88 status.head_before_release = scm.get_head_revision()
89 status.save()
91 working_copy = local_impl.id
92 run_hooks('commit-release', cwd = working_copy, env = {'RELEASE_VERSION': release_version})
94 print "Releasing version", release_version
95 support.publish(local_iface.uri, set_released = 'today', set_version = release_version)
97 support.backup_if_exists(release_version)
98 os.mkdir(release_version)
99 os.chdir(release_version)
101 status.old_snapshot_version = local_impl.get_version()
102 status.release_version = release_version
103 status.head_at_release = scm.commit('Release %s' % release_version, branch = TMP_BRANCH_NAME, parent = 'HEAD')
104 status.save()
106 def set_to_snapshot(snapshot_version):
107 assert snapshot_version.endswith('-post')
108 support.publish(local_iface.uri, set_released = '', set_version = snapshot_version)
109 scm.commit('Start development series %s' % snapshot_version, branch = TMP_BRANCH_NAME, parent = TMP_BRANCH_NAME)
110 status.new_snapshot_version = scm.get_head_revision()
111 status.save()
113 def ensure_ready_to_release():
114 if not options.master_feed_file:
115 raise SafeException("Master feed file not set! Check your configuration")
117 scm.ensure_committed()
118 scm.ensure_versioned(os.path.abspath(local_iface.uri))
119 info("No uncommitted changes. Good.")
120 # Not needed for GIT. For SCMs where tagging is expensive (e.g. svn) this might be useful.
121 #run_unit_tests(local_impl)
123 scm.grep('\(^\\|[^=]\)\<\\(TODO\\|XXX\\|FIXME\\)\>')
125 def create_feed(target_feed, local_iface_path, archive_file, archive_name, main):
126 shutil.copyfile(local_iface_path, target_feed)
128 support.publish(target_feed,
129 set_main = main,
130 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file),
131 archive_file = archive_file,
132 archive_extract = archive_name)
134 def get_previous_release(this_version):
135 """Return the highest numbered verison in the master feed before this_version.
136 @return: version, or None if there wasn't one"""
137 parsed_release_version = model.parse_version(this_version)
139 if os.path.exists(options.master_feed_file):
140 master = model.Interface(os.path.realpath(options.master_feed_file))
141 reader.update(master, master.uri, local = True)
142 versions = [impl.version for impl in master.implementations.values() if impl.version < parsed_release_version]
143 if versions:
144 return model.format_version(max(versions))
145 return None
147 def export_changelog(previous_release):
148 changelog = file('changelog-%s' % status.release_version, 'w')
149 try:
150 try:
151 scm.export_changelog(previous_release, status.head_before_release, changelog)
152 except SafeException, ex:
153 print "WARNING: Failed to generate changelog: " + str(ex)
154 else:
155 print "Wrote changelog from %s to here as %s" % (previous_release or 'start', changelog.name)
156 finally:
157 changelog.close()
159 def fail_candidate(archive_file):
160 cwd = os.getcwd()
161 assert cwd.endswith(status.release_version)
162 support.backup_if_exists(cwd)
163 scm.delete_branch(TMP_BRANCH_NAME)
164 os.unlink(support.release_status_file)
165 print "Restored to state before starting release. Make your fixes and try again..."
167 def accept_and_publish(archive_file, archive_name, src_feed_name):
168 assert options.master_feed_file
170 if not options.archive_dir_public_url:
171 raise SafeException("Archive directory public URL is not set! Edit configuration and try again.")
173 if status.tagged:
174 print "Already tagged in SCM. Not re-tagging."
175 else:
176 scm.ensure_committed()
177 head = scm.get_head_revision()
178 if head != status.head_before_release:
179 raise SafeException("Changes committed since we started!\n" +
180 "HEAD was " + status.head_before_release + "\n"
181 "HEAD now " + head)
183 scm.tag(status.release_version, status.head_at_release)
184 scm.reset_hard(TMP_BRANCH_NAME)
185 scm.delete_branch(TMP_BRANCH_NAME)
187 status.tagged = 'true'
188 status.save()
190 if status.updated_master_feed:
191 print "Already added to master feed. Not changing."
192 else:
193 if os.path.exists(options.master_feed_file):
194 # Check we haven't already released this version
195 master = model.Interface(os.path.realpath(options.master_feed_file))
196 reader.update(master, master.uri, local = True)
197 existing_releases = [impl for impl in master.implementations.values() if impl.get_version() == status.release_version]
198 if len(existing_releases):
199 raise SafeException("Master feed %s already contains an implementation with version number %s!" % (options.master_feed_file, status.release_version))
201 # Merge the source and binary feeds together first, so
202 # that we update the master feed atomically and only
203 # have to sign it once.
204 shutil.copyfile(src_feed_name, 'merged.xml')
205 for b in compiler.get_binary_feeds():
206 support.publish('merged.xml', local = b)
208 support.publish(options.master_feed_file, local = 'merged.xml', xmlsign = True, key = options.key)
209 os.unlink('merged.xml')
211 status.updated_master_feed = 'true'
212 status.save()
214 def is_uploaded(url, size):
215 if url.startswith('http://TESTING/releases'):
216 return True
218 print "Testing URL %s..." % url
219 try:
220 actual_size = int(support.get_size(url))
221 except Exception, ex:
222 print "Can't get size of '%s': %s" % (url, ex)
223 return False
224 else:
225 if actual_size == size:
226 return True
227 print "WARNING: %s exists, but size is %d, not %d!" % (url, actual_size, size)
228 return False
230 # Copy files...
231 print
232 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file)
233 archive_size = os.path.getsize(archive_file)
234 if status.uploaded_archive and is_uploaded(archive_url, archive_size):
235 print "Archive already uploaded. Not uploading again."
236 else:
237 while True:
238 print "Upload %s/%s as %s" % (status.release_version, archive_file, archive_url)
239 cmd = options.archive_upload_command.strip()
240 if cmd:
241 support.show_and_run(cmd, [archive_file])
242 else:
243 print "No upload command is set => please upload the archive manually now"
244 raw_input('Press Return once archive is uploaded.')
245 print
246 if is_uploaded(archive_url, archive_size):
247 print "OK, archive uploaded successfully"
248 status.uploaded_archive = 'true'
249 status.save()
250 break
251 print "** Archive still not uploaded! Try again..."
252 if cmd:
253 raw_input('Press Return to retry upload command.')
255 assert len(local_iface.feed_for) == 1
256 feed_base = os.path.dirname(local_iface.feed_for.keys()[0])
257 feed_files = [options.master_feed_file]
258 print "Upload %s into %s" % (', '.join(feed_files), feed_base)
259 cmd = options.master_feed_upload_command.strip()
260 if cmd:
261 support.show_and_run(cmd, feed_files)
262 else:
263 print "NOTE: No feed upload command set => you'll have to upload them yourself!"
265 print "Push changes to public SCM repository..."
266 public_repos = options.public_scm_repository
267 if public_repos:
268 scm.push_head_and_release(status.release_version)
269 else:
270 print "NOTE: No public repository set => you'll have to push the tag and trunk yourself."
272 os.unlink(support.release_status_file)
274 if status.head_before_release:
275 head = scm.get_head_revision()
276 if status.release_version:
277 print "RESUMING release of %s %s" % (local_iface.get_name(), status.release_version)
278 elif head == status.head_before_release:
279 print "Restarting release of %s (HEAD revision has not changed)" % local_iface.get_name()
280 else:
281 raise SafeException("Something went wrong with the last run:\n" +
282 "HEAD revision for last run was " + status.head_before_release + "\n" +
283 "HEAD revision now is " + head + "\n" +
284 "You should revert your working copy to the previous head and try again.\n" +
285 "If you're sure you want to release from the current head, delete '" + support.release_status_file + "'")
286 else:
287 print "Releasing", local_iface.get_name()
289 ensure_ready_to_release()
291 if status.release_version:
292 if not os.path.isdir(status.release_version):
293 raise SafeException("Can't resume; directory %s missing. Try deleting '%s'." % (status.release_version, support.release_status_file))
294 os.chdir(status.release_version)
295 need_set_snapshot = False
296 if status.tagged:
297 print "Already tagged. Resuming the publishing process..."
298 elif status.new_snapshot_version:
299 head = scm.get_head_revision()
300 if head != status.head_before_release:
301 raise SafeException("There are more commits since we started!\n"
302 "HEAD was " + status.head_before_release + "\n"
303 "HEAD now " + head + "\n"
304 "To include them, delete '" + support.release_status_file + "' and try again.\n"
305 "To leave them out, put them on a new branch and reset HEAD to the release version.")
306 else:
307 raise SafeException("Something went wrong previously when setting the new snapshot version.\n" +
308 "Suggest you reset to the original HEAD of\n%s and delete '%s'." % (status.head_before_release, support.release_status_file))
309 else:
310 set_to_release() # Changes directory
311 assert status.release_version
312 need_set_snapshot = True
314 archive_name = support.make_archive_name(local_iface.get_name(), status.release_version)
315 archive_file = archive_name + '.tar.bz2'
317 export_prefix = archive_name
318 if add_toplevel_dir is not None:
319 export_prefix += '/' + add_toplevel_dir
321 if status.created_archive and os.path.isfile(archive_file):
322 print "Archive already created"
323 else:
324 support.backup_if_exists(archive_file)
325 scm.export(export_prefix, archive_file, status.head_at_release)
327 has_submodules = scm.has_submodules()
329 if phase_actions['generate-archive'] or has_submodules:
330 try:
331 support.unpack_tarball(archive_file)
332 if has_submodules:
333 scm.export_submodules(archive_name)
334 run_hooks('generate-archive', cwd = archive_name, env = {'RELEASE_VERSION': status.release_version})
335 info("Regenerating archive (may have been modified by generate-archive hooks...")
336 support.check_call(['tar', 'cjf', archive_file, archive_name])
337 except SafeException:
338 scm.reset_hard(scm.get_current_branch())
339 fail_candidate(archive_file)
340 raise
342 status.created_archive = 'true'
343 status.save()
345 if need_set_snapshot:
346 set_to_snapshot(status.release_version + '-post')
347 # Revert back to the original revision, so that any fixes the user makes
348 # will get applied before the tag
349 scm.reset_hard(scm.get_current_branch())
351 #backup_if_exists(archive_name)
352 support.unpack_tarball(archive_file)
353 if local_impl.main:
354 main = os.path.join(export_prefix, local_impl.main)
355 if not os.path.exists(main):
356 raise SafeException("Main executable '%s' not found after unpacking archive!" % main)
358 extracted_iface_path = os.path.abspath(os.path.join(export_prefix, local_iface_rel_path))
359 assert os.path.isfile(extracted_iface_path), "Local feed not in archive! Is it under version control?"
360 extracted_iface = model.Interface(extracted_iface_path)
361 reader.update(extracted_iface, extracted_iface_path, local = True)
362 extracted_impl = support.get_singleton_impl(extracted_iface)
364 try:
365 run_unit_tests(extracted_iface_path, extracted_impl)
366 except SafeException:
367 print "(leaving extracted directory for examination)"
368 fail_candidate(archive_file)
369 raise
370 # Unpack it again in case the unit-tests changed anything
371 shutil.rmtree(archive_name)
372 support.unpack_tarball(archive_file)
374 # Generate feed for source
375 stream = open(extracted_iface_path)
376 main = extracted_impl.main
377 if main and add_toplevel_dir:
378 main = os.path.join(add_toplevel_dir, main)
379 src_feed_name = '%s.xml' % archive_name
380 create_feed(src_feed_name, extracted_iface_path, archive_file, archive_name, main)
381 print "Wrote source feed as %s" % src_feed_name
383 # If it's a source package, compile the binaries now...
384 compiler = compile.Compiler(options, os.path.abspath(src_feed_name))
385 compiler.build_binaries()
387 previous_release = get_previous_release(status.release_version)
388 export_changelog(previous_release)
390 print "\nCandidate release archive:", archive_file
391 print "(extracted to %s for inspection)" % os.path.abspath(archive_name)
393 print "\nPlease check candidate and select an action:"
394 print "P) Publish candidate (accept)"
395 print "F) Fail candidate (untag)"
396 if previous_release:
397 print "D) Diff against release archive for %s" % previous_release
398 maybe_diff = ['Diff']
399 else:
400 maybe_diff = []
401 print "(you can also hit CTRL-C and resume this script when done)"
403 while True:
404 choice = support.get_choice(['Publish', 'Fail'] + maybe_diff)
405 if choice == 'Diff':
406 previous_archive_name = support.make_archive_name(local_iface.get_name(), previous_release)
407 previous_archive_file = '../%s/%s.tar.bz2' % (previous_release, previous_archive_name)
409 # For archives created by older versions of 0release
410 if not os.path.isfile(previous_archive_file):
411 old_previous_archive_file = '../%s.tar.bz2' % previous_archive_name
412 if os.path.isfile(old_previous_archive_file):
413 previous_archive_file = old_previous_archive_file
415 if os.path.isfile(previous_archive_file):
416 support.unpack_tarball(previous_archive_file)
417 try:
418 support.show_diff(previous_archive_name, archive_name)
419 finally:
420 shutil.rmtree(previous_archive_name)
421 else:
422 # TODO: download it?
423 print "Sorry, archive file %s not found! Can't show diff." % previous_archive_file
424 else:
425 break
427 info("Deleting extracted archive %s", archive_name)
428 shutil.rmtree(archive_name)
430 if choice == 'Publish':
431 accept_and_publish(archive_file, archive_name, src_feed_name)
432 else:
433 assert choice == 'Fail'
434 fail_candidate(archive_file)