Remember whether we already added the release to the master feed.
[0release.git] / release.py
blob4e25dd6be5918e115b442ccf04e226149e8491f4
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 GIT
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 = GIT(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 scm.ensure_committed()
105 scm.ensure_versioned(local_iface_rel_path)
106 info("No uncommitted changes. Good.")
107 # Not needed for GIT. For SCMs where tagging is expensive (e.g. svn) this might be useful.
108 #run_unit_tests(local_impl)
110 def create_feed(local_iface_stream, archive_file, archive_name):
111 tmp = tempfile.NamedTemporaryFile(prefix = '0release-')
112 shutil.copyfileobj(local_iface_stream, tmp)
113 tmp.flush()
115 support.publish(tmp.name,
116 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file),
117 archive_file = archive_file,
118 archive_extract = archive_name)
119 return tmp
121 def unpack_tarball(archive_file, archive_name):
122 tar = tarfile.open(archive_file, 'r:bz2')
123 members = [m for m in tar.getmembers() if m.name != 'pax_global_header']
124 tar.extractall('.', members = members)
126 def get_previous_release(this_version):
127 """Return the highest numbered verison in the master feed before this_version.
128 @return: version, or None if there wasn't one"""
129 parsed_release_version = model.parse_version(this_version)
131 if os.path.exists(options.master_feed_file):
132 master = model.Interface(os.path.realpath(options.master_feed_file))
133 reader.update(master, master.uri, local = True)
134 versions = [impl.version for impl in master.implementations.values() if impl.version < parsed_release_version]
135 if versions:
136 return model.format_version(max(versions))
137 return None
139 def export_changelog(previous_release = None):
140 changelog = file('changelog-%s' % status.release_version, 'w')
141 try:
142 try:
143 scm.export_changelog(previous_release, status.head_before_release, changelog)
144 except SafeException, ex:
145 print "WARNING: Failed to generate changelog: " + str(ex)
146 else:
147 print "Wrote changelog from %s to here as %s" % (previous_release or 'start', changelog.name)
148 finally:
149 changelog.close()
151 def fail_candidate(archive_file):
152 support.backup_if_exists(archive_file)
153 scm.delete_branch(TMP_BRANCH_NAME)
154 os.unlink(support.release_status_file)
155 print "Restored to state before starting release. Make your fixes and try again..."
157 def accept_and_publish(archive_file, archive_name, local_iface_rel_path):
158 assert options.master_feed_file
160 if not options.archive_dir_public_url:
161 raise SafeException("Archive directory public URL is not set! Edit configuration and try again.")
163 if status.tagged:
164 print "Already tagged in SCM. Not re-tagging."
165 else:
166 scm.ensure_committed()
167 head = scm.get_head_revision()
168 if head != status.head_before_release:
169 raise SafeException("Changes committed since we started!\n" +
170 "HEAD was " + status.head_before_release + "\n"
171 "HEAD now " + head)
173 scm.tag(status.release_version, status.head_at_release)
174 scm.reset_hard(TMP_BRANCH_NAME)
175 scm.delete_branch(TMP_BRANCH_NAME)
177 status.tagged = 'true'
178 status.save()
180 if status.updated_master_feed:
181 print "Already added to master feed. Not changing."
182 else:
183 master = model.Interface(os.path.realpath(options.master_feed_file))
184 reader.update(master, master.uri, local = True)
185 existing_releases = [impl for impl in master.implementations.values() if impl.get_version() == status.release_version]
186 if len(existing_releases):
187 raise SafeException("Master feed %s already contains an implementation with version number %s!" % (options.master_feed_file, status.release_version))
189 tar = tarfile.open(archive_file, 'r:bz2')
190 stream = tar.extractfile(tar.getmember(archive_name + '/' + local_iface_rel_path))
191 remote_dl_iface = create_feed(stream, archive_file, archive_name)
192 stream.close()
194 support.publish(options.master_feed_file, local = remote_dl_iface.name, xmlsign = True, key = options.key)
195 remote_dl_iface.close()
197 status.updated_master_feed = 'true'
198 status.save()
200 def is_uploaded(url, size):
201 try:
202 actual_size = support.get_size(url)
203 except Exception, ex:
204 print "Can't get size of '%s': %s" % (url, ex)
205 return False
206 else:
207 if int(actual_size) == size:
208 return True
209 print "WARNING: %s exists, but size is %d, not %d!" % (url, actual_size, size)
210 return False
212 # Copy files...
213 print
214 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file)
215 archive_size = os.path.getsize(archive_file)
216 if status.uploaded_archive and is_uploaded(archive_url, archive_size):
217 print "Archive already uploaded. Not uploading again."
218 else:
219 while True:
220 print "Upload %s as %s" % (archive_file, archive_url)
221 cmd = options.archive_upload_command.strip()
222 if cmd:
223 support.show_and_run(cmd, [archive_file])
224 else:
225 print "No upload command is set => please upload the archive manually now"
226 raw_input('Press Return once archive is uploaded.')
227 print
228 if is_uploaded(archive_url, archive_size):
229 print "OK, archive uploaded successfully"
230 status.uploaded_archive = 'true'
231 status.save()
232 break
233 print "** Archive still not uploaded! Try again..."
234 if cmd:
235 raw_input('Press Return to retry upload command.')
237 assert len(local_iface.feed_for) == 1
238 feed_base = os.path.dirname(local_iface.feed_for.keys()[0])
239 feed_files = [options.master_feed_file]
240 print "Upload %s into %s" % (', '.join(feed_files), feed_base)
241 cmd = options.master_feed_upload_command.strip()
242 if cmd:
243 support.show_and_run(cmd, feed_files)
244 else:
245 print "NOTE: No feed upload command set => you'll have to upload them yourself!"
247 print "Push changes to public SCM repository..."
248 public_repos = options.public_scm_repository
249 if public_repos:
250 scm.push_head_and_release(status.release_version)
251 else:
252 print "NOTE: No public repository set => you'll have to push the tag and trunk yourself."
254 os.unlink(support.release_status_file)
256 if status.head_before_release:
257 head = scm.get_head_revision()
258 if status.release_version:
259 print "RESUMING release of %s %s" % (local_iface.get_name(), status.release_version)
260 elif head == status.head_before_release:
261 print "Restarting release of %s (HEAD revision has not changed)" % local_iface.get_name()
262 else:
263 raise SafeException("Something went wrong with the last run:\n" +
264 "HEAD revision for last run was " + status.head_before_release + "\n" +
265 "HEAD revision now is " + head + "\n" +
266 "You should revert your working copy to the previous head and try again.\n" +
267 "If you're sure you want to release from the current head, delete '" + support.release_status_file + "'")
268 else:
269 print "Releasing", local_iface.get_name()
271 ensure_ready_to_release()
273 if status.release_version:
274 need_set_snapshot = False
275 if status.tagged:
276 print "Already tagged. Resuming the publishing process..."
277 elif status.new_snapshot_version:
278 head = scm.get_head_revision()
279 if head != status.head_before_release:
280 raise SafeException("There are more commits since we started!\n"
281 "HEAD was " + status.head_before_release + "\n"
282 "HEAD now " + head + "\n"
283 "To include them, delete '" + support.release_status_file + "' and try again.\n"
284 "To leave them out, put them on a new branch and reset HEAD to the release version.")
285 else:
286 raise SafeException("Something went wrong previously when setting the new snapshot version.\n" +
287 "Suggest you reset to the original HEAD of\n%s and delete '%s'." % (status.head_before_release, support.release_status_file))
288 else:
289 set_to_release()
290 assert status.release_version
291 need_set_snapshot = True
293 archive_name = support.make_archive_name(local_iface.get_name(), status.release_version)
294 archive_file = archive_name + '.tar.bz2'
296 if status.created_archive and os.path.isfile(archive_file):
297 print "Archive already created"
298 else:
299 support.backup_if_exists(archive_file)
300 scm.export(archive_name, archive_file, status.head_at_release)
302 if phase_actions['generate-archive']:
303 try:
304 unpack_tarball(archive_file, archive_name)
305 run_hooks('generate-archive', cwd = archive_name, env = {'RELEASE_VERSION': status.release_version})
306 info("Regenerating archive (may have been modified by generate-archive hooks...")
307 support.check_call(['tar', 'cjf', archive_file, archive_name])
308 except SafeException:
309 fail_candidate(archive_file)
310 raise
312 status.created_archive = 'true'
313 status.save()
315 if need_set_snapshot:
316 set_to_snapshot(status.release_version + '-post')
317 # Revert back to the original revision, so that any fixes the user makes
318 # will get applied before the tag
319 scm.reset_hard(scm.get_current_branch())
321 #backup_if_exists(archive_name)
322 unpack_tarball(archive_file, archive_name)
323 if local_impl.main:
324 main = os.path.join(archive_name, local_impl.main)
325 if not os.path.exists(main):
326 raise SafeException("Main executable '%s' not found after unpacking archive!" % main)
328 extracted_iface_path = os.path.abspath(os.path.join(archive_name, local_iface_rel_path))
329 assert os.path.isfile(extracted_iface_path), "Local feed not in archive! Is it under version control?"
330 extracted_iface = model.Interface(extracted_iface_path)
331 reader.update(extracted_iface, extracted_iface_path, local = True)
332 extracted_impl = support.get_singleton_impl(extracted_iface)
334 try:
335 run_unit_tests(extracted_impl)
336 except SafeException:
337 print "(leaving extracted directory for examination)"
338 fail_candidate(archive_file)
339 raise
340 # Unpack it again in case the unit-tests changed anything
341 shutil.rmtree(archive_name)
342 unpack_tarball(archive_file, archive_name)
344 previous_release = get_previous_release(status.release_version)
345 export_changelog()
347 print "\nCandidate release archive:", archive_file
348 print "(extracted to %s for inspection)" % os.path.abspath(archive_name)
350 print "\nPlease check candidate and select an action:"
351 print "P) Publish candidate (accept)"
352 print "F) Fail candidate (untag)"
353 if previous_release:
354 print "D) Diff against release archive for %s" % previous_release
355 maybe_diff = ['Diff']
356 else:
357 maybe_diff = []
358 print "(you can also hit CTRL-C and resume this script when done)"
360 while True:
361 choice = support.get_choice(['Publish', 'Fail'] + maybe_diff)
362 if choice == 'Diff':
363 previous_archive_name = support.make_archive_name(local_iface.get_name(), previous_release)
364 previous_archive_file = previous_archive_name + '.tar.bz2'
365 if os.path.isfile(previous_archive_file):
366 unpack_tarball(previous_archive_file, previous_archive_name)
367 try:
368 support.show_diff(previous_archive_name, archive_name)
369 finally:
370 shutil.rmtree(previous_archive_name)
371 else:
372 # TODO: download it?
373 print "Sorry, archive file %s not found! Can't show diff." % previous_archive_file
374 else:
375 break
377 info("Deleting extracted archive %s", archive_name)
378 shutil.rmtree(archive_name)
380 if choice == 'Publish':
381 accept_and_publish(archive_file, archive_name, local_iface_rel_path)
382 else:
383 assert choice == 'Fail'
384 fail_candidate(archive_file)