If the self-test attribute is set, run the program's unit-tests when releasing.
[0release.git] / release.py
blob3f174dae90ec2b6cb085aac376e1b6ed976adc71
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
8 from scm import GIT
10 release_status_file = 'release-status'
12 def run_unit_tests(impl):
13 self_test = impl.metadata.get('self-test', None)
14 if self_test is None:
15 print "SKIPPING unit tests for %s (no 'self-test' attribute set)" % impl
16 return
17 self_test = os.path.join(impl.id, self_test)
18 print "Running self-test:", self_test
19 exitstatus = subprocess.call([self_test], cwd = impl.id)
20 if exitstatus:
21 raise SafeException("Self-test failed with exit status %d" % exitstatus)
23 def show_and_run(cmd, args):
24 print "Executing: %s %s" % (cmd, ' '.join("[%s]" % x for x in args))
25 subprocess.check_call(['sh', '-c', cmd, '-'] + args)
27 def suggest_release_version(snapshot_version):
28 """Given a snapshot version, suggest a suitable release version.
29 >>> suggest_release_version('1.0-pre')
30 '1.0'
31 >>> suggest_release_version('0.9-post')
32 '0.10'
33 >>> suggest_release_version('3')
34 Traceback (most recent call last):
35 ...
36 SafeException: Version '3' is not a snapshot version (should end in -pre or -post)
37 """
38 version = model.parse_version(snapshot_version)
39 mod = version[-1]
40 if mod == 0:
41 raise SafeException("Version '%s' is not a snapshot version (should end in -pre or -post)" % snapshot_version)
42 if mod > 0:
43 # -post, so increment the number
44 version[-2][-1] += 1
45 version[-1] = 0 # Remove the modifier
46 return model.format_version(version)
48 def publish(iface, **kwargs):
49 args = [os.environ['0PUBLISH']]
50 for k in kwargs:
51 value = kwargs[k]
52 if value is True:
53 args += ['--' + k.replace('_', '-')]
54 elif value is not None:
55 args += ['--' + k.replace('_', '-'), value]
56 args.append(iface)
57 info("Executing %s", args)
58 subprocess.check_call(args)
60 def get_singleton_impl(iface):
61 impls = iface.implementations
62 if len(impls) != 1:
63 raise SafeException("Local feed '%s' contains %d versions! I need exactly one!" % (iface.uri, len(impls)))
64 return impls.values()[0]
66 def backup_if_exists(name):
67 if not os.path.exists(name):
68 return
69 backup = name + '~'
70 if os.path.exists(backup):
71 print "(deleting old backup %s)" % backup
72 if os.path.isdir(backup):
73 shutil.rmtree(backup)
74 else:
75 os.unlink(backup)
76 os.rename(name, backup)
77 print "(renamed old %s as %s; will delete on next run)" % (name, backup)
79 class Status(object):
80 __slots__ = ['old_snapshot_version', 'release_version', 'head_before_release', 'new_snapshot_version', 'head_at_release', 'created_archive', 'tagged']
81 def __init__(self):
82 for name in self.__slots__:
83 setattr(self, name, None)
85 if os.path.isfile(release_status_file):
86 for line in file(release_status_file):
87 assert line.endswith('\n')
88 line = line[:-1]
89 name, value = line.split('=')
90 setattr(self, name, value)
91 info("Loaded status %s=%s", name, value)
93 def save(self):
94 tmp_name = release_status_file + '.new'
95 tmp = file(tmp_name, 'w')
96 try:
97 lines = ["%s=%s\n" % (name, getattr(self, name)) for name in self.__slots__ if getattr(self, name)]
98 tmp.write(''.join(lines))
99 tmp.close()
100 os.rename(tmp_name, release_status_file)
101 info("Wrote status to %s", release_status_file)
102 except:
103 os.unlink(tmp_name)
104 raise
106 def get_choice(*options):
107 while True:
108 choice = raw_input('/'.join(options) + ': ').lower()
109 if not choice: continue
110 for o in options:
111 if o.lower().startswith(choice):
112 return o
114 def do_release(local_iface, options):
115 status = Status()
116 local_impl = get_singleton_impl(local_iface)
118 local_impl_dir = local_impl.id
119 assert local_impl_dir.startswith('/')
120 local_impl_dir = os.path.realpath(local_impl_dir)
121 assert os.path.isdir(local_impl_dir)
122 assert local_iface.uri.startswith(local_impl_dir + '/')
123 local_iface_rel_path = local_iface.uri[len(local_impl_dir) + 1:]
124 assert not local_iface_rel_path.startswith('/')
125 assert os.path.isfile(os.path.join(local_impl_dir, local_iface_rel_path))
127 scm = GIT(local_iface)
129 def set_to_release():
130 print "Snapshot version is " + local_impl.get_version()
131 suggested = suggest_release_version(local_impl.get_version())
132 release_version = raw_input("Version number for new release [%s]: " % suggested)
133 if not release_version:
134 release_version = suggested
136 scm.ensure_no_tag(release_version)
138 status.head_before_release = scm.get_head_revision()
139 status.save()
141 print "Releasing version", release_version
142 publish(local_iface.uri, set_released = 'today', set_version = release_version)
144 status.old_snapshot_version = local_impl.get_version()
145 status.release_version = release_version
146 scm.commit('Release %s' % release_version)
147 status.head_at_release = scm.get_head_revision()
148 status.save()
150 return release_version
152 def set_to_snapshot(snapshot_version):
153 assert snapshot_version.endswith('-post')
154 publish(local_iface.uri, set_released = '', set_version = snapshot_version)
155 scm.commit('Start development series %s' % snapshot_version)
156 status.new_snapshot_version = scm.get_head_revision()
157 status.save()
159 def ensure_ready_to_release():
160 scm.ensure_committed()
161 info("No uncommitted changes. Good.")
162 # Not needed for GIT. For SCMs where tagging is expensive (e.g. svn) this might be useful.
163 #run_unit_tests(local_impl)
165 def create_feed(local_iface_stream, archive_file, archive_name, version):
166 tmp = tempfile.NamedTemporaryFile(prefix = '0release-')
167 shutil.copyfileobj(local_iface_stream, tmp)
168 tmp.flush()
170 publish(tmp.name,
171 archive_url = options.archive_dir_public_url + '/' + os.path.basename(archive_file),
172 archive_file = archive_file,
173 archive_extract = archive_name)
174 return tmp
176 def unpack_tarball(archive_file, archive_name):
177 tar = tarfile.open(archive_file, 'r:bz2')
178 members = [m for m in tar.getmembers() if m.name != 'pax_global_header']
179 tar.extractall('.', members = members)
181 def fail_candidate(archive_file):
182 backup_if_exists(archive_file)
183 head = scm.get_head_revision()
184 if head != status.new_snapshot_version:
185 raise SafeException("There have been commits since starting the release! Please rebase them onto %s" % status.head_before_release)
186 # Check no uncommitted changes
187 scm.ensure_committed()
188 scm.reset_hard(status.head_before_release)
189 os.unlink(release_status_file)
190 print "Restored to state before starting release. Make your fixes and try again..."
192 def accept_and_publish(archive_file, archive_name, local_iface_rel_path):
193 assert options.master_feed_file
195 if status.tagged:
196 print "Already tagged and added to master feed."
197 else:
198 tar = tarfile.open(archive_file, 'r:bz2')
199 stream = tar.extractfile(tar.getmember(archive_name + '/' + local_iface_rel_path))
200 remote_dl_iface = create_feed(stream, archive_file, archive_name, version)
201 stream.close()
203 publish(options.master_feed_file, local = remote_dl_iface.name, xmlsign = True, key = options.key)
204 remote_dl_iface.close()
206 scm.tag(status.release_version, status.head_at_release)
208 status.tagged = 'true'
209 status.save()
211 # Copy files...
212 print "Upload %s as %s" % (archive_file, options.archive_dir_public_url + '/' + os.path.basename(archive_file))
213 cmd = options.archive_upload_command.strip()
214 if cmd:
215 show_and_run(cmd, [archive_file])
216 else:
217 print "NOTE: No upload command set => you'll have to upload it yourself!"
219 assert len(local_iface.feed_for) == 1
220 feed_base = os.path.dirname(local_iface.feed_for.keys()[0])
221 feed_files = [options.master_feed_file]
222 print "Upload %s into %s" % (', '.join(feed_files), feed_base)
223 cmd = options.master_feed_upload_command.strip()
224 if cmd:
225 show_and_run(cmd, feed_files)
226 else:
227 print "NOTE: No feed upload command set => you'll have to upload them yourself!"
229 os.unlink(release_status_file)
231 if status.head_before_release:
232 head = scm.get_head_revision()
233 if status.release_version:
234 print "RESUMING release of version %s" % status.release_version
235 elif head == status.head_before_release:
236 print "Restarting release (HEAD revision has not changed)"
237 else:
238 raise SafeException("Something went wrong with the last run:\n" +
239 "HEAD revision for last run was " + status.head_before_release + "\n" +
240 "HEAD revision now is " + head + "\n" +
241 "You should revert your working copy to the previous head and try again.\n" +
242 "If you're sure you want to release from the current head, delete '" + release_status_file + "'")
244 print "Releasing", local_iface.get_name()
246 ensure_ready_to_release()
248 if status.release_version:
249 version = status.release_version
250 need_set_snapshot = False
251 if status.new_snapshot_version:
252 head = scm.get_head_revision()
253 if head != status.new_snapshot_version:
254 print "WARNING: there are more commits since we tagged; they will not be included in the release!"
255 else:
256 raise SafeException("Something went wrong previously when setting the new snapshot version.\n" +
257 "Suggest you reset to the original HEAD of\n%s and delete '%s'." % (status.head_before_release, release_status_file))
258 else:
259 version = set_to_release()
260 need_set_snapshot = True
262 archive_name = local_iface.get_name().lower().replace(' ', '-') + '-' + version
263 archive_file = archive_name + '.tar.bz2'
265 if status.created_archive and os.path.isfile(archive_file):
266 print "Archive already created"
267 else:
268 backup_if_exists(archive_file)
269 scm.export(archive_name, archive_file)
270 status.created_archive = 'true'
271 status.save()
273 if need_set_snapshot:
274 set_to_snapshot(version + '-post')
276 #backup_if_exists(archive_name)
277 unpack_tarball(archive_file, archive_name)
278 if local_impl.main:
279 main = os.path.join(archive_name, local_impl.main)
280 if not os.path.exists(main):
281 raise SafeException("Main executable '%s' not found after unpacking archive!" % main)
283 extracted_iface_path = os.path.abspath(os.path.join(archive_name, local_iface_rel_path))
284 extracted_iface = model.Interface(extracted_iface_path)
285 reader.update(extracted_iface, extracted_iface_path, local = True)
286 extracted_impl = get_singleton_impl(extracted_iface)
288 try:
289 run_unit_tests(extracted_impl)
290 except SafeException:
291 print "(leaving extracted directory for examination)"
292 fail_candidate(archive_file)
293 raise
295 print "\nCandidate release archive:", archive_file
296 print "(extracted to %s for inspection)" % os.path.abspath(archive_name)
298 print "\nPlease check candidate and select an action:"
299 print "P) Publish candidate (accept)"
300 print "F) Fail candidate (untag)"
301 print "(you can also hit CTRL-C and resume this script when done)"
302 choice = get_choice('Publish', 'Fail')
304 info("Deleting extracted archive %s", archive_name)
305 shutil.rmtree(archive_name)
307 if choice == 'Publish':
308 accept_and_publish(archive_file, archive_name, local_iface_rel_path)
309 else:
310 assert choice == 'Fail'
311 fail_candidate(archive_file)
314 if __name__ == "__main__":
315 import doctest
316 doctest.testmod()